From 1b94baca63f67578b2b869cf103aa08857542bf1 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Tue, 4 Feb 2025 21:16:06 +0530 Subject: [PATCH 001/274] Handle potential OSError when unlinking temporary files in ArtResizer --- beets/util/artresizer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 09cc29e0d..898ffeab8 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -655,7 +655,10 @@ class ArtResizer(metaclass=Shareable): ) finally: if result_path != path_in: - os.unlink(path_in) + try: + os.unlink(path_in) + except OSError: + pass return result_path @property From 01d61c722bf12cb8895cd403b0b50eb114f9209a Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:07:14 +0530 Subject: [PATCH 002/274] add changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 62cd0c4cc..ec7861f98 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -70,6 +70,8 @@ Bug fixes: * :doc:`plugins/lyrics`: Fix plugin crash when ``genius`` backend returns empty lyrics. :bug:`5583` +* Handle potential OSError when unlinking temporary files in ArtResizer. + :bug:`5615` For packagers: From 7acf2b3acfbd831dd7b5ab35a5985ff8854eb346 Mon Sep 17 00:00:00 2001 From: Emi Katagiri-Simpson Date: Sat, 22 Mar 2025 23:15:45 -0400 Subject: [PATCH 003/274] Dereference symlinks before hardlinking (see #5676) --- beets/util/__init__.py | 4 +++- docs/changelog.rst | 4 ++++ test/test_files.py | 18 +++++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index b882ed626..6f51bbd52 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -605,7 +605,9 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False): if os.path.exists(syspath(dest)) and not replace: raise FilesystemError("file exists", "rename", (path, dest)) try: - os.link(syspath(path), syspath(dest)) + # This step dereferences any symlinks and converts to an absolute path + resolved_origin = Path(syspath(path)).resolve() + os.link(resolved_origin, syspath(dest)) except NotImplementedError: raise FilesystemError( "OS does not support hard links." "link", diff --git a/docs/changelog.rst b/docs/changelog.rst index 88d87e32f..27fcbc24f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,6 +29,10 @@ Bug fixes: * :doc:`plugins/fetchart`: Fix fetchart bug where a tempfile could not be deleted due to never being properly closed. :bug:`5521` +* When hardlinking from a symlink (e.g. importing a symlink with hardlinking + enabled), dereference the symlink then hardlink, rather than creating a new + (potentially broken) symlink + :bug:`5676` * :doc:`plugins/lyrics`: LRCLib will fallback to plain lyrics if synced lyrics are not found and `synced` flag is set to `yes`. * Synchronise files included in the source distribution with what we used to diff --git a/test/test_files.py b/test/test_files.py index 72b1610c0..c99f8f02b 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -35,7 +35,8 @@ class MoveTest(BeetsTestCase): super().setUp() # make a temporary file - self.path = join(self.temp_dir, b"temp.mp3") + self.temp_music_file_name = b"temp.mp3" + self.path = join(self.temp_dir, self.temp_music_file_name) shutil.copy( syspath(join(_common.RSRC, b"full.mp3")), syspath(self.path), @@ -199,6 +200,21 @@ class MoveTest(BeetsTestCase): self.i.move(operation=MoveOperation.HARDLINK) assert self.i.path == util.normpath(self.dest) + @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") + def test_hardlink_from_symlink(self): + link_path = join(self.temp_dir, b"temp_link.mp3") + link_source = join(b"./", self.temp_music_file_name) + os.symlink(syspath(link_source), syspath(link_path)) + self.i.path = link_path + self.i.move(operation=MoveOperation.HARDLINK) + + s1 = os.stat(syspath(self.path)) + s2 = os.stat(syspath(self.dest)) + assert (s1[stat.ST_INO], s1[stat.ST_DEV]) == ( + s2[stat.ST_INO], + s2[stat.ST_DEV], + ) + class HelperTest(BeetsTestCase): def test_ancestry_works_on_file(self): From 4a43191c31d0bb0eaa4454dd335634ae245aa0f5 Mon Sep 17 00:00:00 2001 From: Emi Katagiri-Simpson Date: Sun, 23 Mar 2025 15:29:41 -0400 Subject: [PATCH 004/274] BUG: Wrong path edited when running config -e Previously: ALWAYS edited the default config path Corrected: When the --config option is used, that path is edited --- beets/ui/__init__.py | 2 +- beets/ui/commands.py | 9 ++++++--- docs/changelog.rst | 3 +++ test/test_config_command.py | 12 ++++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 386410a09..b478b2443 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1839,7 +1839,7 @@ def _raw_main(args, lib=None): ): from beets.ui.commands import config_edit - return config_edit() + return config_edit(options) test_lib = bool(lib) subcommands, plugins, lib = _setup(options, lib) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 99aa04f0a..66faa0b6e 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -2351,7 +2351,10 @@ def config_func(lib, opts, args): # Open in editor. elif opts.edit: - config_edit() + # Note: This branch *should* be unreachable + # since the normal flow should be short-circuited + # by the special case in ui._raw_main + config_edit(opts) # Dump configuration. else: @@ -2362,11 +2365,11 @@ def config_func(lib, opts, args): print("Empty configuration") -def config_edit(): +def config_edit(cli_options): """Open a program to edit the user configuration. An empty config file is created if no existing config file exists. """ - path = config.user_config_path() + path = cli_options.config or config.user_config_path() editor = util.editor_command() try: if not os.path.isfile(path): diff --git a/docs/changelog.rst b/docs/changelog.rst index 88d87e32f..401640f61 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -49,6 +49,9 @@ Bug fixes: * :ref:`query-sort`: Fix a bug that would raise an exception when sorting on a non-string field that is not populated in all items. :bug:`5512` +* Running `beet --config config -e` now edits `` rather than + the default config path. + :bug:`5652` * :doc:`plugins/lastgenre`: Fix track-level genre handling. Now when an album-level genre is set already, single tracks don't fall back to the album's genre and request their own last.fm genre. Also log messages regarding what's been diff --git a/test/test_config_command.py b/test/test_config_command.py index b68c4f042..c81b143ec 100644 --- a/test/test_config_command.py +++ b/test/test_config_command.py @@ -128,3 +128,15 @@ class ConfigCommandTest(BeetsTestCase): with patch("os.execlp") as execlp: self.run_command("config", "-e") execlp.assert_called_once_with("myeditor", "myeditor", self.config_path) + + def test_edit_config_with_custom_config_path(self): + alt_config_path = os.path.join( + self.temp_dir.decode(), "alt_config.yaml" + ) + with open(self.config_path, "w") as file: + file.write("option: alt value\n") + + os.environ["EDITOR"] = "myeditor" + with patch("os.execlp") as execlp: + self.run_command("--config", alt_config_path, "config", "-e") + execlp.assert_called_once_with("myeditor", "myeditor", alt_config_path) From e253e5417e135947a2b5028b7ac106ee5346745a Mon Sep 17 00:00:00 2001 From: Edgars Supe Date: Wed, 30 Apr 2025 23:30:54 +0300 Subject: [PATCH 005/274] Change link for beets-usertag --- docs/plugins/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index b7998ef19..bd7ece200 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -568,7 +568,7 @@ Here are a few of the plugins written by the beets community: .. _beets-setlister: https://github.com/tomjaspers/beets-setlister .. _beets-noimport: https://gitlab.com/tiago.dias/beets-noimport .. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets -.. _beets-usertag: https://github.com/igordertigor/beets-usertag +.. _beets-usertag: https://github.com/edgars-supe/beets-usertag .. _beets-plexsync: https://github.com/arsaboo/beets-plexsync .. _beets-jiosaavn: https://github.com/arsaboo/beets-jiosaavn .. _beets-youtube: https://github.com/arsaboo/beets-youtube From f51559e16fef413f34b1e591c88512ef224c9cc1 Mon Sep 17 00:00:00 2001 From: rdy2go <47011689+rdy2go@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:01:01 +0200 Subject: [PATCH 006/274] Fix 'from_scratch': delete all tags before writing new tags to file ## Github Issues Fixes #3706 Related #5165 ## Issue Comment tags are written to file even if option 'from_scratch' is used. The same tags are not written to the file if imported together with other files as album. Therefore 'from_scratch' is not working as described in the documentation. ## Solution 1. Add test: Adapt the function from the 'regular' import class and insert it in the class for the singleton import test. 2. Fix bug : Add check for 'from_scratch' option. If used, clear metadata before applying 'new' metadata with autotag. 3. No documentation change needed. Option now works as described in the documentation. 4. Add changelog. --- beets/importer/tasks.py | 2 ++ docs/changelog.rst | 3 +++ test/test_importer.py | 11 +++++++++++ 3 files changed, 16 insertions(+) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 75f04cf5a..4aa1f8a62 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -690,6 +690,8 @@ class SingletonImportTask(ImportTask): return [self.item] def apply_metadata(self): + if config["import"]["from_scratch"]: + self.item.clear() autotag.apply_item_metadata(self.item, self.match.info) def _emit_imported(self, lib): diff --git a/docs/changelog.rst b/docs/changelog.rst index 88b82e4da..bf830bade 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -39,6 +39,9 @@ Bug fixes: :bug:`5797` * :doc:`plugins/musicbrainz`: Fix the MusicBrainz search not taking into account the album/recording aliases +* :doc:`reference/cli`: Fix 'from_scratch' option for singleton imports: delete + all (old) metadata when new metadata is applied. + :bug:`3706` For packagers: diff --git a/test/test_importer.py b/test/test_importer.py index 9bb0e8a63..2fa5b32d3 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -315,6 +315,17 @@ class ImportSingletonTest(AutotagImportTestCase): self.importer.run() self.assert_file_in_lib(b"singletons", b"Applied Track 1.mp3") + def test_apply_from_scratch_removes_other_metadata(self): + config["import"]["from_scratch"] = True + + for mediafile in self.import_media: + mediafile.comments = "Tag Comment" + mediafile.save() + + self.importer.add_choice(importer.Action.APPLY) + self.importer.run() + assert self.lib.items().get().comments == "" + def test_skip_does_not_add_first_track(self): self.importer.add_choice(importer.Action.SKIP) self.importer.run() From 2c240b4788096c2c41b6a662ff59f05186206f47 Mon Sep 17 00:00:00 2001 From: rdy2go <47011689+rdy2go@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:55:30 +0200 Subject: [PATCH 007/274] fix indentation --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 49be459dd..04dbb1cd9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,7 +52,7 @@ Bug fixes: * :doc:`reference/cli`: Fix 'from_scratch' option for singleton imports: delete all (old) metadata when new metadata is applied. :bug:`3706` - * :doc:`/plugins/spotify`: Fix the issue with that every query to spotify was +* :doc:`/plugins/spotify`: Fix the issue with that every query to spotify was ascii encoded. This resulted in bad matches for queries that contained special e.g. non latin characters as 盗作. If you want to keep the legacy behavior set the config option ``spotify.search_query_ascii: yes``. From a8204f8cde013f705c9139bc1d232344e8136835 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 13 Sep 2025 13:25:57 +0200 Subject: [PATCH 008/274] lastgenre: -vvv tuning log helper, remove -d Replace extended_debug config and CLI option with -vvv and add a helper function. --- beetsplug/lastgenre/__init__.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 902cef9ef..3b04e65d6 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -106,7 +106,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): "separator": ", ", "prefer_specific": False, "title_case": True, - "extended_debug": False, "pretend": False, } ) @@ -162,6 +161,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize + def _tunelog(self, msg, *args, **kwargs): + """Log tuning messages at DEBUG level when verbosity level is high enough.""" + if config["verbose"].as_number() >= 3: + self._log.debug(msg, *args, **kwargs) + @property def sources(self) -> tuple[str, ...]: """A tuple of allowed genre sources. May contain 'track', @@ -293,8 +297,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): self._genre_cache[key] = self.fetch_genre(method(*args)) genre = self._genre_cache[key] - if self.config["extended_debug"]: - self._log.debug("last.fm (unfiltered) {} tags: {}", entity, genre) + self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre) return genre def fetch_album_genre(self, obj): @@ -554,13 +557,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): dest="album", help="match albums instead of items (default)", ) - lastgenre_cmd.parser.add_option( - "-d", - "--debug", - action="store_true", - dest="extended_debug", - help="extended last.fm debug logging", - ) lastgenre_cmd.parser.set_defaults(album=True) def lastgenre_func(lib, opts, args): From bf507cd5d4865fa2011612f745461a65e9d62e6f Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 23 Oct 2025 07:55:43 +0200 Subject: [PATCH 009/274] Changelog for lastgenre tuning log #6007 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index cce30a284..8de4eb385 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,6 +14,10 @@ New features: album artist are the same in ftintitle. - :doc:`plugins/play`: Added `$playlist` marker to precisely edit the playlist filepath into the command calling the player program. +- :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed + to receive extra verbose logging around last.fm results and how they are + resolved. The ``extended_debug`` config setting and ``--debug`` option + have been removed. Bug fixes: From 4b1e5056d57b97682d8595d7cbf7a7cb9f4ecd30 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 23 Oct 2025 18:42:23 +0200 Subject: [PATCH 010/274] lastgenre: Document tuning log -vvv --- docs/plugins/lastgenre.rst | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 230694b06..ace7caaf0 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -197,11 +197,6 @@ file. The available options are: internal whitelist, or ``no`` to consider all genres valid. Default: ``yes``. - **title_case**: Convert the new tags to TitleCase before saving. Default: ``yes``. -- **extended_debug**: Add additional debug logging messages that show what - last.fm tags were fetched for tracks, albums and artists. This is done before - any canonicalization and whitelist filtering is applied. It's useful for - tuning the plugin's settings and understanding how it works, but it can be - quite verbose. Default: ``no``. Running Manually ---------------- @@ -219,3 +214,13 @@ or store any changes. To disable automatic genre fetching on import, set the ``auto`` config option to false. + +Tuning Logs +----------- + +To enable tuning logs, run ``beet -vvv lastgenre ...`` or ``beet -vvv import +...``. This enables additional messages at the ``DEBUG`` log level, showing for +example what data was received from last.fm at each stage of genre fetching +(artist, album, and track levels) before any canonicalization or whitelist +filtering is applied. Tuning logs are useful for adjusting the plugin’s settings +and understanding its behavior, though they can be quite verbose. From 1ea3879aae171285274e9c6bdb30f4613a4cde6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 26 Oct 2025 20:00:52 +0000 Subject: [PATCH 011/274] Upgrade librosa and audioread --- .github/workflows/ci.yaml | 14 +- poetry.lock | 555 ++++++++++++++++++++++++++++++-------- pyproject.toml | 16 +- 3 files changed, 473 insertions(+), 112 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fa6e9a7be..8c1530bad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} @@ -39,7 +39,17 @@ jobs: if: matrix.platform == 'ubuntu-latest' run: | sudo apt update - sudo apt install --yes --no-install-recommends ffmpeg gobject-introspection gstreamer1.0-plugins-base python3-gst-1.0 libcairo2-dev libgirepository-2.0-dev pandoc imagemagick + sudo apt install --yes --no-install-recommends \ + ffmpeg \ + gobject-introspection \ + gstreamer1.0-plugins-base \ + python3-gst-1.0 \ + libcairo2-dev \ + libgirepository-2.0-dev \ + libopenblas-dev \ + llvm-20-dev \ + pandoc \ + imagemagick - name: Get changed lyrics files id: lyrics-update diff --git a/poetry.lock b/poetry.lock index 615598d67..813ef6466 100644 --- a/poetry.lock +++ b/poetry.lock @@ -63,18 +63,82 @@ files = [ ] [[package]] -name = "audioread" -version = "3.0.1" -description = "Multi-library, cross-platform audio decoding." +name = "audioop-lts" +version = "0.2.2" +description = "LTS Port of Python audioop" optional = true -python-versions = ">=3.6" +python-versions = ">=3.13" files = [ - {file = "audioread-3.0.1-py3-none-any.whl", hash = "sha256:4cdce70b8adc0da0a3c9e0d85fb10b3ace30fbdf8d1670fd443929b61d117c33"}, - {file = "audioread-3.0.1.tar.gz", hash = "sha256:ac5460a5498c48bdf2e8e767402583a4dcd13f4414d286f42ce4379e8b35066d"}, + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"}, + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"}, + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd"}, + {file = "audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0"}, ] +[[package]] +name = "audioread" +version = "3.1.0" +description = "Multi-library, cross-platform audio decoding." +optional = true +python-versions = ">=3.9" +files = [ + {file = "audioread-3.1.0-py3-none-any.whl", hash = "sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4"}, + {file = "audioread-3.1.0.tar.gz", hash = "sha256:1c4ab2f2972764c896a8ac61ac53e261c8d29f0c6ccd652f84e18f08a4cab190"}, +] + +[package.dependencies] +standard-aifc = {version = "*", markers = "python_version >= \"3.13\""} +standard-sunau = {version = "*", markers = "python_version >= \"3.13\""} + [package.extras] -test = ["tox"] +gi = ["pygobject (>=3.54.2,<4.0.0)"] +mad = ["pymad[mad] (>=0.11.3,<0.12.0)"] +test = ["pytest (>=8.4.2)", "pytest-cov (>=7.0.0)"] [[package]] name = "babel" @@ -1096,13 +1160,13 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "joblib" -version = "1.5.1" +version = "1.5.2" description = "Lightweight pipelining with Python functions" optional = true python-versions = ">=3.9" files = [ - {file = "joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a"}, - {file = "joblib-1.5.1.tar.gz", hash = "sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444"}, + {file = "joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241"}, + {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"}, ] [[package]] @@ -1281,33 +1345,35 @@ typing-extensions = {version = "*", markers = "python_version < \"3.10\""} [[package]] name = "librosa" -version = "0.10.2.post1" +version = "0.11.0" description = "Python module for audio and music processing" optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "librosa-0.10.2.post1-py3-none-any.whl", hash = "sha256:dc882750e8b577a63039f25661b7e39ec4cfbacc99c1cffba666cd664fb0a7a0"}, - {file = "librosa-0.10.2.post1.tar.gz", hash = "sha256:cd99f16717cbcd1e0983e37308d1db46a6f7dfc2e396e5a9e61e6821e44bd2e7"}, + {file = "librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1"}, + {file = "librosa-0.11.0.tar.gz", hash = "sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908"}, ] [package.dependencies] audioread = ">=2.1.9" decorator = ">=4.3.0" -joblib = ">=0.14" -lazy-loader = ">=0.1" +joblib = ">=1.0" +lazy_loader = ">=0.1" msgpack = ">=1.0" numba = ">=0.51.0" -numpy = ">=1.20.3,<1.22.0 || >1.22.0,<1.22.1 || >1.22.1,<1.22.2 || >1.22.2" +numpy = ">=1.22.3" pooch = ">=1.1" -scikit-learn = ">=0.20.0" -scipy = ">=1.2.0" +scikit-learn = ">=1.1.0" +scipy = ">=1.6.0" soundfile = ">=0.12.1" soxr = ">=0.3.2" -typing-extensions = ">=4.1.1" +standard-aifc = {version = "*", markers = "python_version >= \"3.13\""} +standard-sunau = {version = "*", markers = "python_version >= \"3.13\""} +typing_extensions = ">=4.1.1" [package.extras] display = ["matplotlib (>=3.5.0)"] -docs = ["ipython (>=7.0)", "matplotlib (>=3.5.0)", "mir-eval (>=0.5)", "numba (>=0.51)", "numpydoc", "presets", "sphinx (!=1.3.1)", "sphinx-copybutton (>=0.5.2)", "sphinx-gallery (>=0.7)", "sphinx-multiversion (>=0.2.3)", "sphinx-rtd-theme (>=1.2.0)", "sphinxcontrib-svg2pdfconverter"] +docs = ["ipython (>=7.0)", "matplotlib (>=3.5.0)", "mir_eval (>=0.5)", "numba (>=0.51)", "numpydoc", "presets", "sphinx (!=1.3.1)", "sphinx-copybutton (>=0.5.2)", "sphinx-gallery (>=0.7)", "sphinx-multiversion (>=0.2.3)", "sphinx_rtd_theme (>=1.2.0)", "sphinxcontrib-googleanalytics (>=0.4)", "sphinxcontrib-svg2pdfconverter"] tests = ["matplotlib (>=3.5.0)", "packaging (>=20.0)", "pytest", "pytest-cov", "pytest-mpl", "resampy (>=0.2.2)", "samplerate", "types-decorator"] [[package]] @@ -1340,6 +1406,36 @@ files = [ {file = "llvmlite-0.43.0.tar.gz", hash = "sha256:ae2b5b5c3ef67354824fb75517c8db5fbe93bc02cd9671f3c62271626bc041d5"}, ] +[[package]] +name = "llvmlite" +version = "0.45.1" +description = "lightweight wrapper around basic LLVM functionality" +optional = true +python-versions = ">=3.10" +files = [ + {file = "llvmlite-0.45.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:1b1af0c910af0978aa55fa4f60bbb3e9f39b41e97c2a6d94d199897be62ba07a"}, + {file = "llvmlite-0.45.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02a164db2d79088bbd6e0d9633b4fe4021d6379d7e4ac7cc85ed5f44b06a30c5"}, + {file = "llvmlite-0.45.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2d47f34e4029e6df3395de34cc1c66440a8d72712993a6e6168db228686711b"}, + {file = "llvmlite-0.45.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7319e5f9f90720578a7f56fbc805bdfb4bc071b507c7611f170d631c3c0f1e0"}, + {file = "llvmlite-0.45.1-cp310-cp310-win_amd64.whl", hash = "sha256:4edb62e685867799e336723cb9787ec6598d51d0b1ed9af0f38e692aa757e898"}, + {file = "llvmlite-0.45.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:60f92868d5d3af30b4239b50e1717cb4e4e54f6ac1c361a27903b318d0f07f42"}, + {file = "llvmlite-0.45.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98baab513e19beb210f1ef39066288784839a44cd504e24fff5d17f1b3cf0860"}, + {file = "llvmlite-0.45.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3adc2355694d6a6fbcc024d59bb756677e7de506037c878022d7b877e7613a36"}, + {file = "llvmlite-0.45.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f3377a6db40f563058c9515dedcc8a3e562d8693a106a28f2ddccf2c8fcf6ca"}, + {file = "llvmlite-0.45.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9c272682d91e0d57f2a76c6d9ebdfccc603a01828cdbe3d15273bdca0c3363a"}, + {file = "llvmlite-0.45.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:28e763aba92fe9c72296911e040231d486447c01d4f90027c8e893d89d49b20e"}, + {file = "llvmlite-0.45.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1a53f4b74ee9fd30cb3d27d904dadece67a7575198bd80e687ee76474620735f"}, + {file = "llvmlite-0.45.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b3796b1b1e1c14dcae34285d2f4ea488402fbd2c400ccf7137603ca3800864f"}, + {file = "llvmlite-0.45.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:779e2f2ceefef0f4368548685f0b4adde34e5f4b457e90391f570a10b348d433"}, + {file = "llvmlite-0.45.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e6c9949baf25d9aa9cd7cf0f6d011b9ca660dd17f5ba2b23bdbdb77cc86b116"}, + {file = "llvmlite-0.45.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:d9ea9e6f17569a4253515cc01dade70aba536476e3d750b2e18d81d7e670eb15"}, + {file = "llvmlite-0.45.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c9f3cadee1630ce4ac18ea38adebf2a4f57a89bd2740ce83746876797f6e0bfb"}, + {file = "llvmlite-0.45.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:57c48bf2e1083eedbc9406fb83c4e6483017879714916fe8be8a72a9672c995a"}, + {file = "llvmlite-0.45.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aa3dfceda4219ae39cf18806c60eeb518c1680ff834b8b311bd784160b9ce40"}, + {file = "llvmlite-0.45.1-cp313-cp313-win_amd64.whl", hash = "sha256:080e6f8d0778a8239cd47686d402cb66eb165e421efa9391366a9b7e5810a38b"}, + {file = "llvmlite-0.45.1.tar.gz", hash = "sha256:09430bb9d0bb58fc45a45a57c7eae912850bedc095cd0810a57de109c69e1c32"}, +] + [[package]] name = "lxml" version = "6.0.0" @@ -1555,70 +1651,73 @@ test = ["pytest", "pytest-cov"] [[package]] name = "msgpack" -version = "1.1.1" +version = "1.1.2" description = "MessagePack serializer" optional = true -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed"}, - {file = "msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338"}, - {file = "msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd"}, - {file = "msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8"}, - {file = "msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558"}, - {file = "msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752"}, - {file = "msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295"}, - {file = "msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458"}, - {file = "msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238"}, - {file = "msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a"}, - {file = "msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c"}, - {file = "msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4"}, - {file = "msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0"}, - {file = "msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5"}, - {file = "msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323"}, - {file = "msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bba1be28247e68994355e028dcd668316db30c1f758d3241a7b903ac78dcd285"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f93dcddb243159c9e4109c9750ba5b335ab8d48d9522c5308cd05d7e3ce600"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fbbc0b906a24038c9958a1ba7ae0918ad35b06cb449d398b76a7d08470b0ed9"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:61e35a55a546a1690d9d09effaa436c25ae6130573b6ee9829c37ef0f18d5e78"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1abfc6e949b352dadf4bce0eb78023212ec5ac42f6abfd469ce91d783c149c2a"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:996f2609ddf0142daba4cefd767d6db26958aac8439ee41db9cc0db9f4c4c3a6"}, - {file = "msgpack-1.1.1-cp38-cp38-win32.whl", hash = "sha256:4d3237b224b930d58e9d83c81c0dba7aacc20fcc2f89c1e5423aa0529a4cd142"}, - {file = "msgpack-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:da8f41e602574ece93dbbda1fab24650d6bf2a24089f9e9dbb4f5730ec1e58ad"}, - {file = "msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b"}, - {file = "msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478"}, - {file = "msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57"}, - {file = "msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084"}, - {file = "msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd"}, + {file = "msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2"}, + {file = "msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87"}, + {file = "msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251"}, + {file = "msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a"}, + {file = "msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f"}, + {file = "msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f"}, + {file = "msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9"}, + {file = "msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa"}, + {file = "msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c"}, + {file = "msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0"}, + {file = "msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296"}, + {file = "msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef"}, + {file = "msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c"}, + {file = "msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e"}, + {file = "msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e"}, + {file = "msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68"}, + {file = "msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406"}, + {file = "msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa"}, + {file = "msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb"}, + {file = "msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f"}, + {file = "msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42"}, + {file = "msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9"}, + {file = "msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620"}, + {file = "msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029"}, + {file = "msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b"}, + {file = "msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69"}, + {file = "msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf"}, + {file = "msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7"}, + {file = "msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999"}, + {file = "msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e"}, + {file = "msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162"}, + {file = "msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794"}, + {file = "msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c"}, + {file = "msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9"}, + {file = "msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2"}, + {file = "msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717"}, + {file = "msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b"}, + {file = "msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27"}, + {file = "msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46"}, + {file = "msgpack-1.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea5405c46e690122a76531ab97a079e184c0daf491e588592d6a23d3e32af99e"}, + {file = "msgpack-1.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9fba231af7a933400238cb357ecccf8ab5d51535ea95d94fc35b7806218ff844"}, + {file = "msgpack-1.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a8f6e7d30253714751aa0b0c84ae28948e852ee7fb0524082e6716769124bc23"}, + {file = "msgpack-1.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94fd7dc7d8cb0a54432f296f2246bc39474e017204ca6f4ff345941d4ed285a7"}, + {file = "msgpack-1.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:350ad5353a467d9e3b126d8d1b90fe05ad081e2e1cef5753f8c345217c37e7b8"}, + {file = "msgpack-1.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6bde749afe671dc44893f8d08e83bf475a1a14570d67c4bb5cec5573463c8833"}, + {file = "msgpack-1.1.2-cp39-cp39-win32.whl", hash = "sha256:ad09b984828d6b7bb52d1d1d0c9be68ad781fa004ca39216c8a1e63c0f34ba3c"}, + {file = "msgpack-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:67016ae8c8965124fdede9d3769528ad8284f14d635337ffa6a713a580f6c030"}, + {file = "msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e"}, ] [[package]] @@ -1758,6 +1857,40 @@ files = [ llvmlite = "==0.43.*" numpy = ">=1.22,<2.1" +[[package]] +name = "numba" +version = "0.62.1" +description = "compiling Python code using LLVM" +optional = true +python-versions = ">=3.10" +files = [ + {file = "numba-0.62.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a323df9d36a0da1ca9c592a6baaddd0176d9f417ef49a65bb81951dce69d941a"}, + {file = "numba-0.62.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1e1f4781d3f9f7c23f16eb04e76ca10b5a3516e959634bd226fc48d5d8e7a0a"}, + {file = "numba-0.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14432af305ea68627a084cd702124fd5d0c1f5b8a413b05f4e14757202d1cf6c"}, + {file = "numba-0.62.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f180922adf159ae36c2fe79fb94ffaa74cf5cb3688cb72dba0a904b91e978507"}, + {file = "numba-0.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:f41834909d411b4b8d1c68f745144136f21416547009c1e860cc2098754b4ca7"}, + {file = "numba-0.62.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:f43e24b057714e480fe44bc6031de499e7cf8150c63eb461192caa6cc8530bc8"}, + {file = "numba-0.62.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57cbddc53b9ee02830b828a8428757f5c218831ccc96490a314ef569d8342b7b"}, + {file = "numba-0.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:604059730c637c7885386521bb1b0ddcbc91fd56131a6dcc54163d6f1804c872"}, + {file = "numba-0.62.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6c540880170bee817011757dc9049dba5a29db0c09b4d2349295991fe3ee55f"}, + {file = "numba-0.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:03de6d691d6b6e2b76660ba0f38f37b81ece8b2cc524a62f2a0cfae2bfb6f9da"}, + {file = "numba-0.62.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:1b743b32f8fa5fff22e19c2e906db2f0a340782caf024477b97801b918cf0494"}, + {file = "numba-0.62.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:90fa21b0142bcf08ad8e32a97d25d0b84b1e921bc9423f8dda07d3652860eef6"}, + {file = "numba-0.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ef84d0ac19f1bf80431347b6f4ce3c39b7ec13f48f233a48c01e2ec06ecbc59"}, + {file = "numba-0.62.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9315cc5e441300e0ca07c828a627d92a6802bcbf27c5487f31ae73783c58da53"}, + {file = "numba-0.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:44e3aa6228039992f058f5ebfcfd372c83798e9464297bdad8cc79febcf7891e"}, + {file = "numba-0.62.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b72489ba8411cc9fdcaa2458d8f7677751e94f0109eeb53e5becfdc818c64afb"}, + {file = "numba-0.62.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:44a1412095534a26fb5da2717bc755b57da5f3053965128fe3dc286652cc6a92"}, + {file = "numba-0.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c9460b9e936c5bd2f0570e20a0a5909ee6e8b694fd958b210e3bde3a6dba2d7"}, + {file = "numba-0.62.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:728f91a874192df22d74e3fd42c12900b7ce7190b1aad3574c6c61b08313e4c5"}, + {file = "numba-0.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:bbf3f88b461514287df66bc8d0307e949b09f2b6f67da92265094e8fa1282dd8"}, + {file = "numba-0.62.1.tar.gz", hash = "sha256:7b774242aa890e34c21200a1fc62e5b5757d5286267e71103257f4e2af0d5161"}, +] + +[package.dependencies] +llvmlite = "==0.45.*" +numpy = ">=1.22,<2.4" + [[package]] name = "numpy" version = "2.0.2" @@ -1812,6 +1945,89 @@ files = [ {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, ] +[[package]] +name = "numpy" +version = "2.3.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +files = [ + {file = "numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb"}, + {file = "numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f"}, + {file = "numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36"}, + {file = "numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032"}, + {file = "numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7"}, + {file = "numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda"}, + {file = "numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0"}, + {file = "numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a"}, + {file = "numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1"}, + {file = "numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996"}, + {file = "numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c"}, + {file = "numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11"}, + {file = "numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9"}, + {file = "numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667"}, + {file = "numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef"}, + {file = "numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e"}, + {file = "numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a"}, + {file = "numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16"}, + {file = "numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786"}, + {file = "numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc"}, + {file = "numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32"}, + {file = "numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db"}, + {file = "numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966"}, + {file = "numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3"}, + {file = "numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197"}, + {file = "numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e"}, + {file = "numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7"}, + {file = "numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953"}, + {file = "numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37"}, + {file = "numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd"}, + {file = "numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646"}, + {file = "numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d"}, + {file = "numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc"}, + {file = "numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879"}, + {file = "numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562"}, + {file = "numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a"}, + {file = "numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6"}, + {file = "numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7"}, + {file = "numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0"}, + {file = "numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f"}, + {file = "numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64"}, + {file = "numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb"}, + {file = "numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c"}, + {file = "numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40"}, + {file = "numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e"}, + {file = "numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff"}, + {file = "numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f"}, + {file = "numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b"}, + {file = "numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7"}, + {file = "numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2"}, + {file = "numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52"}, + {file = "numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26"}, + {file = "numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc"}, + {file = "numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9"}, + {file = "numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868"}, + {file = "numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec"}, + {file = "numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3"}, + {file = "numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365"}, + {file = "numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252"}, + {file = "numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e"}, + {file = "numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0"}, + {file = "numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0"}, + {file = "numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f"}, + {file = "numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d"}, + {file = "numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6"}, + {file = "numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d"}, + {file = "numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f"}, + {file = "numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a"}, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -3056,6 +3272,84 @@ dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy", "pycodestyle", "pyde doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.12.0)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0)", "sphinx-design (>=0.4.0)"] test = ["array-api-strict", "asv", "gmpy2", "hypothesis (>=6.30)", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +[[package]] +name = "scipy" +version = "1.16.2" +description = "Fundamental algorithms for scientific computing in Python" +optional = true +python-versions = ">=3.11" +files = [ + {file = "scipy-1.16.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6ab88ea43a57da1af33292ebd04b417e8e2eaf9d5aa05700be8d6e1b6501cd92"}, + {file = "scipy-1.16.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c95e96c7305c96ede73a7389f46ccd6c659c4da5ef1b2789466baeaed3622b6e"}, + {file = "scipy-1.16.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:87eb178db04ece7c698220d523c170125dbffebb7af0345e66c3554f6f60c173"}, + {file = "scipy-1.16.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:4e409eac067dcee96a57fbcf424c13f428037827ec7ee3cb671ff525ca4fc34d"}, + {file = "scipy-1.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e574be127bb760f0dad24ff6e217c80213d153058372362ccb9555a10fc5e8d2"}, + {file = "scipy-1.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5db5ba6188d698ba7abab982ad6973265b74bb40a1efe1821b58c87f73892b9"}, + {file = "scipy-1.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec6e74c4e884104ae006d34110677bfe0098203a3fec2f3faf349f4cb05165e3"}, + {file = "scipy-1.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:912f46667d2d3834bc3d57361f854226475f695eb08c08a904aadb1c936b6a88"}, + {file = "scipy-1.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:91e9e8a37befa5a69e9cacbe0bcb79ae5afb4a0b130fd6db6ee6cc0d491695fa"}, + {file = "scipy-1.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:f3bf75a6dcecab62afde4d1f973f1692be013110cad5338007927db8da73249c"}, + {file = "scipy-1.16.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:89d6c100fa5c48472047632e06f0876b3c4931aac1f4291afc81a3644316bb0d"}, + {file = "scipy-1.16.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ca748936cd579d3f01928b30a17dc474550b01272d8046e3e1ee593f23620371"}, + {file = "scipy-1.16.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:fac4f8ce2ddb40e2e3d0f7ec36d2a1e7f92559a2471e59aec37bd8d9de01fec0"}, + {file = "scipy-1.16.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:033570f1dcefd79547a88e18bccacff025c8c647a330381064f561d43b821232"}, + {file = "scipy-1.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea3421209bf00c8a5ef2227de496601087d8f638a2363ee09af059bd70976dc1"}, + {file = "scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f66bd07ba6f84cd4a380b41d1bf3c59ea488b590a2ff96744845163309ee8e2f"}, + {file = "scipy-1.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e9feab931bd2aea4a23388c962df6468af3d808ddf2d40f94a81c5dc38f32ef"}, + {file = "scipy-1.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03dfc75e52f72cf23ec2ced468645321407faad8f0fe7b1f5b49264adbc29cb1"}, + {file = "scipy-1.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:0ce54e07bbb394b417457409a64fd015be623f36e330ac49306433ffe04bc97e"}, + {file = "scipy-1.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a8ffaa4ac0df81a0b94577b18ee079f13fecdb924df3328fc44a7dc5ac46851"}, + {file = "scipy-1.16.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:84f7bf944b43e20b8a894f5fe593976926744f6c185bacfcbdfbb62736b5cc70"}, + {file = "scipy-1.16.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5c39026d12edc826a1ef2ad35ad1e6d7f087f934bb868fc43fa3049c8b8508f9"}, + {file = "scipy-1.16.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e52729ffd45b68777c5319560014d6fd251294200625d9d70fd8626516fc49f5"}, + {file = "scipy-1.16.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:024dd4a118cccec09ca3209b7e8e614931a6ffb804b2a601839499cb88bdf925"}, + {file = "scipy-1.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a5dc7ee9c33019973a470556081b0fd3c9f4c44019191039f9769183141a4d9"}, + {file = "scipy-1.16.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c2275ff105e508942f99d4e3bc56b6ef5e4b3c0af970386ca56b777608ce95b7"}, + {file = "scipy-1.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af80196eaa84f033e48444d2e0786ec47d328ba00c71e4299b602235ffef9acb"}, + {file = "scipy-1.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9fb1eb735fe3d6ed1f89918224e3385fbf6f9e23757cacc35f9c78d3b712dd6e"}, + {file = "scipy-1.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:fda714cf45ba43c9d3bae8f2585c777f64e3f89a2e073b668b32ede412d8f52c"}, + {file = "scipy-1.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:2f5350da923ccfd0b00e07c3e5cfb316c1c0d6c1d864c07a72d092e9f20db104"}, + {file = "scipy-1.16.2-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:53d8d2ee29b925344c13bda64ab51785f016b1b9617849dac10897f0701b20c1"}, + {file = "scipy-1.16.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:9e05e33657efb4c6a9d23bd8300101536abd99c85cca82da0bffff8d8764d08a"}, + {file = "scipy-1.16.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:7fe65b36036357003b3ef9d37547abeefaa353b237e989c21027b8ed62b12d4f"}, + {file = "scipy-1.16.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6406d2ac6d40b861cccf57f49592f9779071655e9f75cd4f977fa0bdd09cb2e4"}, + {file = "scipy-1.16.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff4dc42bd321991fbf611c23fc35912d690f731c9914bf3af8f417e64aca0f21"}, + {file = "scipy-1.16.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:654324826654d4d9133e10675325708fb954bc84dae6e9ad0a52e75c6b1a01d7"}, + {file = "scipy-1.16.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63870a84cd15c44e65220eaed2dac0e8f8b26bbb991456a033c1d9abfe8a94f8"}, + {file = "scipy-1.16.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fa01f0f6a3050fa6a9771a95d5faccc8e2f5a92b4a2e5440a0fa7264a2398472"}, + {file = "scipy-1.16.2-cp313-cp313t-win_amd64.whl", hash = "sha256:116296e89fba96f76353a8579820c2512f6e55835d3fad7780fece04367de351"}, + {file = "scipy-1.16.2-cp313-cp313t-win_arm64.whl", hash = "sha256:98e22834650be81d42982360382b43b17f7ba95e0e6993e2a4f5b9ad9283a94d"}, + {file = "scipy-1.16.2-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:567e77755019bb7461513c87f02bb73fb65b11f049aaaa8ca17cfaa5a5c45d77"}, + {file = "scipy-1.16.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:17d9bb346194e8967296621208fcdfd39b55498ef7d2f376884d5ac47cec1a70"}, + {file = "scipy-1.16.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0a17541827a9b78b777d33b623a6dcfe2ef4a25806204d08ead0768f4e529a88"}, + {file = "scipy-1.16.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:d7d4c6ba016ffc0f9568d012f5f1eb77ddd99412aea121e6fa8b4c3b7cbad91f"}, + {file = "scipy-1.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9702c4c023227785c779cba2e1d6f7635dbb5b2e0936cdd3a4ecb98d78fd41eb"}, + {file = "scipy-1.16.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1cdf0ac28948d225decdefcc45ad7dd91716c29ab56ef32f8e0d50657dffcc7"}, + {file = "scipy-1.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70327d6aa572a17c2941cdfb20673f82e536e91850a2e4cb0c5b858b690e1548"}, + {file = "scipy-1.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5221c0b2a4b58aa7c4ed0387d360fd90ee9086d383bb34d9f2789fafddc8a936"}, + {file = "scipy-1.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:f5a85d7b2b708025af08f060a496dd261055b617d776fc05a1a1cc69e09fe9ff"}, + {file = "scipy-1.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:2cc73a33305b4b24556957d5857d6253ce1e2dcd67fa0ff46d87d1670b3e1e1d"}, + {file = "scipy-1.16.2-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:9ea2a3fed83065d77367775d689401a703d0f697420719ee10c0780bcab594d8"}, + {file = "scipy-1.16.2-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7280d926f11ca945c3ef92ba960fa924e1465f8d07ce3a9923080363390624c4"}, + {file = "scipy-1.16.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8afae1756f6a1fe04636407ef7dbece33d826a5d462b74f3d0eb82deabefd831"}, + {file = "scipy-1.16.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:5c66511f29aa8d233388e7416a3f20d5cae7a2744d5cee2ecd38c081f4e861b3"}, + {file = "scipy-1.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efe6305aeaa0e96b0ccca5ff647a43737d9a092064a3894e46c414db84bc54ac"}, + {file = "scipy-1.16.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f3a337d9ae06a1e8d655ee9d8ecb835ea5ddcdcbd8d23012afa055ab014f374"}, + {file = "scipy-1.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bab3605795d269067d8ce78a910220262711b753de8913d3deeaedb5dded3bb6"}, + {file = "scipy-1.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b0348d8ddb55be2a844c518cd8cc8deeeb8aeba707cf834db5758fc89b476a2c"}, + {file = "scipy-1.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:26284797e38b8a75e14ea6631d29bda11e76ceaa6ddb6fdebbfe4c4d90faf2f9"}, + {file = "scipy-1.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d2a4472c231328d4de38d5f1f68fdd6d28a615138f842580a8a321b5845cf779"}, + {file = "scipy-1.16.2.tar.gz", hash = "sha256:af029b153d243a80afb6eabe40b0a07f8e35c9adc269c019f364ad747f826a6b"}, +] + +[package.dependencies] +numpy = ">=1.25.2,<2.6" + +[package.extras] +dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] +doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] +test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest (>=8.0.0)", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] + [[package]] name = "six" version = "1.17.0" @@ -3145,32 +3439,37 @@ files = [ [[package]] name = "soxr" -version = "0.5.0.post1" +version = "1.0.0" description = "High quality, one-dimensional sample-rate conversion library" optional = true python-versions = ">=3.9" files = [ - {file = "soxr-0.5.0.post1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:7406d782d85f8cf64e66b65e6b7721973de8a1dc50b9e88bc2288c343a987484"}, - {file = "soxr-0.5.0.post1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa0a382fb8d8e2afed2c1642723b2d2d1b9a6728ff89f77f3524034c8885b8c9"}, - {file = "soxr-0.5.0.post1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b01d3efb95a2851f78414bcd00738b0253eec3f5a1e5482838e965ffef84969"}, - {file = "soxr-0.5.0.post1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc049b0a151a65aa75b92f0ac64bb2dba785d16b78c31c2b94e68c141751d6d"}, - {file = "soxr-0.5.0.post1-cp310-cp310-win_amd64.whl", hash = "sha256:97f269bc26937c267a2ace43a77167d0c5c8bba5a2b45863bb6042b5b50c474e"}, - {file = "soxr-0.5.0.post1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6fb77b626773a966e3d8f6cb24f6f74b5327fa5dc90f1ff492450e9cdc03a378"}, - {file = "soxr-0.5.0.post1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:39e0f791ba178d69cd676485dbee37e75a34f20daa478d90341ecb7f6d9d690f"}, - {file = "soxr-0.5.0.post1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f0b558f445ba4b64dbcb37b5f803052eee7d93b1dbbbb97b3ec1787cb5a28eb"}, - {file = "soxr-0.5.0.post1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca6903671808e0a6078b0d146bb7a2952b118dfba44008b2aa60f221938ba829"}, - {file = "soxr-0.5.0.post1-cp311-cp311-win_amd64.whl", hash = "sha256:c4d8d5283ed6f5efead0df2c05ae82c169cfdfcf5a82999c2d629c78b33775e8"}, - {file = "soxr-0.5.0.post1-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:fef509466c9c25f65eae0ce1e4b9ac9705d22c6038c914160ddaf459589c6e31"}, - {file = "soxr-0.5.0.post1-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:4704ba6b13a3f1e41d12acf192878384c1c31f71ce606829c64abdf64a8d7d32"}, - {file = "soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd052a66471a7335b22a6208601a9d0df7b46b8d087dce4ff6e13eed6a33a2a1"}, - {file = "soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3f16810dd649ab1f433991d2a9661e9e6a116c2b4101039b53b3c3e90a094fc"}, - {file = "soxr-0.5.0.post1-cp312-abi3-win_amd64.whl", hash = "sha256:b1be9fee90afb38546bdbd7bde714d1d9a8c5a45137f97478a83b65e7f3146f6"}, - {file = "soxr-0.5.0.post1-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:c5af7b355959061beb90a1d73c4834ece4549f07b708f8c73c088153cec29935"}, - {file = "soxr-0.5.0.post1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e1dda616fc797b1507b65486f3116ed2c929f13c722922963dd419d64ada6c07"}, - {file = "soxr-0.5.0.post1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94de2812368e98cb42b4eaeddf8ee1657ecc19bd053f8e67b9b5aa12a3592012"}, - {file = "soxr-0.5.0.post1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c8e9c980637e03d3f345a4fd81d56477a58c294fb26205fa121bc4eb23d9d01"}, - {file = "soxr-0.5.0.post1-cp39-cp39-win_amd64.whl", hash = "sha256:7e71b0b0db450f36de70f1047505231db77a713f8c47df9342582ae8a4b828f2"}, - {file = "soxr-0.5.0.post1.tar.gz", hash = "sha256:7092b9f3e8a416044e1fa138c8172520757179763b85dc53aa9504f4813cff73"}, + {file = "soxr-1.0.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:b876a3156f67c76aef0cff1084eaf4088d9ca584bb569cb993f89a52ec5f399f"}, + {file = "soxr-1.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d3b957a7b0cc19ae6aa45d40b2181474e53a8dd00efd7bce6bcf4e60e020892"}, + {file = "soxr-1.0.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89685faedebc45af71f08f9957b61cc6143bc94ba43fe38e97067f81e272969"}, + {file = "soxr-1.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d255741b2f0084fd02d4a2ddd77cd495be9e7e7b6f9dba1c9494f86afefac65b"}, + {file = "soxr-1.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:158a4a9055958c4b95ef91dbbe280cabb00946b5423b25a9b0ce31bd9e0a271e"}, + {file = "soxr-1.0.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:28e19d74a5ef45c0d7000f3c70ec1719e89077379df2a1215058914d9603d2d8"}, + {file = "soxr-1.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8dc69fc18884e53b72f6141fdf9d80997edbb4fec9dc2942edcb63abbe0d023"}, + {file = "soxr-1.0.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f15450e6f65f22f02fcd4c5a9219c873b1e583a73e232805ff160c759a6b586"}, + {file = "soxr-1.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f73f57452f9df37b4de7a4052789fcbd474a5b28f38bba43278ae4b489d4384"}, + {file = "soxr-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f417c3d69236051cf5a1a7bad7c4bff04eb3d8fcaa24ac1cb06e26c8d48d8dc"}, + {file = "soxr-1.0.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:abecf4e39017f3fadb5e051637c272ae5778d838e5c3926a35db36a53e3a607f"}, + {file = "soxr-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:e973d487ee46aa8023ca00a139db6e09af053a37a032fe22f9ff0cc2e19c94b4"}, + {file = "soxr-1.0.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ce273cca101aff3d8c387db5a5a41001ba76ef1837883438d3c652507a9ccc"}, + {file = "soxr-1.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f2a69686f2856d37823bbb7b78c3d44904f311fe70ba49b893af11d6b6047b"}, + {file = "soxr-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:2a3b77b115ae7c478eecdbd060ed4f61beda542dfb70639177ac263aceda42a2"}, + {file = "soxr-1.0.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:392a5c70c04eb939c9c176bd6f654dec9a0eaa9ba33d8f1024ed63cf68cdba0a"}, + {file = "soxr-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fdc41a1027ba46777186f26a8fba7893be913383414135577522da2fcc684490"}, + {file = "soxr-1.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:449acd1dfaf10f0ce6dfd75c7e2ef984890df94008765a6742dafb42061c1a24"}, + {file = "soxr-1.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38b35c99e408b8f440c9376a5e1dd48014857cd977c117bdaa4304865ae0edd0"}, + {file = "soxr-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a39b519acca2364aa726b24a6fd55acf29e4c8909102e0b858c23013c38328e5"}, + {file = "soxr-1.0.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:c120775b7d0ef9e974a5797a4695861e88653f7ecd0a2a532f089bc4452ba130"}, + {file = "soxr-1.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e59e5f648bd6144e79a6e0596aa486218876293f5ddce3ca84b9d8f8aa34d6d"}, + {file = "soxr-1.0.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb86c342862697dbd4a44043f275e5196f2d2c49dca374c78f19b7893988675d"}, + {file = "soxr-1.0.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2a4fadd88207c2991fb08c29fc189e7b2e298b598a94ea1747e42c8acb7a01"}, + {file = "soxr-1.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:c7f5ace8f04f924b21caedeeb69f2a7b3d83d2d436639498c08b2cebe181af14"}, + {file = "soxr-1.0.0.tar.gz", hash = "sha256:e07ee6c1d659bc6957034f4800c60cb8b98de798823e34d2a2bba1caa85a4509"}, ] [package.dependencies] @@ -3371,6 +3670,46 @@ lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] +[[package]] +name = "standard-aifc" +version = "3.13.0" +description = "Standard library aifc redistribution. \"dead battery\"." +optional = true +python-versions = "*" +files = [ + {file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"}, + {file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"}, +] + +[package.dependencies] +audioop-lts = {version = "*", markers = "python_version >= \"3.13\""} +standard-chunk = {version = "*", markers = "python_version >= \"3.13\""} + +[[package]] +name = "standard-chunk" +version = "3.13.0" +description = "Standard library chunk redistribution. \"dead battery\"." +optional = true +python-versions = "*" +files = [ + {file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"}, + {file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"}, +] + +[[package]] +name = "standard-sunau" +version = "3.13.0" +description = "Standard library sunau redistribution. \"dead battery\"." +optional = true +python-versions = "*" +files = [ + {file = "standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622"}, + {file = "standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908"}, +] + +[package.dependencies] +audioop-lts = {version = "*", markers = "python_version >= \"3.13\""} + [[package]] name = "tabulate" version = "0.9.0" @@ -3569,13 +3908,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] @@ -3683,4 +4022,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "aedfeb1ac78ae0120855c6a7d6f35963c63cc50a8750142c95dd07ffd213683f" +content-hash = "0482f412ae22099662d8f991b9e6f8074bd8bbbddd3964f704046e10d5920619" diff --git a/pyproject.toml b/pyproject.toml index b546b4dc2..7851f078b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ @@ -49,7 +50,10 @@ jellyfish = "*" lap = ">=0.5.12" mediafile = ">=0.12.0" musicbrainzngs = ">=0.4" -numpy = ">=1.24.4" +numpy = [ + { python = "<3.14", version = ">=2.0.2" }, + { python = ">=3.14", version = ">=2.3.4" }, +] platformdirs = ">=3.5.0" pyyaml = "*" typing_extensions = "*" @@ -60,7 +64,15 @@ dbus-python = { version = "*", optional = true } flask = { version = "*", optional = true } flask-cors = { version = "*", optional = true } langdetect = { version = "*", optional = true } -librosa = { version = "^0.10.2.post1", optional = true } +librosa = { version = ">=0.11", optional = true } +scipy = [ # for librosa + { python = "<3.14", version = ">=1.13.1", optional = true }, + { python = ">=3.14", version = ">=1.16.1", optional = true }, +] +numba = [ # for librosa + { python = "<3.14", version = ">=0.60", optional = true }, + { python = ">=3.14", version = ">=0.62.1", optional = true }, +] mutagen = { version = ">=1.33", optional = true } Pillow = { version = "*", optional = true } py7zr = { version = "*", optional = true } From 3eb68ef830447d91b10a928823edb3c50cf73f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 26 Oct 2025 20:37:08 +0000 Subject: [PATCH 012/274] Use cross-platform shutil.get_terminal_size to get term_width This fixes Python 3.14 incompatibility. --- .github/workflows/ci.yaml | 2 +- beets/ui/__init__.py | 26 +++++--------------------- pyproject.toml | 1 + 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8c1530bad..45352c2a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 60e201448..fe980bb5c 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -23,8 +23,8 @@ import errno import optparse import os.path import re +import shutil import sqlite3 -import struct import sys import textwrap import traceback @@ -699,27 +699,11 @@ def get_replacements(): return replacements -def term_width(): +@cache +def term_width() -> int: """Get the width (columns) of the terminal.""" - fallback = config["ui"]["terminal_width"].get(int) - - # The fcntl and termios modules are not available on non-Unix - # platforms, so we fall back to a constant. - try: - import fcntl - import termios - except ImportError: - return fallback - - try: - buf = fcntl.ioctl(0, termios.TIOCGWINSZ, " " * 4) - except OSError: - return fallback - try: - height, width = struct.unpack("hh", buf) - except struct.error: - return fallback - return width + columns, _ = shutil.get_terminal_size(fallback=(0, 0)) + return columns if columns else config["ui"]["terminal_width"].get(int) def split_into_lines(string, width_tuple): diff --git a/pyproject.toml b/pyproject.toml index 7851f078b..eb80cfe9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ From 77dffd551db0c41fba8571a690c62ecbcb808027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 26 Oct 2025 21:07:25 +0000 Subject: [PATCH 013/274] Add a note in the changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8de4eb385..e6eba65df 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,7 @@ New features: to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. +- Added support for Python 3.13 and 3.14. Bug fixes: From ec141dbfd6079209e994d0a4ad97fedcc3a2ab1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 26 Oct 2025 23:04:17 +0000 Subject: [PATCH 014/274] Explicitly wrap partial with staticmethod for Py3.14 --- beetsplug/lyrics.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index d245d6a14..4c35d8a2e 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -745,7 +745,9 @@ class Translator(RequestHandler): TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate" LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$") SEPARATOR = " | " - remove_translations = partial(re.compile(r" / [^\n]+").sub, "") + remove_translations = staticmethod( + partial(re.compile(r" / [^\n]+").sub, "") + ) _log: Logger api_key: str From e30f7fbe9c75d11863c0e773871f631d8c69c98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 27 Oct 2025 08:45:19 +0000 Subject: [PATCH 015/274] Try env var --- .github/workflows/ci.yaml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 45352c2a5..119115fb1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,11 +46,15 @@ jobs: python3-gst-1.0 \ libcairo2-dev \ libgirepository-2.0-dev \ - libopenblas-dev \ - llvm-20-dev \ pandoc \ imagemagick + if [[ "${{ matrix.python-version }}" == '3.14' ]]; then + sudo apt install --yes --no-install-recommends libopenblas-dev llvm-20-dev clang-20 + sudo update-alternatives --install /usr/bin/llvm-config llvm-config /usr/bin/llvm-config-20 200 + sudo update-alternatives --set llvm-config /usr/bin/llvm-config-20 + fi + - name: Get changed lyrics files id: lyrics-update uses: tj-actions/changed-files@v46 @@ -67,7 +71,14 @@ jobs: - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage + shell: bash run: | + if [[ "${{ matrix.python-version }}" == '3.14' ]]; then + export CC=gcc + export CXX=g++ + export LLVM_DIR=/usr/lib/llvm-20/lib/cmake/llvm + fi + poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe test From fdc6d6e7879c15699dd8a9a262a1325bf3184f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 27 Oct 2025 08:55:08 +0000 Subject: [PATCH 016/274] Revert "Try env var" This reverts commit e30f7fbe9c75d11863c0e773871f631d8c69c98f. --- .github/workflows/ci.yaml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 119115fb1..45352c2a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,15 +46,11 @@ jobs: python3-gst-1.0 \ libcairo2-dev \ libgirepository-2.0-dev \ + libopenblas-dev \ + llvm-20-dev \ pandoc \ imagemagick - if [[ "${{ matrix.python-version }}" == '3.14' ]]; then - sudo apt install --yes --no-install-recommends libopenblas-dev llvm-20-dev clang-20 - sudo update-alternatives --install /usr/bin/llvm-config llvm-config /usr/bin/llvm-config-20 200 - sudo update-alternatives --set llvm-config /usr/bin/llvm-config-20 - fi - - name: Get changed lyrics files id: lyrics-update uses: tj-actions/changed-files@v46 @@ -71,14 +67,7 @@ jobs: - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage - shell: bash run: | - if [[ "${{ matrix.python-version }}" == '3.14' ]]; then - export CC=gcc - export CXX=g++ - export LLVM_DIR=/usr/lib/llvm-20/lib/cmake/llvm - fi - poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe test From e76665bcfb3a943e0811d665dd9f6329e823703d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 27 Oct 2025 09:30:18 +0000 Subject: [PATCH 017/274] Do not support 3.14 for now, until we drop 3.9 in a couple of days --- .github/workflows/ci.yaml | 4 +--- docs/changelog.rst | 2 +- poetry.lock | 2 +- pyproject.toml | 13 ++++++------- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 45352c2a5..e8a532956 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} @@ -46,8 +46,6 @@ jobs: python3-gst-1.0 \ libcairo2-dev \ libgirepository-2.0-dev \ - libopenblas-dev \ - llvm-20-dev \ pandoc \ imagemagick diff --git a/docs/changelog.rst b/docs/changelog.rst index e6eba65df..1dabbc58a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,7 +18,7 @@ New features: to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. -- Added support for Python 3.13 and 3.14. +- Added support for Python 3.13. Bug fixes: diff --git a/poetry.lock b/poetry.lock index 813ef6466..ca58cc732 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4022,4 +4022,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "0482f412ae22099662d8f991b9e6f8074bd8bbbddd3964f704046e10d5920619" +content-hash = "d3a1dc19299b117259ac790773ebef872a0b5a2e318b8a36da0918f3bbc54fb8" diff --git a/pyproject.toml b/pyproject.toml index eb80cfe9e..a0b09c1ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ @@ -52,8 +51,8 @@ lap = ">=0.5.12" mediafile = ">=0.12.0" musicbrainzngs = ">=0.4" numpy = [ - { python = "<3.14", version = ">=2.0.2" }, - { python = ">=3.14", version = ">=2.3.4" }, + { python = "<3.13", version = ">=2.0.2" }, + { python = ">=3.13", version = ">=2.3.4" }, ] platformdirs = ">=3.5.0" pyyaml = "*" @@ -67,12 +66,12 @@ flask-cors = { version = "*", optional = true } langdetect = { version = "*", optional = true } librosa = { version = ">=0.11", optional = true } scipy = [ # for librosa - { python = "<3.14", version = ">=1.13.1", optional = true }, - { python = ">=3.14", version = ">=1.16.1", optional = true }, + { python = "<3.13", version = ">=1.13.1", optional = true }, + { python = ">=3.13", version = ">=1.16.1", optional = true }, ] numba = [ # for librosa - { python = "<3.14", version = ">=0.60", optional = true }, - { python = ">=3.14", version = ">=0.62.1", optional = true }, + { python = "<3.13", version = ">=0.60", optional = true }, + { python = ">=3.13", version = ">=0.62.1", optional = true }, ] mutagen = { version = ">=1.33", optional = true } Pillow = { version = "*", optional = true } From cbd74b31679cbbf733cb74e0694b6f23ef8dd7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 28 Oct 2025 10:25:13 +0000 Subject: [PATCH 018/274] Update confuse --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index ca58cc732..568b20d7d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -639,13 +639,13 @@ files = [ [[package]] name = "confuse" -version = "2.0.1" -description = "Painless YAML configuration." +version = "2.1.0" +description = "Painless YAML config files" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" files = [ - {file = "confuse-2.0.1-py3-none-any.whl", hash = "sha256:9b9e5bbc70e2cb9b318bcab14d917ec88e21bf1b724365e3815eb16e37aabd2a"}, - {file = "confuse-2.0.1.tar.gz", hash = "sha256:7379a2ad49aaa862b79600cc070260c1b7974d349f4fa5e01f9afa6c4dd0611f"}, + {file = "confuse-2.1.0-py3-none-any.whl", hash = "sha256:502be1299aa6bf7c48f7719f56795720c073fb28550c0c7a37394366c9d30316"}, + {file = "confuse-2.1.0.tar.gz", hash = "sha256:abb9674a99c7a6efaef84e2fc84403ecd2dd304503073ff76ea18ed4176e218d"}, ] [package.dependencies] @@ -4022,4 +4022,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "d3a1dc19299b117259ac790773ebef872a0b5a2e318b8a36da0918f3bbc54fb8" +content-hash = "be135ccdcad615804f5fc96290d5d8e6ad51a244599356133c2b68bb030f640f" diff --git a/pyproject.toml b/pyproject.toml index a0b09c1ca..78e85286b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" python = ">=3.9,<4" colorama = { version = "*", markers = "sys_platform == 'win32'" } -confuse = ">=1.5.0" +confuse = ">=2.1.0" jellyfish = "*" lap = ">=0.5.12" mediafile = ">=0.12.0" From f6ba5bcf01b6112ca95d8d8e46be594ba5aaa7d3 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 23 Oct 2025 22:06:07 +0200 Subject: [PATCH 019/274] docs: Move "Handling Paths" to "Developers" chapter --- CONTRIBUTING.rst | 25 ------------------------- docs/dev/index.rst | 1 + docs/dev/paths.rst | 24 ++++++++++++++++++++++++ 3 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 docs/dev/paths.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ee963ab46..d19a376b3 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -286,31 +286,6 @@ according to the specifications required by the project. Similarly, run ``poe format-docs`` and ``poe lint-docs`` to ensure consistent documentation formatting and check for any issues. -Handling Paths -~~~~~~~~~~~~~~ - -A great deal of convention deals with the handling of **paths**. Paths are -stored internally—in the database, for instance—as byte strings (i.e., ``bytes`` -instead of ``str`` in Python 3). This is because POSIX operating systems’ path -names are only reliably usable as byte strings—operating systems typically -recommend but do not require that filenames use a given encoding, so violations -of any reported encoding are inevitable. On Windows, the strings are always -encoded with UTF-8; on Unix, the encoding is controlled by the filesystem. Here -are some guidelines to follow: - -- If you have a Unicode path or you’re not sure whether something is Unicode or - not, pass it through ``bytestring_path`` function in the ``beets.util`` module - to convert it to bytes. -- Pass every path name through the ``syspath`` function (also in ``beets.util``) - before sending it to any *operating system* file operation (``open``, for - example). This is necessary to use long filenames (which, maddeningly, must be - Unicode) on Windows. This allows us to consistently store bytes in the - database but use the native encoding rule on both POSIX and Windows. -- Similarly, the ``displayable_path`` utility function converts bytestring paths - to a Unicode string for displaying to the user. Every time you want to print - out a string to the terminal or log it with the ``logging`` module, feed it - through this function. - Editor Settings ~~~~~~~~~~~~~~~ diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 7bd0ba709..f22aa8c56 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -18,6 +18,7 @@ configuration files, respectively. plugins/index library + paths importer cli ../api/index diff --git a/docs/dev/paths.rst b/docs/dev/paths.rst new file mode 100644 index 000000000..136414edb --- /dev/null +++ b/docs/dev/paths.rst @@ -0,0 +1,24 @@ +Handling Paths +============== + +A great deal of convention deals with the handling of **paths**. Paths are +stored internally—in the database, for instance—as byte strings (i.e., ``bytes`` +instead of ``str`` in Python 3). This is because POSIX operating systems’ path +names are only reliably usable as byte strings—operating systems typically +recommend but do not require that filenames use a given encoding, so violations +of any reported encoding are inevitable. On Windows, the strings are always +encoded with UTF-8; on Unix, the encoding is controlled by the filesystem. Here +are some guidelines to follow: + +- If you have a Unicode path or you’re not sure whether something is Unicode or + not, pass it through ``bytestring_path`` function in the ``beets.util`` module + to convert it to bytes. +- Pass every path name through the ``syspath`` function (also in ``beets.util``) + before sending it to any *operating system* file operation (``open``, for + example). This is necessary to use long filenames (which, maddeningly, must be + Unicode) on Windows. This allows us to consistently store bytes in the + database but use the native encoding rule on both POSIX and Windows. +- Similarly, the ``displayable_path`` utility function converts bytestring paths + to a Unicode string for displaying to the user. Every time you want to print + out a string to the terminal or log it with the ``logging`` module, feed it + through this function. From d283a35a1055296355131b10747cca6e276d8f71 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 23 Oct 2025 22:13:17 +0200 Subject: [PATCH 020/274] docs: Rewrite Handling Paths chapter (pathlib) --- docs/dev/paths.rst | 80 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/dev/paths.rst b/docs/dev/paths.rst index 136414edb..a593580f6 100644 --- a/docs/dev/paths.rst +++ b/docs/dev/paths.rst @@ -1,24 +1,64 @@ Handling Paths ============== -A great deal of convention deals with the handling of **paths**. Paths are -stored internally—in the database, for instance—as byte strings (i.e., ``bytes`` -instead of ``str`` in Python 3). This is because POSIX operating systems’ path -names are only reliably usable as byte strings—operating systems typically -recommend but do not require that filenames use a given encoding, so violations -of any reported encoding are inevitable. On Windows, the strings are always -encoded with UTF-8; on Unix, the encoding is controlled by the filesystem. Here -are some guidelines to follow: +``pathlib`` provides a clean, cross-platform API for working with filesystem +paths. -- If you have a Unicode path or you’re not sure whether something is Unicode or - not, pass it through ``bytestring_path`` function in the ``beets.util`` module - to convert it to bytes. -- Pass every path name through the ``syspath`` function (also in ``beets.util``) - before sending it to any *operating system* file operation (``open``, for - example). This is necessary to use long filenames (which, maddeningly, must be - Unicode) on Windows. This allows us to consistently store bytes in the - database but use the native encoding rule on both POSIX and Windows. -- Similarly, the ``displayable_path`` utility function converts bytestring paths - to a Unicode string for displaying to the user. Every time you want to print - out a string to the terminal or log it with the ``logging`` module, feed it - through this function. +Use the ``.filepath`` property on ``Item`` and ``Album`` library objects to +access paths as ``pathlib.Path`` objects. This produces a readable, native +representation suitable for printing, logging, or further processing. + +Normalize paths using ``Path(...).expanduser().resolve()``, which expands ``~`` +and resolves symlinks. + +Cross-platform differences—such as path separators, Unicode handling, and +long-path support (Windows) are automatically managed by ``pathlib``. + +When storing paths in the database, however, convert them to bytes with +``bytestring_path()``. Paths in Beets are currently stored as bytes, although +there are plans to eventually store ``pathlib.Path`` objects directly. To access +media file paths in their stored form, use the ``.path`` property on ``Item`` +and ``Album``. + +Legacy utilities +---------------- + +Historically, Beets used custom utilities to ensure consistent behavior across +Linux, macOS, and Windows before ``pathlib`` became reliable: + +- ``syspath()``: worked around Windows Unicode and long-path limitations by + converting to a system-safe string (adding the ``\\?\`` prefix where needed). +- ``normpath()``: normalized slashes and removed ``./`` or ``..`` parts but did + not expand ``~``. +- ``bytestring_path()``: converted paths to bytes for database storage (still + used for that purpose today). +- ``displayable_path()``: converted byte paths to Unicode for display or + logging. + +These functions remain safe to use in legacy code, but new code should rely +solely on ``pathlib.Path``. + +Examples +-------- + +Old style + +.. code-block:: python + + displayable_path(item.path) + normpath("~/Music/../Artist") + syspath(path) + +New style + +.. code-block:: python + + item.filepath + Path("~/Music/../Artist").expanduser().resolve() + Path(path) + +When storing paths in the database + +.. code-block:: python + + path_bytes = bytestring_path(Path("/some/path/to/file.mp3")) From 528d5e67e551719c4fcb5e5dcdf1c1ad2854cc55 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Tue, 28 Oct 2025 07:32:39 +0100 Subject: [PATCH 021/274] docs: Changelog for Handling Paths move/rewrite --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1dabbc58a..749ddf005 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,10 @@ For packagers: Other changes: +- The documentation chapter :doc:`dev/paths` has been moved to the "For + Developers" section and revised to reflect current best practices (pathlib + usage). + 2.5.1 (October 14, 2025) ------------------------ From 1e1c649398e0397047933df5363b049e0bbc04e5 Mon Sep 17 00:00:00 2001 From: Emi Katagiri-Simpson Date: Tue, 28 Oct 2025 16:46:43 -0400 Subject: [PATCH 022/274] Use already generated config path in test_edit_config_with_custom_path --- test/test_config_command.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/test/test_config_command.py b/test/test_config_command.py index c81b143ec..c1215ef43 100644 --- a/test/test_config_command.py +++ b/test/test_config_command.py @@ -130,13 +130,9 @@ class ConfigCommandTest(BeetsTestCase): execlp.assert_called_once_with("myeditor", "myeditor", self.config_path) def test_edit_config_with_custom_config_path(self): - alt_config_path = os.path.join( - self.temp_dir.decode(), "alt_config.yaml" - ) - with open(self.config_path, "w") as file: - file.write("option: alt value\n") - os.environ["EDITOR"] = "myeditor" with patch("os.execlp") as execlp: - self.run_command("--config", alt_config_path, "config", "-e") - execlp.assert_called_once_with("myeditor", "myeditor", alt_config_path) + self.run_command("--config", self.cli_config_path, "config", "-e") + execlp.assert_called_once_with( + "myeditor", "myeditor", self.cli_config_path + ) From e181ebeaae628daadb71a5f2acdb481a53c953ca Mon Sep 17 00:00:00 2001 From: Doron Behar Date: Mon, 10 Apr 2023 11:59:01 +0300 Subject: [PATCH 023/274] importsource: Add new plugin (+docs/tests/changlog) --- beetsplug/importsource.py | 167 ++++++++++++++++++++++++++++++ docs/changelog.rst | 1 + docs/plugins/importsource.rst | 80 ++++++++++++++ docs/plugins/index.rst | 1 + test/plugins/test_importsource.py | 115 ++++++++++++++++++++ 5 files changed, 364 insertions(+) create mode 100644 beetsplug/importsource.py create mode 100644 docs/plugins/importsource.rst create mode 100644 test/plugins/test_importsource.py diff --git a/beetsplug/importsource.py b/beetsplug/importsource.py new file mode 100644 index 000000000..1c686d334 --- /dev/null +++ b/beetsplug/importsource.py @@ -0,0 +1,167 @@ +"""Adds a `source_path` attribute to imported albums indicating from what path +the album was imported from. Also suggests removing that source path in case +you've removed the album from the library. + +""" + +import os +from pathlib import Path +from shutil import rmtree + +from beets.dbcore.query import PathQuery +from beets.plugins import BeetsPlugin +from beets.ui import colorize as colorize_text +from beets.ui import input_options + + +class ImportSourcePlugin(BeetsPlugin): + """Main plugin class.""" + + def __init__(self): + """Initialize the plugin and read configuration.""" + super(ImportSourcePlugin, self).__init__() + self.config.add( + { + "suggest_removal": False, + } + ) + self.import_stages = [self.import_stage] + self.register_listener("item_removed", self.suggest_removal) + # In order to stop future removal suggestions for an album we keep + # track of `mb_albumid`s in this set. + self.stop_suggestions_for_albums = set() + # During reimports (import --library) both the import_task_choice and + # the item_removed event are triggered. The item_removed event is + # triggered first. For the import_task_choice event we prevent removal + # suggestions using the existing stop_suggestions_for_album mechanism. + self.register_listener( + "import_task_choice", self.prevent_suggest_removal + ) + + def prevent_suggest_removal(self, session, task): + for item in task.imported_items(): + if "mb_albumid" in item: + self.stop_suggestions_for_albums.add(item.mb_albumid) + + def import_stage(self, _, task): + """Event handler for albums import finished.""" + for item in task.imported_items(): + # During reimports (import --library), we prevent overwriting the + # source_path attribute with the path from the music library + if "source_path" in item: + self._log.info( + "Preserving source_path of reimported item {}", item.id + ) + continue + item["source_path"] = item.path + item.try_sync(write=False, move=False) + + def suggest_removal(self, item): + """Prompts the user to delete the original path the item was imported from.""" + if ( + not self.config["suggest_removal"] + or item.mb_albumid in self.stop_suggestions_for_albums + ): + return + + if "source_path" not in item: + self._log.warning( + "Item without source_path (probably imported before plugin " + "usage): {}", + item.filepath, + ) + return + + srcpath = Path(os.fsdecode(item.source_path)) + if not srcpath.is_file(): + self._log.warning( + "Original source file no longer exists or is not accessible: {}", + srcpath, + ) + return + + if not ( + os.access(srcpath, os.W_OK) + and os.access(srcpath.parent, os.W_OK | os.X_OK) + ): + self._log.warning( + "Original source file cannot be deleted (insufficient permissions): {}", + srcpath, + ) + return + + # We ask the user whether they'd like to delete the item's source + # directory + item_path = colorize_text("text_warning", item.filepath) + source_path = colorize_text("text_warning", srcpath) + + print( + f"The item:\n{item_path}\nis originated from:\n{source_path}\n" + "What would you like to do?" + ) + + resp = input_options( + [ + "Delete the item's source", + "Recursively delete the source's directory", + "do Nothing", + "do nothing and Stop suggesting to delete items from this album", + ], + require=True, + ) + + # Handle user response + if resp == "d": + self._log.info( + "Deleting the item's source file: {}", + srcpath, + ) + srcpath.unlink() + + elif resp == "r": + self._log.info( + "Searching for other items with a source_path attr containing: {}", + srcpath.parent, + ) + + source_dir_query = PathQuery( + "source_path", + srcpath.parent, + # The "source_path" attribute may not be present in all + # items of the library, so we avoid errors with this: + fast=False, + ) + + print("Doing so will delete the following items' sources as well:") + for searched_item in item._db.items(source_dir_query): + print(colorize_text("text_warning", searched_item.filepath)) + + print("Would you like to continue?") + continue_resp = input_options( + ["Yes", "delete None", "delete just the File"], + require=False, # Yes is the a default + ) + + if continue_resp == "y": + self._log.info( + "Deleting the item's source directory: {}", + srcpath.parent, + ) + rmtree(srcpath.parent) + + elif continue_resp == "n": + self._log.info("doing nothing - aborting hook function") + return + + elif continue_resp == "f": + self._log.info( + "removing just the item's original source: {}", + srcpath, + ) + srcpath.unlink() + + elif resp == "s": + self.stop_suggestions_for_albums.add(item.mb_albumid) + + else: + self._log.info("Doing nothing") diff --git a/docs/changelog.rst b/docs/changelog.rst index 749ddf005..a78787273 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -433,6 +433,7 @@ New features: ``beet list -a title:something`` or ``beet list artpath:cover``. Consequently album queries involving ``path`` field have been sped up, like ``beet list -a path:/path/``. +- :doc:`plugins/importsource`: Added plugin - :doc:`plugins/ftintitle`: New ``keep_in_artist`` option for the plugin, which allows keeping the "feat." part in the artist metadata while still changing the title. diff --git a/docs/plugins/importsource.rst b/docs/plugins/importsource.rst new file mode 100644 index 000000000..dda2d5e08 --- /dev/null +++ b/docs/plugins/importsource.rst @@ -0,0 +1,80 @@ +ImportSource Plugin +=================== + +The ``importsource`` plugin adds a ``source_path`` field to every item imported +to the library which stores the original media files' paths. Using this plugin +makes most sense when the general importing workflow is using ``beet import +--copy``. Additionally the plugin interactively suggests deletion of original +source files whenever items are removed from the Beets library. + +To enable it, add ``importsource`` to the list of plugins in your configuration +(see :ref:`using-plugins`). + +Tracking Source Paths +--------------------- + +The primary use case for the plugin is tracking the original location of +imported files using the ``source_path`` field. Consider this scenario: you've +imported all directories in your current working directory using: + +.. code-block:: bash + + beet import --flat --copy */ + +Later, for instance if the import didn't complete successfully, you'll need to +rerun the import but don't want Beets to re-process the already successfully +imported directories. You can view which files were successfully imported using: + +.. code-block:: bash + + beet ls source_path:$PWD --format='$source_path' + +To extract just the directory names, pipe the output to standard UNIX utilities: + +.. code-block:: bash + + beet ls source_path:$PWD --format='$source_path' | awk -F / '{print $(NF-1)}' | sort -u + +This might help to find out what's left to be imported. + +Removal Suggestion +------------------ + +Another feature of the plugin is suggesting removal of original source files +when items are deleted from your library. Consider this scenario: you imported +an album using: + +.. code-block:: bash + + beet import --copy --flat ~/Desktop/interesting-album-to-check/ + +After listening to that album and deciding it wasn't good, you want to delete it +from your library as well as from your ``~/Desktop``, so you run: + +.. code-block:: bash + + beet remove --delete source_path:$HOME/Desktop/interesting-album-to-check + +After approving the deletion, the plugin will prompt: + +.. code-block:: text + + The item: + /Interesting Album/01 Interesting Song.flac + is originated from: + /Desktop/interesting-album-to-check/01-interesting-song.flac + What would you like to do? + Delete the item's source, Recursively delete the source's directory, + do Nothing, + do nothing and Stop suggesting to delete items from this album? + +Configuration +------------- + +To configure the plugin, make an ``importsource:`` section in your configuration +file. There is one option available: + +- **suggest_removal**: By default ``importsource`` suggests to remove the + original directories / files from which the items were imported whenever + library items (and files) are removed. To disable these prompts set this + option to ``no``. Default: ``yes``. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 2c9d94dfd..d1590504d 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -88,6 +88,7 @@ databases. They share the following configuration options: hook ihate importadded + importsource importfeeds info inline diff --git a/test/plugins/test_importsource.py b/test/plugins/test_importsource.py new file mode 100644 index 000000000..e05a8f177 --- /dev/null +++ b/test/plugins/test_importsource.py @@ -0,0 +1,115 @@ +# This file is part of beets. +# Copyright 2025, Stig Inge Lea Bjornsen. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + +"""Tests for the `importsource` plugin.""" + +import os +import time + +from beets import importer +from beets.test.helper import AutotagImportTestCase, PluginMixin, control_stdin +from beets.util import syspath +from beetsplug.importsource import ImportSourcePlugin + +_listeners = ImportSourcePlugin.listeners + + +def preserve_plugin_listeners(): + """Preserve the initial plugin listeners as they would otherwise be + deleted after the first setup / tear down cycle. + """ + if not ImportSourcePlugin.listeners: + ImportSourcePlugin.listeners = _listeners + + +class ImportSourceTest(PluginMixin, AutotagImportTestCase): + plugin = "importsource" + preload_plugin = False + + def setUp(self): + preserve_plugin_listeners() + super().setUp() + self.config[self.plugin]["suggest_removal"] = True + self.load_plugins() + self.prepare_album_for_import(2) + self.importer = self.setup_importer() + self.importer.add_choice(importer.Action.APPLY) + self.importer.run() + self.all_items = self.lib.albums().get().items() + self.item_to_remove = self.all_items[0] + + def interact(self, stdin_input: str): + with control_stdin(stdin_input): + self.run_command( + "remove", + f"path:{syspath(self.item_to_remove.path)}", + ) + + def test_do_nothing(self): + self.interact("N") + + assert os.path.exists(self.item_to_remove.source_path) + + def test_remove_single(self): + self.interact("y\nD") + + assert not os.path.exists(self.item_to_remove.source_path) + + def test_remove_all_from_single(self): + self.interact("y\nR\ny") + + for item in self.all_items: + assert not os.path.exists(item.source_path) + + def test_stop_suggesting(self): + self.interact("y\nS") + + for item in self.all_items: + assert os.path.exists(item.source_path) + + def test_source_path_attribute_written(self): + """Test that source_path attribute is correctly written to imported items. + + The items should already have source_path from the setUp import + """ + for item in self.all_items: + assert "source_path" in item + assert item.source_path # Should not be empty + + def test_source_files_not_modified_during_import(self): + """Test that source files timestamps are not changed during import.""" + # Prepare fresh files and record timestamps + test_album_path = self.import_path / "test_album" + import_paths = self.prepare_album_for_import( + 2, album_path=test_album_path + ) + original_mtimes = { + path: os.stat(path).st_mtime for path in import_paths + } + + # Small delay to detect timestamp changes + time.sleep(0.1) + + # Run a fresh import + importer_session = self.setup_importer() + importer_session.add_choice(importer.Action.APPLY) + importer_session.run() + + # Verify timestamps haven't changed + for path, original_mtime in original_mtimes.items(): + current_mtime = os.stat(path).st_mtime + assert current_mtime == original_mtime, ( + f"Source file timestamp changed: {path}" + ) From 02a662e923dafda844d616b2615da05180bb328e Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 1 Sep 2025 20:49:26 +0200 Subject: [PATCH 024/274] importfeeds: Fix tests - Use self.config instead of global config, which was interfering whith other plugin tests (test_importsource) when run alongside (eg in CI) - Rename test --- test/plugins/test_importfeeds.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/test/plugins/test_importfeeds.py b/test/plugins/test_importfeeds.py index d525bd801..53da87172 100644 --- a/test/plugins/test_importfeeds.py +++ b/test/plugins/test_importfeeds.py @@ -2,21 +2,22 @@ import datetime import os import os.path -from beets import config from beets.library import Album, Item -from beets.test.helper import BeetsTestCase +from beets.test.helper import PluginTestCase from beetsplug.importfeeds import ImportFeedsPlugin -class ImportfeedsTestTest(BeetsTestCase): +class ImportFeedsTest(PluginTestCase): + plugin = "importfeeds" + def setUp(self): super().setUp() self.importfeeds = ImportFeedsPlugin() self.feeds_dir = self.temp_dir_path / "importfeeds" - config["importfeeds"]["dir"] = str(self.feeds_dir) + self.config["importfeeds"]["dir"] = str(self.feeds_dir) def test_multi_format_album_playlist(self): - config["importfeeds"]["formats"] = "m3u_multi" + self.config["importfeeds"]["formats"] = "m3u_multi" album = Album(album="album/name", id=1) item_path = os.path.join("path", "to", "item") item = Item(title="song", album_id=1, path=item_path) @@ -30,8 +31,8 @@ class ImportfeedsTestTest(BeetsTestCase): assert item_path in playlist.read() def test_playlist_in_subdir(self): - config["importfeeds"]["formats"] = "m3u" - config["importfeeds"]["m3u_name"] = os.path.join( + self.config["importfeeds"]["formats"] = "m3u" + self.config["importfeeds"]["m3u_name"] = os.path.join( "subdir", "imported.m3u" ) album = Album(album="album/name", id=1) @@ -41,14 +42,14 @@ class ImportfeedsTestTest(BeetsTestCase): self.lib.add(item) self.importfeeds.album_imported(self.lib, album) - playlist = self.feeds_dir / config["importfeeds"]["m3u_name"].get() + playlist = self.feeds_dir / self.config["importfeeds"]["m3u_name"].get() playlist_subdir = os.path.dirname(playlist) assert os.path.isdir(playlist_subdir) assert os.path.isfile(playlist) def test_playlist_per_session(self): - config["importfeeds"]["formats"] = "m3u_session" - config["importfeeds"]["m3u_name"] = "imports.m3u" + self.config["importfeeds"]["formats"] = "m3u_session" + self.config["importfeeds"]["m3u_name"] = "imports.m3u" album = Album(album="album/name", id=1) item_path = os.path.join("path", "to", "item") item = Item(title="song", album_id=1, path=item_path) From 0d11e19ecf0cb487d6dd12b7f9e871c162a54dbb Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:13:54 -0400 Subject: [PATCH 025/274] Spotify: gracefully handle 403 from deprecated audio-features API Add a dedicated AudioFeaturesUnavailableError and track audio-features availability with an audio_features_available flag. If the audio-features endpoint returns HTTP 403, raise the new error, log a warning once, and disable further audio-features requests for the session. The plugin now skips attempting audio-features lookups when disabled (avoiding repeated failed calls and potential rate-limit issues). Also update changelog to document the behavior. --- beetsplug/spotify.py | 40 ++++++++++++++++++++++++++++++++++------ docs/changelog.rst | 6 ++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 7cb9e330d..dadb0ea4d 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -77,6 +77,11 @@ class APIError(Exception): pass +class AudioFeaturesUnavailableError(Exception): + """Raised when the audio features API returns 403 (deprecated/unavailable).""" + pass + + class SpotifyPlugin( SearchApiMetadataSourcePlugin[ Union[SearchResponseAlbums, SearchResponseTracks] @@ -140,6 +145,7 @@ class SpotifyPlugin( self.config["client_id"].redact = True self.config["client_secret"].redact = True + self.audio_features_available = True # Track if audio features API is available self.setup() def setup(self): @@ -246,6 +252,16 @@ class SpotifyPlugin( f"API Error: {e.response.status_code}\n" f"URL: {url}\nparams: {params}" ) + elif e.response.status_code == 403: + # Check if this is the audio features endpoint + if self.audio_features_url in url: + raise AudioFeaturesUnavailableError( + "Audio features API returned 403 (deprecated or unavailable)" + ) + raise APIError( + f"API Error: {e.response.status_code}\n" + f"URL: {url}\nparams: {params}" + ) elif e.response.status_code == 429: seconds = e.response.headers.get( "Retry-After", DEFAULT_WAITING_TIME @@ -691,13 +707,18 @@ class SpotifyPlugin( item["isrc"] = isrc item["ean"] = ean item["upc"] = upc - audio_features = self.track_audio_features(spotify_track_id) - if audio_features is None: - self._log.info("No audio features found for: {}", item) + + if self.audio_features_available: + audio_features = self.track_audio_features(spotify_track_id) + if audio_features is None: + self._log.info("No audio features found for: {}", item) + else: + for feature, value in audio_features.items(): + if feature in self.spotify_audio_features: + item[self.spotify_audio_features[feature]] = value else: - for feature, value in audio_features.items(): - if feature in self.spotify_audio_features: - item[self.spotify_audio_features[feature]] = value + self._log.debug("Audio features API unavailable, skipping") + item["spotify_updated"] = time.time() item.store() if write: @@ -726,6 +747,13 @@ class SpotifyPlugin( return self._handle_response( "get", f"{self.audio_features_url}{track_id}" ) + except AudioFeaturesUnavailableError as e: + self._log.warning( + "Audio features API is unavailable (403 error). " + "Skipping audio features for remaining tracks." + ) + self.audio_features_available = False + return None except APIError as e: self._log.debug("Spotify API error: {}", e) return None diff --git a/docs/changelog.rst b/docs/changelog.rst index a78787273..e192259b1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,12 @@ New features: Bug fixes: +- :doc:`/plugins/spotify`: The plugin now gracefully handles audio-features API + deprecation (HTTP 403 errors). When a 403 error is encountered from the + audio-features endpoint, the plugin logs a warning once and skips audio + features for all remaining tracks in the session, avoiding unnecessary API + calls and rate limit exhaustion. + For packagers: Other changes: From e6c70f06c1223d5931e0a2e8ae004851d3e198ed Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:20:53 -0400 Subject: [PATCH 026/274] lint --- beetsplug/spotify.py | 60 ++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index dadb0ea4d..acd60d989 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -13,9 +13,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Adds Spotify release and track search support to the autotagger, along with -Spotify playlist construction. -""" +"""Adds Spotify release and track search support to the autotagger, along with Spotify playlist construction.""" from __future__ import annotations @@ -50,13 +48,14 @@ DEFAULT_WAITING_TIME = 5 class SearchResponseAlbums(IDResponse): """A response returned by the Spotify API. - We only use items and disregard the pagination information. - i.e. res["albums"]["items"][0]. + We only use items and disregard the pagination information. i.e. + res["albums"]["items"][0]. - There are more fields in the response, but we only type - the ones we currently use. + There are more fields in the response, but we only type the ones we + currently use. see https://developer.spotify.com/documentation/web-api/reference/search + """ album_type: str @@ -164,9 +163,7 @@ class SpotifyPlugin( return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) def _authenticate(self) -> None: - """Request an access token via the Client Credentials Flow: - https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow - """ + """Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow""" c_id: str = self.config["client_id"].as_str() c_secret: str = self.config["client_secret"].as_str() @@ -207,9 +204,9 @@ class SpotifyPlugin( :param method: HTTP method to use for the request. :param url: URL for the new :class:`Request` object. - :param params: (optional) list of tuples or bytes to send - in the query string for the :class:`Request`. - :type params: dict + :param dict params: (optional) list of tuples or bytes to send in the + query string for the :class:`Request`. + """ if retry_count > max_retries: @@ -292,13 +289,13 @@ class SpotifyPlugin( raise APIError("Request failed.") def album_for_id(self, album_id: str) -> AlbumInfo | None: - """Fetch an album by its Spotify ID or URL and return an - AlbumInfo object or None if the album is not found. + """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. - :param album_id: Spotify ID or URL for the album - :type album_id: str - :return: AlbumInfo object for album + :param str album_id: Spotify ID or URL for the album + + :returns: AlbumInfo object for album :rtype: beets.autotag.hooks.AlbumInfo or None + """ if not (spotify_id := self._extract_id(album_id)): return None @@ -372,7 +369,9 @@ class SpotifyPlugin( :param track_data: Simplified track object (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) - :return: TrackInfo object for track + + :returns: TrackInfo object for track + """ artist, artist_id = self.get_artist(track_data["artists"]) @@ -401,6 +400,7 @@ class SpotifyPlugin( """Fetch a track by its Spotify ID or URL. Returns a TrackInfo object or None if the track is not found. + """ if not (spotify_id := self._extract_id(track_id)): @@ -438,13 +438,13 @@ class SpotifyPlugin( filters: SearchFilter, query_string: str = "", ) -> Sequence[SearchResponseAlbums | SearchResponseTracks]: - """Query the Spotify Search API for the specified ``query_string``, - applying the provided ``filters``. + """Query the Spotify Search API for the specified ``query_string``, applying the provided ``filters``. - :param query_type: Item type to search across. Valid types are: - 'album', 'artist', 'playlist', and 'track'. + :param query_type: Item type to search across. Valid types are: 'album', + 'artist', 'playlist', and 'track'. :param filters: Field filters to apply. :param query_string: Additional query to include in the search. + """ query = self._construct_search_query( filters=filters, query_string=query_string @@ -539,13 +539,14 @@ class SpotifyPlugin( return True def _match_library_tracks(self, library: Library, keywords: str): - """Get a list of simplified track object dicts for library tracks - matching the specified ``keywords``. + """Get a list of simplified track object dicts for library tracks matching the specified ``keywords``. :param library: beets library object to query. :param keywords: Query to match library items against. - :return: List of simplified track object dicts for library items + + :returns: List of simplified track object dicts for library items matching the specified query. + """ results = [] failures = [] @@ -656,12 +657,11 @@ class SpotifyPlugin( return results def _output_match_results(self, results): - """Open a playlist or print Spotify URLs for the provided track - object dicts. + """Open a playlist or print Spotify URLs for the provided track object dicts. - :param results: List of simplified track object dicts + :param list[dict] results: List of simplified track object dicts (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) - :type results: list[dict] + """ if results: spotify_ids = [track_data["id"] for track_data in results] From 4302ca97eb4c0b907c4931d147e0a83d0932e651 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:29:07 -0400 Subject: [PATCH 027/274] resolve sorucery issue....make it thread safe --- beetsplug/spotify.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index acd60d989..c937ed893 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -24,6 +24,7 @@ import re import time import webbrowser from typing import TYPE_CHECKING, Any, Literal, Sequence, Union +import threading import confuse import requests @@ -145,6 +146,7 @@ class SpotifyPlugin( self.config["client_secret"].redact = True self.audio_features_available = True # Track if audio features API is available + self._audio_features_lock = threading.Lock() # Protects audio_features_available self.setup() def setup(self): @@ -251,7 +253,7 @@ class SpotifyPlugin( ) elif e.response.status_code == 403: # Check if this is the audio features endpoint - if self.audio_features_url in url: + if url.startswith(self.audio_features_url): raise AudioFeaturesUnavailableError( "Audio features API returned 403 (deprecated or unavailable)" ) @@ -742,17 +744,33 @@ class SpotifyPlugin( ) def track_audio_features(self, track_id: str): - """Fetch track audio features by its Spotify ID.""" + """Fetch track audio features by its Spotify ID. + + Thread-safe: avoids redundant API calls and logs the 403 warning only + once. + + """ + # Fast path: if we've already detected unavailability, skip the call. + with self._audio_features_lock: + if not self.audio_features_available: + return None + try: return self._handle_response( "get", f"{self.audio_features_url}{track_id}" ) - except AudioFeaturesUnavailableError as e: - self._log.warning( - "Audio features API is unavailable (403 error). " - "Skipping audio features for remaining tracks." - ) - self.audio_features_available = False + except AudioFeaturesUnavailableError: + # Disable globally in a thread-safe manner and warn once. + should_log = False + with self._audio_features_lock: + if self.audio_features_available: + self.audio_features_available = False + should_log = True + if should_log: + self._log.warning( + "Audio features API is unavailable (403 error). " + "Skipping audio features for remaining tracks." + ) return None except APIError as e: self._log.debug("Spotify API error: {}", e) From 8305821488e717a22c5893a225306b660574ea19 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:34:30 -0400 Subject: [PATCH 028/274] more lint --- beetsplug/spotify.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index c937ed893..8225b45be 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -79,6 +79,7 @@ class APIError(Exception): class AudioFeaturesUnavailableError(Exception): """Raised when the audio features API returns 403 (deprecated/unavailable).""" + pass @@ -145,8 +146,12 @@ class SpotifyPlugin( self.config["client_id"].redact = True self.config["client_secret"].redact = True - self.audio_features_available = True # Track if audio features API is available - self._audio_features_lock = threading.Lock() # Protects audio_features_available + self.audio_features_available = ( + True # Track if audio features API is available + ) + self._audio_features_lock = ( + threading.Lock() + ) # Protects audio_features_available self.setup() def setup(self): From 447511b4c866b27c58ead4c1d9b3727ab84c87d5 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:47:07 -0400 Subject: [PATCH 029/274] ruff formating --- beetsplug/spotify.py | 51 +++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 8225b45be..d86ddb9e4 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -13,7 +13,10 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Adds Spotify release and track search support to the autotagger, along with Spotify playlist construction.""" +"""Adds Spotify release and track search support to the autotagger. + +Also includes Spotify playlist construction. +""" from __future__ import annotations @@ -21,10 +24,10 @@ import base64 import collections import json import re +import threading import time import webbrowser from typing import TYPE_CHECKING, Any, Literal, Sequence, Union -import threading import confuse import requests @@ -78,7 +81,7 @@ class APIError(Exception): class AudioFeaturesUnavailableError(Exception): - """Raised when the audio features API returns 403 (deprecated/unavailable).""" + """Raised when audio features API returns 403 (deprecated).""" pass @@ -190,7 +193,8 @@ class SpotifyPlugin( response.raise_for_status() except requests.exceptions.HTTPError as e: raise ui.UserError( - f"Spotify authorization failed: {e}\n{response.text}" + f"Spotify authorization failed: {e}\n" + f"{response.text}" ) self.access_token = response.json()["access_token"] @@ -211,8 +215,8 @@ class SpotifyPlugin( :param method: HTTP method to use for the request. :param url: URL for the new :class:`Request` object. - :param dict params: (optional) list of tuples or bytes to send in the - query string for the :class:`Request`. + :param dict params: (optional) list of tuples or bytes to send + in the query string for the :class:`Request`. """ @@ -260,7 +264,8 @@ class SpotifyPlugin( # Check if this is the audio features endpoint if url.startswith(self.audio_features_url): raise AudioFeaturesUnavailableError( - "Audio features API returned 403 (deprecated or unavailable)" + "Audio features API returned 403 " + "(deprecated or unavailable)" ) raise APIError( f"API Error: {e.response.status_code}\n" @@ -288,7 +293,8 @@ class SpotifyPlugin( raise APIError("Bad Gateway.") elif e.response is not None: raise APIError( - f"{self.data_source} API error:\n{e.response.text}\n" + f"{self.data_source} API error:\n" + f"{e.response.text}\n" f"URL:\n{url}\nparams:\n{params}" ) else: @@ -296,7 +302,8 @@ class SpotifyPlugin( raise APIError("Request failed.") def album_for_id(self, album_id: str) -> AlbumInfo | None: - """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. + """Fetch an album by its Spotify ID or URL and return an + AlbumInfo object or None if the album is not found. :param str album_id: Spotify ID or URL for the album @@ -444,8 +451,11 @@ class SpotifyPlugin( query_type: Literal["album", "track"], filters: SearchFilter, query_string: str = "", - ) -> Sequence[SearchResponseAlbums | SearchResponseTracks]: - """Query the Spotify Search API for the specified ``query_string``, applying the provided ``filters``. + ) -> Sequence[ + SearchResponseAlbums | SearchResponseTracks + ]: + """Query the Spotify Search API for the specified ``query_string``, + applying the provided ``filters``. :param query_type: Item type to search across. Valid types are: 'album', 'artist', 'playlist', and 'track'. @@ -457,7 +467,9 @@ class SpotifyPlugin( filters=filters, query_string=query_string ) - self._log.debug("Searching {.data_source} for '{}'", self, query) + self._log.debug( + "Searching {.data_source} for '{}'", self, query + ) try: response = self._handle_response( "get", @@ -546,13 +558,15 @@ class SpotifyPlugin( return True def _match_library_tracks(self, library: Library, keywords: str): - """Get a list of simplified track object dicts for library tracks matching the specified ``keywords``. + """Get simplified track object dicts for library tracks. + + Matches tracks based on the specified ``keywords``. :param library: beets library object to query. :param keywords: Query to match library items against. - :returns: List of simplified track object dicts for library items - matching the specified query. + :returns: List of simplified track object dicts for library + items matching the specified query. """ results = [] @@ -664,10 +678,13 @@ class SpotifyPlugin( return results def _output_match_results(self, results): - """Open a playlist or print Spotify URLs for the provided track object dicts. + """Open a playlist or print Spotify URLs. + + Uses the provided track object dicts. :param list[dict] results: List of simplified track object dicts - (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) + (https://developer.spotify.com/documentation/web-api/ + reference/object-model/#track-object-simplified) """ if results: From 7724c661a4ecf951c4c0573a476d3d64ca57b25a Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:49:51 -0400 Subject: [PATCH 030/274] hopefully...this works --- beetsplug/spotify.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index d86ddb9e4..a8126b852 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -193,8 +193,7 @@ class SpotifyPlugin( response.raise_for_status() except requests.exceptions.HTTPError as e: raise ui.UserError( - f"Spotify authorization failed: {e}\n" - f"{response.text}" + f"Spotify authorization failed: {e}\n{response.text}" ) self.access_token = response.json()["access_token"] @@ -451,9 +450,7 @@ class SpotifyPlugin( query_type: Literal["album", "track"], filters: SearchFilter, query_string: str = "", - ) -> Sequence[ - SearchResponseAlbums | SearchResponseTracks - ]: + ) -> Sequence[SearchResponseAlbums | SearchResponseTracks]: """Query the Spotify Search API for the specified ``query_string``, applying the provided ``filters``. @@ -467,9 +464,7 @@ class SpotifyPlugin( filters=filters, query_string=query_string ) - self._log.debug( - "Searching {.data_source} for '{}'", self, query - ) + self._log.debug("Searching {.data_source} for '{}'", self, query) try: response = self._handle_response( "get", From 017930dd9918446ce7fd755e74bcbfa3e66dce60 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 20 Jul 2025 10:44:40 +0200 Subject: [PATCH 031/274] Use pseudo-release's track titles for its recordings --- beetsplug/musicbrainz.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8e259e94b..75cc063b8 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -871,11 +871,34 @@ class MusicBrainzPlugin(MetadataSourcePlugin): # should be None unless we're dealing with a pseudo release if actual_res is not None: - actual_release = self.album_info(actual_res["release"]) + actual_release = self._get_actual_release(res, actual_res) return _merge_pseudo_and_actual_album(release, actual_release) else: return release + def _get_actual_release( + self, + res: JSONDict, + actual_res: JSONDict, + ) -> beets.autotag.hooks.AlbumInfo: + medium_list = res["release"]["medium-list"] + for medium in medium_list: + for track in medium.get("track-list", []): + if "recording" not in track: + continue + + recording_overrides = { + k: v + for k, v in track.items() + if (k != "id" and k != "recording") + } + track["recording"].update(recording_overrides) + + actual_res = actual_res["release"] + actual_res["medium-list"] = medium_list + actual_release = self.album_info(actual_res) + return actual_release + def track_for_id( self, track_id: str ) -> beets.autotag.hooks.TrackInfo | None: From ac0b221802852c43e75e84535d385e77f311e398 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 20 Jul 2025 20:09:27 +0200 Subject: [PATCH 032/274] Revert "Use pseudo-release's track titles for its recordings" This reverts commit f3ddda3a422ffbe06722215abeec63436f1a1a43. --- beetsplug/musicbrainz.py | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 75cc063b8..8e259e94b 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -871,34 +871,11 @@ class MusicBrainzPlugin(MetadataSourcePlugin): # should be None unless we're dealing with a pseudo release if actual_res is not None: - actual_release = self._get_actual_release(res, actual_res) + actual_release = self.album_info(actual_res["release"]) return _merge_pseudo_and_actual_album(release, actual_release) else: return release - def _get_actual_release( - self, - res: JSONDict, - actual_res: JSONDict, - ) -> beets.autotag.hooks.AlbumInfo: - medium_list = res["release"]["medium-list"] - for medium in medium_list: - for track in medium.get("track-list", []): - if "recording" not in track: - continue - - recording_overrides = { - k: v - for k, v in track.items() - if (k != "id" and k != "recording") - } - track["recording"].update(recording_overrides) - - actual_res = actual_res["release"] - actual_res["medium-list"] = medium_list - actual_release = self.album_info(actual_res) - return actual_release - def track_for_id( self, track_id: str ) -> beets.autotag.hooks.TrackInfo | None: From f3934dc58bfc0ba8bdcf28e1443f8b51d8bc374b Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 20 Jul 2025 10:44:58 +0200 Subject: [PATCH 033/274] Add mbpseudo plugin --- .github/CODEOWNERS | 3 +- beetsplug/mbpseudo.py | 424 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 beetsplug/mbpseudo.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bb888d520..d014b925b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,4 +2,5 @@ * @beetbox/maintainers # Specific ownerships: -/beets/metadata_plugins.py @semohr \ No newline at end of file +/beets/metadata_plugins.py @semohr +/beetsplug/mbpseudo.py @asardaes \ No newline at end of file diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py new file mode 100644 index 000000000..76e9ac0cd --- /dev/null +++ b/beetsplug/mbpseudo.py @@ -0,0 +1,424 @@ +# This file is part of beets. +# Copyright 2025, Alexis Sarda-Espinosa. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Adds pseudo-releases from MusicBrainz as candidates during import.""" + +import itertools +from typing import Iterable, Sequence + +from typing_extensions import override + +import beetsplug.musicbrainz as mbplugin # avoid implicit loading of main plugin +from beets.autotag import AlbumInfo, Distance +from beets.autotag.distance import distance +from beets.autotag.hooks import V, TrackInfo +from beets.autotag.match import assign_items +from beets.library import Item +from beets.metadata_plugins import MetadataSourcePlugin +from beets.plugins import find_plugins +from beetsplug._typing import JSONDict + +_STATUS_PSEUDO = "Pseudo-Release" + + +class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.config.add({"scripts": [], "include_official_releases": False}) + + self._scripts = self.config["scripts"].as_str_seq() + self._mb = mbplugin.MusicBrainzPlugin() + + self._pseudo_release_ids: dict[str, list[str]] = {} + self._intercepted_candidates: dict[str, AlbumInfo] = {} + self._mb_plugin_loaded_before = True + + self.register_listener("pluginload", self._on_plugins_loaded) + self.register_listener("mb_album_extract", self._intercept_mb_releases) + self.register_listener( + "albuminfo_received", self._intercept_mb_candidates + ) + + self._log.debug("Desired scripts: {0}", self._scripts) + + def _on_plugins_loaded(self): + mb_index = None + self_index = -1 + for i, plugin in enumerate(find_plugins()): + if isinstance(plugin, mbplugin.MusicBrainzPlugin): + mb_index = i + elif isinstance(plugin, MusicBrainzPseudoReleasePlugin): + self_index = i + + if mb_index and self_index < mb_index: + self._mb_plugin_loaded_before = False + self._log.warning( + "The mbpseudo plugin was loaded before the musicbrainz plugin" + ", this will result in redundant network calls" + ) + + def _intercept_mb_releases(self, data: JSONDict): + album_id = data["id"] if "id" in data else None + if ( + self._has_desired_script(data) + or not isinstance(album_id, str) + or album_id in self._pseudo_release_ids + ): + return None + + pseudo_release_ids = ( + self._wanted_pseudo_release_id(rel) + for rel in data.get("release-relation-list", []) + ) + pseudo_release_ids = [ + rel for rel in pseudo_release_ids if rel is not None + ] + + if len(pseudo_release_ids) > 0: + self._log.debug("Intercepted release with album id {0}", album_id) + self._pseudo_release_ids[album_id] = pseudo_release_ids + + return None + + def _has_desired_script(self, release: JSONDict) -> bool: + if len(self._scripts) == 0: + return False + elif script := release.get("text-representation", {}).get("script"): + return script in self._scripts + else: + return False + + def _wanted_pseudo_release_id( + self, + relation: JSONDict, + ) -> str | None: + if ( + len(self._scripts) == 0 + or relation.get("type", "") != "transl-tracklisting" + or relation.get("direction", "") != "forward" + or "release" not in relation + ): + return None + + release = relation["release"] + if "id" in release and self._has_desired_script(release): + return release["id"] + else: + return None + + def _intercept_mb_candidates(self, info: AlbumInfo): + if ( + not isinstance(info, PseudoAlbumInfo) + and info.album_id in self._pseudo_release_ids + and info.album_id not in self._intercepted_candidates + ): + self._log.debug( + "Intercepted candidate with album id {0.album_id}", info + ) + self._intercepted_candidates[info.album_id] = info.copy() + + elif info.get("albumstatus", "") == _STATUS_PSEUDO: + self._purge_intercepted_pseudo_releases(info) + + def candidates( + self, + items: Sequence[Item], + artist: str, + album: str, + va_likely: bool, + ) -> Iterable[AlbumInfo]: + """Even though a candidate might have extra and/or missing tracks, the set of paths from the items that + were actually matched (which are stored in the corresponding ``mapping``) must be a subset of the set of + paths from the input items. This helps us figure out which intercepted candidate might be relevant for + the items we get in this call even if other candidates have been concurrently intercepted as well. + """ + + if len(self._scripts) == 0: + return [] + + try: + item_paths = {item.path for item in items} + official_release_id = next( + key + for key, info in self._intercepted_candidates.items() + if "mapping" in info + and all( + mapping_key.path in item_paths + for mapping_key in info.mapping.keys() + ) + ) + pseudo_release_ids = self._pseudo_release_ids[official_release_id] + self._log.debug( + "Processing pseudo-releases for {0}: {1}", + official_release_id, + pseudo_release_ids, + ) + except StopIteration: + official_release_id = None + pseudo_release_ids = [] + + if official_release_id is not None: + pseudo_releases = self._get_pseudo_releases( + items, official_release_id, pseudo_release_ids + ) + del self._pseudo_release_ids[official_release_id] + del self._intercepted_candidates[official_release_id] + return pseudo_releases + + if ( + any( + isinstance(plugin, mbplugin.MusicBrainzPlugin) + for plugin in find_plugins() + ) + and self._mb_plugin_loaded_before + ): + self._log.debug( + "No releases found after main MusicBrainz plugin executed" + ) + return [] + + # musicbrainz plugin isn't enabled + self._log.debug("Searching for official releases") + + try: + existing_album_id = next( + item.mb_albumid for item in items if item.mb_albumid + ) + existing_album_info = self._mb.album_for_id(existing_album_id) + if not isinstance(existing_album_info, AlbumInfo): + official_candidates = list( + self._mb.candidates(items, artist, album, va_likely) + ) + else: + official_candidates = [existing_album_info] + except StopIteration: + official_candidates = list( + self._mb.candidates(items, artist, album, va_likely) + ) + + recursion = self._mb_plugin_simulation_matched( + items, official_candidates + ) + + if recursion and not self.config.get().get("include_official_releases"): + official_candidates = [] + + self._log.debug( + "Emitting {0} official match(es)", len(official_candidates) + ) + if recursion: + self._log.debug("Matches found after search") + return itertools.chain( + self.candidates(items, artist, album, va_likely), + iter(official_candidates), + ) + else: + return iter(official_candidates) + + def _get_pseudo_releases( + self, + items: Sequence[Item], + official_release_id: str, + pseudo_release_ids: list[str], + ) -> list[AlbumInfo]: + pseudo_releases: list[AlbumInfo] = [] + for pr_id in pseudo_release_ids: + if match := self._mb.album_for_id(pr_id): + pseudo_album_info = PseudoAlbumInfo( + pseudo_release=match, + official_release=self._intercepted_candidates[ + official_release_id + ], + data_source=self.data_source, + ) + self._log.debug( + "Using {0} release for distance calculations for album {1}", + pseudo_album_info.determine_best_ref(items), + pr_id, + ) + pseudo_releases.append(pseudo_album_info) + return pseudo_releases + + def _mb_plugin_simulation_matched( + self, + items: Sequence[Item], + official_candidates: list[AlbumInfo], + ) -> bool: + """Simulate how we would have been called if the MusicBrainz plugin had actually executed. + + At this point we already called ``self._mb.candidates()``, + which emits the ``mb_album_extract`` events, + so now we simulate: + + 1. Intercepting the ``AlbumInfo`` candidate that would have come in the ``albuminfo_received`` event. + 2. Intercepting the distance calculation of the aforementioned candidate to store its mapping. + + If the official candidate is already a pseudo-release, we clean up internal state. + This is needed because the MusicBrainz plugin emits official releases even if + it receives a pseudo-release as input, so the chain would actually be: + pseudo-release input -> official release with pseudo emitted -> intercepted -> pseudo-release resolved (again) + + To avoid resolving again in the last step, we remove the pseudo-release's id. + """ + + matched = False + for official_candidate in official_candidates: + if official_candidate.album_id in self._pseudo_release_ids: + self._intercept_mb_candidates(official_candidate) + + if official_candidate.album_id in self._intercepted_candidates: + intercepted = self._intercepted_candidates[ + official_candidate.album_id + ] + intercepted.mapping, _, _ = assign_items( + items, intercepted.tracks + ) + matched = True + + if official_candidate.get("albumstatus", "") == _STATUS_PSEUDO: + self._purge_intercepted_pseudo_releases(official_candidate) + + return matched + + def _purge_intercepted_pseudo_releases(self, official_candidate: AlbumInfo): + rm_keys = [ + album_id + for album_id, pseudo_album_ids in self._pseudo_release_ids.items() + if official_candidate.album_id in pseudo_album_ids + ] + if rm_keys: + self._log.debug( + "No need to resolve {0}, removing", + rm_keys, + ) + for k in rm_keys: + del self._pseudo_release_ids[k] + + @override + def album_distance( + self, + items: Sequence[Item], + album_info: AlbumInfo, + mapping: dict[Item, TrackInfo], + ) -> Distance: + """We use this function more like a listener for the extra details we are injecting. + + For instances of ``PseudoAlbumInfo`` whose corresponding ``mapping`` is _not_ an + instance of ``ImmutableMapping``, we know at this point that all penalties from the + normal auto-tagging flow have been applied, so we can switch to the metadata from + the pseudo-release for the final proposal. + + Other instances of ``AlbumInfo`` must come from other plugins, so we just check if + we intercepted them as candidates with pseudo-releases and store their ``mapping``. + This is needed because the real listeners we use never expose information from the + input ``Item``s, so we intercept that here. + + The paths from the items are used to figure out which pseudo-releases should be + provided for them, which is specially important for concurrent stage execution + where we might have intercepted releases from different import tasks when we run. + """ + + if isinstance(album_info, PseudoAlbumInfo): + if not isinstance(mapping, ImmutableMapping): + self._log.debug( + "Switching {0.album_id} to pseudo-release source for final proposal", + album_info, + ) + album_info.use_pseudo_as_ref() + new_mappings, _, _ = assign_items(items, album_info.tracks) + mapping.update(new_mappings) + + elif album_info.album_id in self._intercepted_candidates: + self._log.debug("Storing mapping for {0.album_id}", album_info) + self._intercepted_candidates[album_info.album_id].mapping = mapping + + return super().album_distance(items, album_info, mapping) + + def album_for_id(self, album_id: str) -> AlbumInfo | None: + pass + + def track_for_id(self, track_id: str) -> TrackInfo | None: + pass + + def item_candidates( + self, + item: Item, + artist: str, + title: str, + ) -> Iterable[TrackInfo]: + return [] + + +class PseudoAlbumInfo(AlbumInfo): + """This is a not-so-ugly hack. + + We want the pseudo-release to result in a distance that is lower or equal to that of the official release, + otherwise it won't qualify as a good candidate. However, if the input is in a script that's different from + the pseudo-release (and we want to translate/transliterate it in the library), it will receive unwanted penalties. + + This class is essentially a view of the ``AlbumInfo`` of both official and pseudo-releases, + where it's possible to change the details that are exposed to other parts of the auto-tagger, + enabling a "fair" distance calculation based on the current input's script but still preferring + the translation/transliteration in the final proposal. + """ + + def __init__( + self, + pseudo_release: AlbumInfo, + official_release: AlbumInfo, + **kwargs, + ): + super().__init__(pseudo_release.tracks, **kwargs) + self.__dict__["_pseudo_source"] = True + self.__dict__["_official_release"] = official_release + for k, v in pseudo_release.items(): + if k not in kwargs: + self[k] = v + + def determine_best_ref(self, items: Sequence[Item]) -> str: + self.use_pseudo_as_ref() + pseudo_dist = self._compute_distance(items) + + self.use_official_as_ref() + official_dist = self._compute_distance(items) + + if official_dist < pseudo_dist: + self.use_official_as_ref() + return "official" + else: + self.use_pseudo_as_ref() + return "pseudo" + + def _compute_distance(self, items: Sequence[Item]) -> Distance: + mapping, _, _ = assign_items(items, self.tracks) + return distance(items, self, ImmutableMapping(mapping)) + + def use_pseudo_as_ref(self): + self.__dict__["_pseudo_source"] = True + + def use_official_as_ref(self): + self.__dict__["_pseudo_source"] = False + + def __getattr__(self, attr: str) -> V: + # ensure we don't duplicate an official release's id by always returning pseudo's + if self.__dict__["_pseudo_source"] or attr == "album_id": + return super().__getattr__(attr) + else: + return self.__dict__["_official_release"].__getattr__(attr) + + +class ImmutableMapping(dict[Item, TrackInfo]): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) From 0d9064902974ad3a7340c2afb8caccb1a254c9b4 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 20 Jul 2025 23:43:39 +0200 Subject: [PATCH 034/274] Fix linting issues --- beetsplug/mbpseudo.py | 91 ++++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 76e9ac0cd..19c5317a1 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -15,14 +15,14 @@ """Adds pseudo-releases from MusicBrainz as candidates during import.""" import itertools -from typing import Iterable, Sequence +from typing import Any, Iterable, Sequence from typing_extensions import override import beetsplug.musicbrainz as mbplugin # avoid implicit loading of main plugin -from beets.autotag import AlbumInfo, Distance -from beets.autotag.distance import distance -from beets.autotag.hooks import V, TrackInfo +from beets.autotag import AlbumInfo +from beets.autotag.distance import Distance, distance +from beets.autotag.hooks import TrackInfo from beets.autotag.match import assign_items from beets.library import Item from beets.metadata_plugins import MetadataSourcePlugin @@ -78,12 +78,10 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): ): return None - pseudo_release_ids = ( - self._wanted_pseudo_release_id(rel) - for rel in data.get("release-relation-list", []) - ) pseudo_release_ids = [ - rel for rel in pseudo_release_ids if rel is not None + pr_id + for rel in data.get("release-relation-list", []) + if (pr_id := self._wanted_pseudo_release_id(rel)) is not None ] if len(pseudo_release_ids) > 0: @@ -139,10 +137,12 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): album: str, va_likely: bool, ) -> Iterable[AlbumInfo]: - """Even though a candidate might have extra and/or missing tracks, the set of paths from the items that - were actually matched (which are stored in the corresponding ``mapping``) must be a subset of the set of - paths from the input items. This helps us figure out which intercepted candidate might be relevant for - the items we get in this call even if other candidates have been concurrently intercepted as well. + """Even though a candidate might have extra and/or missing tracks, the set of + paths from the items that were actually matched (which are stored in the + corresponding ``mapping``) must be a subset of the set of paths from the input + items. This helps us figure out which intercepted candidate might be relevant + for the items we get in this call even if other candidates have been + concurrently intercepted as well. """ if len(self._scripts) == 0: @@ -256,19 +256,26 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): items: Sequence[Item], official_candidates: list[AlbumInfo], ) -> bool: - """Simulate how we would have been called if the MusicBrainz plugin had actually executed. + """Simulate how we would have been called if the MusicBrainz plugin had actually + executed. At this point we already called ``self._mb.candidates()``, which emits the ``mb_album_extract`` events, so now we simulate: - 1. Intercepting the ``AlbumInfo`` candidate that would have come in the ``albuminfo_received`` event. - 2. Intercepting the distance calculation of the aforementioned candidate to store its mapping. + 1. Intercepting the ``AlbumInfo`` candidate that would have come in the + ``albuminfo_received`` event. + 2. Intercepting the distance calculation of the aforementioned candidate to + store its mapping. - If the official candidate is already a pseudo-release, we clean up internal state. - This is needed because the MusicBrainz plugin emits official releases even if - it receives a pseudo-release as input, so the chain would actually be: - pseudo-release input -> official release with pseudo emitted -> intercepted -> pseudo-release resolved (again) + If the official candidate is already a pseudo-release, we clean up internal + state. This is needed because the MusicBrainz plugin emits official releases + even if it receives a pseudo-release as input, so the chain would actually be: + + pseudo-release input -> + official release with pseudo emitted -> + intercepted -> + pseudo-release resolved (again) To avoid resolving again in the last step, we remove the pseudo-release's id. """ @@ -313,28 +320,30 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): album_info: AlbumInfo, mapping: dict[Item, TrackInfo], ) -> Distance: - """We use this function more like a listener for the extra details we are injecting. + """We use this function more like a listener for the extra details we are + injecting. For instances of ``PseudoAlbumInfo`` whose corresponding ``mapping`` is _not_ an - instance of ``ImmutableMapping``, we know at this point that all penalties from the - normal auto-tagging flow have been applied, so we can switch to the metadata from - the pseudo-release for the final proposal. + instance of ``ImmutableMapping``, we know at this point that all penalties from + the normal auto-tagging flow have been applied, so we can switch to the metadata + from the pseudo-release for the final proposal. - Other instances of ``AlbumInfo`` must come from other plugins, so we just check if - we intercepted them as candidates with pseudo-releases and store their ``mapping``. - This is needed because the real listeners we use never expose information from the - input ``Item``s, so we intercept that here. + Other instances of ``AlbumInfo`` must come from other plugins, so we just check + if we intercepted them as candidates with pseudo-releases and store their + ``mapping``. This is needed because the real listeners we use never expose + information from the input ``Item``s, so we intercept that here. The paths from the items are used to figure out which pseudo-releases should be provided for them, which is specially important for concurrent stage execution - where we might have intercepted releases from different import tasks when we run. + where we might have already intercepted releases from different import tasks + when we run. """ if isinstance(album_info, PseudoAlbumInfo): if not isinstance(mapping, ImmutableMapping): self._log.debug( - "Switching {0.album_id} to pseudo-release source for final proposal", - album_info, + "Switching {0} to pseudo-release source for final proposal", + album_info.album_id, ) album_info.use_pseudo_as_ref() new_mappings, _, _ = assign_items(items, album_info.tracks) @@ -364,14 +373,16 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): class PseudoAlbumInfo(AlbumInfo): """This is a not-so-ugly hack. - We want the pseudo-release to result in a distance that is lower or equal to that of the official release, - otherwise it won't qualify as a good candidate. However, if the input is in a script that's different from - the pseudo-release (and we want to translate/transliterate it in the library), it will receive unwanted penalties. + We want the pseudo-release to result in a distance that is lower or equal to that of + the official release, otherwise it won't qualify as a good candidate. However, if + the input is in a script that's different from the pseudo-release (and we want to + translate/transliterate it in the library), it will receive unwanted penalties. - This class is essentially a view of the ``AlbumInfo`` of both official and pseudo-releases, - where it's possible to change the details that are exposed to other parts of the auto-tagger, - enabling a "fair" distance calculation based on the current input's script but still preferring - the translation/transliteration in the final proposal. + This class is essentially a view of the ``AlbumInfo`` of both official and + pseudo-releases, where it's possible to change the details that are exposed to other + parts of the auto-tagger, enabling a "fair" distance calculation based on the + current input's script but still preferring the translation/transliteration in the + final proposal. """ def __init__( @@ -411,8 +422,8 @@ class PseudoAlbumInfo(AlbumInfo): def use_official_as_ref(self): self.__dict__["_pseudo_source"] = False - def __getattr__(self, attr: str) -> V: - # ensure we don't duplicate an official release's id by always returning pseudo's + def __getattr__(self, attr: str) -> Any: + # ensure we don't duplicate an official release's id, always return pseudo's if self.__dict__["_pseudo_source"] or attr == "album_id": return super().__getattr__(attr) else: From 79f691832c68b532398fbcf03c4dc45c34e31309 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sat, 9 Aug 2025 15:36:28 +0200 Subject: [PATCH 035/274] Use Optional --- beetsplug/mbpseudo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 19c5317a1..c49e5e5b6 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -15,7 +15,7 @@ """Adds pseudo-releases from MusicBrainz as candidates during import.""" import itertools -from typing import Any, Iterable, Sequence +from typing import Any, Iterable, Optional, Sequence from typing_extensions import override @@ -101,7 +101,7 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): def _wanted_pseudo_release_id( self, relation: JSONDict, - ) -> str | None: + ) -> Optional[str]: if ( len(self._scripts) == 0 or relation.get("type", "") != "transl-tracklisting" @@ -355,10 +355,10 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): return super().album_distance(items, album_info, mapping) - def album_for_id(self, album_id: str) -> AlbumInfo | None: + def album_for_id(self, album_id: str) -> Optional[AlbumInfo]: pass - def track_for_id(self, track_id: str) -> TrackInfo | None: + def track_for_id(self, track_id: str) -> Optional[TrackInfo]: pass def item_candidates( From ab5705f444a4be8f8bf0d4910dd52c7d6322f173 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 5 Oct 2025 22:00:46 -0600 Subject: [PATCH 036/274] Reimplement mbpseudo plugin inheriting from MusicBrainzPlugin --- beetsplug/mbpseudo.py | 371 +++++++++++---------------------------- beetsplug/musicbrainz.py | 2 +- 2 files changed, 99 insertions(+), 274 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index c49e5e5b6..d544a5624 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -14,82 +14,108 @@ """Adds pseudo-releases from MusicBrainz as candidates during import.""" -import itertools +from copy import deepcopy from typing import Any, Iterable, Optional, Sequence +import musicbrainzngs from typing_extensions import override -import beetsplug.musicbrainz as mbplugin # avoid implicit loading of main plugin -from beets.autotag import AlbumInfo from beets.autotag.distance import Distance, distance -from beets.autotag.hooks import TrackInfo +from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.autotag.match import assign_items from beets.library import Item -from beets.metadata_plugins import MetadataSourcePlugin from beets.plugins import find_plugins +from beets.util.id_extractors import extract_release_id from beetsplug._typing import JSONDict +from beetsplug.musicbrainz import ( + RELEASE_INCLUDES, + MusicBrainzPlugin, + _merge_pseudo_and_actual_album, +) _STATUS_PSEUDO = "Pseudo-Release" -class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.config.add({"scripts": [], "include_official_releases": False}) +class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): + def __init__(self) -> None: + super().__init__() + self.config.add({"scripts": []}) self._scripts = self.config["scripts"].as_str_seq() - self._mb = mbplugin.MusicBrainzPlugin() - - self._pseudo_release_ids: dict[str, list[str]] = {} - self._intercepted_candidates: dict[str, AlbumInfo] = {} - self._mb_plugin_loaded_before = True - - self.register_listener("pluginload", self._on_plugins_loaded) - self.register_listener("mb_album_extract", self._intercept_mb_releases) - self.register_listener( - "albuminfo_received", self._intercept_mb_candidates - ) - self._log.debug("Desired scripts: {0}", self._scripts) + self.register_listener("pluginload", self._on_plugins_loaded) + + # noinspection PyMethodMayBeStatic def _on_plugins_loaded(self): - mb_index = None - self_index = -1 - for i, plugin in enumerate(find_plugins()): - if isinstance(plugin, mbplugin.MusicBrainzPlugin): - mb_index = i - elif isinstance(plugin, MusicBrainzPseudoReleasePlugin): - self_index = i + for plugin in find_plugins(): + if isinstance(plugin, MusicBrainzPlugin) and not isinstance( + plugin, MusicBrainzPseudoReleasePlugin + ): + raise RuntimeError( + "The musicbrainz plugin should not be enabled together with" + " the mbpseudo plugin" + ) - if mb_index and self_index < mb_index: - self._mb_plugin_loaded_before = False - self._log.warning( - "The mbpseudo plugin was loaded before the musicbrainz plugin" - ", this will result in redundant network calls" + @override + def candidates( + self, + items: Sequence[Item], + artist: str, + album: str, + va_likely: bool, + ) -> Iterable[AlbumInfo]: + if len(self._scripts) == 0: + yield from super().candidates(items, artist, album, va_likely) + else: + for album_info in super().candidates( + items, artist, album, va_likely + ): + if isinstance(album_info, PseudoAlbumInfo): + yield album_info.get_official_release() + self._log.debug( + "Using {0} release for distance calculations for album {1}", + album_info.determine_best_ref(items), + album_info.album_id, + ) + + yield album_info + + @override + def album_info(self, release: JSONDict) -> AlbumInfo: + official_release = super().album_info(release) + official_release.data_source = "MusicBrainz" + + if release.get("status") == _STATUS_PSEUDO: + return official_release + elif pseudo_release_ids := self._intercept_mb_release(release): + album_id = self._extract_id(pseudo_release_ids[0]) + raw_pseudo_release = musicbrainzngs.get_release_by_id( + album_id, RELEASE_INCLUDES ) + pseudo_release = super().album_info(raw_pseudo_release["release"]) + return PseudoAlbumInfo( + pseudo_release=_merge_pseudo_and_actual_album( + pseudo_release, official_release + ), + official_release=official_release, + data_source=self.data_source, + ) + else: + return official_release - def _intercept_mb_releases(self, data: JSONDict): + def _intercept_mb_release(self, data: JSONDict) -> list[str]: album_id = data["id"] if "id" in data else None - if ( - self._has_desired_script(data) - or not isinstance(album_id, str) - or album_id in self._pseudo_release_ids - ): - return None + if self._has_desired_script(data) or not isinstance(album_id, str): + return [] - pseudo_release_ids = [ + return [ pr_id for rel in data.get("release-relation-list", []) - if (pr_id := self._wanted_pseudo_release_id(rel)) is not None + if (pr_id := self._wanted_pseudo_release_id(album_id, rel)) + is not None ] - if len(pseudo_release_ids) > 0: - self._log.debug("Intercepted release with album id {0}", album_id) - self._pseudo_release_ids[album_id] = pseudo_release_ids - - return None - def _has_desired_script(self, release: JSONDict) -> bool: if len(self._scripts) == 0: return False @@ -100,6 +126,7 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): def _wanted_pseudo_release_id( self, + album_id: str, relation: JSONDict, ) -> Optional[str]: if ( @@ -112,207 +139,15 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): release = relation["release"] if "id" in release and self._has_desired_script(release): + self._log.debug( + "Adding pseudo-release {0} for main release {1}", + release["id"], + album_id, + ) return release["id"] else: return None - def _intercept_mb_candidates(self, info: AlbumInfo): - if ( - not isinstance(info, PseudoAlbumInfo) - and info.album_id in self._pseudo_release_ids - and info.album_id not in self._intercepted_candidates - ): - self._log.debug( - "Intercepted candidate with album id {0.album_id}", info - ) - self._intercepted_candidates[info.album_id] = info.copy() - - elif info.get("albumstatus", "") == _STATUS_PSEUDO: - self._purge_intercepted_pseudo_releases(info) - - def candidates( - self, - items: Sequence[Item], - artist: str, - album: str, - va_likely: bool, - ) -> Iterable[AlbumInfo]: - """Even though a candidate might have extra and/or missing tracks, the set of - paths from the items that were actually matched (which are stored in the - corresponding ``mapping``) must be a subset of the set of paths from the input - items. This helps us figure out which intercepted candidate might be relevant - for the items we get in this call even if other candidates have been - concurrently intercepted as well. - """ - - if len(self._scripts) == 0: - return [] - - try: - item_paths = {item.path for item in items} - official_release_id = next( - key - for key, info in self._intercepted_candidates.items() - if "mapping" in info - and all( - mapping_key.path in item_paths - for mapping_key in info.mapping.keys() - ) - ) - pseudo_release_ids = self._pseudo_release_ids[official_release_id] - self._log.debug( - "Processing pseudo-releases for {0}: {1}", - official_release_id, - pseudo_release_ids, - ) - except StopIteration: - official_release_id = None - pseudo_release_ids = [] - - if official_release_id is not None: - pseudo_releases = self._get_pseudo_releases( - items, official_release_id, pseudo_release_ids - ) - del self._pseudo_release_ids[official_release_id] - del self._intercepted_candidates[official_release_id] - return pseudo_releases - - if ( - any( - isinstance(plugin, mbplugin.MusicBrainzPlugin) - for plugin in find_plugins() - ) - and self._mb_plugin_loaded_before - ): - self._log.debug( - "No releases found after main MusicBrainz plugin executed" - ) - return [] - - # musicbrainz plugin isn't enabled - self._log.debug("Searching for official releases") - - try: - existing_album_id = next( - item.mb_albumid for item in items if item.mb_albumid - ) - existing_album_info = self._mb.album_for_id(existing_album_id) - if not isinstance(existing_album_info, AlbumInfo): - official_candidates = list( - self._mb.candidates(items, artist, album, va_likely) - ) - else: - official_candidates = [existing_album_info] - except StopIteration: - official_candidates = list( - self._mb.candidates(items, artist, album, va_likely) - ) - - recursion = self._mb_plugin_simulation_matched( - items, official_candidates - ) - - if recursion and not self.config.get().get("include_official_releases"): - official_candidates = [] - - self._log.debug( - "Emitting {0} official match(es)", len(official_candidates) - ) - if recursion: - self._log.debug("Matches found after search") - return itertools.chain( - self.candidates(items, artist, album, va_likely), - iter(official_candidates), - ) - else: - return iter(official_candidates) - - def _get_pseudo_releases( - self, - items: Sequence[Item], - official_release_id: str, - pseudo_release_ids: list[str], - ) -> list[AlbumInfo]: - pseudo_releases: list[AlbumInfo] = [] - for pr_id in pseudo_release_ids: - if match := self._mb.album_for_id(pr_id): - pseudo_album_info = PseudoAlbumInfo( - pseudo_release=match, - official_release=self._intercepted_candidates[ - official_release_id - ], - data_source=self.data_source, - ) - self._log.debug( - "Using {0} release for distance calculations for album {1}", - pseudo_album_info.determine_best_ref(items), - pr_id, - ) - pseudo_releases.append(pseudo_album_info) - return pseudo_releases - - def _mb_plugin_simulation_matched( - self, - items: Sequence[Item], - official_candidates: list[AlbumInfo], - ) -> bool: - """Simulate how we would have been called if the MusicBrainz plugin had actually - executed. - - At this point we already called ``self._mb.candidates()``, - which emits the ``mb_album_extract`` events, - so now we simulate: - - 1. Intercepting the ``AlbumInfo`` candidate that would have come in the - ``albuminfo_received`` event. - 2. Intercepting the distance calculation of the aforementioned candidate to - store its mapping. - - If the official candidate is already a pseudo-release, we clean up internal - state. This is needed because the MusicBrainz plugin emits official releases - even if it receives a pseudo-release as input, so the chain would actually be: - - pseudo-release input -> - official release with pseudo emitted -> - intercepted -> - pseudo-release resolved (again) - - To avoid resolving again in the last step, we remove the pseudo-release's id. - """ - - matched = False - for official_candidate in official_candidates: - if official_candidate.album_id in self._pseudo_release_ids: - self._intercept_mb_candidates(official_candidate) - - if official_candidate.album_id in self._intercepted_candidates: - intercepted = self._intercepted_candidates[ - official_candidate.album_id - ] - intercepted.mapping, _, _ = assign_items( - items, intercepted.tracks - ) - matched = True - - if official_candidate.get("albumstatus", "") == _STATUS_PSEUDO: - self._purge_intercepted_pseudo_releases(official_candidate) - - return matched - - def _purge_intercepted_pseudo_releases(self, official_candidate: AlbumInfo): - rm_keys = [ - album_id - for album_id, pseudo_album_ids in self._pseudo_release_ids.items() - if official_candidate.album_id in pseudo_album_ids - ] - if rm_keys: - self._log.debug( - "No need to resolve {0}, removing", - rm_keys, - ) - for k in rm_keys: - del self._pseudo_release_ids[k] - @override def album_distance( self, @@ -327,16 +162,6 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): instance of ``ImmutableMapping``, we know at this point that all penalties from the normal auto-tagging flow have been applied, so we can switch to the metadata from the pseudo-release for the final proposal. - - Other instances of ``AlbumInfo`` must come from other plugins, so we just check - if we intercepted them as candidates with pseudo-releases and store their - ``mapping``. This is needed because the real listeners we use never expose - information from the input ``Item``s, so we intercept that here. - - The paths from the items are used to figure out which pseudo-releases should be - provided for them, which is specially important for concurrent stage execution - where we might have already intercepted releases from different import tasks - when we run. """ if isinstance(album_info, PseudoAlbumInfo): @@ -349,25 +174,11 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): new_mappings, _, _ = assign_items(items, album_info.tracks) mapping.update(new_mappings) - elif album_info.album_id in self._intercepted_candidates: - self._log.debug("Storing mapping for {0.album_id}", album_info) - self._intercepted_candidates[album_info.album_id].mapping = mapping - return super().album_distance(items, album_info, mapping) - def album_for_id(self, album_id: str) -> Optional[AlbumInfo]: - pass - - def track_for_id(self, track_id: str) -> Optional[TrackInfo]: - pass - - def item_candidates( - self, - item: Item, - artist: str, - title: str, - ) -> Iterable[TrackInfo]: - return [] + @override + def _extract_id(self, url: str) -> Optional[str]: + return extract_release_id("MusicBrainz", url) class PseudoAlbumInfo(AlbumInfo): @@ -398,6 +209,9 @@ class PseudoAlbumInfo(AlbumInfo): if k not in kwargs: self[k] = v + def get_official_release(self) -> AlbumInfo: + return self.__dict__["_official_release"] + def determine_best_ref(self, items: Sequence[Item]) -> str: self.use_pseudo_as_ref() pseudo_dist = self._compute_distance(items) @@ -429,6 +243,17 @@ class PseudoAlbumInfo(AlbumInfo): else: return self.__dict__["_official_release"].__getattr__(attr) + def __deepcopy__(self, memo): + cls = self.__class__ + result = cls.__new__(cls) + + memo[id(self)] = result + result.__dict__.update(self.__dict__) + for k, v in self.items(): + result[k] = deepcopy(v, memo) + + return result + class ImmutableMapping(dict[Item, TrackInfo]): def __init__(self, *args, **kwargs): diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8e259e94b..cd53c3156 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -323,7 +323,7 @@ def _find_actual_release_from_pseudo_release( def _merge_pseudo_and_actual_album( pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo -) -> beets.autotag.hooks.AlbumInfo | None: +) -> beets.autotag.hooks.AlbumInfo: """ Merges a pseudo release with its actual release. From a42cabb47701677de01e76535ff4415944a7f453 Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 13 Oct 2025 16:04:44 -0600 Subject: [PATCH 037/274] Don't use Optional --- beetsplug/mbpseudo.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index d544a5624..faf6cc485 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -14,8 +14,10 @@ """Adds pseudo-releases from MusicBrainz as candidates during import.""" +from __future__ import annotations + from copy import deepcopy -from typing import Any, Iterable, Optional, Sequence +from typing import TYPE_CHECKING, Any, Iterable, Sequence import musicbrainzngs from typing_extensions import override @@ -23,16 +25,19 @@ from typing_extensions import override from beets.autotag.distance import Distance, distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.autotag.match import assign_items -from beets.library import Item from beets.plugins import find_plugins from beets.util.id_extractors import extract_release_id -from beetsplug._typing import JSONDict from beetsplug.musicbrainz import ( RELEASE_INCLUDES, MusicBrainzPlugin, _merge_pseudo_and_actual_album, ) +if TYPE_CHECKING: + from beets.autotag import AlbumMatch + from beets.library import Item + from beetsplug._typing import JSONDict + _STATUS_PSEUDO = "Pseudo-Release" @@ -128,7 +133,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): self, album_id: str, relation: JSONDict, - ) -> Optional[str]: + ) -> str | None: if ( len(self._scripts) == 0 or relation.get("type", "") != "transl-tracklisting" @@ -177,7 +182,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): return super().album_distance(items, album_info, mapping) @override - def _extract_id(self, url: str) -> Optional[str]: + def _extract_id(self, url: str) -> str | None: return extract_release_id("MusicBrainz", url) From 229651dcad0dd6d7aef10cfea3f9587b9667b94f Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 13 Oct 2025 15:55:24 -0600 Subject: [PATCH 038/274] Update mbpseudo implementation for beets 2.5 --- beets/autotag/match.py | 9 +++- beets/plugins.py | 1 + beetsplug/mbpseudo.py | 94 +++++++++++++++++++++--------------------- 3 files changed, 56 insertions(+), 48 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 8fec844a6..d0f3fd134 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar import lap import numpy as np -from beets import config, logging, metadata_plugins +from beets import config, logging, metadata_plugins, plugins from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks from beets.util import get_most_common_tags @@ -274,12 +274,17 @@ def tag_album( log.debug("Searching for album ID: {}", search_id) if info := metadata_plugins.album_for_id(search_id): _add_candidate(items, candidates, info) + if opt_candidate := candidates.get(info.album_id): + plugins.send("album_matched", match=opt_candidate) # Use existing metadata or text search. else: # Try search based on current ID. if info := match_by_id(items): _add_candidate(items, candidates, info) + for candidate in candidates.values(): + plugins.send("album_matched", match=candidate) + rec = _recommendation(list(candidates.values())) log.debug("Album ID match recommendation is {}", rec) if candidates and not config["import"]["timid"]: @@ -313,6 +318,8 @@ def tag_album( items, search_artist, search_album, va_likely ): _add_candidate(items, candidates, matched_candidate) + if opt_candidate := candidates.get(matched_candidate.album_id): + plugins.send("album_matched", match=opt_candidate) log.debug("Evaluating {} candidates.", len(candidates)) # Sort and get the recommendation. diff --git a/beets/plugins.py b/beets/plugins.py index e10dcf80c..4fdad9807 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -72,6 +72,7 @@ EventType = Literal[ "album_imported", "album_removed", "albuminfo_received", + "album_matched", "before_choose_candidate", "before_item_moved", "cli_exit", diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index faf6cc485..8a07049b9 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -16,6 +16,7 @@ from __future__ import annotations +import traceback from copy import deepcopy from typing import TYPE_CHECKING, Any, Iterable, Sequence @@ -23,12 +24,13 @@ import musicbrainzngs from typing_extensions import override from beets.autotag.distance import Distance, distance -from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.autotag.hooks import AlbumInfo from beets.autotag.match import assign_items from beets.plugins import find_plugins from beets.util.id_extractors import extract_release_id from beetsplug.musicbrainz import ( RELEASE_INCLUDES, + MusicBrainzAPIError, MusicBrainzPlugin, _merge_pseudo_and_actual_album, ) @@ -50,6 +52,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): self._log.debug("Desired scripts: {0}", self._scripts) self.register_listener("pluginload", self._on_plugins_loaded) + self.register_listener("album_matched", self._adjust_final_album_match) # noinspection PyMethodMayBeStatic def _on_plugins_loaded(self): @@ -77,14 +80,15 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): items, artist, album, va_likely ): if isinstance(album_info, PseudoAlbumInfo): - yield album_info.get_official_release() self._log.debug( "Using {0} release for distance calculations for album {1}", album_info.determine_best_ref(items), album_info.album_id, ) - - yield album_info + yield album_info # first yield pseudo to give it priority + yield album_info.get_official_release() + else: + yield album_info @override def album_info(self, release: JSONDict) -> AlbumInfo: @@ -95,17 +99,27 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): return official_release elif pseudo_release_ids := self._intercept_mb_release(release): album_id = self._extract_id(pseudo_release_ids[0]) - raw_pseudo_release = musicbrainzngs.get_release_by_id( - album_id, RELEASE_INCLUDES - ) - pseudo_release = super().album_info(raw_pseudo_release["release"]) - return PseudoAlbumInfo( - pseudo_release=_merge_pseudo_and_actual_album( - pseudo_release, official_release - ), - official_release=official_release, - data_source=self.data_source, - ) + try: + raw_pseudo_release = musicbrainzngs.get_release_by_id( + album_id, RELEASE_INCLUDES + ) + pseudo_release = super().album_info( + raw_pseudo_release["release"] + ) + return PseudoAlbumInfo( + pseudo_release=_merge_pseudo_and_actual_album( + pseudo_release, official_release + ), + official_release=official_release, + data_source="MusicBrainz", + ) + except musicbrainzngs.MusicBrainzError as exc: + raise MusicBrainzAPIError( + exc, + "get pseudo-release by ID", + album_id, + traceback.format_exc(), + ) else: return official_release @@ -153,33 +167,19 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): else: return None - @override - def album_distance( - self, - items: Sequence[Item], - album_info: AlbumInfo, - mapping: dict[Item, TrackInfo], - ) -> Distance: - """We use this function more like a listener for the extra details we are - injecting. - - For instances of ``PseudoAlbumInfo`` whose corresponding ``mapping`` is _not_ an - instance of ``ImmutableMapping``, we know at this point that all penalties from - the normal auto-tagging flow have been applied, so we can switch to the metadata - from the pseudo-release for the final proposal. - """ - + def _adjust_final_album_match(self, match: AlbumMatch): + album_info = match.info if isinstance(album_info, PseudoAlbumInfo): - if not isinstance(mapping, ImmutableMapping): - self._log.debug( - "Switching {0} to pseudo-release source for final proposal", - album_info.album_id, - ) - album_info.use_pseudo_as_ref() - new_mappings, _, _ = assign_items(items, album_info.tracks) - mapping.update(new_mappings) - - return super().album_distance(items, album_info, mapping) + mapping = match.mapping + self._log.debug( + "Switching {0} to pseudo-release source for final proposal", + album_info.album_id, + ) + album_info.use_pseudo_as_ref() + new_mappings, _, _ = assign_items( + list(mapping.keys()), album_info.tracks + ) + mapping.update(new_mappings) @override def _extract_id(self, url: str) -> str | None: @@ -218,12 +218,17 @@ class PseudoAlbumInfo(AlbumInfo): return self.__dict__["_official_release"] def determine_best_ref(self, items: Sequence[Item]) -> str: + ds = self.data_source + self.data_source = None + self.use_pseudo_as_ref() pseudo_dist = self._compute_distance(items) self.use_official_as_ref() official_dist = self._compute_distance(items) + self.data_source = ds + if official_dist < pseudo_dist: self.use_official_as_ref() return "official" @@ -233,7 +238,7 @@ class PseudoAlbumInfo(AlbumInfo): def _compute_distance(self, items: Sequence[Item]) -> Distance: mapping, _, _ = assign_items(items, self.tracks) - return distance(items, self, ImmutableMapping(mapping)) + return distance(items, self, mapping) def use_pseudo_as_ref(self): self.__dict__["_pseudo_source"] = True @@ -258,8 +263,3 @@ class PseudoAlbumInfo(AlbumInfo): result[k] = deepcopy(v, memo) return result - - -class ImmutableMapping(dict[Item, TrackInfo]): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) From 160297b086d92b1a9e61f27fef1d0e5c1ad46153 Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 13 Oct 2025 19:38:00 -0600 Subject: [PATCH 039/274] Add tests for mbpseudo plugin --- beetsplug/mbpseudo.py | 4 +- test/plugins/test_mbpseudo.py | 176 +++++ test/rsrc/mbpseudo/official_release.json | 841 +++++++++++++++++++++++ test/rsrc/mbpseudo/pseudo_release.json | 346 ++++++++++ 4 files changed, 1366 insertions(+), 1 deletion(-) create mode 100644 test/plugins/test_mbpseudo.py create mode 100644 test/rsrc/mbpseudo/official_release.json create mode 100644 test/rsrc/mbpseudo/pseudo_release.json diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 8a07049b9..bb12d4eae 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -47,6 +47,8 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): def __init__(self) -> None: super().__init__() + self._release_getter = musicbrainzngs.get_release_by_id + self.config.add({"scripts": []}) self._scripts = self.config["scripts"].as_str_seq() self._log.debug("Desired scripts: {0}", self._scripts) @@ -100,7 +102,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): elif pseudo_release_ids := self._intercept_mb_release(release): album_id = self._extract_id(pseudo_release_ids[0]) try: - raw_pseudo_release = musicbrainzngs.get_release_by_id( + raw_pseudo_release = self._release_getter( album_id, RELEASE_INCLUDES ) pseudo_release = super().album_info( diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py new file mode 100644 index 000000000..4a39a3952 --- /dev/null +++ b/test/plugins/test_mbpseudo.py @@ -0,0 +1,176 @@ +import json +import pathlib + +import pytest + +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.library import Item +from beets.test.helper import PluginMixin +from beetsplug._typing import JSONDict +from beetsplug.mbpseudo import ( + _STATUS_PSEUDO, + MusicBrainzPseudoReleasePlugin, + PseudoAlbumInfo, +) + + +class TestPseudoAlbumInfo: + @pytest.fixture + def official_release(self) -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="百花繚乱")], + album_id="official", + album="百花繚乱", + ) + + @pytest.fixture + def pseudo_release(self) -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="In Bloom")], + album_id="pseudo", + album="In Bloom", + ) + + def test_album_id_always_from_pseudo( + self, official_release: AlbumInfo, pseudo_release: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release, official_release) + info.use_official_as_ref() + assert info.album_id == "pseudo" + + def test_get_attr_from_pseudo( + self, official_release: AlbumInfo, pseudo_release: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release, official_release) + assert info.album == "In Bloom" + + def test_get_attr_from_official( + self, official_release: AlbumInfo, pseudo_release: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release, official_release) + info.use_official_as_ref() + assert info.album == info.get_official_release().album + + def test_determine_best_ref( + self, official_release: AlbumInfo, pseudo_release: AlbumInfo + ): + info = PseudoAlbumInfo( + pseudo_release, official_release, data_source="test" + ) + item = Item() + item["title"] = "百花繚乱" + + assert info.determine_best_ref([item]) == "official" + + info.use_pseudo_as_ref() + assert info.data_source == "test" + + +@pytest.fixture(scope="module") +def rsrc_dir(pytestconfig: pytest.Config): + return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo" + + +class TestMBPseudoPlugin(PluginMixin): + plugin = "mbpseudo" + + @pytest.fixture(scope="class") + def plugin_config(self): + return {"scripts": ["Latn", "Dummy"]} + + @pytest.fixture(scope="class") + def mbpseudo_plugin(self, plugin_config) -> MusicBrainzPseudoReleasePlugin: + self.config[self.plugin].set(plugin_config) + return MusicBrainzPseudoReleasePlugin() + + @pytest.fixture + def official_release(self, rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "official_release.json").read_text( + encoding="utf-8" + ) + return json.loads(info_json) + + @pytest.fixture + def pseudo_release(self, rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "pseudo_release.json").read_text( + encoding="utf-8" + ) + return json.loads(info_json) + + def test_scripts_init( + self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin + ): + assert mbpseudo_plugin._scripts == ["Latn", "Dummy"] + + @pytest.mark.parametrize( + "album_id", + [ + "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "-5ce1d11-2e32-45a4-b37f-c1589d46b103", + ], + ) + def test_extract_id_uses_music_brainz_pattern( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + album_id: str, + ): + if album_id.startswith("-"): + assert mbpseudo_plugin._extract_id(album_id) is None + else: + assert mbpseudo_plugin._extract_id(album_id) == album_id + + def test_album_info_for_pseudo_release( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + pseudo_release: JSONDict, + ): + album_info = mbpseudo_plugin.album_info(pseudo_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" + assert album_info.albumstatus == _STATUS_PSEUDO + + @pytest.mark.parametrize( + "json_key", + [ + "type", + "direction", + "release", + ], + ) + def test_interception_skip_when_rel_values_dont_match( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + json_key: str, + ): + del official_release["release"]["release-relation-list"][0][json_key] + + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" + + def test_interception_skip_when_script_doesnt_match( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + ): + official_release["release"]["release-relation-list"][0]["release"][ + "text-representation" + ]["script"] = "Null" + + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" + + def test_interception( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + pseudo_release: JSONDict, + ): + mbpseudo_plugin._release_getter = ( + lambda album_id, includes: pseudo_release + ) + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" diff --git a/test/rsrc/mbpseudo/official_release.json b/test/rsrc/mbpseudo/official_release.json new file mode 100644 index 000000000..63f1d60dd --- /dev/null +++ b/test/rsrc/mbpseudo/official_release.json @@ -0,0 +1,841 @@ +{ + "release": { + "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "title": "百花繚乱", + "status": "Official", + "quality": "normal", + "packaging": "None", + "text-representation": { + "language": "jpn", + "script": "Jpan" + }, + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "release-group": { + "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", + "type": "Single", + "title": "百花繚乱", + "first-release-date": "2025-01-10", + "primary-type": "Single", + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら" + }, + "date": "2025-01-10", + "country": "XW", + "release-event-list": [ + { + "date": "2025-01-10", + "area": { + "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", + "name": "[Worldwide]", + "sort-name": "[Worldwide]", + "iso-3166-1-code-list": [ + "XW" + ] + } + } + ], + "release-event-count": 1, + "barcode": "199066336168", + "asin": "B0DR8Y2YDC", + "cover-art-archive": { + "artwork": "true", + "count": "1", + "front": "true", + "back": "false" + }, + "label-info-list": [ + { + "catalog-number": "Lilas-020", + "label": { + "id": "157afde4-4bf5-4039-8ad2-5a15acc85176", + "type": "Production", + "name": "[no label]", + "sort-name": "[no label]", + "disambiguation": "Special purpose label – white labels, self-published releases and other “no label” releases", + "alias-list": [ + { + "sort-name": "2636621 Records DK", + "alias": "2636621 Records DK" + }, + { + "sort-name": "Auto production", + "type": "Search hint", + "alias": "Auto production" + }, + { + "sort-name": "Auto-Edición", + "type": "Search hint", + "alias": "Auto-Edición" + }, + { + "sort-name": "Auto-Product", + "type": "Search hint", + "alias": "Auto-Product" + }, + { + "sort-name": "Autoedición", + "type": "Search hint", + "alias": "Autoedición" + }, + { + "sort-name": "Autoeditado", + "type": "Search hint", + "alias": "Autoeditado" + }, + { + "sort-name": "Autoproduit", + "type": "Search hint", + "alias": "Autoproduit" + }, + { + "sort-name": "D.I.Y.", + "type": "Search hint", + "alias": "D.I.Y." + }, + { + "sort-name": "Demo", + "type": "Search hint", + "alias": "Demo" + }, + { + "sort-name": "DistroKid", + "type": "Search hint", + "alias": "DistroKid" + }, + { + "sort-name": "Eigenverlag", + "type": "Search hint", + "alias": "Eigenverlag" + }, + { + "sort-name": "Eigenvertrieb", + "type": "Search hint", + "alias": "Eigenvertrieb" + }, + { + "sort-name": "GRIND MODE", + "alias": "GRIND MODE" + }, + { + "sort-name": "INDIPENDANT", + "type": "Search hint", + "alias": "INDIPENDANT" + }, + { + "sort-name": "Indepandant", + "type": "Search hint", + "alias": "Indepandant" + }, + { + "sort-name": "Independant release", + "type": "Search hint", + "alias": "Independant release" + }, + { + "sort-name": "Independent", + "type": "Search hint", + "alias": "Independent" + }, + { + "sort-name": "Independente", + "type": "Search hint", + "alias": "Independente" + }, + { + "sort-name": "Independiente", + "type": "Search hint", + "alias": "Independiente" + }, + { + "sort-name": "Indie", + "type": "Search hint", + "alias": "Indie" + }, + { + "sort-name": "Joost Klein", + "alias": "Joost Klein" + }, + { + "sort-name": "MoroseSound", + "alias": "MoroseSound" + }, + { + "sort-name": "N/A", + "type": "Search hint", + "alias": "N/A" + }, + { + "sort-name": "No Label", + "type": "Search hint", + "alias": "No Label" + }, + { + "sort-name": "None", + "type": "Search hint", + "alias": "None" + }, + { + "sort-name": "Not On A Lebel", + "type": "Search hint", + "alias": "Not On A Lebel" + }, + { + "sort-name": "Not On Label", + "type": "Search hint", + "alias": "Not On Label" + }, + { + "sort-name": "P2019", + "alias": "P2019" + }, + { + "sort-name": "P2020", + "alias": "P2020" + }, + { + "sort-name": "P2021", + "alias": "P2021" + }, + { + "sort-name": "P2022", + "alias": "P2022" + }, + { + "sort-name": "P2023", + "alias": "P2023" + }, + { + "sort-name": "P2024", + "alias": "P2024" + }, + { + "sort-name": "P2025", + "alias": "P2025" + }, + { + "sort-name": "Records DK", + "type": "Search hint", + "alias": "Records DK" + }, + { + "sort-name": "Self Digital", + "type": "Search hint", + "alias": "Self Digital" + }, + { + "sort-name": "Self Release", + "type": "Search hint", + "alias": "Self Release" + }, + { + "sort-name": "Self Released", + "type": "Search hint", + "alias": "Self Released" + }, + { + "sort-name": "Self-release", + "type": "Search hint", + "alias": "Self-release" + }, + { + "sort-name": "Self-released", + "type": "Search hint", + "alias": "Self-released" + }, + { + "sort-name": "Self-released/independent", + "type": "Search hint", + "alias": "Self-released/independent" + }, + { + "sort-name": "Sevdaliza", + "alias": "Sevdaliza" + }, + { + "sort-name": "TOMMY CASH", + "alias": "TOMMY CASH" + }, + { + "sort-name": "Talwiinder", + "alias": "Talwiinder" + }, + { + "sort-name": "Unsigned", + "type": "Search hint", + "alias": "Unsigned" + }, + { + "locale": "fi", + "sort-name": "ei levymerkkiä", + "type": "Label name", + "primary": "primary", + "alias": "[ei levymerkkiä]" + }, + { + "locale": "nl", + "sort-name": "[geen platenmaatschappij]", + "type": "Label name", + "primary": "primary", + "alias": "[geen platenmaatschappij]" + }, + { + "locale": "et", + "sort-name": "[ilma plaadifirmata]", + "type": "Label name", + "alias": "[ilma plaadifirmata]" + }, + { + "locale": "es", + "sort-name": "[nada]", + "type": "Label name", + "primary": "primary", + "alias": "[nada]" + }, + { + "locale": "en", + "sort-name": "[no label]", + "type": "Label name", + "primary": "primary", + "alias": "[no label]" + }, + { + "sort-name": "[nolabel]", + "type": "Search hint", + "alias": "[nolabel]" + }, + { + "sort-name": "[none]", + "type": "Search hint", + "alias": "[none]" + }, + { + "locale": "lt", + "sort-name": "[nėra leidybinės kompanijos]", + "type": "Label name", + "alias": "[nėra leidybinės kompanijos]" + }, + { + "locale": "lt", + "sort-name": "[nėra leidyklos]", + "type": "Label name", + "alias": "[nėra leidyklos]" + }, + { + "locale": "lt", + "sort-name": "[nėra įrašų kompanijos]", + "type": "Label name", + "primary": "primary", + "alias": "[nėra įrašų kompanijos]" + }, + { + "locale": "et", + "sort-name": "[puudub]", + "type": "Label name", + "alias": "[puudub]" + }, + { + "locale": "ru", + "sort-name": "samizdat", + "type": "Label name", + "alias": "[самиздат]" + }, + { + "locale": "ja", + "sort-name": "[レーベルなし]", + "type": "Label name", + "primary": "primary", + "alias": "[レーベルなし]" + }, + { + "sort-name": "auto-release", + "type": "Search hint", + "alias": "auto-release" + }, + { + "sort-name": "autoprod.", + "type": "Search hint", + "alias": "autoprod." + }, + { + "sort-name": "blank", + "type": "Search hint", + "alias": "blank" + }, + { + "sort-name": "d.silvestre", + "alias": "d.silvestre" + }, + { + "sort-name": "independent release", + "type": "Search hint", + "alias": "independent release" + }, + { + "sort-name": "nyamura", + "alias": "nyamura" + }, + { + "sort-name": "pls dnt stp", + "alias": "pls dnt stp" + }, + { + "sort-name": "self", + "type": "Search hint", + "alias": "self" + }, + { + "sort-name": "self issued", + "type": "Search hint", + "alias": "self issued" + }, + { + "sort-name": "self-issued", + "type": "Search hint", + "alias": "self-issued" + }, + { + "sort-name": "white label", + "type": "Search hint", + "alias": "white label" + }, + { + "sort-name": "но лабел", + "type": "Search hint", + "alias": "но лабел" + }, + { + "sort-name": "独立发行", + "type": "Search hint", + "alias": "独立发行" + } + ], + "alias-count": 71, + "tag-list": [ + { + "count": "12", + "name": "special purpose" + }, + { + "count": "18", + "name": "special purpose label" + } + ] + } + } + ], + "label-info-count": 1, + "medium-list": [ + { + "position": "1", + "format": "Digital Media", + "track-list": [ + { + "id": "0bd01e8b-18e1-4708-b0a3-c9603b89ab97", + "position": "1", + "number": "1", + "length": "179239", + "recording": { + "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", + "title": "百花繚乱", + "length": "179546", + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "isrc-list": [ + "JPP302400868" + ], + "isrc-count": 1, + "artist-relation-list": [ + { + "type": "arranger", + "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", + "target": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "direction": "backward", + "artist": { + "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "type": "Person", + "name": "KOHD", + "sort-name": "KOHD", + "country": "JP", + "disambiguation": "Japanese composer/arranger/guitarist, agehasprings" + } + }, + { + "type": "phonographic copyright", + "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "begin": "2025", + "end": "2025", + "ended": "true", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + }, + "target-credit": "Lilas Ikuta" + }, + { + "type": "producer", + "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", + "target": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "direction": "backward", + "artist": { + "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "type": "Person", + "name": "山本秀哉", + "sort-name": "Yamamoto, Shuya", + "country": "JP" + } + }, + { + "type": "vocal", + "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "work-relation-list": [ + { + "type": "performance", + "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", + "target": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "direction": "forward", + "work": { + "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "type": "Song", + "title": "百花繚乱", + "language": "jpn", + "artist-relation-list": [ + { + "type": "composer", + "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + }, + { + "type": "lyricist", + "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "url-relation-list": [ + { + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "target": "https://utaten.com/lyric/tt24121002/", + "direction": "backward" + }, + { + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "target": "https://www.uta-net.com/song/366579/", + "direction": "backward" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら" + }, + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら", + "track_or_recording_length": "179239" + } + ], + "track-count": 1 + } + ], + "medium-count": 1, + "artist-relation-list": [ + { + "type": "copyright", + "type-id": "730b5251-7432-4896-8fc6-e1cba943bfe1", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "begin": "2025", + "end": "2025", + "ended": "true", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + }, + "target-credit": "Lilas Ikuta" + }, + { + "type": "phonographic copyright", + "type-id": "01d3488d-8d2a-4cff-9226-5250404db4dc", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "begin": "2025", + "end": "2025", + "ended": "true", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + }, + "target-credit": "Lilas Ikuta" + } + ], + "release-relation-list": [ + { + "type": "transl-tracklisting", + "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644", + "target": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "direction": "forward", + "release": { + "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "title": "In Bloom", + "quality": "normal", + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "artist-credit": [ + { + "name": "Lilas Ikuta", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "medium-list": [], + "medium-count": 0, + "artist-credit-phrase": "Lilas Ikuta" + } + } + ], + "url-relation-list": [ + { + "type": "amazon asin", + "type-id": "4f2e710d-166c-480c-a293-2e2c8d658d87", + "target": "https://www.amazon.co.jp/gp/product/B0DR8Y2YDC", + "direction": "forward" + }, + { + "type": "free streaming", + "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "target": "https://open.spotify.com/album/3LDV2xGL9HiqCsQujEPQLb", + "direction": "forward" + }, + { + "type": "free streaming", + "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "target": "https://www.deezer.com/album/687686261", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168_HD/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168_LL/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://music.apple.com/jp/album/1786972161", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://ototoy.jp/_/default/p/2501951", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/fl9tx2j78reza", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/l1dnc4xoi6l7a", + "direction": "forward" + }, + { + "type": "streaming", + "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", + "target": "https://music.amazon.co.jp/albums/B0DR8Y2YDC", + "direction": "forward" + }, + { + "type": "streaming", + "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", + "target": "https://music.apple.com/jp/album/1786972161", + "direction": "forward" + }, + { + "type": "vgmdb", + "type-id": "6af0134a-df6a-425a-96e2-895f9cd342ba", + "target": "https://vgmdb.net/album/145936", + "direction": "forward" + } + ], + "artist-credit-phrase": "幾田りら" + } +} diff --git a/test/rsrc/mbpseudo/pseudo_release.json b/test/rsrc/mbpseudo/pseudo_release.json new file mode 100644 index 000000000..99fa0b417 --- /dev/null +++ b/test/rsrc/mbpseudo/pseudo_release.json @@ -0,0 +1,346 @@ +{ + "release": { + "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "title": "In Bloom", + "status": "Pseudo-Release", + "quality": "normal", + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "artist-credit": [ + { + "name": "Lilas Ikuta", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "release-group": { + "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", + "type": "Single", + "title": "百花繚乱", + "first-release-date": "2025-01-10", + "primary-type": "Single", + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら" + }, + "cover-art-archive": { + "artwork": "false", + "count": "0", + "front": "false", + "back": "false" + }, + "label-info-list": [], + "label-info-count": 0, + "medium-list": [ + { + "position": "1", + "format": "Digital Media", + "track-list": [ + { + "id": "2018b012-a184-49a2-a464-fb4628a89588", + "position": "1", + "number": "1", + "title": "In Bloom", + "length": "179239", + "artist-credit": [ + { + "name": "Lilas Ikuta", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "recording": { + "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", + "title": "百花繚乱", + "length": "179546", + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "isrc-list": [ + "JPP302400868" + ], + "isrc-count": 1, + "artist-relation-list": [ + { + "type": "arranger", + "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", + "target": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "direction": "backward", + "artist": { + "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "type": "Person", + "name": "KOHD", + "sort-name": "KOHD", + "country": "JP", + "disambiguation": "Japanese composer/arranger/guitarist, agehasprings" + } + }, + { + "type": "phonographic copyright", + "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "begin": "2025", + "end": "2025", + "ended": "true", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + }, + "target-credit": "Lilas Ikuta" + }, + { + "type": "producer", + "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", + "target": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "direction": "backward", + "artist": { + "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "type": "Person", + "name": "山本秀哉", + "sort-name": "Yamamoto, Shuya", + "country": "JP" + } + }, + { + "type": "vocal", + "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "work-relation-list": [ + { + "type": "performance", + "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", + "target": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "direction": "forward", + "work": { + "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "type": "Song", + "title": "百花繚乱", + "language": "jpn", + "artist-relation-list": [ + { + "type": "composer", + "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + }, + { + "type": "lyricist", + "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "url-relation-list": [ + { + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "target": "https://utaten.com/lyric/tt24121002/", + "direction": "backward" + }, + { + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "target": "https://www.uta-net.com/song/366579/", + "direction": "backward" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら" + }, + "artist-credit-phrase": "Lilas Ikuta", + "track_or_recording_length": "179239" + } + ], + "track-count": 1 + } + ], + "medium-count": 1, + "release-relation-list": [ + { + "type": "transl-tracklisting", + "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644", + "target": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "direction": "backward", + "release": { + "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "title": "百花繚乱", + "quality": "normal", + "text-representation": { + "language": "jpn", + "script": "Jpan" + }, + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "date": "2025-01-10", + "country": "XW", + "release-event-list": [ + { + "date": "2025-01-10", + "area": { + "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", + "name": "[Worldwide]", + "sort-name": "[Worldwide]", + "iso-3166-1-code-list": [ + "XW" + ] + } + } + ], + "release-event-count": 1, + "barcode": "199066336168", + "medium-list": [], + "medium-count": 0, + "artist-credit-phrase": "幾田りら" + } + } + ], + "artist-credit-phrase": "Lilas Ikuta" + } +} \ No newline at end of file From defc60231034902d83c0e51449f8eecf82235c43 Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 13 Oct 2025 17:23:22 -0600 Subject: [PATCH 040/274] Update docs for mbpseudo plugin --- docs/changelog.rst | 8 ++++++ docs/dev/plugins/events.rst | 7 +++++ docs/plugins/index.rst | 4 +++ docs/plugins/mbpseudo.rst | 56 +++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 docs/plugins/mbpseudo.rst diff --git a/docs/changelog.rst b/docs/changelog.rst index e192259b1..5ebf3f53e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,8 @@ New features: to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. +- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive + MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. Bug fixes: @@ -28,6 +30,12 @@ Bug fixes: features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. +For plugin developers: + +- A new plugin event, ``album_matched``, is sent when an album that is being + imported has been matched to its metadata and the corresponding distance has + been calculated. + For packagers: Other changes: diff --git a/docs/dev/plugins/events.rst b/docs/dev/plugins/events.rst index 68773db3b..aaab9ccd7 100644 --- a/docs/dev/plugins/events.rst +++ b/docs/dev/plugins/events.rst @@ -178,6 +178,13 @@ registration process in this case: :Parameters: ``info`` (|AlbumInfo|) :Description: Like ``trackinfo_received`` but for album-level metadata. +``album_matched`` + :Parameters: ``match`` (``AlbumMatch``) + :Description: Called after ``Item`` objects from a folder that's being + imported have been matched to an ``AlbumInfo`` and the corresponding + distance has been calculated. Missing and extra tracks, if any, are + included in the match. + ``before_choose_candidate`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called before prompting the user during interactive import. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index d1590504d..c211616e4 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -102,6 +102,7 @@ databases. They share the following configuration options: loadext lyrics mbcollection + mbpseudo mbsubmit mbsync metasync @@ -153,6 +154,9 @@ Autotagger Extensions :doc:`musicbrainz ` Search for releases in the MusicBrainz_ database. +:doc:`mbpseudo ` + Search for releases and pseudo-releases in the MusicBrainz_ database. + :doc:`spotify ` Search for releases in the Spotify_ database. diff --git a/docs/plugins/mbpseudo.rst b/docs/plugins/mbpseudo.rst new file mode 100644 index 000000000..ad718eef1 --- /dev/null +++ b/docs/plugins/mbpseudo.rst @@ -0,0 +1,56 @@ +MusicBrainz Pseudo-Release Plugin +================================= + +The `mbpseudo` plugin can be used *instead of* the `musicbrainz` plugin to +search for MusicBrainz pseudo-releases_ during the import process, which are +added to the normal candidates from the MusicBrainz search. + +.. _pseudo-releases: https://musicbrainz.org/doc/Style/Specific_types_of_releases/Pseudo-Releases + +This is useful for releases whose title and track titles are written with a +script_ that can be translated or transliterated into a different one. + +.. _script: https://en.wikipedia.org/wiki/ISO_15924 + +Pseudo-releases will only be included if the initial search in MusicBrainz +returns releases whose script is *not* desired and whose relationships include +pseudo-releases with desired scripts. + +Configuration +------------- + +Since this plugin first searches for official releases from MusicBrainz, most +options from the `musicbrainz` plugin's :ref:`musicbrainz-config` are supported, +but they must be specified under `mbpseudo` in the configuration file. +Additionally, the configuration expects an array of scripts that are desired for +the pseudo-releases. Therefore, the minimum configuration for this plugin looks +like this: + +.. code-block:: yaml + + plugins: mbpseudo # remove musicbrainz + + mbpseudo: + scripts: + - Latn + +Note that the `search_limit` configuration applies to the initial search for +official releases, and that the `data_source` in the database will be +"MusicBrainz". Because of this, the only configuration that must remain under +`musicbrainz` is `data_source_mismatch_penalty` (see also +:ref:`metadata-source-plugin-configuration`). An example with multiple data +sources may look like this: + +.. code-block:: yaml + + plugins: mbpseudo deezer + + mbpseudo: + scripts: + - Latn + + musicbrainz: + data_source_mismatch_penalty: 0 + + deezer: + data_source_mismatch_penalty: 0.5 From cb758988ed6cc71e37d5ddf15145d1208b1df40a Mon Sep 17 00:00:00 2001 From: asardaes Date: Tue, 14 Oct 2025 12:09:42 -0600 Subject: [PATCH 041/274] Fix data source penalty for mbpseudo --- beetsplug/mbpseudo.py | 12 ++-- docs/plugins/mbpseudo.rst | 12 ++-- test/plugins/test_mbpseudo.py | 105 +++++++++++++++++++++++++--------- 3 files changed, 86 insertions(+), 43 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index bb12d4eae..8aca07366 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -95,7 +95,6 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): @override def album_info(self, release: JSONDict) -> AlbumInfo: official_release = super().album_info(release) - official_release.data_source = "MusicBrainz" if release.get("status") == _STATUS_PSEUDO: return official_release @@ -113,7 +112,6 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): pseudo_release, official_release ), official_release=official_release, - data_source="MusicBrainz", ) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( @@ -172,17 +170,20 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): def _adjust_final_album_match(self, match: AlbumMatch): album_info = match.info if isinstance(album_info, PseudoAlbumInfo): - mapping = match.mapping self._log.debug( "Switching {0} to pseudo-release source for final proposal", album_info.album_id, ) album_info.use_pseudo_as_ref() + mapping = match.mapping new_mappings, _, _ = assign_items( list(mapping.keys()), album_info.tracks ) mapping.update(new_mappings) + if album_info.data_source == self.data_source: + album_info.data_source = "MusicBrainz" + @override def _extract_id(self, url: str) -> str | None: return extract_release_id("MusicBrainz", url) @@ -220,17 +221,12 @@ class PseudoAlbumInfo(AlbumInfo): return self.__dict__["_official_release"] def determine_best_ref(self, items: Sequence[Item]) -> str: - ds = self.data_source - self.data_source = None - self.use_pseudo_as_ref() pseudo_dist = self._compute_distance(items) self.use_official_as_ref() official_dist = self._compute_distance(items) - self.data_source = ds - if official_dist < pseudo_dist: self.use_official_as_ref() return "official" diff --git a/docs/plugins/mbpseudo.rst b/docs/plugins/mbpseudo.rst index ad718eef1..186cb5a6f 100644 --- a/docs/plugins/mbpseudo.rst +++ b/docs/plugins/mbpseudo.rst @@ -19,7 +19,7 @@ pseudo-releases with desired scripts. Configuration ------------- -Since this plugin first searches for official releases from MusicBrainz, most +Since this plugin first searches for official releases from MusicBrainz, all options from the `musicbrainz` plugin's :ref:`musicbrainz-config` are supported, but they must be specified under `mbpseudo` in the configuration file. Additionally, the configuration expects an array of scripts that are desired for @@ -36,8 +36,8 @@ like this: Note that the `search_limit` configuration applies to the initial search for official releases, and that the `data_source` in the database will be -"MusicBrainz". Because of this, the only configuration that must remain under -`musicbrainz` is `data_source_mismatch_penalty` (see also +"MusicBrainz". Nevertheless, `data_source_mismatch_penalty` must also be +specified under `mbpseudo` (see also :ref:`metadata-source-plugin-configuration`). An example with multiple data sources may look like this: @@ -46,11 +46,9 @@ sources may look like this: plugins: mbpseudo deezer mbpseudo: + data_source_mismatch_penalty: 0 scripts: - Latn - musicbrainz: - data_source_mismatch_penalty: 0 - deezer: - data_source_mismatch_penalty: 0.5 + data_source_mismatch_penalty: 0.2 diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index 4a39a3952..b40bdbcc9 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -3,6 +3,8 @@ import pathlib import pytest +from beets.autotag import AlbumMatch +from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item from beets.test.helper import PluginMixin @@ -14,48 +16,50 @@ from beetsplug.mbpseudo import ( ) +@pytest.fixture(scope="module") +def official_release_info() -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="百花繚乱")], + album_id="official", + album="百花繚乱", + ) + + +@pytest.fixture(scope="module") +def pseudo_release_info() -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="In Bloom")], + album_id="pseudo", + album="In Bloom", + ) + + class TestPseudoAlbumInfo: - @pytest.fixture - def official_release(self) -> AlbumInfo: - return AlbumInfo( - tracks=[TrackInfo(title="百花繚乱")], - album_id="official", - album="百花繚乱", - ) - - @pytest.fixture - def pseudo_release(self) -> AlbumInfo: - return AlbumInfo( - tracks=[TrackInfo(title="In Bloom")], - album_id="pseudo", - album="In Bloom", - ) - def test_album_id_always_from_pseudo( - self, official_release: AlbumInfo, pseudo_release: AlbumInfo + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): - info = PseudoAlbumInfo(pseudo_release, official_release) + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) info.use_official_as_ref() assert info.album_id == "pseudo" def test_get_attr_from_pseudo( - self, official_release: AlbumInfo, pseudo_release: AlbumInfo + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): - info = PseudoAlbumInfo(pseudo_release, official_release) + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) assert info.album == "In Bloom" def test_get_attr_from_official( - self, official_release: AlbumInfo, pseudo_release: AlbumInfo + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): - info = PseudoAlbumInfo(pseudo_release, official_release) + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) info.use_official_as_ref() assert info.album == info.get_official_release().album def test_determine_best_ref( - self, official_release: AlbumInfo, pseudo_release: AlbumInfo + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): info = PseudoAlbumInfo( - pseudo_release, official_release, data_source="test" + pseudo_release_info, official_release_info, data_source="test" ) item = Item() item["title"] = "百花繚乱" @@ -126,7 +130,7 @@ class TestMBPseudoPlugin(PluginMixin): ): album_info = mbpseudo_plugin.album_info(pseudo_release["release"]) assert not isinstance(album_info, PseudoAlbumInfo) - assert album_info.data_source == "MusicBrainz" + assert album_info.data_source == "MusicBrainzPseudoRelease" assert album_info.albumstatus == _STATUS_PSEUDO @pytest.mark.parametrize( @@ -147,7 +151,7 @@ class TestMBPseudoPlugin(PluginMixin): album_info = mbpseudo_plugin.album_info(official_release["release"]) assert not isinstance(album_info, PseudoAlbumInfo) - assert album_info.data_source == "MusicBrainz" + assert album_info.data_source == "MusicBrainzPseudoRelease" def test_interception_skip_when_script_doesnt_match( self, @@ -160,7 +164,7 @@ class TestMBPseudoPlugin(PluginMixin): album_info = mbpseudo_plugin.album_info(official_release["release"]) assert not isinstance(album_info, PseudoAlbumInfo) - assert album_info.data_source == "MusicBrainz" + assert album_info.data_source == "MusicBrainzPseudoRelease" def test_interception( self, @@ -173,4 +177,49 @@ class TestMBPseudoPlugin(PluginMixin): ) album_info = mbpseudo_plugin.album_info(official_release["release"]) assert isinstance(album_info, PseudoAlbumInfo) - assert album_info.data_source == "MusicBrainz" + assert album_info.data_source == "MusicBrainzPseudoRelease" + + def test_final_adjustment_skip( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + ): + match = AlbumMatch( + distance=Distance(), + info=AlbumInfo(tracks=[], data_source="mb"), + mapping={}, + extra_items=[], + extra_tracks=[], + ) + + mbpseudo_plugin._adjust_final_album_match(match) + assert match.info.data_source == "mb" + + def test_final_adjustment( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release_info: AlbumInfo, + pseudo_release_info: AlbumInfo, + ): + pseudo_album_info = PseudoAlbumInfo( + pseudo_release=pseudo_release_info, + official_release=official_release_info, + data_source=mbpseudo_plugin.data_source, + ) + pseudo_album_info.use_official_as_ref() + + item = Item() + item["title"] = "百花繚乱" + + match = AlbumMatch( + distance=Distance(), + info=pseudo_album_info, + mapping={item: pseudo_album_info.tracks[0]}, + extra_items=[], + extra_tracks=[], + ) + + mbpseudo_plugin._adjust_final_album_match(match) + + assert match.info.data_source == "MusicBrainz" + assert match.info.album_id == "pseudo" + assert match.info.album == "In Bloom" From 040b2dd940ff5db03496e615626268bcc638e052 Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 20 Oct 2025 14:05:28 -0600 Subject: [PATCH 042/274] Add custom_tags_only mode for mbpseudo plugin --- beetsplug/mbpseudo.py | 76 +++++++++++++++++++++++++++++++---- docs/plugins/mbpseudo.rst | 55 +++++++++++++++++++++++-- test/plugins/test_mbpseudo.py | 42 +++++++++++++++++++ 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 8aca07366..e55847f81 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -20,6 +20,7 @@ import traceback from copy import deepcopy from typing import TYPE_CHECKING, Any, Iterable, Sequence +import mediafile import musicbrainzngs from typing_extensions import override @@ -49,10 +50,49 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): self._release_getter = musicbrainzngs.get_release_by_id - self.config.add({"scripts": []}) + self.config.add( + { + "scripts": [], + "custom_tags_only": False, + "album_custom_tags": { + "album_transl": "album", + "album_artist_transl": "artist", + }, + "track_custom_tags": { + "title_transl": "title", + "artist_transl": "artist", + }, + } + ) + self._scripts = self.config["scripts"].as_str_seq() self._log.debug("Desired scripts: {0}", self._scripts) + album_custom_tags = self.config["album_custom_tags"].get().keys() + track_custom_tags = self.config["track_custom_tags"].get().keys() + self._log.debug( + "Custom tags for albums and tracks: {0} + {1}", + album_custom_tags, + track_custom_tags, + ) + for custom_tag in album_custom_tags | track_custom_tags: + if not isinstance(custom_tag, str): + continue + + media_field = mediafile.MediaField( + mediafile.MP3DescStorageStyle(custom_tag), + mediafile.MP4StorageStyle( + f"----:com.apple.iTunes:{custom_tag}" + ), + mediafile.StorageStyle(custom_tag), + mediafile.ASFStorageStyle(custom_tag), + ) + try: + self.add_media_field(custom_tag, media_field) + except ValueError: + # ignore errors due to duplicates + pass + self.register_listener("pluginload", self._on_plugins_loaded) self.register_listener("album_matched", self._adjust_final_album_match) @@ -107,12 +147,17 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): pseudo_release = super().album_info( raw_pseudo_release["release"] ) - return PseudoAlbumInfo( - pseudo_release=_merge_pseudo_and_actual_album( - pseudo_release, official_release - ), - official_release=official_release, - ) + + if self.config["custom_tags_only"].get(bool): + self._add_custom_tags(official_release, pseudo_release) + return official_release + else: + return PseudoAlbumInfo( + pseudo_release=_merge_pseudo_and_actual_album( + pseudo_release, official_release + ), + official_release=official_release, + ) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( exc, @@ -167,6 +212,23 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): else: return None + def _add_custom_tags( + self, + official_release: AlbumInfo, + pseudo_release: AlbumInfo, + ): + for tag_key, pseudo_key in ( + self.config["album_custom_tags"].get().items() + ): + official_release[tag_key] = pseudo_release[pseudo_key] + + track_custom_tags = self.config["track_custom_tags"].get().items() + for track, pseudo_track in zip( + official_release.tracks, pseudo_release.tracks + ): + for tag_key, pseudo_key in track_custom_tags: + track[tag_key] = pseudo_track[pseudo_key] + def _adjust_final_album_match(self, match: AlbumMatch): album_info = match.info if isinstance(album_info, PseudoAlbumInfo): diff --git a/docs/plugins/mbpseudo.rst b/docs/plugins/mbpseudo.rst index 186cb5a6f..56658db26 100644 --- a/docs/plugins/mbpseudo.rst +++ b/docs/plugins/mbpseudo.rst @@ -23,13 +23,18 @@ Since this plugin first searches for official releases from MusicBrainz, all options from the `musicbrainz` plugin's :ref:`musicbrainz-config` are supported, but they must be specified under `mbpseudo` in the configuration file. Additionally, the configuration expects an array of scripts that are desired for -the pseudo-releases. Therefore, the minimum configuration for this plugin looks -like this: +the pseudo-releases. For ``artist`` in particular, keep in mind that even +pseudo-releases might specify it with the original script, so you should also +configure import :ref:`languages` to give artist aliases more priority. +Therefore, the minimum configuration for this plugin looks like this: .. code-block:: yaml plugins: mbpseudo # remove musicbrainz + import: + languages: en + mbpseudo: scripts: - Latn @@ -37,7 +42,7 @@ like this: Note that the `search_limit` configuration applies to the initial search for official releases, and that the `data_source` in the database will be "MusicBrainz". Nevertheless, `data_source_mismatch_penalty` must also be -specified under `mbpseudo` (see also +specified under `mbpseudo` if desired (see also :ref:`metadata-source-plugin-configuration`). An example with multiple data sources may look like this: @@ -45,6 +50,9 @@ sources may look like this: plugins: mbpseudo deezer + import: + languages: en + mbpseudo: data_source_mismatch_penalty: 0 scripts: @@ -52,3 +60,44 @@ sources may look like this: deezer: data_source_mismatch_penalty: 0.2 + +By default, the data from the pseudo-release will be used to create a proposal +that is independent from the official release and sets all properties in its +metadata. It's possible to change the configuration so that some information +from the pseudo-release is instead added as custom tags, keeping the metadata +from the official release: + +.. code-block:: yaml + + mbpseudo: + # other config not shown + custom_tags_only: yes + +The default custom tags with this configuration are specified as mappings where +the keys define the tag names and the values define the pseudo-release property +that will be used to set the tag's value: + +.. code-block:: yaml + + mbpseudo: + album_custom_tags: + album_transl: album + album_artist_transl: artist + track_custom_tags: + title_transl: title + artist_transl: artist + +Note that the information for each set of custom tags corresponds to different +metadata levels (album or track level), which is why ``artist`` appears twice +even though it effectively references album artist and track artist +respectively. + +If you want to modify any mapping under ``album_custom_tags`` or +``track_custom_tags``, you must specify *everything* for that set of tags in +your configuration file because any customization replaces the whole dictionary +of mappings for that level. + +.. note:: + + These custom tags are also added to the music files, not only to the + database. diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index b40bdbcc9..8046dd0e6 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -223,3 +223,45 @@ class TestMBPseudoPlugin(PluginMixin): assert match.info.data_source == "MusicBrainz" assert match.info.album_id == "pseudo" assert match.info.album == "In Bloom" + + +class TestMBPseudoPluginCustomTagsOnly(PluginMixin): + plugin = "mbpseudo" + + @pytest.fixture(scope="class") + def mbpseudo_plugin(self) -> MusicBrainzPseudoReleasePlugin: + self.config["import"]["languages"] = ["en", "jp"] + self.config[self.plugin]["scripts"] = ["Latn"] + self.config[self.plugin]["custom_tags_only"] = True + return MusicBrainzPseudoReleasePlugin() + + @pytest.fixture(scope="class") + def official_release(self, rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "official_release.json").read_text( + encoding="utf-8" + ) + return json.loads(info_json) + + @pytest.fixture(scope="class") + def pseudo_release(self, rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "pseudo_release.json").read_text( + encoding="utf-8" + ) + return json.loads(info_json) + + def test_custom_tags( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + pseudo_release: JSONDict, + ): + mbpseudo_plugin._release_getter = ( + lambda album_id, includes: pseudo_release + ) + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + assert album_info["album_transl"] == "In Bloom" + assert album_info["album_artist_transl"] == "Lilas Ikuta" + assert album_info.tracks[0]["title_transl"] == "In Bloom" + assert album_info.tracks[0]["artist_transl"] == "Lilas Ikuta" From c087851770f21e374692b0182a0ba779e15fc907 Mon Sep 17 00:00:00 2001 From: asardaes Date: Wed, 22 Oct 2025 11:14:30 -0600 Subject: [PATCH 043/274] Prefer alias if import languages not defined --- beetsplug/mbpseudo.py | 47 ++++++++++++++++++++++++++++++++--- beetsplug/musicbrainz.py | 17 +++++++++---- test/plugins/test_mbpseudo.py | 21 +++++++++++++++- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index e55847f81..448aef365 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -16,6 +16,7 @@ from __future__ import annotations +import itertools import traceback from copy import deepcopy from typing import TYPE_CHECKING, Any, Iterable, Sequence @@ -24,6 +25,7 @@ import mediafile import musicbrainzngs from typing_extensions import override +from beets import config from beets.autotag.distance import Distance, distance from beets.autotag.hooks import AlbumInfo from beets.autotag.match import assign_items @@ -34,6 +36,7 @@ from beetsplug.musicbrainz import ( MusicBrainzAPIError, MusicBrainzPlugin, _merge_pseudo_and_actual_album, + _preferred_alias, ) if TYPE_CHECKING: @@ -143,12 +146,13 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): try: raw_pseudo_release = self._release_getter( album_id, RELEASE_INCLUDES - ) - pseudo_release = super().album_info( - raw_pseudo_release["release"] - ) + )["release"] + pseudo_release = super().album_info(raw_pseudo_release) if self.config["custom_tags_only"].get(bool): + self._replace_artist_with_alias( + raw_pseudo_release, pseudo_release + ) self._add_custom_tags(official_release, pseudo_release) return official_release else: @@ -212,6 +216,41 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): else: return None + def _replace_artist_with_alias( + self, + raw_pseudo_release: JSONDict, + pseudo_release: AlbumInfo, + ): + """Use the pseudo-release's language to search for artist + alias if the user hasn't configured import languages.""" + + if len(config["import"]["languages"].as_str_seq()) > 0: + return + + lang = raw_pseudo_release.get("text-representation", {}).get("language") + artist_credits = raw_pseudo_release.get("release-group", {}).get( + "artist-credit", [] + ) + aliases = [ + artist_credit.get("artist", {}).get("alias-list", []) + for artist_credit in artist_credits + ] + + if lang and len(lang) >= 2 and len(aliases) > 0: + locale = lang[0:2] + aliases_flattened = list(itertools.chain.from_iterable(aliases)) + self._log.debug( + "Using locale '{0}' to search aliases {1}", + locale, + aliases_flattened, + ) + if alias_dict := _preferred_alias(aliases_flattened, [locale]): + if alias := alias_dict.get("alias"): + self._log.debug("Got alias '{0}'", alias) + pseudo_release.artist = alias + for track in pseudo_release.tracks: + track.artist = alias + def _add_custom_tags( self, official_release: AlbumInfo, diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index cd53c3156..29bbc26d0 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -118,13 +118,15 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 -def _preferred_alias(aliases: list[JSONDict]): - """Given an list of alias structures for an artist credit, select - and return the user's preferred alias alias or None if no matching +def _preferred_alias( + aliases: list[JSONDict], languages: list[str] | None = None +) -> JSONDict | None: + """Given a list of alias structures for an artist credit, select + and return the user's preferred alias or None if no matching alias is found. """ if not aliases: - return + return None # Only consider aliases that have locales set. valid_aliases = [a for a in aliases if "locale" in a] @@ -134,7 +136,10 @@ def _preferred_alias(aliases: list[JSONDict]): ignored_alias_types = [a.lower() for a in ignored_alias_types] # Search configured locales in order. - for locale in config["import"]["languages"].as_str_seq(): + if languages is None: + languages = config["import"]["languages"].as_str_seq() + + for locale in languages: # Find matching primary aliases for this locale that are not # being ignored matches = [] @@ -152,6 +157,8 @@ def _preferred_alias(aliases: list[JSONDict]): return matches[0] + return None + def _multi_artist_credit( credit: list[JSONDict], include_join_phrase: bool diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index 8046dd0e6..621e08950 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -3,6 +3,7 @@ import pathlib import pytest +from beets import config from beets.autotag import AlbumMatch from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo @@ -230,7 +231,6 @@ class TestMBPseudoPluginCustomTagsOnly(PluginMixin): @pytest.fixture(scope="class") def mbpseudo_plugin(self) -> MusicBrainzPseudoReleasePlugin: - self.config["import"]["languages"] = ["en", "jp"] self.config[self.plugin]["scripts"] = ["Latn"] self.config[self.plugin]["custom_tags_only"] = True return MusicBrainzPseudoReleasePlugin() @@ -255,6 +255,25 @@ class TestMBPseudoPluginCustomTagsOnly(PluginMixin): official_release: JSONDict, pseudo_release: JSONDict, ): + config["import"]["languages"] = [] + mbpseudo_plugin._release_getter = ( + lambda album_id, includes: pseudo_release + ) + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + assert album_info["album_transl"] == "In Bloom" + assert album_info["album_artist_transl"] == "Lilas Ikuta" + assert album_info.tracks[0]["title_transl"] == "In Bloom" + assert album_info.tracks[0]["artist_transl"] == "Lilas Ikuta" + + def test_custom_tags_with_import_languages( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + pseudo_release: JSONDict, + ): + config["import"]["languages"] = ["en", "jp"] mbpseudo_plugin._release_getter = ( lambda album_id, includes: pseudo_release ) From 59c93e70139f70e9fd1c6f3c1bceb005945bec33 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 21 Oct 2025 20:20:01 +0200 Subject: [PATCH 044/274] refactor: reorganize command modules and utils Moved commands.py into commands/__init__.py for easier refactoring. Moved `version` command into its own file. Moved `help` command into its own file. Moved `stats` command into its own file. Moved `list` command into its own file. Moved `config` command into its own file. Moved `completion` command into its own file. Moved utility functions into own file. Moved `move` command into its own file. Moved `fields` command into its own file. Moved `update` command into its own file. Moved `remove` command into its own file. Moved `modify` command into its own file. Moved `write` command into its own file. Moved `import` command into its own folder, more commit following. Moved ImportSession related functions into `importer/session.py`. Moved import display display related functions into `importer/display.py` Renamed import to import_ as a module cant be named import. Fixed imports in init file. --- beets/ui/commands.py | 2490 ------------------------- beets/ui/commands/__init__.py | 58 + beets/ui/commands/_utils.py | 67 + beets/ui/commands/completion.py | 115 ++ beets/ui/commands/config.py | 89 + beets/ui/commands/fields.py | 41 + beets/ui/commands/help.py | 22 + beets/ui/commands/import_/__init__.py | 281 +++ beets/ui/commands/import_/display.py | 573 ++++++ beets/ui/commands/import_/session.py | 558 ++++++ beets/ui/commands/list.py | 25 + beets/ui/commands/modify.py | 162 ++ beets/ui/commands/move.py | 154 ++ beets/ui/commands/remove.py | 84 + beets/ui/commands/stats.py | 62 + beets/ui/commands/update.py | 196 ++ beets/ui/commands/version.py | 23 + beets/ui/commands/write.py | 60 + 18 files changed, 2570 insertions(+), 2490 deletions(-) delete mode 100755 beets/ui/commands.py create mode 100644 beets/ui/commands/__init__.py create mode 100644 beets/ui/commands/_utils.py create mode 100644 beets/ui/commands/completion.py create mode 100644 beets/ui/commands/config.py create mode 100644 beets/ui/commands/fields.py create mode 100644 beets/ui/commands/help.py create mode 100644 beets/ui/commands/import_/__init__.py create mode 100644 beets/ui/commands/import_/display.py create mode 100644 beets/ui/commands/import_/session.py create mode 100644 beets/ui/commands/list.py create mode 100644 beets/ui/commands/modify.py create mode 100644 beets/ui/commands/move.py create mode 100644 beets/ui/commands/remove.py create mode 100644 beets/ui/commands/stats.py create mode 100644 beets/ui/commands/update.py create mode 100644 beets/ui/commands/version.py create mode 100644 beets/ui/commands/write.py diff --git a/beets/ui/commands.py b/beets/ui/commands.py deleted file mode 100755 index b52e965b7..000000000 --- a/beets/ui/commands.py +++ /dev/null @@ -1,2490 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""This module provides the default commands for beets' command-line -interface. -""" - -import os -import re -import textwrap -from collections import Counter -from collections.abc import Sequence -from functools import cached_property -from itertools import chain -from platform import python_version -from typing import Any, NamedTuple - -import beets -from beets import autotag, config, importer, library, logging, plugins, ui, util -from beets.autotag import Recommendation, hooks -from beets.ui import ( - input_, - print_, - print_column_layout, - print_newline_layout, - show_path_changes, -) -from beets.util import ( - MoveOperation, - ancestry, - displayable_path, - functemplate, - normpath, - syspath, -) -from beets.util.units import human_bytes, human_seconds, human_seconds_short - -from . import _store_dict - -VARIOUS_ARTISTS = "Various Artists" - -# Global logger. -log = logging.getLogger("beets") - -# The list of default subcommands. This is populated with Subcommand -# objects that can be fed to a SubcommandsOptionParser. -default_commands = [] - - -# Utilities. - - -def _do_query(lib, query, album, also_items=True): - """For commands that operate on matched items, performs a query - and returns a list of matching items and a list of matching - albums. (The latter is only nonempty when album is True.) Raises - a UserError if no items match. also_items controls whether, when - fetching albums, the associated items should be fetched also. - """ - if album: - albums = list(lib.albums(query)) - items = [] - if also_items: - for al in albums: - items += al.items() - - else: - albums = [] - items = list(lib.items(query)) - - if album and not albums: - raise ui.UserError("No matching albums found.") - elif not album and not items: - raise ui.UserError("No matching items found.") - - return items, albums - - -def _paths_from_logfile(path): - """Parse the logfile and yield skipped paths to pass to the `import` - command. - """ - with open(path, encoding="utf-8") as fp: - for i, line in enumerate(fp, start=1): - verb, sep, paths = line.rstrip("\n").partition(" ") - if not sep: - raise ValueError(f"line {i} is invalid") - - # Ignore informational lines that don't need to be re-imported. - if verb in {"import", "duplicate-keep", "duplicate-replace"}: - continue - - if verb not in {"asis", "skip", "duplicate-skip"}: - raise ValueError(f"line {i} contains unknown verb {verb}") - - yield os.path.commonpath(paths.split("; ")) - - -def _parse_logfiles(logfiles): - """Parse all `logfiles` and yield paths from it.""" - for logfile in logfiles: - try: - yield from _paths_from_logfile(syspath(normpath(logfile))) - except ValueError as err: - raise ui.UserError( - f"malformed logfile {util.displayable_path(logfile)}: {err}" - ) from err - except OSError as err: - raise ui.UserError( - f"unreadable logfile {util.displayable_path(logfile)}: {err}" - ) from err - - -# fields: Shows a list of available fields for queries and format strings. - - -def _print_keys(query): - """Given a SQLite query result, print the `key` field of each - returned row, with indentation of 2 spaces. - """ - for row in query: - print_(f" {row['key']}") - - -def fields_func(lib, opts, args): - def _print_rows(names): - names.sort() - print_(textwrap.indent("\n".join(names), " ")) - - print_("Item fields:") - _print_rows(library.Item.all_keys()) - - print_("Album fields:") - _print_rows(library.Album.all_keys()) - - with lib.transaction() as tx: - # The SQL uses the DISTINCT to get unique values from the query - unique_fields = "SELECT DISTINCT key FROM ({})" - - print_("Item flexible attributes:") - _print_keys(tx.query(unique_fields.format(library.Item._flex_table))) - - print_("Album flexible attributes:") - _print_keys(tx.query(unique_fields.format(library.Album._flex_table))) - - -fields_cmd = ui.Subcommand( - "fields", help="show fields available for queries and format strings" -) -fields_cmd.func = fields_func -default_commands.append(fields_cmd) - - -# help: Print help text for commands - - -class HelpCommand(ui.Subcommand): - def __init__(self): - super().__init__( - "help", - aliases=("?",), - help="give detailed help on a specific sub-command", - ) - - def func(self, lib, opts, args): - if args: - cmdname = args[0] - helpcommand = self.root_parser._subcommand_for_name(cmdname) - if not helpcommand: - raise ui.UserError(f"unknown command '{cmdname}'") - helpcommand.print_help() - else: - self.root_parser.print_help() - - -default_commands.append(HelpCommand()) - - -# import: Autotagger and importer. - -# Importer utilities and support. - - -def disambig_string(info): - """Generate a string for an AlbumInfo or TrackInfo object that - provides context that helps disambiguate similar-looking albums and - tracks. - """ - if isinstance(info, hooks.AlbumInfo): - disambig = get_album_disambig_fields(info) - elif isinstance(info, hooks.TrackInfo): - disambig = get_singleton_disambig_fields(info) - else: - return "" - - return ", ".join(disambig) - - -def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]: - out = [] - chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq() - calculated_values = { - "index": f"Index {info.index}", - "track_alt": f"Track {info.track_alt}", - "album": ( - f"[{info.album}]" - if ( - config["import"]["singleton_album_disambig"].get() - and info.get("album") - ) - else "" - ), - } - - for field in chosen_fields: - if field in calculated_values: - out.append(str(calculated_values[field])) - else: - try: - out.append(str(info[field])) - except (AttributeError, KeyError): - print(f"Disambiguation string key {field} does not exist.") - - return out - - -def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: - out = [] - chosen_fields = config["match"]["album_disambig_fields"].as_str_seq() - calculated_values = { - "media": ( - f"{info.mediums}x{info.media}" - if (info.mediums and info.mediums > 1) - else info.media - ), - } - - for field in chosen_fields: - if field in calculated_values: - out.append(str(calculated_values[field])) - else: - try: - out.append(str(info[field])) - except (AttributeError, KeyError): - print(f"Disambiguation string key {field} does not exist.") - - return out - - -def dist_colorize(string, dist): - """Formats a string as a colorized similarity string according to - a distance. - """ - if dist <= config["match"]["strong_rec_thresh"].as_number(): - string = ui.colorize("text_success", string) - elif dist <= config["match"]["medium_rec_thresh"].as_number(): - string = ui.colorize("text_warning", string) - else: - string = ui.colorize("text_error", string) - return string - - -def dist_string(dist): - """Formats a distance (a float) as a colorized similarity percentage - string. - """ - string = f"{(1 - dist) * 100:.1f}%" - return dist_colorize(string, dist) - - -def penalty_string(distance, limit=None): - """Returns a colorized string that indicates all the penalties - applied to a distance object. - """ - penalties = [] - for key in distance.keys(): - key = key.replace("album_", "") - key = key.replace("track_", "") - key = key.replace("_", " ") - penalties.append(key) - if penalties: - if limit and len(penalties) > limit: - penalties = penalties[:limit] + ["..."] - # Prefix penalty string with U+2260: Not Equal To - penalty_string = f"\u2260 {', '.join(penalties)}" - return ui.colorize("changed", penalty_string) - - -class ChangeRepresentation: - """Keeps track of all information needed to generate a (colored) text - representation of the changes that will be made if an album or singleton's - tags are changed according to `match`, which must be an AlbumMatch or - TrackMatch object, accordingly. - """ - - @cached_property - def changed_prefix(self) -> str: - return ui.colorize("changed", "\u2260") - - cur_artist = None - # cur_album set if album, cur_title set if singleton - cur_album = None - cur_title = None - match = None - indent_header = "" - indent_detail = "" - - def __init__(self): - # Read match header indentation width from config. - match_header_indent_width = config["ui"]["import"]["indentation"][ - "match_header" - ].as_number() - self.indent_header = ui.indent(match_header_indent_width) - - # Read match detail indentation width from config. - match_detail_indent_width = config["ui"]["import"]["indentation"][ - "match_details" - ].as_number() - self.indent_detail = ui.indent(match_detail_indent_width) - - # Read match tracklist indentation width from config - match_tracklist_indent_width = config["ui"]["import"]["indentation"][ - "match_tracklist" - ].as_number() - self.indent_tracklist = ui.indent(match_tracklist_indent_width) - self.layout = config["ui"]["import"]["layout"].as_choice( - { - "column": 0, - "newline": 1, - } - ) - - def print_layout( - self, indent, left, right, separator=" -> ", max_width=None - ): - if not max_width: - # If no max_width provided, use terminal width - max_width = ui.term_width() - if self.layout == 0: - print_column_layout(indent, left, right, separator, max_width) - else: - print_newline_layout(indent, left, right, separator, max_width) - - def show_match_header(self): - """Print out a 'header' identifying the suggested match (album name, - artist name,...) and summarizing the changes that would be made should - the user accept the match. - """ - # Print newline at beginning of change block. - print_("") - - # 'Match' line and similarity. - print_( - f"{self.indent_header}Match ({dist_string(self.match.distance)}):" - ) - - if isinstance(self.match.info, autotag.hooks.AlbumInfo): - # Matching an album - print that - artist_album_str = ( - f"{self.match.info.artist} - {self.match.info.album}" - ) - else: - # Matching a single track - artist_album_str = ( - f"{self.match.info.artist} - {self.match.info.title}" - ) - print_( - self.indent_header - + dist_colorize(artist_album_str, self.match.distance) - ) - - # Penalties. - penalties = penalty_string(self.match.distance) - if penalties: - print_(f"{self.indent_header}{penalties}") - - # Disambiguation. - disambig = disambig_string(self.match.info) - if disambig: - print_(f"{self.indent_header}{disambig}") - - # Data URL. - if self.match.info.data_url: - url = ui.colorize("text_faint", f"{self.match.info.data_url}") - print_(f"{self.indent_header}{url}") - - def show_match_details(self): - """Print out the details of the match, including changes in album name - and artist name. - """ - # Artist. - artist_l, artist_r = self.cur_artist or "", self.match.info.artist - if artist_r == VARIOUS_ARTISTS: - # Hide artists for VA releases. - artist_l, artist_r = "", "" - if artist_l != artist_r: - artist_l, artist_r = ui.colordiff(artist_l, artist_r) - left = { - "prefix": f"{self.changed_prefix} Artist: ", - "contents": artist_l, - "suffix": "", - } - right = {"prefix": "", "contents": artist_r, "suffix": ""} - self.print_layout(self.indent_detail, left, right) - - else: - print_(f"{self.indent_detail}*", "Artist:", artist_r) - - if self.cur_album: - # Album - album_l, album_r = self.cur_album or "", self.match.info.album - if ( - self.cur_album != self.match.info.album - and self.match.info.album != VARIOUS_ARTISTS - ): - album_l, album_r = ui.colordiff(album_l, album_r) - left = { - "prefix": f"{self.changed_prefix} Album: ", - "contents": album_l, - "suffix": "", - } - right = {"prefix": "", "contents": album_r, "suffix": ""} - self.print_layout(self.indent_detail, left, right) - else: - print_(f"{self.indent_detail}*", "Album:", album_r) - elif self.cur_title: - # Title - for singletons - title_l, title_r = self.cur_title or "", self.match.info.title - if self.cur_title != self.match.info.title: - title_l, title_r = ui.colordiff(title_l, title_r) - left = { - "prefix": f"{self.changed_prefix} Title: ", - "contents": title_l, - "suffix": "", - } - right = {"prefix": "", "contents": title_r, "suffix": ""} - self.print_layout(self.indent_detail, left, right) - else: - print_(f"{self.indent_detail}*", "Title:", title_r) - - def make_medium_info_line(self, track_info): - """Construct a line with the current medium's info.""" - track_media = track_info.get("media", "Media") - # Build output string. - if self.match.info.mediums > 1 and track_info.disctitle: - return ( - f"* {track_media} {track_info.medium}: {track_info.disctitle}" - ) - elif self.match.info.mediums > 1: - return f"* {track_media} {track_info.medium}" - elif track_info.disctitle: - return f"* {track_media}: {track_info.disctitle}" - else: - return "" - - def format_index(self, track_info): - """Return a string representing the track index of the given - TrackInfo or Item object. - """ - if isinstance(track_info, hooks.TrackInfo): - index = track_info.index - medium_index = track_info.medium_index - medium = track_info.medium - mediums = self.match.info.mediums - else: - index = medium_index = track_info.track - medium = track_info.disc - mediums = track_info.disctotal - if config["per_disc_numbering"]: - if mediums and mediums > 1: - return f"{medium}-{medium_index}" - else: - return str(medium_index if medium_index is not None else index) - else: - return str(index) - - def make_track_numbers(self, item, track_info): - """Format colored track indices.""" - cur_track = self.format_index(item) - new_track = self.format_index(track_info) - changed = False - # Choose color based on change. - if cur_track != new_track: - changed = True - if item.track in (track_info.index, track_info.medium_index): - highlight_color = "text_highlight_minor" - else: - highlight_color = "text_highlight" - else: - highlight_color = "text_faint" - - lhs_track = ui.colorize(highlight_color, f"(#{cur_track})") - rhs_track = ui.colorize(highlight_color, f"(#{new_track})") - return lhs_track, rhs_track, changed - - @staticmethod - def make_track_titles(item, track_info): - """Format colored track titles.""" - new_title = track_info.title - if not item.title.strip(): - # If there's no title, we use the filename. Don't colordiff. - cur_title = displayable_path(os.path.basename(item.path)) - return cur_title, new_title, True - else: - # If there is a title, highlight differences. - cur_title = item.title.strip() - cur_col, new_col = ui.colordiff(cur_title, new_title) - return cur_col, new_col, cur_title != new_title - - @staticmethod - def make_track_lengths(item, track_info): - """Format colored track lengths.""" - changed = False - if ( - item.length - and track_info.length - and abs(item.length - track_info.length) - >= config["ui"]["length_diff_thresh"].as_number() - ): - highlight_color = "text_highlight" - changed = True - else: - highlight_color = "text_highlight_minor" - - # Handle nonetype lengths by setting to 0 - cur_length0 = item.length if item.length else 0 - new_length0 = track_info.length if track_info.length else 0 - # format into string - cur_length = f"({human_seconds_short(cur_length0)})" - new_length = f"({human_seconds_short(new_length0)})" - # colorize - lhs_length = ui.colorize(highlight_color, cur_length) - rhs_length = ui.colorize(highlight_color, new_length) - - return lhs_length, rhs_length, changed - - def make_line(self, item, track_info): - """Extract changes from item -> new TrackInfo object, and colorize - appropriately. Returns (lhs, rhs) for column printing. - """ - # Track titles. - lhs_title, rhs_title, diff_title = self.make_track_titles( - item, track_info - ) - # Track number change. - lhs_track, rhs_track, diff_track = self.make_track_numbers( - item, track_info - ) - # Length change. - lhs_length, rhs_length, diff_length = self.make_track_lengths( - item, track_info - ) - - changed = diff_title or diff_track or diff_length - - # Construct lhs and rhs dicts. - # Previously, we printed the penalties, however this is no longer - # the case, thus the 'info' dictionary is unneeded. - # penalties = penalty_string(self.match.distance.tracks[track_info]) - - lhs = { - "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", - "contents": lhs_title, - "suffix": f" {lhs_length}", - } - rhs = {"prefix": "", "contents": "", "suffix": ""} - if not changed: - # Only return the left side, as nothing changed. - return (lhs, rhs) - else: - # Construct a dictionary for the "changed to" side - rhs = { - "prefix": f"{rhs_track} ", - "contents": rhs_title, - "suffix": f" {rhs_length}", - } - return (lhs, rhs) - - def print_tracklist(self, lines): - """Calculates column widths for tracks stored as line tuples: - (left, right). Then prints each line of tracklist. - """ - if len(lines) == 0: - # If no lines provided, e.g. details not required, do nothing. - return - - def get_width(side): - """Return the width of left or right in uncolorized characters.""" - try: - return len( - ui.uncolorize( - " ".join( - [side["prefix"], side["contents"], side["suffix"]] - ) - ) - ) - except KeyError: - # An empty dictionary -> Nothing to report - return 0 - - # Check how to fit content into terminal window - indent_width = len(self.indent_tracklist) - terminal_width = ui.term_width() - joiner_width = len("".join(["* ", " -> "])) - col_width = (terminal_width - indent_width - joiner_width) // 2 - max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines) - max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines) - - if ( - (max_width_l <= col_width) - and (max_width_r <= col_width) - or ( - ((max_width_l > col_width) or (max_width_r > col_width)) - and ((max_width_l + max_width_r) <= col_width * 2) - ) - ): - # All content fits. Either both maximum widths are below column - # widths, or one of the columns is larger than allowed but the - # other is smaller than allowed. - # In this case we can afford to shrink the columns to fit their - # largest string - col_width_l = max_width_l - col_width_r = max_width_r - else: - # Not all content fits - stick with original half/half split - col_width_l = col_width - col_width_r = col_width - - # Print out each line, using the calculated width from above. - for left, right in lines: - left["width"] = col_width_l - right["width"] = col_width_r - self.print_layout(self.indent_tracklist, left, right) - - -class AlbumChange(ChangeRepresentation): - """Album change representation, setting cur_album""" - - def __init__(self, cur_artist, cur_album, match): - super().__init__() - self.cur_artist = cur_artist - self.cur_album = cur_album - self.match = match - - def show_match_tracks(self): - """Print out the tracks of the match, summarizing changes the match - suggests for them. - """ - # Tracks. - # match is an AlbumMatch NamedTuple, mapping is a dict - # Sort the pairs by the track_info index (at index 1 of the NamedTuple) - pairs = list(self.match.mapping.items()) - pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index) - # Build up LHS and RHS for track difference display. The `lines` list - # contains `(left, right)` tuples. - lines = [] - medium = disctitle = None - for item, track_info in pairs: - # If the track is the first on a new medium, show medium - # number and title. - if medium != track_info.medium or disctitle != track_info.disctitle: - # Create header for new medium - header = self.make_medium_info_line(track_info) - if header != "": - # Print tracks from previous medium - self.print_tracklist(lines) - lines = [] - print_(f"{self.indent_detail}{header}") - # Save new medium details for future comparison. - medium, disctitle = track_info.medium, track_info.disctitle - - # Construct the line tuple for the track. - left, right = self.make_line(item, track_info) - if right["contents"] != "": - lines.append((left, right)) - else: - if config["import"]["detail"]: - lines.append((left, right)) - self.print_tracklist(lines) - - # Missing and unmatched tracks. - if self.match.extra_tracks: - print_( - "Missing tracks" - f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -" - f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):" - ) - for track_info in self.match.extra_tracks: - line = f" ! {track_info.title} (#{self.format_index(track_info)})" - if track_info.length: - line += f" ({human_seconds_short(track_info.length)})" - print_(ui.colorize("text_warning", line)) - if self.match.extra_items: - print_(f"Unmatched tracks ({len(self.match.extra_items)}):") - for item in self.match.extra_items: - line = f" ! {item.title} (#{self.format_index(item)})" - if item.length: - line += f" ({human_seconds_short(item.length)})" - print_(ui.colorize("text_warning", line)) - - -class TrackChange(ChangeRepresentation): - """Track change representation, comparing item with match.""" - - def __init__(self, cur_artist, cur_title, match): - super().__init__() - self.cur_artist = cur_artist - self.cur_title = cur_title - self.match = match - - -def show_change(cur_artist, cur_album, match): - """Print out a representation of the changes that will be made if an - album's tags are changed according to `match`, which must be an AlbumMatch - object. - """ - change = AlbumChange( - cur_artist=cur_artist, cur_album=cur_album, match=match - ) - - # Print the match header. - change.show_match_header() - - # Print the match details. - change.show_match_details() - - # Print the match tracks. - change.show_match_tracks() - - -def show_item_change(item, match): - """Print out the change that would occur by tagging `item` with the - metadata from `match`, a TrackMatch object. - """ - change = TrackChange( - cur_artist=item.artist, cur_title=item.title, match=match - ) - # Print the match header. - change.show_match_header() - # Print the match details. - change.show_match_details() - - -def summarize_items(items, singleton): - """Produces a brief summary line describing a set of items. Used for - manually resolving duplicates during import. - - `items` is a list of `Item` objects. `singleton` indicates whether - this is an album or single-item import (if the latter, them `items` - should only have one element). - """ - summary_parts = [] - if not singleton: - summary_parts.append(f"{len(items)} items") - - format_counts = {} - for item in items: - format_counts[item.format] = format_counts.get(item.format, 0) + 1 - if len(format_counts) == 1: - # A single format. - summary_parts.append(items[0].format) - else: - # Enumerate all the formats by decreasing frequencies: - for fmt, count in sorted( - format_counts.items(), - key=lambda fmt_and_count: (-fmt_and_count[1], fmt_and_count[0]), - ): - summary_parts.append(f"{fmt} {count}") - - if items: - average_bitrate = sum([item.bitrate for item in items]) / len(items) - total_duration = sum([item.length for item in items]) - total_filesize = sum([item.filesize for item in items]) - summary_parts.append(f"{int(average_bitrate / 1000)}kbps") - if items[0].format == "FLAC": - sample_bits = ( - f"{round(int(items[0].samplerate) / 1000, 1)}kHz" - f"/{items[0].bitdepth} bit" - ) - summary_parts.append(sample_bits) - summary_parts.append(human_seconds_short(total_duration)) - summary_parts.append(human_bytes(total_filesize)) - - return ", ".join(summary_parts) - - -def _summary_judgment(rec): - """Determines whether a decision should be made without even asking - the user. This occurs in quiet mode and when an action is chosen for - NONE recommendations. Return None if the user should be queried. - Otherwise, returns an action. May also print to the console if a - summary judgment is made. - """ - - if config["import"]["quiet"]: - if rec == Recommendation.strong: - return importer.Action.APPLY - else: - action = config["import"]["quiet_fallback"].as_choice( - { - "skip": importer.Action.SKIP, - "asis": importer.Action.ASIS, - } - ) - elif config["import"]["timid"]: - return None - elif rec == Recommendation.none: - action = config["import"]["none_rec_action"].as_choice( - { - "skip": importer.Action.SKIP, - "asis": importer.Action.ASIS, - "ask": None, - } - ) - else: - return None - - if action == importer.Action.SKIP: - print_("Skipping.") - elif action == importer.Action.ASIS: - print_("Importing as-is.") - return action - - -class PromptChoice(NamedTuple): - short: str - long: str - callback: Any - - -def choose_candidate( - candidates, - singleton, - rec, - cur_artist=None, - cur_album=None, - item=None, - itemcount=None, - choices=[], -): - """Given a sorted list of candidates, ask the user for a selection - of which candidate to use. Applies to both full albums and - singletons (tracks). Candidates are either AlbumMatch or TrackMatch - objects depending on `singleton`. for albums, `cur_artist`, - `cur_album`, and `itemcount` must be provided. For singletons, - `item` must be provided. - - `choices` is a list of `PromptChoice`s to be used in each prompt. - - Returns one of the following: - * the result of the choice, which may be SKIP or ASIS - * a candidate (an AlbumMatch/TrackMatch object) - * a chosen `PromptChoice` from `choices` - """ - # Sanity check. - if singleton: - assert item is not None - else: - assert cur_artist is not None - assert cur_album is not None - - # Build helper variables for the prompt choices. - choice_opts = tuple(c.long for c in choices) - choice_actions = {c.short: c for c in choices} - - # Zero candidates. - if not candidates: - if singleton: - print_("No matching recordings found.") - else: - print_(f"No matching release found for {itemcount} tracks.") - print_( - "For help, see: " - "https://beets.readthedocs.org/en/latest/faq.html#nomatch" - ) - sel = ui.input_options(choice_opts) - if sel in choice_actions: - return choice_actions[sel] - else: - assert False - - # Is the change good enough? - bypass_candidates = False - if rec != Recommendation.none: - match = candidates[0] - bypass_candidates = True - - while True: - # Display and choose from candidates. - require = rec <= Recommendation.low - - if not bypass_candidates: - # Display list of candidates. - print_("") - print_( - f"Finding tags for {'track' if singleton else 'album'} " - f'"{item.artist if singleton else cur_artist} -' - f' {item.title if singleton else cur_album}".' - ) - - print_(" Candidates:") - for i, match in enumerate(candidates): - # Index, metadata, and distance. - index0 = f"{i + 1}." - index = dist_colorize(index0, match.distance) - dist = f"({(1 - match.distance) * 100:.1f}%)" - distance = dist_colorize(dist, match.distance) - metadata = ( - f"{match.info.artist} -" - f" {match.info.title if singleton else match.info.album}" - ) - if i == 0: - metadata = dist_colorize(metadata, match.distance) - else: - metadata = ui.colorize("text_highlight_minor", metadata) - line1 = [index, distance, metadata] - print_(f" {' '.join(line1)}") - - # Penalties. - penalties = penalty_string(match.distance, 3) - if penalties: - print_(f"{' ' * 13}{penalties}") - - # Disambiguation - disambig = disambig_string(match.info) - if disambig: - print_(f"{' ' * 13}{disambig}") - - # Ask the user for a choice. - sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) - if sel == "m": - pass - elif sel in choice_actions: - return choice_actions[sel] - else: # Numerical selection. - match = candidates[sel - 1] - if sel != 1: - # When choosing anything but the first match, - # disable the default action. - require = True - bypass_candidates = False - - # Show what we're about to do. - if singleton: - show_item_change(item, match) - else: - show_change(cur_artist, cur_album, match) - - # Exact match => tag automatically if we're not in timid mode. - if rec == Recommendation.strong and not config["import"]["timid"]: - return match - - # Ask for confirmation. - default = config["import"]["default_action"].as_choice( - { - "apply": "a", - "skip": "s", - "asis": "u", - "none": None, - } - ) - if default is None: - require = True - # Bell ring when user interaction is needed. - if config["import"]["bell"]: - ui.print_("\a", end="") - sel = ui.input_options( - ("Apply", "More candidates") + choice_opts, - require=require, - default=default, - ) - if sel == "a": - return match - elif sel in choice_actions: - return choice_actions[sel] - - -def manual_search(session, task): - """Get a new `Proposal` using manual search criteria. - - Input either an artist and album (for full albums) or artist and - track name (for singletons) for manual search. - """ - artist = input_("Artist:").strip() - name = input_("Album:" if task.is_album else "Track:").strip() - - if task.is_album: - _, _, prop = autotag.tag_album(task.items, artist, name) - return prop - else: - return autotag.tag_item(task.item, artist, name) - - -def manual_id(session, task): - """Get a new `Proposal` using a manually-entered ID. - - Input an ID, either for an album ("release") or a track ("recording"). - """ - prompt = f"Enter {'release' if task.is_album else 'recording'} ID:" - search_id = input_(prompt).strip() - - if task.is_album: - _, _, prop = autotag.tag_album(task.items, search_ids=search_id.split()) - return prop - else: - return autotag.tag_item(task.item, search_ids=search_id.split()) - - -def abort_action(session, task): - """A prompt choice callback that aborts the importer.""" - raise importer.ImportAbortError() - - -class TerminalImportSession(importer.ImportSession): - """An import session that runs in a terminal.""" - - def choose_match(self, task): - """Given an initial autotagging of items, go through an interactive - dance with the user to ask for a choice of metadata. Returns an - AlbumMatch object, ASIS, or SKIP. - """ - # Show what we're tagging. - print_() - - path_str0 = displayable_path(task.paths, "\n") - path_str = ui.colorize("import_path", path_str0) - items_str0 = f"({len(task.items)} items)" - items_str = ui.colorize("import_path_items", items_str0) - print_(" ".join([path_str, items_str])) - - # Let plugins display info or prompt the user before we go through the - # process of selecting candidate. - results = plugins.send( - "import_task_before_choice", session=self, task=task - ) - actions = [action for action in results if action] - - if len(actions) == 1: - return actions[0] - elif len(actions) > 1: - raise plugins.PluginConflictError( - "Only one handler for `import_task_before_choice` may return " - "an action." - ) - - # Take immediate action if appropriate. - action = _summary_judgment(task.rec) - if action == importer.Action.APPLY: - match = task.candidates[0] - show_change(task.cur_artist, task.cur_album, match) - return match - elif action is not None: - return action - - # Loop until we have a choice. - while True: - # Ask for a choice from the user. The result of - # `choose_candidate` may be an `importer.Action`, an - # `AlbumMatch` object for a specific selection, or a - # `PromptChoice`. - choices = self._get_choices(task) - choice = choose_candidate( - task.candidates, - False, - task.rec, - task.cur_artist, - task.cur_album, - itemcount=len(task.items), - choices=choices, - ) - - # Basic choices that require no more action here. - if choice in (importer.Action.SKIP, importer.Action.ASIS): - # Pass selection to main control flow. - return choice - - # Plugin-provided choices. We invoke the associated callback - # function. - elif choice in choices: - post_choice = choice.callback(self, task) - if isinstance(post_choice, importer.Action): - return post_choice - elif isinstance(post_choice, autotag.Proposal): - # Use the new candidates and continue around the loop. - task.candidates = post_choice.candidates - task.rec = post_choice.recommendation - - # Otherwise, we have a specific match selection. - else: - # We have a candidate! Finish tagging. Here, choice is an - # AlbumMatch object. - assert isinstance(choice, autotag.AlbumMatch) - return choice - - def choose_item(self, task): - """Ask the user for a choice about tagging a single item. Returns - either an action constant or a TrackMatch object. - """ - print_() - print_(displayable_path(task.item.path)) - candidates, rec = task.candidates, task.rec - - # Take immediate action if appropriate. - action = _summary_judgment(task.rec) - if action == importer.Action.APPLY: - match = candidates[0] - show_item_change(task.item, match) - return match - elif action is not None: - return action - - while True: - # Ask for a choice. - choices = self._get_choices(task) - choice = choose_candidate( - candidates, True, rec, item=task.item, choices=choices - ) - - if choice in (importer.Action.SKIP, importer.Action.ASIS): - return choice - - elif choice in choices: - post_choice = choice.callback(self, task) - if isinstance(post_choice, importer.Action): - return post_choice - elif isinstance(post_choice, autotag.Proposal): - candidates = post_choice.candidates - rec = post_choice.recommendation - - else: - # Chose a candidate. - assert isinstance(choice, autotag.TrackMatch) - return choice - - def resolve_duplicate(self, task, found_duplicates): - """Decide what to do when a new album or item seems similar to one - that's already in the library. - """ - log.warning( - "This {} is already in the library!", - ("album" if task.is_album else "item"), - ) - - if config["import"]["quiet"]: - # In quiet mode, don't prompt -- just skip. - log.info("Skipping.") - sel = "s" - else: - # Print some detail about the existing and new items so the - # user can make an informed decision. - for duplicate in found_duplicates: - print_( - "Old: " - + summarize_items( - ( - list(duplicate.items()) - if task.is_album - else [duplicate] - ), - not task.is_album, - ) - ) - if config["import"]["duplicate_verbose_prompt"]: - if task.is_album: - for dup in duplicate.items(): - print(f" {dup}") - else: - print(f" {duplicate}") - - print_( - "New: " - + summarize_items( - task.imported_items(), - not task.is_album, - ) - ) - if config["import"]["duplicate_verbose_prompt"]: - for item in task.imported_items(): - print(f" {item}") - - sel = ui.input_options( - ("Skip new", "Keep all", "Remove old", "Merge all") - ) - - if sel == "s": - # Skip new. - task.set_choice(importer.Action.SKIP) - elif sel == "k": - # Keep both. Do nothing; leave the choice intact. - pass - elif sel == "r": - # Remove old. - task.should_remove_duplicates = True - elif sel == "m": - task.should_merge_duplicates = True - else: - assert False - - def should_resume(self, path): - return ui.input_yn( - f"Import of the directory:\n{displayable_path(path)}\n" - "was interrupted. Resume (Y/n)?" - ) - - def _get_choices(self, task): - """Get the list of prompt choices that should be presented to the - user. This consists of both built-in choices and ones provided by - plugins. - - The `before_choose_candidate` event is sent to the plugins, with - session and task as its parameters. Plugins are responsible for - checking the right conditions and returning a list of `PromptChoice`s, - which is flattened and checked for conflicts. - - If two or more choices have the same short letter, a warning is - emitted and all but one choices are discarded, giving preference - to the default importer choices. - - Returns a list of `PromptChoice`s. - """ - # Standard, built-in choices. - choices = [ - PromptChoice("s", "Skip", lambda s, t: importer.Action.SKIP), - PromptChoice("u", "Use as-is", lambda s, t: importer.Action.ASIS), - ] - if task.is_album: - choices += [ - PromptChoice( - "t", "as Tracks", lambda s, t: importer.Action.TRACKS - ), - PromptChoice( - "g", "Group albums", lambda s, t: importer.Action.ALBUMS - ), - ] - choices += [ - PromptChoice("e", "Enter search", manual_search), - PromptChoice("i", "enter Id", manual_id), - PromptChoice("b", "aBort", abort_action), - ] - - # Send the before_choose_candidate event and flatten list. - extra_choices = list( - chain( - *plugins.send( - "before_choose_candidate", session=self, task=task - ) - ) - ) - - # Add a "dummy" choice for the other baked-in option, for - # duplicate checking. - all_choices = ( - [ - PromptChoice("a", "Apply", None), - ] - + choices - + extra_choices - ) - - # Check for conflicts. - short_letters = [c.short for c in all_choices] - if len(short_letters) != len(set(short_letters)): - # Duplicate short letter has been found. - duplicates = [ - i for i, count in Counter(short_letters).items() if count > 1 - ] - for short in duplicates: - # Keep the first of the choices, removing the rest. - dup_choices = [c for c in all_choices if c.short == short] - for c in dup_choices[1:]: - log.warning( - "Prompt choice '{0.long}' removed due to conflict " - "with '{1[0].long}' (short letter: '{0.short}')", - c, - dup_choices, - ) - extra_choices.remove(c) - - return choices + extra_choices - - -# The import command. - - -def import_files(lib, paths: list[bytes], query): - """Import the files in the given list of paths or matching the - query. - """ - # Check parameter consistency. - if config["import"]["quiet"] and config["import"]["timid"]: - raise ui.UserError("can't be both quiet and timid") - - # Open the log. - if config["import"]["log"].get() is not None: - logpath = syspath(config["import"]["log"].as_filename()) - try: - loghandler = logging.FileHandler(logpath, encoding="utf-8") - except OSError: - raise ui.UserError( - "Could not open log file for writing:" - f" {displayable_path(logpath)}" - ) - else: - loghandler = None - - # Never ask for input in quiet mode. - if config["import"]["resume"].get() == "ask" and config["import"]["quiet"]: - config["import"]["resume"] = False - - session = TerminalImportSession(lib, loghandler, paths, query) - session.run() - - # Emit event. - plugins.send("import", lib=lib, paths=paths) - - -def import_func(lib, opts, args: list[str]): - config["import"].set_args(opts) - - # Special case: --copy flag suppresses import_move (which would - # otherwise take precedence). - if opts.copy: - config["import"]["move"] = False - - if opts.library: - query = args - byte_paths = [] - else: - query = None - paths = args - - # The paths from the logfiles go into a separate list to allow handling - # errors differently from user-specified paths. - paths_from_logfiles = list(_parse_logfiles(opts.from_logfiles or [])) - - if not paths and not paths_from_logfiles: - raise ui.UserError("no path specified") - - byte_paths = [os.fsencode(p) for p in paths] - paths_from_logfiles = [os.fsencode(p) for p in paths_from_logfiles] - - # Check the user-specified directories. - for path in byte_paths: - if not os.path.exists(syspath(normpath(path))): - raise ui.UserError( - f"no such file or directory: {displayable_path(path)}" - ) - - # Check the directories from the logfiles, but don't throw an error in - # case those paths don't exist. Maybe some of those paths have already - # been imported and moved separately, so logging a warning should - # suffice. - for path in paths_from_logfiles: - if not os.path.exists(syspath(normpath(path))): - log.warning( - "No such file or directory: {}", displayable_path(path) - ) - continue - - byte_paths.append(path) - - # If all paths were read from a logfile, and none of them exist, throw - # an error - if not paths: - raise ui.UserError("none of the paths are importable") - - import_files(lib, byte_paths, query) - - -import_cmd = ui.Subcommand( - "import", help="import new music", aliases=("imp", "im") -) -import_cmd.parser.add_option( - "-c", - "--copy", - action="store_true", - default=None, - help="copy tracks into library directory (default)", -) -import_cmd.parser.add_option( - "-C", - "--nocopy", - action="store_false", - dest="copy", - help="don't copy tracks (opposite of -c)", -) -import_cmd.parser.add_option( - "-m", - "--move", - action="store_true", - dest="move", - help="move tracks into the library (overrides -c)", -) -import_cmd.parser.add_option( - "-w", - "--write", - action="store_true", - default=None, - help="write new metadata to files' tags (default)", -) -import_cmd.parser.add_option( - "-W", - "--nowrite", - action="store_false", - dest="write", - help="don't write metadata (opposite of -w)", -) -import_cmd.parser.add_option( - "-a", - "--autotag", - action="store_true", - dest="autotag", - help="infer tags for imported files (default)", -) -import_cmd.parser.add_option( - "-A", - "--noautotag", - action="store_false", - dest="autotag", - help="don't infer tags for imported files (opposite of -a)", -) -import_cmd.parser.add_option( - "-p", - "--resume", - action="store_true", - default=None, - help="resume importing if interrupted", -) -import_cmd.parser.add_option( - "-P", - "--noresume", - action="store_false", - dest="resume", - help="do not try to resume importing", -) -import_cmd.parser.add_option( - "-q", - "--quiet", - action="store_true", - dest="quiet", - help="never prompt for input: skip albums instead", -) -import_cmd.parser.add_option( - "--quiet-fallback", - type="string", - dest="quiet_fallback", - help="decision in quiet mode when no strong match: skip or asis", -) -import_cmd.parser.add_option( - "-l", - "--log", - dest="log", - help="file to log untaggable albums for later review", -) -import_cmd.parser.add_option( - "-s", - "--singletons", - action="store_true", - help="import individual tracks instead of full albums", -) -import_cmd.parser.add_option( - "-t", - "--timid", - dest="timid", - action="store_true", - help="always confirm all actions", -) -import_cmd.parser.add_option( - "-L", - "--library", - dest="library", - action="store_true", - help="retag items matching a query", -) -import_cmd.parser.add_option( - "-i", - "--incremental", - dest="incremental", - action="store_true", - help="skip already-imported directories", -) -import_cmd.parser.add_option( - "-I", - "--noincremental", - dest="incremental", - action="store_false", - help="do not skip already-imported directories", -) -import_cmd.parser.add_option( - "-R", - "--incremental-skip-later", - action="store_true", - dest="incremental_skip_later", - help="do not record skipped files during incremental import", -) -import_cmd.parser.add_option( - "-r", - "--noincremental-skip-later", - action="store_false", - dest="incremental_skip_later", - help="record skipped files during incremental import", -) -import_cmd.parser.add_option( - "--from-scratch", - dest="from_scratch", - action="store_true", - help="erase existing metadata before applying new metadata", -) -import_cmd.parser.add_option( - "--flat", - dest="flat", - action="store_true", - help="import an entire tree as a single album", -) -import_cmd.parser.add_option( - "-g", - "--group-albums", - dest="group_albums", - action="store_true", - help="group tracks in a folder into separate albums", -) -import_cmd.parser.add_option( - "--pretend", - dest="pretend", - action="store_true", - help="just print the files to import", -) -import_cmd.parser.add_option( - "-S", - "--search-id", - dest="search_ids", - action="append", - metavar="ID", - help="restrict matching to a specific metadata backend ID", -) -import_cmd.parser.add_option( - "--from-logfile", - dest="from_logfiles", - action="append", - metavar="PATH", - help="read skipped paths from an existing logfile", -) -import_cmd.parser.add_option( - "--set", - dest="set_fields", - action="callback", - callback=_store_dict, - metavar="FIELD=VALUE", - help="set the given fields to the supplied values", -) -import_cmd.func = import_func -default_commands.append(import_cmd) - - -# list: Query and show library contents. - - -def list_items(lib, query, album, fmt=""): - """Print out items in lib matching query. If album, then search for - albums instead of single items. - """ - if album: - for album in lib.albums(query): - ui.print_(format(album, fmt)) - else: - for item in lib.items(query): - ui.print_(format(item, fmt)) - - -def list_func(lib, opts, args): - list_items(lib, args, opts.album) - - -list_cmd = ui.Subcommand("list", help="query the library", aliases=("ls",)) -list_cmd.parser.usage += "\nExample: %prog -f '$album: $title' artist:beatles" -list_cmd.parser.add_all_common_options() -list_cmd.func = list_func -default_commands.append(list_cmd) - - -# update: Update library contents according to on-disk tags. - - -def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): - """For all the items matched by the query, update the library to - reflect the item's embedded tags. - :param fields: The fields to be stored. If not specified, all fields will - be. - :param exclude_fields: The fields to not be stored. If not specified, all - fields will be. - """ - with lib.transaction(): - items, _ = _do_query(lib, query, album) - if move and fields is not None and "path" not in fields: - # Special case: if an item needs to be moved, the path field has to - # updated; otherwise the new path will not be reflected in the - # database. - fields.append("path") - if fields is None: - # no fields were provided, update all media fields - item_fields = fields or library.Item._media_fields - if move and "path" not in item_fields: - # move is enabled, add 'path' to the list of fields to update - item_fields.add("path") - else: - # fields was provided, just update those - item_fields = fields - # get all the album fields to update - album_fields = fields or library.Album._fields.keys() - if exclude_fields: - # remove any excluded fields from the item and album sets - item_fields = [f for f in item_fields if f not in exclude_fields] - album_fields = [f for f in album_fields if f not in exclude_fields] - - # Walk through the items and pick up their changes. - affected_albums = set() - for item in items: - # Item deleted? - if not item.path or not os.path.exists(syspath(item.path)): - ui.print_(format(item)) - ui.print_(ui.colorize("text_error", " deleted")) - if not pretend: - item.remove(True) - affected_albums.add(item.album_id) - continue - - # Did the item change since last checked? - if item.current_mtime() <= item.mtime: - log.debug( - "skipping {0.filepath} because mtime is up to date ({0.mtime})", - item, - ) - continue - - # Read new data. - try: - item.read() - except library.ReadError as exc: - log.error("error reading {.filepath}: {}", item, exc) - continue - - # Special-case album artist when it matches track artist. (Hacky - # but necessary for preserving album-level metadata for non- - # autotagged imports.) - if not item.albumartist: - old_item = lib.get_item(item.id) - if old_item.albumartist == old_item.artist == item.artist: - item.albumartist = old_item.albumartist - item._dirty.discard("albumartist") - - # Check for and display changes. - changed = ui.show_model_changes(item, fields=item_fields) - - # Save changes. - if not pretend: - if changed: - # Move the item if it's in the library. - if move and lib.directory in ancestry(item.path): - item.move(store=False) - - item.store(fields=item_fields) - affected_albums.add(item.album_id) - else: - # The file's mtime was different, but there were no - # changes to the metadata. Store the new mtime, - # which is set in the call to read(), so we don't - # check this again in the future. - item.store(fields=item_fields) - - # Skip album changes while pretending. - if pretend: - return - - # Modify affected albums to reflect changes in their items. - for album_id in affected_albums: - if album_id is None: # Singletons. - continue - album = lib.get_album(album_id) - if not album: # Empty albums have already been removed. - log.debug("emptied album {}", album_id) - continue - first_item = album.items().get() - - # Update album structure to reflect an item in it. - for key in library.Album.item_keys: - album[key] = first_item[key] - album.store(fields=album_fields) - - # Move album art (and any inconsistent items). - if move and lib.directory in ancestry(first_item.path): - log.debug("moving album {}", album_id) - - # Manually moving and storing the album. - items = list(album.items()) - for item in items: - item.move(store=False, with_album=False) - item.store(fields=item_fields) - album.move(store=False) - album.store(fields=album_fields) - - -def update_func(lib, opts, args): - # Verify that the library folder exists to prevent accidental wipes. - if not os.path.isdir(syspath(lib.directory)): - ui.print_("Library path is unavailable or does not exist.") - ui.print_(lib.directory) - if not ui.input_yn("Are you sure you want to continue (y/n)?", True): - return - update_items( - lib, - args, - opts.album, - ui.should_move(opts.move), - opts.pretend, - opts.fields, - opts.exclude_fields, - ) - - -update_cmd = ui.Subcommand( - "update", - help="update the library", - aliases=( - "upd", - "up", - ), -) -update_cmd.parser.add_album_option() -update_cmd.parser.add_format_option() -update_cmd.parser.add_option( - "-m", - "--move", - action="store_true", - dest="move", - help="move files in the library directory", -) -update_cmd.parser.add_option( - "-M", - "--nomove", - action="store_false", - dest="move", - help="don't move files in library", -) -update_cmd.parser.add_option( - "-p", - "--pretend", - action="store_true", - help="show all changes but do nothing", -) -update_cmd.parser.add_option( - "-F", - "--field", - default=None, - action="append", - dest="fields", - help="list of fields to update", -) -update_cmd.parser.add_option( - "-e", - "--exclude-field", - default=None, - action="append", - dest="exclude_fields", - help="list of fields to exclude from updates", -) -update_cmd.func = update_func -default_commands.append(update_cmd) - - -# remove: Remove items from library, delete files. - - -def remove_items(lib, query, album, delete, force): - """Remove items matching query from lib. If album, then match and - remove whole albums. If delete, also remove files from disk. - """ - # Get the matching items. - items, albums = _do_query(lib, query, album) - objs = albums if album else items - - # Confirm file removal if not forcing removal. - if not force: - # Prepare confirmation with user. - album_str = ( - f" in {len(albums)} album{'s' if len(albums) > 1 else ''}" - if album - else "" - ) - - if delete: - fmt = "$path - $title" - prompt = "Really DELETE" - prompt_all = ( - "Really DELETE" - f" {len(items)} file{'s' if len(items) > 1 else ''}{album_str}" - ) - else: - fmt = "" - prompt = "Really remove from the library?" - prompt_all = ( - "Really remove" - f" {len(items)} item{'s' if len(items) > 1 else ''}{album_str}" - " from the library?" - ) - - # Helpers for printing affected items - def fmt_track(t): - ui.print_(format(t, fmt)) - - def fmt_album(a): - ui.print_() - for i in a.items(): - fmt_track(i) - - fmt_obj = fmt_album if album else fmt_track - - # Show all the items. - for o in objs: - fmt_obj(o) - - # Confirm with user. - objs = ui.input_select_objects( - prompt, objs, fmt_obj, prompt_all=prompt_all - ) - - if not objs: - return - - # Remove (and possibly delete) items. - with lib.transaction(): - for obj in objs: - obj.remove(delete) - - -def remove_func(lib, opts, args): - remove_items(lib, args, opts.album, opts.delete, opts.force) - - -remove_cmd = ui.Subcommand( - "remove", help="remove matching items from the library", aliases=("rm",) -) -remove_cmd.parser.add_option( - "-d", "--delete", action="store_true", help="also remove files from disk" -) -remove_cmd.parser.add_option( - "-f", "--force", action="store_true", help="do not ask when removing items" -) -remove_cmd.parser.add_album_option() -remove_cmd.func = remove_func -default_commands.append(remove_cmd) - - -# stats: Show library/query statistics. - - -def show_stats(lib, query, exact): - """Shows some statistics about the matched items.""" - items = lib.items(query) - - total_size = 0 - total_time = 0.0 - total_items = 0 - artists = set() - albums = set() - album_artists = set() - - for item in items: - if exact: - try: - total_size += os.path.getsize(syspath(item.path)) - except OSError as exc: - log.info("could not get size of {.path}: {}", item, exc) - else: - total_size += int(item.length * item.bitrate / 8) - total_time += item.length - total_items += 1 - artists.add(item.artist) - album_artists.add(item.albumartist) - if item.album_id: - albums.add(item.album_id) - - size_str = human_bytes(total_size) - if exact: - size_str += f" ({total_size} bytes)" - - print_(f"""Tracks: {total_items} -Total time: {human_seconds(total_time)} -{f" ({total_time:.2f} seconds)" if exact else ""} -{"Total size" if exact else "Approximate total size"}: {size_str} -Artists: {len(artists)} -Albums: {len(albums)} -Album artists: {len(album_artists)}""") - - -def stats_func(lib, opts, args): - show_stats(lib, args, opts.exact) - - -stats_cmd = ui.Subcommand( - "stats", help="show statistics about the library or a query" -) -stats_cmd.parser.add_option( - "-e", "--exact", action="store_true", help="exact size and time" -) -stats_cmd.func = stats_func -default_commands.append(stats_cmd) - - -# version: Show current beets version. - - -def show_version(lib, opts, args): - print_(f"beets version {beets.__version__}") - print_(f"Python version {python_version()}") - # Show plugins. - names = sorted(p.name for p in plugins.find_plugins()) - if names: - print_("plugins:", ", ".join(names)) - else: - print_("no plugins loaded") - - -version_cmd = ui.Subcommand("version", help="output version information") -version_cmd.func = show_version -default_commands.append(version_cmd) - - -# modify: Declaratively change metadata. - - -def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): - """Modifies matching items according to user-specified assignments and - deletions. - - `mods` is a dictionary of field and value pairse indicating - assignments. `dels` is a list of fields to be deleted. - """ - # Parse key=value specifications into a dictionary. - model_cls = library.Album if album else library.Item - - # Get the items to modify. - items, albums = _do_query(lib, query, album, False) - objs = albums if album else items - - # Apply changes *temporarily*, preview them, and collect modified - # objects. - print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") - changed = [] - templates = { - key: functemplate.template(value) for key, value in mods.items() - } - for obj in objs: - obj_mods = { - key: model_cls._parse(key, obj.evaluate_template(templates[key])) - for key in mods.keys() - } - if print_and_modify(obj, obj_mods, dels) and obj not in changed: - changed.append(obj) - - # Still something to do? - if not changed: - print_("No changes to make.") - return - - # Confirm action. - if confirm: - if write and move: - extra = ", move and write tags" - elif write: - extra = " and write tags" - elif move: - extra = " and move" - else: - extra = "" - - changed = ui.input_select_objects( - f"Really modify{extra}", - changed, - lambda o: print_and_modify(o, mods, dels), - ) - - # Apply changes to database and files - with lib.transaction(): - for obj in changed: - obj.try_sync(write, move, inherit) - - -def print_and_modify(obj, mods, dels): - """Print the modifications to an item and return a bool indicating - whether any changes were made. - - `mods` is a dictionary of fields and values to update on the object; - `dels` is a sequence of fields to delete. - """ - obj.update(mods) - for field in dels: - try: - del obj[field] - except KeyError: - pass - return ui.show_model_changes(obj) - - -def modify_parse_args(args): - """Split the arguments for the modify subcommand into query parts, - assignments (field=value), and deletions (field!). Returns the result as - a three-tuple in that order. - """ - mods = {} - dels = [] - query = [] - for arg in args: - if arg.endswith("!") and "=" not in arg and ":" not in arg: - dels.append(arg[:-1]) # Strip trailing !. - elif "=" in arg and ":" not in arg.split("=", 1)[0]: - key, val = arg.split("=", 1) - mods[key] = val - else: - query.append(arg) - return query, mods, dels - - -def modify_func(lib, opts, args): - query, mods, dels = modify_parse_args(args) - if not mods and not dels: - raise ui.UserError("no modifications specified") - modify_items( - lib, - mods, - dels, - query, - ui.should_write(opts.write), - ui.should_move(opts.move), - opts.album, - not opts.yes, - opts.inherit, - ) - - -modify_cmd = ui.Subcommand( - "modify", help="change metadata fields", aliases=("mod",) -) -modify_cmd.parser.add_option( - "-m", - "--move", - action="store_true", - dest="move", - help="move files in the library directory", -) -modify_cmd.parser.add_option( - "-M", - "--nomove", - action="store_false", - dest="move", - help="don't move files in library", -) -modify_cmd.parser.add_option( - "-w", - "--write", - action="store_true", - default=None, - help="write new metadata to files' tags (default)", -) -modify_cmd.parser.add_option( - "-W", - "--nowrite", - action="store_false", - dest="write", - help="don't write metadata (opposite of -w)", -) -modify_cmd.parser.add_album_option() -modify_cmd.parser.add_format_option(target="item") -modify_cmd.parser.add_option( - "-y", "--yes", action="store_true", help="skip confirmation" -) -modify_cmd.parser.add_option( - "-I", - "--noinherit", - action="store_false", - dest="inherit", - default=True, - help="when modifying albums, don't also change item data", -) -modify_cmd.func = modify_func -default_commands.append(modify_cmd) - - -# move: Move/copy files to the library or a new base directory. - - -def move_items( - lib, - dest_path: util.PathLike, - query, - copy, - album, - pretend, - confirm=False, - export=False, -): - """Moves or copies items to a new base directory, given by dest. If - dest is None, then the library's base directory is used, making the - command "consolidate" files. - """ - dest = os.fsencode(dest_path) if dest_path else dest_path - items, albums = _do_query(lib, query, album, False) - objs = albums if album else items - num_objs = len(objs) - - # Filter out files that don't need to be moved. - def isitemmoved(item): - return item.path != item.destination(basedir=dest) - - def isalbummoved(album): - return any(isitemmoved(i) for i in album.items()) - - objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] - num_unmoved = num_objs - len(objs) - # Report unmoved files that match the query. - unmoved_msg = "" - if num_unmoved > 0: - unmoved_msg = f" ({num_unmoved} already in place)" - - copy = copy or export # Exporting always copies. - action = "Copying" if copy else "Moving" - act = "copy" if copy else "move" - entity = "album" if album else "item" - log.info( - "{} {} {}{}{}.", - action, - len(objs), - entity, - "s" if len(objs) != 1 else "", - unmoved_msg, - ) - if not objs: - return - - if pretend: - if album: - show_path_changes( - [ - (item.path, item.destination(basedir=dest)) - for obj in objs - for item in obj.items() - ] - ) - else: - show_path_changes( - [(obj.path, obj.destination(basedir=dest)) for obj in objs] - ) - else: - if confirm: - objs = ui.input_select_objects( - f"Really {act}", - objs, - lambda o: show_path_changes( - [(o.path, o.destination(basedir=dest))] - ), - ) - - for obj in objs: - log.debug("moving: {.filepath}", obj) - - if export: - # Copy without affecting the database. - obj.move( - operation=MoveOperation.COPY, basedir=dest, store=False - ) - else: - # Ordinary move/copy: store the new path. - if copy: - obj.move(operation=MoveOperation.COPY, basedir=dest) - else: - obj.move(operation=MoveOperation.MOVE, basedir=dest) - - -def move_func(lib, opts, args): - dest = opts.dest - if dest is not None: - dest = normpath(dest) - if not os.path.isdir(syspath(dest)): - raise ui.UserError(f"no such directory: {displayable_path(dest)}") - - move_items( - lib, - dest, - args, - opts.copy, - opts.album, - opts.pretend, - opts.timid, - opts.export, - ) - - -move_cmd = ui.Subcommand("move", help="move or copy items", aliases=("mv",)) -move_cmd.parser.add_option( - "-d", "--dest", metavar="DIR", dest="dest", help="destination directory" -) -move_cmd.parser.add_option( - "-c", - "--copy", - default=False, - action="store_true", - help="copy instead of moving", -) -move_cmd.parser.add_option( - "-p", - "--pretend", - default=False, - action="store_true", - help="show how files would be moved, but don't touch anything", -) -move_cmd.parser.add_option( - "-t", - "--timid", - dest="timid", - action="store_true", - help="always confirm all actions", -) -move_cmd.parser.add_option( - "-e", - "--export", - default=False, - action="store_true", - help="copy without changing the database path", -) -move_cmd.parser.add_album_option() -move_cmd.func = move_func -default_commands.append(move_cmd) - - -# write: Write tags into files. - - -def write_items(lib, query, pretend, force): - """Write tag information from the database to the respective files - in the filesystem. - """ - items, albums = _do_query(lib, query, False, False) - - for item in items: - # Item deleted? - if not os.path.exists(syspath(item.path)): - log.info("missing file: {.filepath}", item) - continue - - # Get an Item object reflecting the "clean" (on-disk) state. - try: - clean_item = library.Item.from_path(item.path) - except library.ReadError as exc: - log.error("error reading {.filepath}: {}", item, exc) - continue - - # Check for and display changes. - changed = ui.show_model_changes( - item, clean_item, library.Item._media_tag_fields, force - ) - if (changed or force) and not pretend: - # We use `try_sync` here to keep the mtime up to date in the - # database. - item.try_sync(True, False) - - -def write_func(lib, opts, args): - write_items(lib, args, opts.pretend, opts.force) - - -write_cmd = ui.Subcommand("write", help="write tag information to files") -write_cmd.parser.add_option( - "-p", - "--pretend", - action="store_true", - help="show all changes but do nothing", -) -write_cmd.parser.add_option( - "-f", - "--force", - action="store_true", - help="write tags even if the existing tags match the database", -) -write_cmd.func = write_func -default_commands.append(write_cmd) - - -# config: Show and edit user configuration. - - -def config_func(lib, opts, args): - # Make sure lazy configuration is loaded - config.resolve() - - # Print paths. - if opts.paths: - filenames = [] - for source in config.sources: - if not opts.defaults and source.default: - continue - if source.filename: - filenames.append(source.filename) - - # In case the user config file does not exist, prepend it to the - # list. - user_path = config.user_config_path() - if user_path not in filenames: - filenames.insert(0, user_path) - - for filename in filenames: - print_(displayable_path(filename)) - - # Open in editor. - elif opts.edit: - config_edit() - - # Dump configuration. - else: - config_out = config.dump(full=opts.defaults, redact=opts.redact) - if config_out.strip() != "{}": - print_(config_out) - else: - print("Empty configuration") - - -def config_edit(): - """Open a program to edit the user configuration. - An empty config file is created if no existing config file exists. - """ - path = config.user_config_path() - editor = util.editor_command() - try: - if not os.path.isfile(path): - open(path, "w+").close() - util.interactive_open([path], editor) - except OSError as exc: - message = f"Could not edit configuration: {exc}" - if not editor: - message += ( - ". Please set the VISUAL (or EDITOR) environment variable" - ) - raise ui.UserError(message) - - -config_cmd = ui.Subcommand("config", help="show or edit the user configuration") -config_cmd.parser.add_option( - "-p", - "--paths", - action="store_true", - help="show files that configuration was loaded from", -) -config_cmd.parser.add_option( - "-e", - "--edit", - action="store_true", - help="edit user configuration with $VISUAL (or $EDITOR)", -) -config_cmd.parser.add_option( - "-d", - "--defaults", - action="store_true", - help="include the default configuration", -) -config_cmd.parser.add_option( - "-c", - "--clear", - action="store_false", - dest="redact", - default=True, - help="do not redact sensitive fields", -) -config_cmd.func = config_func -default_commands.append(config_cmd) - - -# completion: print completion script - - -def print_completion(*args): - for line in completion_script(default_commands + plugins.commands()): - print_(line, end="") - if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS): - log.warning( - "Warning: Unable to find the bash-completion package. " - "Command line completion might not work." - ) - - -BASH_COMPLETION_PATHS = [ - b"/etc/bash_completion", - b"/usr/share/bash-completion/bash_completion", - b"/usr/local/share/bash-completion/bash_completion", - # SmartOS - b"/opt/local/share/bash-completion/bash_completion", - # Homebrew (before bash-completion2) - b"/usr/local/etc/bash_completion", -] - - -def completion_script(commands): - """Yield the full completion shell script as strings. - - ``commands`` is alist of ``ui.Subcommand`` instances to generate - completion data for. - """ - base_script = os.path.join(os.path.dirname(__file__), "completion_base.sh") - with open(base_script) as base_script: - yield base_script.read() - - options = {} - aliases = {} - command_names = [] - - # Collect subcommands - for cmd in commands: - name = cmd.name - command_names.append(name) - - for alias in cmd.aliases: - if re.match(r"^\w+$", alias): - aliases[alias] = name - - options[name] = {"flags": [], "opts": []} - for opts in cmd.parser._get_all_options()[1:]: - if opts.action in ("store_true", "store_false"): - option_type = "flags" - else: - option_type = "opts" - - options[name][option_type].extend( - opts._short_opts + opts._long_opts - ) - - # Add global options - options["_global"] = { - "flags": ["-v", "--verbose"], - "opts": "-l --library -c --config -d --directory -h --help".split(" "), - } - - # Add flags common to all commands - options["_common"] = {"flags": ["-h", "--help"]} - - # Start generating the script - yield "_beet() {\n" - - # Command names - yield f" local commands={' '.join(command_names)!r}\n" - yield "\n" - - # Command aliases - yield f" local aliases={' '.join(aliases.keys())!r}\n" - for alias, cmd in aliases.items(): - yield f" local alias__{alias.replace('-', '_')}={cmd}\n" - yield "\n" - - # Fields - fields = library.Item._fields.keys() | library.Album._fields.keys() - yield f" fields={' '.join(fields)!r}\n" - - # Command options - for cmd, opts in options.items(): - for option_type, option_list in opts.items(): - if option_list: - option_list = " ".join(option_list) - yield ( - " local" - f" {option_type}__{cmd.replace('-', '_')}='{option_list}'\n" - ) - - yield " _beet_dispatch\n" - yield "}\n" - - -completion_cmd = ui.Subcommand( - "completion", - help="print shell script that provides command line completion", -) -completion_cmd.func = print_completion -completion_cmd.hide = True -default_commands.append(completion_cmd) diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py new file mode 100644 index 000000000..ba64523cc --- /dev/null +++ b/beets/ui/commands/__init__.py @@ -0,0 +1,58 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""This module provides the default commands for beets' command-line +interface. +""" + +from beets import plugins + +from .completion import register_print_completion +from .config import config_cmd +from .fields import fields_cmd +from .help import HelpCommand +from .import_ import import_cmd +from .list import list_cmd +from .modify import modify_cmd +from .move import move_cmd +from .remove import remove_cmd +from .stats import stats_cmd +from .update import update_cmd +from .version import version_cmd +from .write import write_cmd + +# The list of default subcommands. This is populated with Subcommand +# objects that can be fed to a SubcommandsOptionParser. +default_commands = [ + fields_cmd, + HelpCommand(), + import_cmd, + list_cmd, + update_cmd, + remove_cmd, + stats_cmd, + version_cmd, + modify_cmd, + move_cmd, + write_cmd, + config_cmd, + *plugins.commands(), +] + + +# Register the completion command last as it needs all +# other commands to be present. +register_print_completion(default_commands) + +__all__ = ["default_commands"] diff --git a/beets/ui/commands/_utils.py b/beets/ui/commands/_utils.py new file mode 100644 index 000000000..17e2f34c8 --- /dev/null +++ b/beets/ui/commands/_utils.py @@ -0,0 +1,67 @@ +"""Utility functions for beets UI commands.""" + +import os + +from beets import ui +from beets.util import displayable_path, normpath, syspath + + +def do_query(lib, query, album, also_items=True): + """For commands that operate on matched items, performs a query + and returns a list of matching items and a list of matching + albums. (The latter is only nonempty when album is True.) Raises + a UserError if no items match. also_items controls whether, when + fetching albums, the associated items should be fetched also. + """ + if album: + albums = list(lib.albums(query)) + items = [] + if also_items: + for al in albums: + items += al.items() + + else: + albums = [] + items = list(lib.items(query)) + + if album and not albums: + raise ui.UserError("No matching albums found.") + elif not album and not items: + raise ui.UserError("No matching items found.") + + return items, albums + + +def paths_from_logfile(path): + """Parse the logfile and yield skipped paths to pass to the `import` + command. + """ + with open(path, encoding="utf-8") as fp: + for i, line in enumerate(fp, start=1): + verb, sep, paths = line.rstrip("\n").partition(" ") + if not sep: + raise ValueError(f"line {i} is invalid") + + # Ignore informational lines that don't need to be re-imported. + if verb in {"import", "duplicate-keep", "duplicate-replace"}: + continue + + if verb not in {"asis", "skip", "duplicate-skip"}: + raise ValueError(f"line {i} contains unknown verb {verb}") + + yield os.path.commonpath(paths.split("; ")) + + +def parse_logfiles(logfiles): + """Parse all `logfiles` and yield paths from it.""" + for logfile in logfiles: + try: + yield from paths_from_logfile(syspath(normpath(logfile))) + except ValueError as err: + raise ui.UserError( + f"malformed logfile {displayable_path(logfile)}: {err}" + ) from err + except OSError as err: + raise ui.UserError( + f"unreadable logfile {displayable_path(logfile)}: {err}" + ) from err diff --git a/beets/ui/commands/completion.py b/beets/ui/commands/completion.py new file mode 100644 index 000000000..70636a022 --- /dev/null +++ b/beets/ui/commands/completion.py @@ -0,0 +1,115 @@ +"""The 'completion' command: print shell script for command line completion.""" + +import os +import re + +from beets import library, logging, plugins, ui +from beets.util import syspath + +# Global logger. +log = logging.getLogger("beets") + + +def register_print_completion(default_commands: list[ui.Subcommand]): + def print_completion(*args): + for line in completion_script(default_commands + plugins.commands()): + ui.print_(line, end="") + if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS): + log.warning( + "Warning: Unable to find the bash-completion package. " + "Command line completion might not work." + ) + + completion_cmd = ui.Subcommand( + "completion", + help="print shell script that provides command line completion", + ) + completion_cmd.func = print_completion + completion_cmd.hide = True + + default_commands.append(completion_cmd) + + +BASH_COMPLETION_PATHS = [ + b"/etc/bash_completion", + b"/usr/share/bash-completion/bash_completion", + b"/usr/local/share/bash-completion/bash_completion", + # SmartOS + b"/opt/local/share/bash-completion/bash_completion", + # Homebrew (before bash-completion2) + b"/usr/local/etc/bash_completion", +] + + +def completion_script(commands): + """Yield the full completion shell script as strings. + + ``commands`` is alist of ``ui.Subcommand`` instances to generate + completion data for. + """ + base_script = os.path.join(os.path.dirname(__file__), "completion_base.sh") + with open(base_script) as base_script: + yield base_script.read() + + options = {} + aliases = {} + command_names = [] + + # Collect subcommands + for cmd in commands: + name = cmd.name + command_names.append(name) + + for alias in cmd.aliases: + if re.match(r"^\w+$", alias): + aliases[alias] = name + + options[name] = {"flags": [], "opts": []} + for opts in cmd.parser._get_all_options()[1:]: + if opts.action in ("store_true", "store_false"): + option_type = "flags" + else: + option_type = "opts" + + options[name][option_type].extend( + opts._short_opts + opts._long_opts + ) + + # Add global options + options["_global"] = { + "flags": ["-v", "--verbose"], + "opts": "-l --library -c --config -d --directory -h --help".split(" "), + } + + # Add flags common to all commands + options["_common"] = {"flags": ["-h", "--help"]} + + # Start generating the script + yield "_beet() {\n" + + # Command names + yield f" local commands={' '.join(command_names)!r}\n" + yield "\n" + + # Command aliases + yield f" local aliases={' '.join(aliases.keys())!r}\n" + for alias, cmd in aliases.items(): + yield f" local alias__{alias.replace('-', '_')}={cmd}\n" + yield "\n" + + # Fields + fields = library.Item._fields.keys() | library.Album._fields.keys() + yield f" fields={' '.join(fields)!r}\n" + + # Command options + for cmd, opts in options.items(): + for option_type, option_list in opts.items(): + if option_list: + option_list = " ".join(option_list) + yield ( + " local" + f" {option_type}__{cmd.replace('-', '_')}='{option_list}'\n" + ) + + yield " _beet_dispatch\n" + yield "}\n" diff --git a/beets/ui/commands/config.py b/beets/ui/commands/config.py new file mode 100644 index 000000000..81cc2851a --- /dev/null +++ b/beets/ui/commands/config.py @@ -0,0 +1,89 @@ +"""The 'config' command: show and edit user configuration.""" + +import os + +from beets import config, ui, util + + +def config_func(lib, opts, args): + # Make sure lazy configuration is loaded + config.resolve() + + # Print paths. + if opts.paths: + filenames = [] + for source in config.sources: + if not opts.defaults and source.default: + continue + if source.filename: + filenames.append(source.filename) + + # In case the user config file does not exist, prepend it to the + # list. + user_path = config.user_config_path() + if user_path not in filenames: + filenames.insert(0, user_path) + + for filename in filenames: + ui.print_(util.displayable_path(filename)) + + # Open in editor. + elif opts.edit: + config_edit() + + # Dump configuration. + else: + config_out = config.dump(full=opts.defaults, redact=opts.redact) + if config_out.strip() != "{}": + ui.print_(config_out) + else: + print("Empty configuration") + + +def config_edit(): + """Open a program to edit the user configuration. + An empty config file is created if no existing config file exists. + """ + path = config.user_config_path() + editor = util.editor_command() + try: + if not os.path.isfile(path): + open(path, "w+").close() + util.interactive_open([path], editor) + except OSError as exc: + message = f"Could not edit configuration: {exc}" + if not editor: + message += ( + ". Please set the VISUAL (or EDITOR) environment variable" + ) + raise ui.UserError(message) + + +config_cmd = ui.Subcommand("config", help="show or edit the user configuration") +config_cmd.parser.add_option( + "-p", + "--paths", + action="store_true", + help="show files that configuration was loaded from", +) +config_cmd.parser.add_option( + "-e", + "--edit", + action="store_true", + help="edit user configuration with $VISUAL (or $EDITOR)", +) +config_cmd.parser.add_option( + "-d", + "--defaults", + action="store_true", + help="include the default configuration", +) +config_cmd.parser.add_option( + "-c", + "--clear", + action="store_false", + dest="redact", + default=True, + help="do not redact sensitive fields", +) +config_cmd.func = config_func diff --git a/beets/ui/commands/fields.py b/beets/ui/commands/fields.py new file mode 100644 index 000000000..de8f89103 --- /dev/null +++ b/beets/ui/commands/fields.py @@ -0,0 +1,41 @@ +"""The `fields` command: show available fields for queries and format strings.""" + +import textwrap + +from beets import library, ui + + +def _print_keys(query): + """Given a SQLite query result, print the `key` field of each + returned row, with indentation of 2 spaces. + """ + for row in query: + ui.print_(f" {row['key']}") + + +def fields_func(lib, opts, args): + def _print_rows(names): + names.sort() + ui.print_(textwrap.indent("\n".join(names), " ")) + + ui.print_("Item fields:") + _print_rows(library.Item.all_keys()) + + ui.print_("Album fields:") + _print_rows(library.Album.all_keys()) + + with lib.transaction() as tx: + # The SQL uses the DISTINCT to get unique values from the query + unique_fields = "SELECT DISTINCT key FROM ({})" + + ui.print_("Item flexible attributes:") + _print_keys(tx.query(unique_fields.format(library.Item._flex_table))) + + ui.print_("Album flexible attributes:") + _print_keys(tx.query(unique_fields.format(library.Album._flex_table))) + + +fields_cmd = ui.Subcommand( + "fields", help="show fields available for queries and format strings" +) +fields_cmd.func = fields_func diff --git a/beets/ui/commands/help.py b/beets/ui/commands/help.py new file mode 100644 index 000000000..345f94c67 --- /dev/null +++ b/beets/ui/commands/help.py @@ -0,0 +1,22 @@ +"""The 'help' command: show help information for commands.""" + +from beets import ui + + +class HelpCommand(ui.Subcommand): + def __init__(self): + super().__init__( + "help", + aliases=("?",), + help="give detailed help on a specific sub-command", + ) + + def func(self, lib, opts, args): + if args: + cmdname = args[0] + helpcommand = self.root_parser._subcommand_for_name(cmdname) + if not helpcommand: + raise ui.UserError(f"unknown command '{cmdname}'") + helpcommand.print_help() + else: + self.root_parser.print_help() diff --git a/beets/ui/commands/import_/__init__.py b/beets/ui/commands/import_/__init__.py new file mode 100644 index 000000000..6940528ad --- /dev/null +++ b/beets/ui/commands/import_/__init__.py @@ -0,0 +1,281 @@ +"""The `import` command: import new music into the library.""" + +import os + +from beets import config, logging, plugins, ui +from beets.util import displayable_path, normpath, syspath + +from .._utils import parse_logfiles +from .session import TerminalImportSession + +# Global logger. +log = logging.getLogger("beets") + + +def import_files(lib, paths: list[bytes], query): + """Import the files in the given list of paths or matching the + query. + """ + # Check parameter consistency. + if config["import"]["quiet"] and config["import"]["timid"]: + raise ui.UserError("can't be both quiet and timid") + + # Open the log. + if config["import"]["log"].get() is not None: + logpath = syspath(config["import"]["log"].as_filename()) + try: + loghandler = logging.FileHandler(logpath, encoding="utf-8") + except OSError: + raise ui.UserError( + "Could not open log file for writing:" + f" {displayable_path(logpath)}" + ) + else: + loghandler = None + + # Never ask for input in quiet mode. + if config["import"]["resume"].get() == "ask" and config["import"]["quiet"]: + config["import"]["resume"] = False + + session = TerminalImportSession(lib, loghandler, paths, query) + session.run() + + # Emit event. + plugins.send("import", lib=lib, paths=paths) + + +def import_func(lib, opts, args: list[str]): + config["import"].set_args(opts) + + # Special case: --copy flag suppresses import_move (which would + # otherwise take precedence). + if opts.copy: + config["import"]["move"] = False + + if opts.library: + query = args + byte_paths = [] + else: + query = None + paths = args + + # The paths from the logfiles go into a separate list to allow handling + # errors differently from user-specified paths. + paths_from_logfiles = list(parse_logfiles(opts.from_logfiles or [])) + + if not paths and not paths_from_logfiles: + raise ui.UserError("no path specified") + + byte_paths = [os.fsencode(p) for p in paths] + paths_from_logfiles = [os.fsencode(p) for p in paths_from_logfiles] + + # Check the user-specified directories. + for path in byte_paths: + if not os.path.exists(syspath(normpath(path))): + raise ui.UserError( + f"no such file or directory: {displayable_path(path)}" + ) + + # Check the directories from the logfiles, but don't throw an error in + # case those paths don't exist. Maybe some of those paths have already + # been imported and moved separately, so logging a warning should + # suffice. + for path in paths_from_logfiles: + if not os.path.exists(syspath(normpath(path))): + log.warning( + "No such file or directory: {}", displayable_path(path) + ) + continue + + byte_paths.append(path) + + # If all paths were read from a logfile, and none of them exist, throw + # an error + if not paths: + raise ui.UserError("none of the paths are importable") + + import_files(lib, byte_paths, query) + + +import_cmd = ui.Subcommand( + "import", help="import new music", aliases=("imp", "im") +) +import_cmd.parser.add_option( + "-c", + "--copy", + action="store_true", + default=None, + help="copy tracks into library directory (default)", +) +import_cmd.parser.add_option( + "-C", + "--nocopy", + action="store_false", + dest="copy", + help="don't copy tracks (opposite of -c)", +) +import_cmd.parser.add_option( + "-m", + "--move", + action="store_true", + dest="move", + help="move tracks into the library (overrides -c)", +) +import_cmd.parser.add_option( + "-w", + "--write", + action="store_true", + default=None, + help="write new metadata to files' tags (default)", +) +import_cmd.parser.add_option( + "-W", + "--nowrite", + action="store_false", + dest="write", + help="don't write metadata (opposite of -w)", +) +import_cmd.parser.add_option( + "-a", + "--autotag", + action="store_true", + dest="autotag", + help="infer tags for imported files (default)", +) +import_cmd.parser.add_option( + "-A", + "--noautotag", + action="store_false", + dest="autotag", + help="don't infer tags for imported files (opposite of -a)", +) +import_cmd.parser.add_option( + "-p", + "--resume", + action="store_true", + default=None, + help="resume importing if interrupted", +) +import_cmd.parser.add_option( + "-P", + "--noresume", + action="store_false", + dest="resume", + help="do not try to resume importing", +) +import_cmd.parser.add_option( + "-q", + "--quiet", + action="store_true", + dest="quiet", + help="never prompt for input: skip albums instead", +) +import_cmd.parser.add_option( + "--quiet-fallback", + type="string", + dest="quiet_fallback", + help="decision in quiet mode when no strong match: skip or asis", +) +import_cmd.parser.add_option( + "-l", + "--log", + dest="log", + help="file to log untaggable albums for later review", +) +import_cmd.parser.add_option( + "-s", + "--singletons", + action="store_true", + help="import individual tracks instead of full albums", +) +import_cmd.parser.add_option( + "-t", + "--timid", + dest="timid", + action="store_true", + help="always confirm all actions", +) +import_cmd.parser.add_option( + "-L", + "--library", + dest="library", + action="store_true", + help="retag items matching a query", +) +import_cmd.parser.add_option( + "-i", + "--incremental", + dest="incremental", + action="store_true", + help="skip already-imported directories", +) +import_cmd.parser.add_option( + "-I", + "--noincremental", + dest="incremental", + action="store_false", + help="do not skip already-imported directories", +) +import_cmd.parser.add_option( + "-R", + "--incremental-skip-later", + action="store_true", + dest="incremental_skip_later", + help="do not record skipped files during incremental import", +) +import_cmd.parser.add_option( + "-r", + "--noincremental-skip-later", + action="store_false", + dest="incremental_skip_later", + help="record skipped files during incremental import", +) +import_cmd.parser.add_option( + "--from-scratch", + dest="from_scratch", + action="store_true", + help="erase existing metadata before applying new metadata", +) +import_cmd.parser.add_option( + "--flat", + dest="flat", + action="store_true", + help="import an entire tree as a single album", +) +import_cmd.parser.add_option( + "-g", + "--group-albums", + dest="group_albums", + action="store_true", + help="group tracks in a folder into separate albums", +) +import_cmd.parser.add_option( + "--pretend", + dest="pretend", + action="store_true", + help="just print the files to import", +) +import_cmd.parser.add_option( + "-S", + "--search-id", + dest="search_ids", + action="append", + metavar="ID", + help="restrict matching to a specific metadata backend ID", +) +import_cmd.parser.add_option( + "--from-logfile", + dest="from_logfiles", + action="append", + metavar="PATH", + help="read skipped paths from an existing logfile", +) +import_cmd.parser.add_option( + "--set", + dest="set_fields", + action="callback", + callback=ui._store_dict, + metavar="FIELD=VALUE", + help="set the given fields to the supplied values", +) +import_cmd.func = import_func diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py new file mode 100644 index 000000000..b6617d487 --- /dev/null +++ b/beets/ui/commands/import_/display.py @@ -0,0 +1,573 @@ +import os +from collections.abc import Sequence +from functools import cached_property + +from beets import autotag, config, logging, ui +from beets.autotag import hooks +from beets.util import displayable_path +from beets.util.units import human_seconds_short + +VARIOUS_ARTISTS = "Various Artists" + +# Global logger. +log = logging.getLogger("beets") + + +class ChangeRepresentation: + """Keeps track of all information needed to generate a (colored) text + representation of the changes that will be made if an album or singleton's + tags are changed according to `match`, which must be an AlbumMatch or + TrackMatch object, accordingly. + """ + + @cached_property + def changed_prefix(self) -> str: + return ui.colorize("changed", "\u2260") + + cur_artist = None + # cur_album set if album, cur_title set if singleton + cur_album = None + cur_title = None + match = None + indent_header = "" + indent_detail = "" + + def __init__(self): + # Read match header indentation width from config. + match_header_indent_width = config["ui"]["import"]["indentation"][ + "match_header" + ].as_number() + self.indent_header = ui.indent(match_header_indent_width) + + # Read match detail indentation width from config. + match_detail_indent_width = config["ui"]["import"]["indentation"][ + "match_details" + ].as_number() + self.indent_detail = ui.indent(match_detail_indent_width) + + # Read match tracklist indentation width from config + match_tracklist_indent_width = config["ui"]["import"]["indentation"][ + "match_tracklist" + ].as_number() + self.indent_tracklist = ui.indent(match_tracklist_indent_width) + self.layout = config["ui"]["import"]["layout"].as_choice( + { + "column": 0, + "newline": 1, + } + ) + + def print_layout( + self, indent, left, right, separator=" -> ", max_width=None + ): + if not max_width: + # If no max_width provided, use terminal width + max_width = ui.term_width() + if self.layout == 0: + ui.print_column_layout(indent, left, right, separator, max_width) + else: + ui.print_newline_layout(indent, left, right, separator, max_width) + + def show_match_header(self): + """Print out a 'header' identifying the suggested match (album name, + artist name,...) and summarizing the changes that would be made should + the user accept the match. + """ + # Print newline at beginning of change block. + ui.print_("") + + # 'Match' line and similarity. + ui.print_( + f"{self.indent_header}Match ({dist_string(self.match.distance)}):" + ) + + if isinstance(self.match.info, autotag.hooks.AlbumInfo): + # Matching an album - print that + artist_album_str = ( + f"{self.match.info.artist} - {self.match.info.album}" + ) + else: + # Matching a single track + artist_album_str = ( + f"{self.match.info.artist} - {self.match.info.title}" + ) + ui.print_( + self.indent_header + + dist_colorize(artist_album_str, self.match.distance) + ) + + # Penalties. + penalties = penalty_string(self.match.distance) + if penalties: + ui.print_(f"{self.indent_header}{penalties}") + + # Disambiguation. + disambig = disambig_string(self.match.info) + if disambig: + ui.print_(f"{self.indent_header}{disambig}") + + # Data URL. + if self.match.info.data_url: + url = ui.colorize("text_faint", f"{self.match.info.data_url}") + ui.print_(f"{self.indent_header}{url}") + + def show_match_details(self): + """Print out the details of the match, including changes in album name + and artist name. + """ + # Artist. + artist_l, artist_r = self.cur_artist or "", self.match.info.artist + if artist_r == VARIOUS_ARTISTS: + # Hide artists for VA releases. + artist_l, artist_r = "", "" + if artist_l != artist_r: + artist_l, artist_r = ui.colordiff(artist_l, artist_r) + left = { + "prefix": f"{self.changed_prefix} Artist: ", + "contents": artist_l, + "suffix": "", + } + right = {"prefix": "", "contents": artist_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + + else: + ui.print_(f"{self.indent_detail}*", "Artist:", artist_r) + + if self.cur_album: + # Album + album_l, album_r = self.cur_album or "", self.match.info.album + if ( + self.cur_album != self.match.info.album + and self.match.info.album != VARIOUS_ARTISTS + ): + album_l, album_r = ui.colordiff(album_l, album_r) + left = { + "prefix": f"{self.changed_prefix} Album: ", + "contents": album_l, + "suffix": "", + } + right = {"prefix": "", "contents": album_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + else: + ui.print_(f"{self.indent_detail}*", "Album:", album_r) + elif self.cur_title: + # Title - for singletons + title_l, title_r = self.cur_title or "", self.match.info.title + if self.cur_title != self.match.info.title: + title_l, title_r = ui.colordiff(title_l, title_r) + left = { + "prefix": f"{self.changed_prefix} Title: ", + "contents": title_l, + "suffix": "", + } + right = {"prefix": "", "contents": title_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + else: + ui.print_(f"{self.indent_detail}*", "Title:", title_r) + + def make_medium_info_line(self, track_info): + """Construct a line with the current medium's info.""" + track_media = track_info.get("media", "Media") + # Build output string. + if self.match.info.mediums > 1 and track_info.disctitle: + return ( + f"* {track_media} {track_info.medium}: {track_info.disctitle}" + ) + elif self.match.info.mediums > 1: + return f"* {track_media} {track_info.medium}" + elif track_info.disctitle: + return f"* {track_media}: {track_info.disctitle}" + else: + return "" + + def format_index(self, track_info): + """Return a string representing the track index of the given + TrackInfo or Item object. + """ + if isinstance(track_info, hooks.TrackInfo): + index = track_info.index + medium_index = track_info.medium_index + medium = track_info.medium + mediums = self.match.info.mediums + else: + index = medium_index = track_info.track + medium = track_info.disc + mediums = track_info.disctotal + if config["per_disc_numbering"]: + if mediums and mediums > 1: + return f"{medium}-{medium_index}" + else: + return str(medium_index if medium_index is not None else index) + else: + return str(index) + + def make_track_numbers(self, item, track_info): + """Format colored track indices.""" + cur_track = self.format_index(item) + new_track = self.format_index(track_info) + changed = False + # Choose color based on change. + if cur_track != new_track: + changed = True + if item.track in (track_info.index, track_info.medium_index): + highlight_color = "text_highlight_minor" + else: + highlight_color = "text_highlight" + else: + highlight_color = "text_faint" + + lhs_track = ui.colorize(highlight_color, f"(#{cur_track})") + rhs_track = ui.colorize(highlight_color, f"(#{new_track})") + return lhs_track, rhs_track, changed + + @staticmethod + def make_track_titles(item, track_info): + """Format colored track titles.""" + new_title = track_info.title + if not item.title.strip(): + # If there's no title, we use the filename. Don't colordiff. + cur_title = displayable_path(os.path.basename(item.path)) + return cur_title, new_title, True + else: + # If there is a title, highlight differences. + cur_title = item.title.strip() + cur_col, new_col = ui.colordiff(cur_title, new_title) + return cur_col, new_col, cur_title != new_title + + @staticmethod + def make_track_lengths(item, track_info): + """Format colored track lengths.""" + changed = False + if ( + item.length + and track_info.length + and abs(item.length - track_info.length) + >= config["ui"]["length_diff_thresh"].as_number() + ): + highlight_color = "text_highlight" + changed = True + else: + highlight_color = "text_highlight_minor" + + # Handle nonetype lengths by setting to 0 + cur_length0 = item.length if item.length else 0 + new_length0 = track_info.length if track_info.length else 0 + # format into string + cur_length = f"({human_seconds_short(cur_length0)})" + new_length = f"({human_seconds_short(new_length0)})" + # colorize + lhs_length = ui.colorize(highlight_color, cur_length) + rhs_length = ui.colorize(highlight_color, new_length) + + return lhs_length, rhs_length, changed + + def make_line(self, item, track_info): + """Extract changes from item -> new TrackInfo object, and colorize + appropriately. Returns (lhs, rhs) for column printing. + """ + # Track titles. + lhs_title, rhs_title, diff_title = self.make_track_titles( + item, track_info + ) + # Track number change. + lhs_track, rhs_track, diff_track = self.make_track_numbers( + item, track_info + ) + # Length change. + lhs_length, rhs_length, diff_length = self.make_track_lengths( + item, track_info + ) + + changed = diff_title or diff_track or diff_length + + # Construct lhs and rhs dicts. + # Previously, we printed the penalties, however this is no longer + # the case, thus the 'info' dictionary is unneeded. + # penalties = penalty_string(self.match.distance.tracks[track_info]) + + lhs = { + "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", + "contents": lhs_title, + "suffix": f" {lhs_length}", + } + rhs = {"prefix": "", "contents": "", "suffix": ""} + if not changed: + # Only return the left side, as nothing changed. + return (lhs, rhs) + else: + # Construct a dictionary for the "changed to" side + rhs = { + "prefix": f"{rhs_track} ", + "contents": rhs_title, + "suffix": f" {rhs_length}", + } + return (lhs, rhs) + + def print_tracklist(self, lines): + """Calculates column widths for tracks stored as line tuples: + (left, right). Then prints each line of tracklist. + """ + if len(lines) == 0: + # If no lines provided, e.g. details not required, do nothing. + return + + def get_width(side): + """Return the width of left or right in uncolorized characters.""" + try: + return len( + ui.uncolorize( + " ".join( + [side["prefix"], side["contents"], side["suffix"]] + ) + ) + ) + except KeyError: + # An empty dictionary -> Nothing to report + return 0 + + # Check how to fit content into terminal window + indent_width = len(self.indent_tracklist) + terminal_width = ui.term_width() + joiner_width = len("".join(["* ", " -> "])) + col_width = (terminal_width - indent_width - joiner_width) // 2 + max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines) + max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines) + + if ( + (max_width_l <= col_width) + and (max_width_r <= col_width) + or ( + ((max_width_l > col_width) or (max_width_r > col_width)) + and ((max_width_l + max_width_r) <= col_width * 2) + ) + ): + # All content fits. Either both maximum widths are below column + # widths, or one of the columns is larger than allowed but the + # other is smaller than allowed. + # In this case we can afford to shrink the columns to fit their + # largest string + col_width_l = max_width_l + col_width_r = max_width_r + else: + # Not all content fits - stick with original half/half split + col_width_l = col_width + col_width_r = col_width + + # Print out each line, using the calculated width from above. + for left, right in lines: + left["width"] = col_width_l + right["width"] = col_width_r + self.print_layout(self.indent_tracklist, left, right) + + +class AlbumChange(ChangeRepresentation): + """Album change representation, setting cur_album""" + + def __init__(self, cur_artist, cur_album, match): + super().__init__() + self.cur_artist = cur_artist + self.cur_album = cur_album + self.match = match + + def show_match_tracks(self): + """Print out the tracks of the match, summarizing changes the match + suggests for them. + """ + # Tracks. + # match is an AlbumMatch NamedTuple, mapping is a dict + # Sort the pairs by the track_info index (at index 1 of the NamedTuple) + pairs = list(self.match.mapping.items()) + pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index) + # Build up LHS and RHS for track difference display. The `lines` list + # contains `(left, right)` tuples. + lines = [] + medium = disctitle = None + for item, track_info in pairs: + # If the track is the first on a new medium, show medium + # number and title. + if medium != track_info.medium or disctitle != track_info.disctitle: + # Create header for new medium + header = self.make_medium_info_line(track_info) + if header != "": + # Print tracks from previous medium + self.print_tracklist(lines) + lines = [] + ui.print_(f"{self.indent_detail}{header}") + # Save new medium details for future comparison. + medium, disctitle = track_info.medium, track_info.disctitle + + # Construct the line tuple for the track. + left, right = self.make_line(item, track_info) + if right["contents"] != "": + lines.append((left, right)) + else: + if config["import"]["detail"]: + lines.append((left, right)) + self.print_tracklist(lines) + + # Missing and unmatched tracks. + if self.match.extra_tracks: + ui.print_( + "Missing tracks" + f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -" + f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):" + ) + for track_info in self.match.extra_tracks: + line = f" ! {track_info.title} (#{self.format_index(track_info)})" + if track_info.length: + line += f" ({human_seconds_short(track_info.length)})" + ui.print_(ui.colorize("text_warning", line)) + if self.match.extra_items: + ui.print_(f"Unmatched tracks ({len(self.match.extra_items)}):") + for item in self.match.extra_items: + line = f" ! {item.title} (#{self.format_index(item)})" + if item.length: + line += f" ({human_seconds_short(item.length)})" + ui.print_(ui.colorize("text_warning", line)) + + +class TrackChange(ChangeRepresentation): + """Track change representation, comparing item with match.""" + + def __init__(self, cur_artist, cur_title, match): + super().__init__() + self.cur_artist = cur_artist + self.cur_title = cur_title + self.match = match + + +def show_change(cur_artist, cur_album, match): + """Print out a representation of the changes that will be made if an + album's tags are changed according to `match`, which must be an AlbumMatch + object. + """ + change = AlbumChange( + cur_artist=cur_artist, cur_album=cur_album, match=match + ) + + # Print the match header. + change.show_match_header() + + # Print the match details. + change.show_match_details() + + # Print the match tracks. + change.show_match_tracks() + + +def show_item_change(item, match): + """Print out the change that would occur by tagging `item` with the + metadata from `match`, a TrackMatch object. + """ + change = TrackChange( + cur_artist=item.artist, cur_title=item.title, match=match + ) + # Print the match header. + change.show_match_header() + # Print the match details. + change.show_match_details() + + +def disambig_string(info): + """Generate a string for an AlbumInfo or TrackInfo object that + provides context that helps disambiguate similar-looking albums and + tracks. + """ + if isinstance(info, hooks.AlbumInfo): + disambig = get_album_disambig_fields(info) + elif isinstance(info, hooks.TrackInfo): + disambig = get_singleton_disambig_fields(info) + else: + return "" + + return ", ".join(disambig) + + +def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]: + out = [] + chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq() + calculated_values = { + "index": f"Index {info.index}", + "track_alt": f"Track {info.track_alt}", + "album": ( + f"[{info.album}]" + if ( + config["import"]["singleton_album_disambig"].get() + and info.get("album") + ) + else "" + ), + } + + for field in chosen_fields: + if field in calculated_values: + out.append(str(calculated_values[field])) + else: + try: + out.append(str(info[field])) + except (AttributeError, KeyError): + print(f"Disambiguation string key {field} does not exist.") + + return out + + +def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: + out = [] + chosen_fields = config["match"]["album_disambig_fields"].as_str_seq() + calculated_values = { + "media": ( + f"{info.mediums}x{info.media}" + if (info.mediums and info.mediums > 1) + else info.media + ), + } + + for field in chosen_fields: + if field in calculated_values: + out.append(str(calculated_values[field])) + else: + try: + out.append(str(info[field])) + except (AttributeError, KeyError): + print(f"Disambiguation string key {field} does not exist.") + + return out + + +def dist_colorize(string, dist): + """Formats a string as a colorized similarity string according to + a distance. + """ + if dist <= config["match"]["strong_rec_thresh"].as_number(): + string = ui.colorize("text_success", string) + elif dist <= config["match"]["medium_rec_thresh"].as_number(): + string = ui.colorize("text_warning", string) + else: + string = ui.colorize("text_error", string) + return string + + +def dist_string(dist): + """Formats a distance (a float) as a colorized similarity percentage + string. + """ + string = f"{(1 - dist) * 100:.1f}%" + return dist_colorize(string, dist) + + +def penalty_string(distance, limit=None): + """Returns a colorized string that indicates all the penalties + applied to a distance object. + """ + penalties = [] + for key in distance.keys(): + key = key.replace("album_", "") + key = key.replace("track_", "") + key = key.replace("_", " ") + penalties.append(key) + if penalties: + if limit and len(penalties) > limit: + penalties = penalties[:limit] + ["..."] + # Prefix penalty string with U+2260: Not Equal To + penalty_string = f"\u2260 {', '.join(penalties)}" + return ui.colorize("changed", penalty_string) diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py new file mode 100644 index 000000000..6608705a8 --- /dev/null +++ b/beets/ui/commands/import_/session.py @@ -0,0 +1,558 @@ +from collections import Counter +from itertools import chain +from typing import Any, NamedTuple + +from beets import autotag, config, importer, logging, plugins, ui +from beets.autotag import Recommendation +from beets.util import displayable_path +from beets.util.units import human_bytes, human_seconds_short +from beetsplug.bareasc import print_ + +from .display import ( + disambig_string, + dist_colorize, + penalty_string, + show_change, + show_item_change, +) + +# Global logger. +log = logging.getLogger("beets") + + +class TerminalImportSession(importer.ImportSession): + """An import session that runs in a terminal.""" + + def choose_match(self, task): + """Given an initial autotagging of items, go through an interactive + dance with the user to ask for a choice of metadata. Returns an + AlbumMatch object, ASIS, or SKIP. + """ + # Show what we're tagging. + ui.print_() + + path_str0 = displayable_path(task.paths, "\n") + path_str = ui.colorize("import_path", path_str0) + items_str0 = f"({len(task.items)} items)" + items_str = ui.colorize("import_path_items", items_str0) + ui.print_(" ".join([path_str, items_str])) + + # Let plugins display info or prompt the user before we go through the + # process of selecting candidate. + results = plugins.send( + "import_task_before_choice", session=self, task=task + ) + actions = [action for action in results if action] + + if len(actions) == 1: + return actions[0] + elif len(actions) > 1: + raise plugins.PluginConflictError( + "Only one handler for `import_task_before_choice` may return " + "an action." + ) + + # Take immediate action if appropriate. + action = _summary_judgment(task.rec) + if action == importer.Action.APPLY: + match = task.candidates[0] + show_change(task.cur_artist, task.cur_album, match) + return match + elif action is not None: + return action + + # Loop until we have a choice. + while True: + # Ask for a choice from the user. The result of + # `choose_candidate` may be an `importer.Action`, an + # `AlbumMatch` object for a specific selection, or a + # `PromptChoice`. + choices = self._get_choices(task) + choice = choose_candidate( + task.candidates, + False, + task.rec, + task.cur_artist, + task.cur_album, + itemcount=len(task.items), + choices=choices, + ) + + # Basic choices that require no more action here. + if choice in (importer.Action.SKIP, importer.Action.ASIS): + # Pass selection to main control flow. + return choice + + # Plugin-provided choices. We invoke the associated callback + # function. + elif choice in choices: + post_choice = choice.callback(self, task) + if isinstance(post_choice, importer.Action): + return post_choice + elif isinstance(post_choice, autotag.Proposal): + # Use the new candidates and continue around the loop. + task.candidates = post_choice.candidates + task.rec = post_choice.recommendation + + # Otherwise, we have a specific match selection. + else: + # We have a candidate! Finish tagging. Here, choice is an + # AlbumMatch object. + assert isinstance(choice, autotag.AlbumMatch) + return choice + + def choose_item(self, task): + """Ask the user for a choice about tagging a single item. Returns + either an action constant or a TrackMatch object. + """ + ui.print_() + ui.print_(displayable_path(task.item.path)) + candidates, rec = task.candidates, task.rec + + # Take immediate action if appropriate. + action = _summary_judgment(task.rec) + if action == importer.Action.APPLY: + match = candidates[0] + show_item_change(task.item, match) + return match + elif action is not None: + return action + + while True: + # Ask for a choice. + choices = self._get_choices(task) + choice = choose_candidate( + candidates, True, rec, item=task.item, choices=choices + ) + + if choice in (importer.Action.SKIP, importer.Action.ASIS): + return choice + + elif choice in choices: + post_choice = choice.callback(self, task) + if isinstance(post_choice, importer.Action): + return post_choice + elif isinstance(post_choice, autotag.Proposal): + candidates = post_choice.candidates + rec = post_choice.recommendation + + else: + # Chose a candidate. + assert isinstance(choice, autotag.TrackMatch) + return choice + + def resolve_duplicate(self, task, found_duplicates): + """Decide what to do when a new album or item seems similar to one + that's already in the library. + """ + log.warning( + "This {} is already in the library!", + ("album" if task.is_album else "item"), + ) + + if config["import"]["quiet"]: + # In quiet mode, don't prompt -- just skip. + log.info("Skipping.") + sel = "s" + else: + # Print some detail about the existing and new items so the + # user can make an informed decision. + for duplicate in found_duplicates: + ui.print_( + "Old: " + + summarize_items( + ( + list(duplicate.items()) + if task.is_album + else [duplicate] + ), + not task.is_album, + ) + ) + if config["import"]["duplicate_verbose_prompt"]: + if task.is_album: + for dup in duplicate.items(): + print(f" {dup}") + else: + print(f" {duplicate}") + + ui.print_( + "New: " + + summarize_items( + task.imported_items(), + not task.is_album, + ) + ) + if config["import"]["duplicate_verbose_prompt"]: + for item in task.imported_items(): + print(f" {item}") + + sel = ui.input_options( + ("Skip new", "Keep all", "Remove old", "Merge all") + ) + + if sel == "s": + # Skip new. + task.set_choice(importer.Action.SKIP) + elif sel == "k": + # Keep both. Do nothing; leave the choice intact. + pass + elif sel == "r": + # Remove old. + task.should_remove_duplicates = True + elif sel == "m": + task.should_merge_duplicates = True + else: + assert False + + def should_resume(self, path): + return ui.input_yn( + f"Import of the directory:\n{displayable_path(path)}\n" + "was interrupted. Resume (Y/n)?" + ) + + def _get_choices(self, task): + """Get the list of prompt choices that should be presented to the + user. This consists of both built-in choices and ones provided by + plugins. + + The `before_choose_candidate` event is sent to the plugins, with + session and task as its parameters. Plugins are responsible for + checking the right conditions and returning a list of `PromptChoice`s, + which is flattened and checked for conflicts. + + If two or more choices have the same short letter, a warning is + emitted and all but one choices are discarded, giving preference + to the default importer choices. + + Returns a list of `PromptChoice`s. + """ + # Standard, built-in choices. + choices = [ + PromptChoice("s", "Skip", lambda s, t: importer.Action.SKIP), + PromptChoice("u", "Use as-is", lambda s, t: importer.Action.ASIS), + ] + if task.is_album: + choices += [ + PromptChoice( + "t", "as Tracks", lambda s, t: importer.Action.TRACKS + ), + PromptChoice( + "g", "Group albums", lambda s, t: importer.Action.ALBUMS + ), + ] + choices += [ + PromptChoice("e", "Enter search", manual_search), + PromptChoice("i", "enter Id", manual_id), + PromptChoice("b", "aBort", abort_action), + ] + + # Send the before_choose_candidate event and flatten list. + extra_choices = list( + chain( + *plugins.send( + "before_choose_candidate", session=self, task=task + ) + ) + ) + + # Add a "dummy" choice for the other baked-in option, for + # duplicate checking. + all_choices = ( + [ + PromptChoice("a", "Apply", None), + ] + + choices + + extra_choices + ) + + # Check for conflicts. + short_letters = [c.short for c in all_choices] + if len(short_letters) != len(set(short_letters)): + # Duplicate short letter has been found. + duplicates = [ + i for i, count in Counter(short_letters).items() if count > 1 + ] + for short in duplicates: + # Keep the first of the choices, removing the rest. + dup_choices = [c for c in all_choices if c.short == short] + for c in dup_choices[1:]: + log.warning( + "Prompt choice '{0.long}' removed due to conflict " + "with '{1[0].long}' (short letter: '{0.short}')", + c, + dup_choices, + ) + extra_choices.remove(c) + + return choices + extra_choices + + +def summarize_items(items, singleton): + """Produces a brief summary line describing a set of items. Used for + manually resolving duplicates during import. + + `items` is a list of `Item` objects. `singleton` indicates whether + this is an album or single-item import (if the latter, them `items` + should only have one element). + """ + summary_parts = [] + if not singleton: + summary_parts.append(f"{len(items)} items") + + format_counts = {} + for item in items: + format_counts[item.format] = format_counts.get(item.format, 0) + 1 + if len(format_counts) == 1: + # A single format. + summary_parts.append(items[0].format) + else: + # Enumerate all the formats by decreasing frequencies: + for fmt, count in sorted( + format_counts.items(), + key=lambda fmt_and_count: (-fmt_and_count[1], fmt_and_count[0]), + ): + summary_parts.append(f"{fmt} {count}") + + if items: + average_bitrate = sum([item.bitrate for item in items]) / len(items) + total_duration = sum([item.length for item in items]) + total_filesize = sum([item.filesize for item in items]) + summary_parts.append(f"{int(average_bitrate / 1000)}kbps") + if items[0].format == "FLAC": + sample_bits = ( + f"{round(int(items[0].samplerate) / 1000, 1)}kHz" + f"/{items[0].bitdepth} bit" + ) + summary_parts.append(sample_bits) + summary_parts.append(human_seconds_short(total_duration)) + summary_parts.append(human_bytes(total_filesize)) + + return ", ".join(summary_parts) + + +def _summary_judgment(rec): + """Determines whether a decision should be made without even asking + the user. This occurs in quiet mode and when an action is chosen for + NONE recommendations. Return None if the user should be queried. + Otherwise, returns an action. May also print to the console if a + summary judgment is made. + """ + + if config["import"]["quiet"]: + if rec == Recommendation.strong: + return importer.Action.APPLY + else: + action = config["import"]["quiet_fallback"].as_choice( + { + "skip": importer.Action.SKIP, + "asis": importer.Action.ASIS, + } + ) + elif config["import"]["timid"]: + return None + elif rec == Recommendation.none: + action = config["import"]["none_rec_action"].as_choice( + { + "skip": importer.Action.SKIP, + "asis": importer.Action.ASIS, + "ask": None, + } + ) + else: + return None + + if action == importer.Action.SKIP: + ui.print_("Skipping.") + elif action == importer.Action.ASIS: + ui.print_("Importing as-is.") + return action + + +class PromptChoice(NamedTuple): + short: str + long: str + callback: Any + + +def choose_candidate( + candidates, + singleton, + rec, + cur_artist=None, + cur_album=None, + item=None, + itemcount=None, + choices=[], +): + """Given a sorted list of candidates, ask the user for a selection + of which candidate to use. Applies to both full albums and + singletons (tracks). Candidates are either AlbumMatch or TrackMatch + objects depending on `singleton`. for albums, `cur_artist`, + `cur_album`, and `itemcount` must be provided. For singletons, + `item` must be provided. + + `choices` is a list of `PromptChoice`s to be used in each prompt. + + Returns one of the following: + * the result of the choice, which may be SKIP or ASIS + * a candidate (an AlbumMatch/TrackMatch object) + * a chosen `PromptChoice` from `choices` + """ + # Sanity check. + if singleton: + assert item is not None + else: + assert cur_artist is not None + assert cur_album is not None + + # Build helper variables for the prompt choices. + choice_opts = tuple(c.long for c in choices) + choice_actions = {c.short: c for c in choices} + + # Zero candidates. + if not candidates: + if singleton: + ui.print_("No matching recordings found.") + else: + print_(f"No matching release found for {itemcount} tracks.") + print_( + "For help, see: " + "https://beets.readthedocs.org/en/latest/faq.html#nomatch" + ) + sel = ui.input_options(choice_opts) + if sel in choice_actions: + return choice_actions[sel] + else: + assert False + + # Is the change good enough? + bypass_candidates = False + if rec != Recommendation.none: + match = candidates[0] + bypass_candidates = True + + while True: + # Display and choose from candidates. + require = rec <= Recommendation.low + + if not bypass_candidates: + # Display list of candidates. + ui.print_("") + ui.print_( + f"Finding tags for {'track' if singleton else 'album'} " + f'"{item.artist if singleton else cur_artist} -' + f' {item.title if singleton else cur_album}".' + ) + + ui.print_(" Candidates:") + for i, match in enumerate(candidates): + # Index, metadata, and distance. + index0 = f"{i + 1}." + index = dist_colorize(index0, match.distance) + dist = f"({(1 - match.distance) * 100:.1f}%)" + distance = dist_colorize(dist, match.distance) + metadata = ( + f"{match.info.artist} -" + f" {match.info.title if singleton else match.info.album}" + ) + if i == 0: + metadata = dist_colorize(metadata, match.distance) + else: + metadata = ui.colorize("text_highlight_minor", metadata) + line1 = [index, distance, metadata] + print_(f" {' '.join(line1)}") + + # Penalties. + penalties = penalty_string(match.distance, 3) + if penalties: + print_(f"{' ' * 13}{penalties}") + + # Disambiguation + disambig = disambig_string(match.info) + if disambig: + print_(f"{' ' * 13}{disambig}") + + # Ask the user for a choice. + sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) + if sel == "m": + pass + elif sel in choice_actions: + return choice_actions[sel] + else: # Numerical selection. + match = candidates[sel - 1] + if sel != 1: + # When choosing anything but the first match, + # disable the default action. + require = True + bypass_candidates = False + + # Show what we're about to do. + if singleton: + show_item_change(item, match) + else: + show_change(cur_artist, cur_album, match) + + # Exact match => tag automatically if we're not in timid mode. + if rec == Recommendation.strong and not config["import"]["timid"]: + return match + + # Ask for confirmation. + default = config["import"]["default_action"].as_choice( + { + "apply": "a", + "skip": "s", + "asis": "u", + "none": None, + } + ) + if default is None: + require = True + # Bell ring when user interaction is needed. + if config["import"]["bell"]: + ui.print_("\a", end="") + sel = ui.input_options( + ("Apply", "More candidates") + choice_opts, + require=require, + default=default, + ) + if sel == "a": + return match + elif sel in choice_actions: + return choice_actions[sel] + + +def manual_search(session, task): + """Get a new `Proposal` using manual search criteria. + + Input either an artist and album (for full albums) or artist and + track name (for singletons) for manual search. + """ + artist = ui.input_("Artist:").strip() + name = ui.input_("Album:" if task.is_album else "Track:").strip() + + if task.is_album: + _, _, prop = autotag.tag_album(task.items, artist, name) + return prop + else: + return autotag.tag_item(task.item, artist, name) + + +def manual_id(session, task): + """Get a new `Proposal` using a manually-entered ID. + + Input an ID, either for an album ("release") or a track ("recording"). + """ + prompt = f"Enter {'release' if task.is_album else 'recording'} ID:" + search_id = ui.input_(prompt).strip() + + if task.is_album: + _, _, prop = autotag.tag_album(task.items, search_ids=search_id.split()) + return prop + else: + return autotag.tag_item(task.item, search_ids=search_id.split()) + + +def abort_action(session, task): + """A prompt choice callback that aborts the importer.""" + raise importer.ImportAbortError() diff --git a/beets/ui/commands/list.py b/beets/ui/commands/list.py new file mode 100644 index 000000000..cb92b9b79 --- /dev/null +++ b/beets/ui/commands/list.py @@ -0,0 +1,25 @@ +"""The 'list' command: query and show library contents.""" + +from beets import ui + + +def list_items(lib, query, album, fmt=""): + """Print out items in lib matching query. If album, then search for + albums instead of single items. + """ + if album: + for album in lib.albums(query): + ui.print_(format(album, fmt)) + else: + for item in lib.items(query): + ui.print_(format(item, fmt)) + + +def list_func(lib, opts, args): + list_items(lib, args, opts.album) + + +list_cmd = ui.Subcommand("list", help="query the library", aliases=("ls",)) +list_cmd.parser.usage += "\nExample: %prog -f '$album: $title' artist:beatles" +list_cmd.parser.add_all_common_options() +list_cmd.func = list_func diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py new file mode 100644 index 000000000..dab68a3fc --- /dev/null +++ b/beets/ui/commands/modify.py @@ -0,0 +1,162 @@ +"""The `modify` command: change metadata fields.""" + +from beets import library, ui +from beets.util import functemplate + +from ._utils import do_query + + +def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): + """Modifies matching items according to user-specified assignments and + deletions. + + `mods` is a dictionary of field and value pairse indicating + assignments. `dels` is a list of fields to be deleted. + """ + # Parse key=value specifications into a dictionary. + model_cls = library.Album if album else library.Item + + # Get the items to modify. + items, albums = do_query(lib, query, album, False) + objs = albums if album else items + + # Apply changes *temporarily*, preview them, and collect modified + # objects. + ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") + changed = [] + templates = { + key: functemplate.template(value) for key, value in mods.items() + } + for obj in objs: + obj_mods = { + key: model_cls._parse(key, obj.evaluate_template(templates[key])) + for key in mods.keys() + } + if print_and_modify(obj, obj_mods, dels) and obj not in changed: + changed.append(obj) + + # Still something to do? + if not changed: + ui.print_("No changes to make.") + return + + # Confirm action. + if confirm: + if write and move: + extra = ", move and write tags" + elif write: + extra = " and write tags" + elif move: + extra = " and move" + else: + extra = "" + + changed = ui.input_select_objects( + f"Really modify{extra}", + changed, + lambda o: print_and_modify(o, mods, dels), + ) + + # Apply changes to database and files + with lib.transaction(): + for obj in changed: + obj.try_sync(write, move, inherit) + + +def print_and_modify(obj, mods, dels): + """Print the modifications to an item and return a bool indicating + whether any changes were made. + + `mods` is a dictionary of fields and values to update on the object; + `dels` is a sequence of fields to delete. + """ + obj.update(mods) + for field in dels: + try: + del obj[field] + except KeyError: + pass + return ui.show_model_changes(obj) + + +def modify_parse_args(args): + """Split the arguments for the modify subcommand into query parts, + assignments (field=value), and deletions (field!). Returns the result as + a three-tuple in that order. + """ + mods = {} + dels = [] + query = [] + for arg in args: + if arg.endswith("!") and "=" not in arg and ":" not in arg: + dels.append(arg[:-1]) # Strip trailing !. + elif "=" in arg and ":" not in arg.split("=", 1)[0]: + key, val = arg.split("=", 1) + mods[key] = val + else: + query.append(arg) + return query, mods, dels + + +def modify_func(lib, opts, args): + query, mods, dels = modify_parse_args(args) + if not mods and not dels: + raise ui.UserError("no modifications specified") + modify_items( + lib, + mods, + dels, + query, + ui.should_write(opts.write), + ui.should_move(opts.move), + opts.album, + not opts.yes, + opts.inherit, + ) + + +modify_cmd = ui.Subcommand( + "modify", help="change metadata fields", aliases=("mod",) +) +modify_cmd.parser.add_option( + "-m", + "--move", + action="store_true", + dest="move", + help="move files in the library directory", +) +modify_cmd.parser.add_option( + "-M", + "--nomove", + action="store_false", + dest="move", + help="don't move files in library", +) +modify_cmd.parser.add_option( + "-w", + "--write", + action="store_true", + default=None, + help="write new metadata to files' tags (default)", +) +modify_cmd.parser.add_option( + "-W", + "--nowrite", + action="store_false", + dest="write", + help="don't write metadata (opposite of -w)", +) +modify_cmd.parser.add_album_option() +modify_cmd.parser.add_format_option(target="item") +modify_cmd.parser.add_option( + "-y", "--yes", action="store_true", help="skip confirmation" +) +modify_cmd.parser.add_option( + "-I", + "--noinherit", + action="store_false", + dest="inherit", + default=True, + help="when modifying albums, don't also change item data", +) +modify_cmd.func = modify_func diff --git a/beets/ui/commands/move.py b/beets/ui/commands/move.py new file mode 100644 index 000000000..6d6f4f16a --- /dev/null +++ b/beets/ui/commands/move.py @@ -0,0 +1,154 @@ +"""The 'move' command: Move/copy files to the library or a new base directory.""" + +import os + +from beets import logging, ui, util + +from ._utils import do_query + +# Global logger. +log = logging.getLogger("beets") + + +def move_items( + lib, + dest_path: util.PathLike, + query, + copy, + album, + pretend, + confirm=False, + export=False, +): + """Moves or copies items to a new base directory, given by dest. If + dest is None, then the library's base directory is used, making the + command "consolidate" files. + """ + dest = os.fsencode(dest_path) if dest_path else dest_path + items, albums = do_query(lib, query, album, False) + objs = albums if album else items + num_objs = len(objs) + + # Filter out files that don't need to be moved. + def isitemmoved(item): + return item.path != item.destination(basedir=dest) + + def isalbummoved(album): + return any(isitemmoved(i) for i in album.items()) + + objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] + num_unmoved = num_objs - len(objs) + # Report unmoved files that match the query. + unmoved_msg = "" + if num_unmoved > 0: + unmoved_msg = f" ({num_unmoved} already in place)" + + copy = copy or export # Exporting always copies. + action = "Copying" if copy else "Moving" + act = "copy" if copy else "move" + entity = "album" if album else "item" + log.info( + "{} {} {}{}{}.", + action, + len(objs), + entity, + "s" if len(objs) != 1 else "", + unmoved_msg, + ) + if not objs: + return + + if pretend: + if album: + ui.show_path_changes( + [ + (item.path, item.destination(basedir=dest)) + for obj in objs + for item in obj.items() + ] + ) + else: + ui.show_path_changes( + [(obj.path, obj.destination(basedir=dest)) for obj in objs] + ) + else: + if confirm: + objs = ui.input_select_objects( + f"Really {act}", + objs, + lambda o: ui.show_path_changes( + [(o.path, o.destination(basedir=dest))] + ), + ) + + for obj in objs: + log.debug("moving: {.filepath}", obj) + + if export: + # Copy without affecting the database. + obj.move( + operation=util.MoveOperation.COPY, basedir=dest, store=False + ) + else: + # Ordinary move/copy: store the new path. + if copy: + obj.move(operation=util.MoveOperation.COPY, basedir=dest) + else: + obj.move(operation=util.MoveOperation.MOVE, basedir=dest) + + +def move_func(lib, opts, args): + dest = opts.dest + if dest is not None: + dest = util.normpath(dest) + if not os.path.isdir(util.syspath(dest)): + raise ui.UserError( + f"no such directory: {util.displayable_path(dest)}" + ) + + move_items( + lib, + dest, + args, + opts.copy, + opts.album, + opts.pretend, + opts.timid, + opts.export, + ) + + +move_cmd = ui.Subcommand("move", help="move or copy items", aliases=("mv",)) +move_cmd.parser.add_option( + "-d", "--dest", metavar="DIR", dest="dest", help="destination directory" +) +move_cmd.parser.add_option( + "-c", + "--copy", + default=False, + action="store_true", + help="copy instead of moving", +) +move_cmd.parser.add_option( + "-p", + "--pretend", + default=False, + action="store_true", + help="show how files would be moved, but don't touch anything", +) +move_cmd.parser.add_option( + "-t", + "--timid", + dest="timid", + action="store_true", + help="always confirm all actions", +) +move_cmd.parser.add_option( + "-e", + "--export", + default=False, + action="store_true", + help="copy without changing the database path", +) +move_cmd.parser.add_album_option() +move_cmd.func = move_func diff --git a/beets/ui/commands/remove.py b/beets/ui/commands/remove.py new file mode 100644 index 000000000..574f0c4d4 --- /dev/null +++ b/beets/ui/commands/remove.py @@ -0,0 +1,84 @@ +"""The `remove` command: remove items from the library (and optionally delete files).""" + +from beets import ui + +from ._utils import do_query + + +def remove_items(lib, query, album, delete, force): + """Remove items matching query from lib. If album, then match and + remove whole albums. If delete, also remove files from disk. + """ + # Get the matching items. + items, albums = do_query(lib, query, album) + objs = albums if album else items + + # Confirm file removal if not forcing removal. + if not force: + # Prepare confirmation with user. + album_str = ( + f" in {len(albums)} album{'s' if len(albums) > 1 else ''}" + if album + else "" + ) + + if delete: + fmt = "$path - $title" + prompt = "Really DELETE" + prompt_all = ( + "Really DELETE" + f" {len(items)} file{'s' if len(items) > 1 else ''}{album_str}" + ) + else: + fmt = "" + prompt = "Really remove from the library?" + prompt_all = ( + "Really remove" + f" {len(items)} item{'s' if len(items) > 1 else ''}{album_str}" + " from the library?" + ) + + # Helpers for printing affected items + def fmt_track(t): + ui.print_(format(t, fmt)) + + def fmt_album(a): + ui.print_() + for i in a.items(): + fmt_track(i) + + fmt_obj = fmt_album if album else fmt_track + + # Show all the items. + for o in objs: + fmt_obj(o) + + # Confirm with user. + objs = ui.input_select_objects( + prompt, objs, fmt_obj, prompt_all=prompt_all + ) + + if not objs: + return + + # Remove (and possibly delete) items. + with lib.transaction(): + for obj in objs: + obj.remove(delete) + + +def remove_func(lib, opts, args): + remove_items(lib, args, opts.album, opts.delete, opts.force) + + +remove_cmd = ui.Subcommand( + "remove", help="remove matching items from the library", aliases=("rm",) +) +remove_cmd.parser.add_option( + "-d", "--delete", action="store_true", help="also remove files from disk" +) +remove_cmd.parser.add_option( + "-f", "--force", action="store_true", help="do not ask when removing items" +) +remove_cmd.parser.add_album_option() +remove_cmd.func = remove_func diff --git a/beets/ui/commands/stats.py b/beets/ui/commands/stats.py new file mode 100644 index 000000000..d51d4d8ae --- /dev/null +++ b/beets/ui/commands/stats.py @@ -0,0 +1,62 @@ +"""The 'stats' command: show library statistics.""" + +import os + +from beets import logging, ui +from beets.util import syspath +from beets.util.units import human_bytes, human_seconds + +# Global logger. +log = logging.getLogger("beets") + + +def show_stats(lib, query, exact): + """Shows some statistics about the matched items.""" + items = lib.items(query) + + total_size = 0 + total_time = 0.0 + total_items = 0 + artists = set() + albums = set() + album_artists = set() + + for item in items: + if exact: + try: + total_size += os.path.getsize(syspath(item.path)) + except OSError as exc: + log.info("could not get size of {.path}: {}", item, exc) + else: + total_size += int(item.length * item.bitrate / 8) + total_time += item.length + total_items += 1 + artists.add(item.artist) + album_artists.add(item.albumartist) + if item.album_id: + albums.add(item.album_id) + + size_str = human_bytes(total_size) + if exact: + size_str += f" ({total_size} bytes)" + + ui.print_(f"""Tracks: {total_items} +Total time: {human_seconds(total_time)} +{f" ({total_time:.2f} seconds)" if exact else ""} +{"Total size" if exact else "Approximate total size"}: {size_str} +Artists: {len(artists)} +Albums: {len(albums)} +Album artists: {len(album_artists)}""") + + +def stats_func(lib, opts, args): + show_stats(lib, args, opts.exact) + + +stats_cmd = ui.Subcommand( + "stats", help="show statistics about the library or a query" +) +stats_cmd.parser.add_option( + "-e", "--exact", action="store_true", help="exact size and time" +) +stats_cmd.func = stats_func diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py new file mode 100644 index 000000000..71be6bbd9 --- /dev/null +++ b/beets/ui/commands/update.py @@ -0,0 +1,196 @@ +"""The `update` command: Update library contents according to on-disk tags.""" + +import os + +from beets import library, logging, ui +from beets.util import ancestry, syspath + +from ._utils import do_query + +# Global logger. +log = logging.getLogger("beets") + + +def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): + """For all the items matched by the query, update the library to + reflect the item's embedded tags. + :param fields: The fields to be stored. If not specified, all fields will + be. + :param exclude_fields: The fields to not be stored. If not specified, all + fields will be. + """ + with lib.transaction(): + items, _ = do_query(lib, query, album) + if move and fields is not None and "path" not in fields: + # Special case: if an item needs to be moved, the path field has to + # updated; otherwise the new path will not be reflected in the + # database. + fields.append("path") + if fields is None: + # no fields were provided, update all media fields + item_fields = fields or library.Item._media_fields + if move and "path" not in item_fields: + # move is enabled, add 'path' to the list of fields to update + item_fields.add("path") + else: + # fields was provided, just update those + item_fields = fields + # get all the album fields to update + album_fields = fields or library.Album._fields.keys() + if exclude_fields: + # remove any excluded fields from the item and album sets + item_fields = [f for f in item_fields if f not in exclude_fields] + album_fields = [f for f in album_fields if f not in exclude_fields] + + # Walk through the items and pick up their changes. + affected_albums = set() + for item in items: + # Item deleted? + if not item.path or not os.path.exists(syspath(item.path)): + ui.print_(format(item)) + ui.print_(ui.colorize("text_error", " deleted")) + if not pretend: + item.remove(True) + affected_albums.add(item.album_id) + continue + + # Did the item change since last checked? + if item.current_mtime() <= item.mtime: + log.debug( + "skipping {0.filepath} because mtime is up to date ({0.mtime})", + item, + ) + continue + + # Read new data. + try: + item.read() + except library.ReadError as exc: + log.error("error reading {.filepath}: {}", item, exc) + continue + + # Special-case album artist when it matches track artist. (Hacky + # but necessary for preserving album-level metadata for non- + # autotagged imports.) + if not item.albumartist: + old_item = lib.get_item(item.id) + if old_item.albumartist == old_item.artist == item.artist: + item.albumartist = old_item.albumartist + item._dirty.discard("albumartist") + + # Check for and display changes. + changed = ui.show_model_changes(item, fields=item_fields) + + # Save changes. + if not pretend: + if changed: + # Move the item if it's in the library. + if move and lib.directory in ancestry(item.path): + item.move(store=False) + + item.store(fields=item_fields) + affected_albums.add(item.album_id) + else: + # The file's mtime was different, but there were no + # changes to the metadata. Store the new mtime, + # which is set in the call to read(), so we don't + # check this again in the future. + item.store(fields=item_fields) + + # Skip album changes while pretending. + if pretend: + return + + # Modify affected albums to reflect changes in their items. + for album_id in affected_albums: + if album_id is None: # Singletons. + continue + album = lib.get_album(album_id) + if not album: # Empty albums have already been removed. + log.debug("emptied album {}", album_id) + continue + first_item = album.items().get() + + # Update album structure to reflect an item in it. + for key in library.Album.item_keys: + album[key] = first_item[key] + album.store(fields=album_fields) + + # Move album art (and any inconsistent items). + if move and lib.directory in ancestry(first_item.path): + log.debug("moving album {}", album_id) + + # Manually moving and storing the album. + items = list(album.items()) + for item in items: + item.move(store=False, with_album=False) + item.store(fields=item_fields) + album.move(store=False) + album.store(fields=album_fields) + + +def update_func(lib, opts, args): + # Verify that the library folder exists to prevent accidental wipes. + if not os.path.isdir(syspath(lib.directory)): + ui.print_("Library path is unavailable or does not exist.") + ui.print_(lib.directory) + if not ui.input_yn("Are you sure you want to continue (y/n)?", True): + return + update_items( + lib, + args, + opts.album, + ui.should_move(opts.move), + opts.pretend, + opts.fields, + opts.exclude_fields, + ) + + +update_cmd = ui.Subcommand( + "update", + help="update the library", + aliases=( + "upd", + "up", + ), +) +update_cmd.parser.add_album_option() +update_cmd.parser.add_format_option() +update_cmd.parser.add_option( + "-m", + "--move", + action="store_true", + dest="move", + help="move files in the library directory", +) +update_cmd.parser.add_option( + "-M", + "--nomove", + action="store_false", + dest="move", + help="don't move files in library", +) +update_cmd.parser.add_option( + "-p", + "--pretend", + action="store_true", + help="show all changes but do nothing", +) +update_cmd.parser.add_option( + "-F", + "--field", + default=None, + action="append", + dest="fields", + help="list of fields to update", +) +update_cmd.parser.add_option( + "-e", + "--exclude-field", + default=None, + action="append", + dest="exclude_fields", + help="list of fields to exclude from updates", +) +update_cmd.func = update_func diff --git a/beets/ui/commands/version.py b/beets/ui/commands/version.py new file mode 100644 index 000000000..a93c373a4 --- /dev/null +++ b/beets/ui/commands/version.py @@ -0,0 +1,23 @@ +"""The 'version' command: show version information.""" + +from platform import python_version + +import beets +from beets import plugins, ui + + +def show_version(*args): + ui.print_(f"beets version {beets.__version__}") + ui.print_(f"Python version {python_version()}") + # Show plugins. + names = sorted(p.name for p in plugins.find_plugins()) + if names: + ui.print_("plugins:", ", ".join(names)) + else: + ui.print_("no plugins loaded") + + +version_cmd = ui.Subcommand("version", help="output version information") +version_cmd.func = show_version + +__all__ = ["version_cmd"] diff --git a/beets/ui/commands/write.py b/beets/ui/commands/write.py new file mode 100644 index 000000000..84f2fb5b6 --- /dev/null +++ b/beets/ui/commands/write.py @@ -0,0 +1,60 @@ +"""The `write` command: write tag information to files.""" + +import os + +from beets import library, logging, ui +from beets.util import syspath + +from ._utils import do_query + +# Global logger. +log = logging.getLogger("beets") + + +def write_items(lib, query, pretend, force): + """Write tag information from the database to the respective files + in the filesystem. + """ + items, albums = do_query(lib, query, False, False) + + for item in items: + # Item deleted? + if not os.path.exists(syspath(item.path)): + log.info("missing file: {.filepath}", item) + continue + + # Get an Item object reflecting the "clean" (on-disk) state. + try: + clean_item = library.Item.from_path(item.path) + except library.ReadError as exc: + log.error("error reading {.filepath}: {}", item, exc) + continue + + # Check for and display changes. + changed = ui.show_model_changes( + item, clean_item, library.Item._media_tag_fields, force + ) + if (changed or force) and not pretend: + # We use `try_sync` here to keep the mtime up to date in the + # database. + item.try_sync(True, False) + + +def write_func(lib, opts, args): + write_items(lib, args, opts.pretend, opts.force) + + +write_cmd = ui.Subcommand("write", help="write tag information to files") +write_cmd.parser.add_option( + "-p", + "--pretend", + action="store_true", + help="show all changes but do nothing", +) +write_cmd.parser.add_option( + "-f", + "--force", + action="store_true", + help="write tags even if the existing tags match the database", +) +write_cmd.func = write_func From a59e41a88365e414db3282658d2aa456e0b3468a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 21 Oct 2025 21:55:29 +0200 Subject: [PATCH 045/274] tests: move command tests into dedicated files Moved tests related to ui into own folder. Moved 'modify' command tests into own file. Moved 'write' command tests into own file. Moved 'fields' command tests into own file. Moved 'do_query' test into own file. Moved 'list' command tests into own file. Moved 'remove' command tests into own file. Moved 'move' command tests into own file. Moved 'update' command tests into own file. Moved 'show_change' test into test_import file. Moved 'summarize_items' test into test_import file. Moved 'completion' command test into own file. --- beets/test/_common.py | 6 +- beets/test/helper.py | 2 +- beets/ui/commands/__init__.py | 10 +- beets/ui/commands/completion.py | 36 +- beets/ui/{ => commands}/completion_base.sh | 0 test/test_ui.py | 1587 ----------------- test/ui/__init__.py | 0 test/ui/commands/__init__.py | 0 test/ui/commands/test_completion.py | 64 + .../commands/test_config.py} | 0 test/ui/commands/test_fields.py | 24 + test/ui/commands/test_import.py | 256 +++ test/ui/commands/test_list.py | 69 + test/ui/commands/test_modify.py | 216 +++ test/ui/commands/test_move.py | 102 ++ test/ui/commands/test_remove.py | 80 + test/ui/commands/test_update.py | 205 +++ .../commands/test_utils.py} | 47 +- test/ui/commands/test_write.py | 46 + test/ui/test_ui.py | 590 ++++++ test/{ => ui}/test_ui_importer.py | 0 test/{ => ui}/test_ui_init.py | 0 22 files changed, 1685 insertions(+), 1655 deletions(-) rename beets/ui/{ => commands}/completion_base.sh (100%) delete mode 100644 test/test_ui.py create mode 100644 test/ui/__init__.py create mode 100644 test/ui/commands/__init__.py create mode 100644 test/ui/commands/test_completion.py rename test/{test_config_command.py => ui/commands/test_config.py} (100%) create mode 100644 test/ui/commands/test_fields.py create mode 100644 test/ui/commands/test_import.py create mode 100644 test/ui/commands/test_list.py create mode 100644 test/ui/commands/test_modify.py create mode 100644 test/ui/commands/test_move.py create mode 100644 test/ui/commands/test_remove.py create mode 100644 test/ui/commands/test_update.py rename test/{test_ui_commands.py => ui/commands/test_utils.py} (50%) create mode 100644 test/ui/commands/test_write.py create mode 100644 test/ui/test_ui.py rename test/{ => ui}/test_ui_importer.py (100%) rename test/{ => ui}/test_ui_init.py (100%) diff --git a/beets/test/_common.py b/beets/test/_common.py index ffb2bfd65..487f7c442 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -107,7 +107,11 @@ def item(lib=None, **kwargs): # Dummy import session. def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False): - cls = commands.TerminalImportSession if cli else importer.ImportSession + cls = ( + commands.import_.session.TerminalImportSession + if cli + else importer.ImportSession + ) return cls(lib, loghandler, paths, query) diff --git a/beets/test/helper.py b/beets/test/helper.py index ea08ec840..3cb1e4c3c 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -54,7 +54,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.importer import ImportSession from beets.library import Item, Library from beets.test import _common -from beets.ui.commands import TerminalImportSession +from beets.ui.commands.import_.session import TerminalImportSession from beets.util import ( MoveOperation, bytestring_path, diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index ba64523cc..0691be045 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -16,9 +16,9 @@ interface. """ -from beets import plugins +from beets.util import deprecate_imports -from .completion import register_print_completion +from .completion import completion_cmd from .config import config_cmd from .fields import fields_cmd from .help import HelpCommand @@ -47,12 +47,8 @@ default_commands = [ move_cmd, write_cmd, config_cmd, - *plugins.commands(), + completion_cmd, ] -# Register the completion command last as it needs all -# other commands to be present. -register_print_completion(default_commands) - __all__ = ["default_commands"] diff --git a/beets/ui/commands/completion.py b/beets/ui/commands/completion.py index 70636a022..266b2740a 100644 --- a/beets/ui/commands/completion.py +++ b/beets/ui/commands/completion.py @@ -10,24 +10,24 @@ from beets.util import syspath log = logging.getLogger("beets") -def register_print_completion(default_commands: list[ui.Subcommand]): - def print_completion(*args): - for line in completion_script(default_commands + plugins.commands()): - ui.print_(line, end="") - if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS): - log.warning( - "Warning: Unable to find the bash-completion package. " - "Command line completion might not work." - ) +def print_completion(*args): + from beets.ui.commands import default_commands - completion_cmd = ui.Subcommand( - "completion", - help="print shell script that provides command line completion", - ) - completion_cmd.func = print_completion - completion_cmd.hide = True + for line in completion_script(default_commands + plugins.commands()): + ui.print_(line, end="") + if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS): + log.warning( + "Warning: Unable to find the bash-completion package. " + "Command line completion might not work." + ) - default_commands.append(completion_cmd) + +completion_cmd = ui.Subcommand( + "completion", + help="print shell script that provides command line completion", +) +completion_cmd.func = print_completion +completion_cmd.hide = True BASH_COMPLETION_PATHS = [ @@ -47,7 +47,9 @@ def completion_script(commands): ``commands`` is alist of ``ui.Subcommand`` instances to generate completion data for. """ - base_script = os.path.join(os.path.dirname(__file__), "completion_base.sh") + base_script = os.path.join( + os.path.dirname(__file__), "../completion_base.sh" + ) with open(base_script) as base_script: yield base_script.read() diff --git a/beets/ui/completion_base.sh b/beets/ui/commands/completion_base.sh similarity index 100% rename from beets/ui/completion_base.sh rename to beets/ui/commands/completion_base.sh diff --git a/test/test_ui.py b/test/test_ui.py deleted file mode 100644 index 534d0e466..000000000 --- a/test/test_ui.py +++ /dev/null @@ -1,1587 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Tests for the command-line interface.""" - -import os -import platform -import re -import shutil -import subprocess -import sys -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -import pytest -from confuse import ConfigError -from mediafile import MediaFile - -from beets import autotag, config, library, plugins, ui, util -from beets.autotag.match import distance -from beets.test import _common -from beets.test.helper import ( - BeetsTestCase, - IOMixin, - PluginTestCase, - capture_stdout, - control_stdin, - has_program, -) -from beets.ui import commands -from beets.util import MoveOperation, syspath - - -class ListTest(BeetsTestCase): - def setUp(self): - super().setUp() - self.item = _common.item() - self.item.path = "xxx/yyy" - self.lib.add(self.item) - self.lib.add_album([self.item]) - - def _run_list(self, query="", album=False, path=False, fmt=""): - with capture_stdout() as stdout: - commands.list_items(self.lib, query, album, fmt) - return stdout - - def test_list_outputs_item(self): - stdout = self._run_list() - assert "the title" in stdout.getvalue() - - def test_list_unicode_query(self): - self.item.title = "na\xefve" - self.item.store() - self.lib._connection().commit() - - stdout = self._run_list(["na\xefve"]) - out = stdout.getvalue() - assert "na\xefve" in out - - def test_list_item_path(self): - stdout = self._run_list(fmt="$path") - assert stdout.getvalue().strip() == "xxx/yyy" - - def test_list_album_outputs_something(self): - stdout = self._run_list(album=True) - assert len(stdout.getvalue()) > 0 - - def test_list_album_path(self): - stdout = self._run_list(album=True, fmt="$path") - assert stdout.getvalue().strip() == "xxx" - - def test_list_album_omits_title(self): - stdout = self._run_list(album=True) - assert "the title" not in stdout.getvalue() - - def test_list_uses_track_artist(self): - stdout = self._run_list() - assert "the artist" in stdout.getvalue() - assert "the album artist" not in stdout.getvalue() - - def test_list_album_uses_album_artist(self): - stdout = self._run_list(album=True) - assert "the artist" not in stdout.getvalue() - assert "the album artist" in stdout.getvalue() - - def test_list_item_format_artist(self): - stdout = self._run_list(fmt="$artist") - assert "the artist" in stdout.getvalue() - - def test_list_item_format_multiple(self): - stdout = self._run_list(fmt="$artist - $album - $year") - assert "the artist - the album - 0001" == stdout.getvalue().strip() - - def test_list_album_format(self): - stdout = self._run_list(album=True, fmt="$genre") - assert "the genre" in stdout.getvalue() - assert "the album" not in stdout.getvalue() - - -class RemoveTest(IOMixin, BeetsTestCase): - def setUp(self): - super().setUp() - - # Copy a file into the library. - self.i = library.Item.from_path(self.resource_path) - self.lib.add(self.i) - self.i.move(operation=MoveOperation.COPY) - - def test_remove_items_no_delete(self): - self.io.addinput("y") - commands.remove_items(self.lib, "", False, False, False) - items = self.lib.items() - assert len(list(items)) == 0 - assert self.i.filepath.exists() - - def test_remove_items_with_delete(self): - self.io.addinput("y") - commands.remove_items(self.lib, "", False, True, False) - items = self.lib.items() - assert len(list(items)) == 0 - assert not self.i.filepath.exists() - - def test_remove_items_with_force_no_delete(self): - commands.remove_items(self.lib, "", False, False, True) - items = self.lib.items() - assert len(list(items)) == 0 - assert self.i.filepath.exists() - - def test_remove_items_with_force_delete(self): - commands.remove_items(self.lib, "", False, True, True) - items = self.lib.items() - assert len(list(items)) == 0 - assert not self.i.filepath.exists() - - def test_remove_items_select_with_delete(self): - i2 = library.Item.from_path(self.resource_path) - self.lib.add(i2) - i2.move(operation=MoveOperation.COPY) - - for s in ("s", "y", "n"): - self.io.addinput(s) - commands.remove_items(self.lib, "", False, True, False) - items = self.lib.items() - assert len(list(items)) == 1 - # There is probably no guarantee that the items are queried in any - # spcecific order, thus just ensure that exactly one was removed. - # To improve upon this, self.io would need to have the capability to - # generate input that depends on previous output. - num_existing = 0 - num_existing += 1 if os.path.exists(syspath(self.i.path)) else 0 - num_existing += 1 if os.path.exists(syspath(i2.path)) else 0 - assert num_existing == 1 - - def test_remove_albums_select_with_delete(self): - a1 = self.add_album_fixture() - a2 = self.add_album_fixture() - path1 = a1.items()[0].path - path2 = a2.items()[0].path - items = self.lib.items() - assert len(list(items)) == 3 - - for s in ("s", "y", "n"): - self.io.addinput(s) - commands.remove_items(self.lib, "", True, True, False) - items = self.lib.items() - assert len(list(items)) == 2 # incl. the item from setUp() - # See test_remove_items_select_with_delete() - num_existing = 0 - num_existing += 1 if os.path.exists(syspath(path1)) else 0 - num_existing += 1 if os.path.exists(syspath(path2)) else 0 - assert num_existing == 1 - - -class ModifyTest(BeetsTestCase): - def setUp(self): - super().setUp() - self.album = self.add_album_fixture() - [self.item] = self.album.items() - - def modify_inp(self, inp, *args): - with control_stdin(inp): - self.run_command("modify", *args) - - def modify(self, *args): - self.modify_inp("y", *args) - - # Item tests - - def test_modify_item(self): - self.modify("title=newTitle") - item = self.lib.items().get() - assert item.title == "newTitle" - - def test_modify_item_abort(self): - item = self.lib.items().get() - title = item.title - self.modify_inp("n", "title=newTitle") - item = self.lib.items().get() - assert item.title == title - - def test_modify_item_no_change(self): - title = "Tracktitle" - item = self.add_item_fixture(title=title) - self.modify_inp("y", "title", f"title={title}") - item = self.lib.items(title).get() - assert item.title == title - - def test_modify_write_tags(self): - self.modify("title=newTitle") - item = self.lib.items().get() - item.read() - assert item.title == "newTitle" - - def test_modify_dont_write_tags(self): - self.modify("--nowrite", "title=newTitle") - item = self.lib.items().get() - item.read() - assert item.title != "newTitle" - - def test_move(self): - self.modify("title=newTitle") - item = self.lib.items().get() - assert b"newTitle" in item.path - - def test_not_move(self): - self.modify("--nomove", "title=newTitle") - item = self.lib.items().get() - assert b"newTitle" not in item.path - - def test_no_write_no_move(self): - self.modify("--nomove", "--nowrite", "title=newTitle") - item = self.lib.items().get() - item.read() - assert b"newTitle" not in item.path - assert item.title != "newTitle" - - def test_update_mtime(self): - item = self.item - old_mtime = item.mtime - - self.modify("title=newTitle") - item.load() - assert old_mtime != item.mtime - assert item.current_mtime() == item.mtime - - def test_reset_mtime_with_no_write(self): - item = self.item - - self.modify("--nowrite", "title=newTitle") - item.load() - assert 0 == item.mtime - - def test_selective_modify(self): - title = "Tracktitle" - album = "album" - original_artist = "composer" - new_artist = "coverArtist" - for i in range(0, 10): - self.add_item_fixture( - title=f"{title}{i}", artist=original_artist, album=album - ) - self.modify_inp( - "s\ny\ny\ny\nn\nn\ny\ny\ny\ny\nn", title, f"artist={new_artist}" - ) - original_items = self.lib.items(f"artist:{original_artist}") - new_items = self.lib.items(f"artist:{new_artist}") - assert len(list(original_items)) == 3 - assert len(list(new_items)) == 7 - - def test_modify_formatted(self): - for i in range(0, 3): - self.add_item_fixture( - title=f"title{i}", artist="artist", album="album" - ) - items = list(self.lib.items()) - self.modify("title=${title} - append") - for item in items: - orig_title = item.title - item.load() - assert item.title == f"{orig_title} - append" - - # Album Tests - - def test_modify_album(self): - self.modify("--album", "album=newAlbum") - album = self.lib.albums().get() - assert album.album == "newAlbum" - - def test_modify_album_write_tags(self): - self.modify("--album", "album=newAlbum") - item = self.lib.items().get() - item.read() - assert item.album == "newAlbum" - - def test_modify_album_dont_write_tags(self): - self.modify("--album", "--nowrite", "album=newAlbum") - item = self.lib.items().get() - item.read() - assert item.album == "the album" - - def test_album_move(self): - self.modify("--album", "album=newAlbum") - item = self.lib.items().get() - item.read() - assert b"newAlbum" in item.path - - def test_album_not_move(self): - self.modify("--nomove", "--album", "album=newAlbum") - item = self.lib.items().get() - item.read() - assert b"newAlbum" not in item.path - - def test_modify_album_formatted(self): - item = self.lib.items().get() - orig_album = item.album - self.modify("--album", "album=${album} - append") - item.load() - assert item.album == f"{orig_album} - append" - - # Misc - - def test_write_initial_key_tag(self): - self.modify("initial_key=C#m") - item = self.lib.items().get() - mediafile = MediaFile(syspath(item.path)) - assert mediafile.initial_key == "C#m" - - def test_set_flexattr(self): - self.modify("flexattr=testAttr") - item = self.lib.items().get() - assert item.flexattr == "testAttr" - - def test_remove_flexattr(self): - item = self.lib.items().get() - item.flexattr = "testAttr" - item.store() - - self.modify("flexattr!") - item = self.lib.items().get() - assert "flexattr" not in item - - @unittest.skip("not yet implemented") - def test_delete_initial_key_tag(self): - item = self.lib.items().get() - item.initial_key = "C#m" - item.write() - item.store() - - mediafile = MediaFile(syspath(item.path)) - assert mediafile.initial_key == "C#m" - - self.modify("initial_key!") - mediafile = MediaFile(syspath(item.path)) - assert mediafile.initial_key is None - - def test_arg_parsing_colon_query(self): - (query, mods, dels) = commands.modify_parse_args( - ["title:oldTitle", "title=newTitle"] - ) - assert query == ["title:oldTitle"] - assert mods == {"title": "newTitle"} - - def test_arg_parsing_delete(self): - (query, mods, dels) = commands.modify_parse_args( - ["title:oldTitle", "title!"] - ) - assert query == ["title:oldTitle"] - assert dels == ["title"] - - def test_arg_parsing_query_with_exclaimation(self): - (query, mods, dels) = commands.modify_parse_args( - ["title:oldTitle!", "title=newTitle!"] - ) - assert query == ["title:oldTitle!"] - assert mods == {"title": "newTitle!"} - - def test_arg_parsing_equals_in_value(self): - (query, mods, dels) = commands.modify_parse_args( - ["title:foo=bar", "title=newTitle"] - ) - assert query == ["title:foo=bar"] - assert mods == {"title": "newTitle"} - - -class WriteTest(BeetsTestCase): - def write_cmd(self, *args): - return self.run_with_output("write", *args) - - def test_update_mtime(self): - item = self.add_item_fixture() - item["title"] = "a new title" - item.store() - - item = self.lib.items().get() - assert item.mtime == 0 - - self.write_cmd() - item = self.lib.items().get() - assert item.mtime == item.current_mtime() - - def test_non_metadata_field_unchanged(self): - """Changing a non-"tag" field like `bitrate` and writing should - have no effect. - """ - # An item that starts out "clean". - item = self.add_item_fixture() - item.read() - - # ... but with a mismatched bitrate. - item.bitrate = 123 - item.store() - - output = self.write_cmd() - - assert output == "" - - def test_write_metadata_field(self): - item = self.add_item_fixture() - item.read() - old_title = item.title - - item.title = "new title" - item.store() - - output = self.write_cmd() - - assert f"{old_title} -> new title" in output - - -class MoveTest(BeetsTestCase): - def setUp(self): - super().setUp() - - self.initial_item_path = self.lib_path / "srcfile" - shutil.copy(self.resource_path, self.initial_item_path) - - # Add a file to the library but don't copy it in yet. - self.i = library.Item.from_path(self.initial_item_path) - self.lib.add(self.i) - self.album = self.lib.add_album([self.i]) - - # Alternate destination directory. - self.otherdir = self.temp_dir_path / "testotherdir" - - def _move( - self, - query=(), - dest=None, - copy=False, - album=False, - pretend=False, - export=False, - ): - commands.move_items( - self.lib, dest, query, copy, album, pretend, export=export - ) - - def test_move_item(self): - self._move() - self.i.load() - assert b"libdir" in self.i.path - assert self.i.filepath.exists() - assert not self.initial_item_path.exists() - - def test_copy_item(self): - self._move(copy=True) - self.i.load() - assert b"libdir" in self.i.path - assert self.i.filepath.exists() - assert self.initial_item_path.exists() - - def test_move_album(self): - self._move(album=True) - self.i.load() - assert b"libdir" in self.i.path - assert self.i.filepath.exists() - assert not self.initial_item_path.exists() - - def test_copy_album(self): - self._move(copy=True, album=True) - self.i.load() - assert b"libdir" in self.i.path - assert self.i.filepath.exists() - assert self.initial_item_path.exists() - - def test_move_item_custom_dir(self): - self._move(dest=self.otherdir) - self.i.load() - assert b"testotherdir" in self.i.path - assert self.i.filepath.exists() - assert not self.initial_item_path.exists() - - def test_move_album_custom_dir(self): - self._move(dest=self.otherdir, album=True) - self.i.load() - assert b"testotherdir" in self.i.path - assert self.i.filepath.exists() - assert not self.initial_item_path.exists() - - def test_pretend_move_item(self): - self._move(dest=self.otherdir, pretend=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - - def test_pretend_move_album(self): - self._move(album=True, pretend=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - - def test_export_item_custom_dir(self): - self._move(dest=self.otherdir, export=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - assert self.otherdir.exists() - - def test_export_album_custom_dir(self): - self._move(dest=self.otherdir, album=True, export=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - assert self.otherdir.exists() - - def test_pretend_export_item(self): - self._move(dest=self.otherdir, pretend=True, export=True) - self.i.load() - assert self.i.filepath == self.initial_item_path - assert not self.otherdir.exists() - - -class UpdateTest(IOMixin, BeetsTestCase): - def setUp(self): - super().setUp() - - # Copy a file into the library. - item_path = os.path.join(_common.RSRC, b"full.mp3") - item_path_two = os.path.join(_common.RSRC, b"full.flac") - self.i = library.Item.from_path(item_path) - self.i2 = library.Item.from_path(item_path_two) - self.lib.add(self.i) - self.lib.add(self.i2) - self.i.move(operation=MoveOperation.COPY) - self.i2.move(operation=MoveOperation.COPY) - self.album = self.lib.add_album([self.i, self.i2]) - - # Album art. - artfile = os.path.join(self.temp_dir, b"testart.jpg") - _common.touch(artfile) - self.album.set_art(artfile) - self.album.store() - util.remove(artfile) - - def _update( - self, - query=(), - album=False, - move=False, - reset_mtime=True, - fields=None, - exclude_fields=None, - ): - self.io.addinput("y") - if reset_mtime: - self.i.mtime = 0 - self.i.store() - commands.update_items( - self.lib, - query, - album, - move, - False, - fields=fields, - exclude_fields=exclude_fields, - ) - - def test_delete_removes_item(self): - assert list(self.lib.items()) - util.remove(self.i.path) - util.remove(self.i2.path) - self._update() - assert not list(self.lib.items()) - - def test_delete_removes_album(self): - assert self.lib.albums() - util.remove(self.i.path) - util.remove(self.i2.path) - self._update() - assert not self.lib.albums() - - def test_delete_removes_album_art(self): - art_filepath = self.album.art_filepath - assert art_filepath.exists() - util.remove(self.i.path) - util.remove(self.i2.path) - self._update() - assert not art_filepath.exists() - - def test_modified_metadata_detected(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.save() - self._update() - item = self.lib.items().get() - assert item.title == "differentTitle" - - def test_modified_metadata_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.save() - self._update(move=True) - item = self.lib.items().get() - assert b"differentTitle" in item.path - - def test_modified_metadata_not_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.save() - self._update(move=False) - item = self.lib.items().get() - assert b"differentTitle" not in item.path - - def test_selective_modified_metadata_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.genre = "differentGenre" - mf.save() - self._update(move=True, fields=["title"]) - item = self.lib.items().get() - assert b"differentTitle" in item.path - assert item.genre != "differentGenre" - - def test_selective_modified_metadata_not_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.genre = "differentGenre" - mf.save() - self._update(move=False, fields=["title"]) - item = self.lib.items().get() - assert b"differentTitle" not in item.path - assert item.genre != "differentGenre" - - def test_modified_album_metadata_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.album = "differentAlbum" - mf.save() - self._update(move=True) - item = self.lib.items().get() - assert b"differentAlbum" in item.path - - def test_modified_album_metadata_art_moved(self): - artpath = self.album.artpath - mf = MediaFile(syspath(self.i.path)) - mf.album = "differentAlbum" - mf.save() - self._update(move=True) - album = self.lib.albums()[0] - assert artpath != album.artpath - assert album.artpath is not None - - def test_selective_modified_album_metadata_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.album = "differentAlbum" - mf.genre = "differentGenre" - mf.save() - self._update(move=True, fields=["album"]) - item = self.lib.items().get() - assert b"differentAlbum" in item.path - assert item.genre != "differentGenre" - - def test_selective_modified_album_metadata_not_moved(self): - mf = MediaFile(syspath(self.i.path)) - mf.album = "differentAlbum" - mf.genre = "differentGenre" - mf.save() - self._update(move=True, fields=["genre"]) - item = self.lib.items().get() - assert b"differentAlbum" not in item.path - assert item.genre == "differentGenre" - - def test_mtime_match_skips_update(self): - mf = MediaFile(syspath(self.i.path)) - mf.title = "differentTitle" - mf.save() - - # Make in-memory mtime match on-disk mtime. - self.i.mtime = os.path.getmtime(syspath(self.i.path)) - self.i.store() - - self._update(reset_mtime=False) - item = self.lib.items().get() - assert item.title == "full" - - def test_multivalued_albumtype_roundtrip(self): - # https://github.com/beetbox/beets/issues/4528 - - # albumtypes is empty for our test fixtures, so populate it first - album = self.album - correct_albumtypes = ["album", "live"] - - # Setting albumtypes does not set albumtype, currently. - # Using x[0] mirrors https://github.com/beetbox/mediafile/blob/057432ad53b3b84385e5582f69f44dc00d0a725d/mediafile.py#L1928 # noqa: E501 - correct_albumtype = correct_albumtypes[0] - - album.albumtype = correct_albumtype - album.albumtypes = correct_albumtypes - album.try_sync(write=True, move=False) - - album.load() - assert album.albumtype == correct_albumtype - assert album.albumtypes == correct_albumtypes - - self._update() - - album.load() - assert album.albumtype == correct_albumtype - assert album.albumtypes == correct_albumtypes - - def test_modified_metadata_excluded(self): - mf = MediaFile(syspath(self.i.path)) - mf.lyrics = "new lyrics" - mf.save() - self._update(exclude_fields=["lyrics"]) - item = self.lib.items().get() - assert item.lyrics != "new lyrics" - - -class PrintTest(IOMixin, unittest.TestCase): - def test_print_without_locale(self): - lang = os.environ.get("LANG") - if lang: - del os.environ["LANG"] - - try: - ui.print_("something") - except TypeError: - self.fail("TypeError during print") - finally: - if lang: - os.environ["LANG"] = lang - - def test_print_with_invalid_locale(self): - old_lang = os.environ.get("LANG") - os.environ["LANG"] = "" - old_ctype = os.environ.get("LC_CTYPE") - os.environ["LC_CTYPE"] = "UTF-8" - - try: - ui.print_("something") - except ValueError: - self.fail("ValueError during print") - finally: - if old_lang: - os.environ["LANG"] = old_lang - else: - del os.environ["LANG"] - if old_ctype: - os.environ["LC_CTYPE"] = old_ctype - else: - del os.environ["LC_CTYPE"] - - -class ImportTest(BeetsTestCase): - def test_quiet_timid_disallowed(self): - config["import"]["quiet"] = True - config["import"]["timid"] = True - with pytest.raises(ui.UserError): - commands.import_files(None, [], None) - - def test_parse_paths_from_logfile(self): - if os.path.__name__ == "ntpath": - logfile_content = ( - "import started Wed Jun 15 23:08:26 2022\n" - "asis C:\\music\\Beatles, The\\The Beatles; C:\\music\\Beatles, The\\The Beatles\\CD 01; C:\\music\\Beatles, The\\The Beatles\\CD 02\n" # noqa: E501 - "duplicate-replace C:\\music\\Bill Evans\\Trio '65\n" - "skip C:\\music\\Michael Jackson\\Bad\n" - "skip C:\\music\\Soulwax\\Any Minute Now\n" - ) - expected_paths = [ - "C:\\music\\Beatles, The\\The Beatles", - "C:\\music\\Michael Jackson\\Bad", - "C:\\music\\Soulwax\\Any Minute Now", - ] - else: - logfile_content = ( - "import started Wed Jun 15 23:08:26 2022\n" - "asis /music/Beatles, The/The Beatles; /music/Beatles, The/The Beatles/CD 01; /music/Beatles, The/The Beatles/CD 02\n" # noqa: E501 - "duplicate-replace /music/Bill Evans/Trio '65\n" - "skip /music/Michael Jackson/Bad\n" - "skip /music/Soulwax/Any Minute Now\n" - ) - expected_paths = [ - "/music/Beatles, The/The Beatles", - "/music/Michael Jackson/Bad", - "/music/Soulwax/Any Minute Now", - ] - - logfile = os.path.join(self.temp_dir, b"logfile.log") - with open(logfile, mode="w") as fp: - fp.write(logfile_content) - actual_paths = list(commands._paths_from_logfile(logfile)) - assert actual_paths == expected_paths - - -@_common.slow_test() -class TestPluginTestCase(PluginTestCase): - plugin = "test" - - def setUp(self): - super().setUp() - config["pluginpath"] = [_common.PLUGINPATH] - - -class ConfigTest(TestPluginTestCase): - def setUp(self): - super().setUp() - - # Don't use the BEETSDIR from `helper`. Instead, we point the home - # directory there. Some tests will set `BEETSDIR` themselves. - del os.environ["BEETSDIR"] - - # Also set APPDATA, the Windows equivalent of setting $HOME. - appdata_dir = self.temp_dir_path / "AppData" / "Roaming" - - self._orig_cwd = os.getcwd() - self.test_cmd = self._make_test_cmd() - commands.default_commands.append(self.test_cmd) - - # Default user configuration - if platform.system() == "Windows": - self.user_config_dir = appdata_dir / "beets" - else: - self.user_config_dir = self.temp_dir_path / ".config" / "beets" - self.user_config_dir.mkdir(parents=True, exist_ok=True) - self.user_config_path = self.user_config_dir / "config.yaml" - - # Custom BEETSDIR - self.beetsdir = self.temp_dir_path / "beetsdir" - self.beetsdir.mkdir(parents=True, exist_ok=True) - - self.env_config_path = str(self.beetsdir / "config.yaml") - self.cli_config_path = str(self.temp_dir_path / "config.yaml") - self.env_patcher = patch( - "os.environ", - {"HOME": str(self.temp_dir_path), "APPDATA": str(appdata_dir)}, - ) - self.env_patcher.start() - - self._reset_config() - - def tearDown(self): - self.env_patcher.stop() - commands.default_commands.pop() - os.chdir(syspath(self._orig_cwd)) - super().tearDown() - - def _make_test_cmd(self): - test_cmd = ui.Subcommand("test", help="test") - - def run(lib, options, args): - test_cmd.lib = lib - test_cmd.options = options - test_cmd.args = args - - test_cmd.func = run - return test_cmd - - def _reset_config(self): - # Config should read files again on demand - config.clear() - config._materialized = False - - def write_config_file(self): - return open(self.user_config_path, "w") - - def test_paths_section_respected(self): - with self.write_config_file() as config: - config.write("paths: {x: y}") - - self.run_command("test", lib=None) - key, template = self.test_cmd.lib.path_formats[0] - assert key == "x" - assert template.original == "y" - - def test_default_paths_preserved(self): - default_formats = ui.get_path_formats() - - self._reset_config() - with self.write_config_file() as config: - config.write("paths: {x: y}") - self.run_command("test", lib=None) - key, template = self.test_cmd.lib.path_formats[0] - assert key == "x" - assert template.original == "y" - assert self.test_cmd.lib.path_formats[1:] == default_formats - - def test_nonexistant_db(self): - with self.write_config_file() as config: - config.write("library: /xxx/yyy/not/a/real/path") - - with pytest.raises(ui.UserError): - self.run_command("test", lib=None) - - def test_user_config_file(self): - with self.write_config_file() as file: - file.write("anoption: value") - - self.run_command("test", lib=None) - assert config["anoption"].get() == "value" - - def test_replacements_parsed(self): - with self.write_config_file() as config: - config.write("replace: {'[xy]': z}") - - self.run_command("test", lib=None) - replacements = self.test_cmd.lib.replacements - repls = [(p.pattern, s) for p, s in replacements] # Compare patterns. - assert repls == [("[xy]", "z")] - - def test_multiple_replacements_parsed(self): - with self.write_config_file() as config: - config.write("replace: {'[xy]': z, foo: bar}") - self.run_command("test", lib=None) - replacements = self.test_cmd.lib.replacements - repls = [(p.pattern, s) for p, s in replacements] - assert repls == [("[xy]", "z"), ("foo", "bar")] - - def test_cli_config_option(self): - with open(self.cli_config_path, "w") as file: - file.write("anoption: value") - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["anoption"].get() == "value" - - def test_cli_config_file_overwrites_user_defaults(self): - with open(self.user_config_path, "w") as file: - file.write("anoption: value") - - with open(self.cli_config_path, "w") as file: - file.write("anoption: cli overwrite") - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["anoption"].get() == "cli overwrite" - - def test_cli_config_file_overwrites_beetsdir_defaults(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - with open(self.env_config_path, "w") as file: - file.write("anoption: value") - - with open(self.cli_config_path, "w") as file: - file.write("anoption: cli overwrite") - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["anoption"].get() == "cli overwrite" - - # @unittest.skip('Difficult to implement with optparse') - # def test_multiple_cli_config_files(self): - # cli_config_path_1 = os.path.join(self.temp_dir, b'config.yaml') - # cli_config_path_2 = os.path.join(self.temp_dir, b'config_2.yaml') - # - # with open(cli_config_path_1, 'w') as file: - # file.write('first: value') - # - # with open(cli_config_path_2, 'w') as file: - # file.write('second: value') - # - # self.run_command('--config', cli_config_path_1, - # '--config', cli_config_path_2, 'test', lib=None) - # assert config['first'].get() == 'value' - # assert config['second'].get() == 'value' - # - # @unittest.skip('Difficult to implement with optparse') - # def test_multiple_cli_config_overwrite(self): - # cli_overwrite_config_path = os.path.join(self.temp_dir, - # b'overwrite_config.yaml') - # - # with open(self.cli_config_path, 'w') as file: - # file.write('anoption: value') - # - # with open(cli_overwrite_config_path, 'w') as file: - # file.write('anoption: overwrite') - # - # self.run_command('--config', self.cli_config_path, - # '--config', cli_overwrite_config_path, 'test') - # assert config['anoption'].get() == 'cli overwrite' - - # FIXME: fails on windows - @unittest.skipIf(sys.platform == "win32", "win32") - def test_cli_config_paths_resolve_relative_to_user_dir(self): - with open(self.cli_config_path, "w") as file: - file.write("library: beets.db\n") - file.write("statefile: state") - - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["library"].as_path() == self.user_config_dir / "beets.db" - assert config["statefile"].as_path() == self.user_config_dir / "state" - - def test_cli_config_paths_resolve_relative_to_beetsdir(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - with open(self.cli_config_path, "w") as file: - file.write("library: beets.db\n") - file.write("statefile: state") - - self.run_command("--config", self.cli_config_path, "test", lib=None) - assert config["library"].as_path() == self.beetsdir / "beets.db" - assert config["statefile"].as_path() == self.beetsdir / "state" - - def test_command_line_option_relative_to_working_dir(self): - config.read() - os.chdir(syspath(self.temp_dir)) - self.run_command("--library", "foo.db", "test", lib=None) - assert config["library"].as_path() == Path.cwd() / "foo.db" - - def test_cli_config_file_loads_plugin_commands(self): - with open(self.cli_config_path, "w") as file: - file.write(f"pluginpath: {_common.PLUGINPATH}\n") - file.write("plugins: test") - - self.run_command("--config", self.cli_config_path, "plugin", lib=None) - plugs = plugins.find_plugins() - assert len(plugs) == 1 - assert plugs[0].is_test_plugin - self.unload_plugins() - - def test_beetsdir_config(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - with open(self.env_config_path, "w") as file: - file.write("anoption: overwrite") - - config.read() - assert config["anoption"].get() == "overwrite" - - def test_beetsdir_points_to_file_error(self): - beetsdir = str(self.temp_dir_path / "beetsfile") - open(beetsdir, "a").close() - os.environ["BEETSDIR"] = beetsdir - with pytest.raises(ConfigError): - self.run_command("test") - - def test_beetsdir_config_does_not_load_default_user_config(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - with open(self.user_config_path, "w") as file: - file.write("anoption: value") - - config.read() - assert not config["anoption"].exists() - - def test_default_config_paths_resolve_relative_to_beetsdir(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - config.read() - assert config["library"].as_path() == self.beetsdir / "library.db" - assert config["statefile"].as_path() == self.beetsdir / "state.pickle" - - def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): - os.environ["BEETSDIR"] = str(self.beetsdir) - - with open(self.env_config_path, "w") as file: - file.write("library: beets.db\n") - file.write("statefile: state") - - config.read() - assert config["library"].as_path() == self.beetsdir / "beets.db" - assert config["statefile"].as_path() == self.beetsdir / "state" - - -class ShowModelChangeTest(IOMixin, unittest.TestCase): - def setUp(self): - super().setUp() - self.a = _common.item() - self.b = _common.item() - self.a.path = self.b.path - - def _show(self, **kwargs): - change = ui.show_model_changes(self.a, self.b, **kwargs) - out = self.io.getoutput() - return change, out - - def test_identical(self): - change, out = self._show() - assert not change - assert out == "" - - def test_string_fixed_field_change(self): - self.b.title = "x" - change, out = self._show() - assert change - assert "title" in out - - def test_int_fixed_field_change(self): - self.b.track = 9 - change, out = self._show() - assert change - assert "track" in out - - def test_floats_close_to_identical(self): - self.a.length = 1.00001 - self.b.length = 1.00005 - change, out = self._show() - assert not change - assert out == "" - - def test_floats_different(self): - self.a.length = 1.00001 - self.b.length = 2.00001 - change, out = self._show() - assert change - assert "length" in out - - def test_both_values_shown(self): - self.a.title = "foo" - self.b.title = "bar" - change, out = self._show() - assert "foo" in out - assert "bar" in out - - -class ShowChangeTest(IOMixin, unittest.TestCase): - def setUp(self): - super().setUp() - - self.items = [_common.item()] - self.items[0].track = 1 - self.items[0].path = b"/path/to/file.mp3" - self.info = autotag.AlbumInfo( - album="the album", - album_id="album id", - artist="the artist", - artist_id="artist id", - tracks=[ - autotag.TrackInfo( - title="the title", track_id="track id", index=1 - ) - ], - ) - - def _show_change( - self, - items=None, - info=None, - color=False, - cur_artist="the artist", - cur_album="the album", - dist=0.1, - ): - """Return an unicode string representing the changes""" - items = items or self.items - info = info or self.info - mapping = dict(zip(items, info.tracks)) - config["ui"]["color"] = color - config["import"]["detail"] = True - change_dist = distance(items, info, mapping) - change_dist._penalties = {"album": [dist], "artist": [dist]} - commands.show_change( - cur_artist, - cur_album, - autotag.AlbumMatch(change_dist, info, mapping, set(), set()), - ) - return self.io.getoutput().lower() - - def test_null_change(self): - msg = self._show_change() - assert "match (90.0%)" in msg - assert "album, artist" in msg - - def test_album_data_change(self): - msg = self._show_change( - cur_artist="another artist", cur_album="another album" - ) - assert "another artist -> the artist" in msg - assert "another album -> the album" in msg - - def test_item_data_change(self): - self.items[0].title = "different" - msg = self._show_change() - assert "different" in msg - assert "the title" in msg - - def test_item_data_change_with_unicode(self): - self.items[0].title = "caf\xe9" - msg = self._show_change() - assert "caf\xe9" in msg - assert "the title" in msg - - def test_album_data_change_with_unicode(self): - msg = self._show_change(cur_artist="caf\xe9", cur_album="another album") - assert "caf\xe9" in msg - assert "the artist" in msg - - def test_item_data_change_title_missing(self): - self.items[0].title = "" - msg = re.sub(r" +", " ", self._show_change()) - assert "file.mp3" in msg - assert "the title" in msg - - def test_item_data_change_title_missing_with_unicode_filename(self): - self.items[0].title = "" - self.items[0].path = "/path/to/caf\xe9.mp3".encode() - msg = re.sub(r" +", " ", self._show_change()) - assert "caf\xe9.mp3" in msg or "caf.mp3" in msg - - def test_colorize(self): - assert "test" == ui.uncolorize("test") - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m") - assert "test" == txt - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m test") - assert "test test" == txt - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00mtest") - assert "testtest" == txt - txt = ui.uncolorize("test \x1b[31mtest\x1b[39;49;00m test") - assert "test test test" == txt - - def test_color_split(self): - exp = ("test", "") - res = ui.color_split("test", 5) - assert exp == res - exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m") - res = ui.color_split("\x1b[31mtest\x1b[39;49;00m", 3) - assert exp == res - - def test_split_into_lines(self): - # Test uncolored text - txt = ui.split_into_lines("test test test", [5, 5, 5]) - assert txt == ["test", "test", "test"] - # Test multiple colored texts - colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3 - split_txt = [ - "\x1b[31mtest\x1b[39;49;00m", - "\x1b[31mtest\x1b[39;49;00m", - "\x1b[31mtest\x1b[39;49;00m", - ] - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - # Test single color, multi space text - colored_text = "\x1b[31m test test test \x1b[39;49;00m" - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - # Test single color, different spacing - colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test" - # ToDo: fix color_len to handle mid-text color escapes, and thus - # split colored texts over newlines (potentially with dashes?) - split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"] - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - - def test_album_data_change_wrap_newline(self): - # Patch ui.term_width to force wrapping - with patch("beets.ui.commands.ui.term_width", return_value=30): - # Test newline layout - config["ui"]["import"]["layout"] = "newline" - long_name = f"another artist with a{' very' * 10} long name" - msg = self._show_change( - cur_artist=long_name, cur_album="another album" - ) - assert "artist: another artist" in msg - assert " -> the artist" in msg - assert "another album -> the album" not in msg - - def test_item_data_change_wrap_column(self): - # Patch ui.term_width to force wrapping - with patch("beets.ui.commands.ui.term_width", return_value=54): - # Test Column layout - config["ui"]["import"]["layout"] = "column" - long_title = f"a track with a{' very' * 10} long name" - self.items[0].title = long_title - msg = self._show_change() - assert "(#1) a track (1:00) -> (#1) the title (0:00)" in msg - - def test_item_data_change_wrap_newline(self): - # Patch ui.term_width to force wrapping - with patch("beets.ui.commands.ui.term_width", return_value=30): - config["ui"]["import"]["layout"] = "newline" - long_title = f"a track with a{' very' * 10} long name" - self.items[0].title = long_title - msg = self._show_change() - assert "(#1) a track with" in msg - assert " -> (#1) the title (0:00)" in msg - - -@patch("beets.library.Item.try_filesize", Mock(return_value=987)) -class SummarizeItemsTest(unittest.TestCase): - def setUp(self): - super().setUp() - item = library.Item() - item.bitrate = 4321 - item.length = 10 * 60 + 54 - item.format = "F" - self.item = item - - def test_summarize_item(self): - summary = commands.summarize_items([], True) - assert summary == "" - - summary = commands.summarize_items([self.item], True) - assert summary == "F, 4kbps, 10:54, 987.0 B" - - def test_summarize_items(self): - summary = commands.summarize_items([], False) - assert summary == "0 items" - - summary = commands.summarize_items([self.item], False) - assert summary == "1 items, F, 4kbps, 10:54, 987.0 B" - - # make a copy of self.item - i2 = self.item.copy() - - summary = commands.summarize_items([self.item, i2], False) - assert summary == "2 items, F, 4kbps, 21:48, 1.9 KiB" - - i2.format = "G" - summary = commands.summarize_items([self.item, i2], False) - assert summary == "2 items, F 1, G 1, 4kbps, 21:48, 1.9 KiB" - - summary = commands.summarize_items([self.item, i2, i2], False) - assert summary == "3 items, G 2, F 1, 4kbps, 32:42, 2.9 KiB" - - -class PathFormatTest(unittest.TestCase): - def test_custom_paths_prepend(self): - default_formats = ui.get_path_formats() - - config["paths"] = {"foo": "bar"} - pf = ui.get_path_formats() - key, tmpl = pf[0] - assert key == "foo" - assert tmpl.original == "bar" - assert pf[1:] == default_formats - - -@_common.slow_test() -class PluginTest(TestPluginTestCase): - def test_plugin_command_from_pluginpath(self): - self.run_command("test", lib=None) - - -@_common.slow_test() -@pytest.mark.xfail( - os.environ.get("GITHUB_ACTIONS") == "true" and sys.platform == "linux", - reason="Completion is for some reason unhappy on Ubuntu 24.04 in CI", -) -class CompletionTest(IOMixin, TestPluginTestCase): - def test_completion(self): - # Do not load any other bash completion scripts on the system. - env = dict(os.environ) - env["BASH_COMPLETION_DIR"] = os.devnull - env["BASH_COMPLETION_COMPAT_DIR"] = os.devnull - - # Open a `bash` process to run the tests in. We'll pipe in bash - # commands via stdin. - cmd = os.environ.get("BEETS_TEST_SHELL", "/bin/bash --norc").split() - if not has_program(cmd[0]): - self.skipTest("bash not available") - tester = subprocess.Popen( - cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env - ) - - # Load bash_completion library. - for path in commands.BASH_COMPLETION_PATHS: - if os.path.exists(syspath(path)): - bash_completion = path - break - else: - self.skipTest("bash-completion script not found") - try: - with open(util.syspath(bash_completion), "rb") as f: - tester.stdin.writelines(f) - except OSError: - self.skipTest("could not read bash-completion script") - - # Load completion script. - self.run_command("completion", lib=None) - completion_script = self.io.getoutput().encode("utf-8") - self.io.restore() - tester.stdin.writelines(completion_script.splitlines(True)) - - # Load test suite. - test_script_name = os.path.join(_common.RSRC, b"test_completion.sh") - with open(test_script_name, "rb") as test_script_file: - tester.stdin.writelines(test_script_file) - out, err = tester.communicate() - assert tester.returncode == 0 - assert out == b"completion tests passed\n", ( - "test/test_completion.sh did not execute properly. " - f"Output:{out.decode('utf-8')}" - ) - - -class CommonOptionsParserCliTest(BeetsTestCase): - """Test CommonOptionsParser and formatting LibModel formatting on 'list' - command. - """ - - def setUp(self): - super().setUp() - self.item = _common.item() - self.item.path = b"xxx/yyy" - self.lib.add(self.item) - self.lib.add_album([self.item]) - - def test_base(self): - output = self.run_with_output("ls") - assert output == "the artist - the album - the title\n" - - output = self.run_with_output("ls", "-a") - assert output == "the album artist - the album\n" - - def test_path_option(self): - output = self.run_with_output("ls", "-p") - assert output == "xxx/yyy\n" - - output = self.run_with_output("ls", "-a", "-p") - assert output == "xxx\n" - - def test_format_option(self): - output = self.run_with_output("ls", "-f", "$artist") - assert output == "the artist\n" - - output = self.run_with_output("ls", "-a", "-f", "$albumartist") - assert output == "the album artist\n" - - def test_format_option_unicode(self): - output = self.run_with_output("ls", "-f", "caf\xe9") - assert output == "caf\xe9\n" - - def test_root_format_option(self): - output = self.run_with_output( - "--format-item", "$artist", "--format-album", "foo", "ls" - ) - assert output == "the artist\n" - - output = self.run_with_output( - "--format-item", "foo", "--format-album", "$albumartist", "ls", "-a" - ) - assert output == "the album artist\n" - - def test_help(self): - output = self.run_with_output("help") - assert "Usage:" in output - - output = self.run_with_output("help", "list") - assert "Usage:" in output - - with pytest.raises(ui.UserError): - self.run_command("help", "this.is.not.a.real.command") - - def test_stats(self): - output = self.run_with_output("stats") - assert "Approximate total size:" in output - - # # Need to have more realistic library setup for this to work - # output = self.run_with_output('stats', '-e') - # assert 'Total size:' in output - - def test_version(self): - output = self.run_with_output("version") - assert "Python version" in output - assert "no plugins loaded" in output - - # # Need to have plugin loaded - # output = self.run_with_output('version') - # assert 'plugins: ' in output - - -class CommonOptionsParserTest(unittest.TestCase): - def test_album_option(self): - parser = ui.CommonOptionsParser() - assert not parser._album_flags - parser.add_album_option() - assert bool(parser._album_flags) - - assert parser.parse_args([]) == ({"album": None}, []) - assert parser.parse_args(["-a"]) == ({"album": True}, []) - assert parser.parse_args(["--album"]) == ({"album": True}, []) - - def test_path_option(self): - parser = ui.CommonOptionsParser() - parser.add_path_option() - assert not parser._album_flags - - config["format_item"].set("$foo") - assert parser.parse_args([]) == ({"path": None}, []) - assert config["format_item"].as_str() == "$foo" - - assert parser.parse_args(["-p"]) == ( - {"path": True, "format": "$path"}, - [], - ) - assert parser.parse_args(["--path"]) == ( - {"path": True, "format": "$path"}, - [], - ) - - assert config["format_item"].as_str() == "$path" - assert config["format_album"].as_str() == "$path" - - def test_format_option(self): - parser = ui.CommonOptionsParser() - parser.add_format_option() - assert not parser._album_flags - - config["format_item"].set("$foo") - assert parser.parse_args([]) == ({"format": None}, []) - assert config["format_item"].as_str() == "$foo" - - assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) - assert parser.parse_args(["--format", "$baz"]) == ( - {"format": "$baz"}, - [], - ) - - assert config["format_item"].as_str() == "$baz" - assert config["format_album"].as_str() == "$baz" - - def test_format_option_with_target(self): - with pytest.raises(KeyError): - ui.CommonOptionsParser().add_format_option(target="thingy") - - parser = ui.CommonOptionsParser() - parser.add_format_option(target="item") - - config["format_item"].set("$item") - config["format_album"].set("$album") - - assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) - - assert config["format_item"].as_str() == "$bar" - assert config["format_album"].as_str() == "$album" - - def test_format_option_with_album(self): - parser = ui.CommonOptionsParser() - parser.add_album_option() - parser.add_format_option() - - config["format_item"].set("$item") - config["format_album"].set("$album") - - parser.parse_args(["-f", "$bar"]) - assert config["format_item"].as_str() == "$bar" - assert config["format_album"].as_str() == "$album" - - parser.parse_args(["-a", "-f", "$foo"]) - assert config["format_item"].as_str() == "$bar" - assert config["format_album"].as_str() == "$foo" - - parser.parse_args(["-f", "$foo2", "-a"]) - assert config["format_album"].as_str() == "$foo2" - - def test_add_all_common_options(self): - parser = ui.CommonOptionsParser() - parser.add_all_common_options() - assert parser.parse_args([]) == ( - {"album": None, "path": None, "format": None}, - [], - ) - - -class EncodingTest(unittest.TestCase): - """Tests for the `terminal_encoding` config option and our - `_in_encoding` and `_out_encoding` utility functions. - """ - - def out_encoding_overridden(self): - config["terminal_encoding"] = "fake_encoding" - assert ui._out_encoding() == "fake_encoding" - - def in_encoding_overridden(self): - config["terminal_encoding"] = "fake_encoding" - assert ui._in_encoding() == "fake_encoding" - - def out_encoding_default_utf8(self): - with patch("sys.stdout") as stdout: - stdout.encoding = None - assert ui._out_encoding() == "utf-8" - - def in_encoding_default_utf8(self): - with patch("sys.stdin") as stdin: - stdin.encoding = None - assert ui._in_encoding() == "utf-8" diff --git a/test/ui/__init__.py b/test/ui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/ui/commands/__init__.py b/test/ui/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/ui/commands/test_completion.py b/test/ui/commands/test_completion.py new file mode 100644 index 000000000..f1e53f238 --- /dev/null +++ b/test/ui/commands/test_completion.py @@ -0,0 +1,64 @@ +import os +import subprocess +import sys + +import pytest + +from beets.test import _common +from beets.test.helper import IOMixin, has_program +from beets.ui.commands.completion import BASH_COMPLETION_PATHS +from beets.util import syspath + +from ..test_ui import TestPluginTestCase + + +@_common.slow_test() +@pytest.mark.xfail( + os.environ.get("GITHUB_ACTIONS") == "true" and sys.platform == "linux", + reason="Completion is for some reason unhappy on Ubuntu 24.04 in CI", +) +class CompletionTest(IOMixin, TestPluginTestCase): + def test_completion(self): + # Do not load any other bash completion scripts on the system. + env = dict(os.environ) + env["BASH_COMPLETION_DIR"] = os.devnull + env["BASH_COMPLETION_COMPAT_DIR"] = os.devnull + + # Open a `bash` process to run the tests in. We'll pipe in bash + # commands via stdin. + cmd = os.environ.get("BEETS_TEST_SHELL", "/bin/bash --norc").split() + if not has_program(cmd[0]): + self.skipTest("bash not available") + tester = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env + ) + + # Load bash_completion library. + for path in BASH_COMPLETION_PATHS: + if os.path.exists(syspath(path)): + bash_completion = path + break + else: + self.skipTest("bash-completion script not found") + try: + with open(syspath(bash_completion), "rb") as f: + tester.stdin.writelines(f) + except OSError: + self.skipTest("could not read bash-completion script") + + # Load completion script. + self.run_command("completion", lib=None) + completion_script = self.io.getoutput().encode("utf-8") + self.io.restore() + tester.stdin.writelines(completion_script.splitlines(True)) + + # Load test suite. + test_script_name = os.path.join(_common.RSRC, b"test_completion.sh") + with open(test_script_name, "rb") as test_script_file: + tester.stdin.writelines(test_script_file) + out, err = tester.communicate() + assert tester.returncode == 0 + assert out == b"completion tests passed\n", ( + "test/test_completion.sh did not execute properly. " + f"Output:{out.decode('utf-8')}" + ) diff --git a/test/test_config_command.py b/test/ui/commands/test_config.py similarity index 100% rename from test/test_config_command.py rename to test/ui/commands/test_config.py diff --git a/test/ui/commands/test_fields.py b/test/ui/commands/test_fields.py new file mode 100644 index 000000000..0eaaa9ceb --- /dev/null +++ b/test/ui/commands/test_fields.py @@ -0,0 +1,24 @@ +from beets import library +from beets.test.helper import IOMixin, ItemInDBTestCase +from beets.ui.commands.fields import fields_func + + +class FieldsTest(IOMixin, ItemInDBTestCase): + def remove_keys(self, keys, text): + for i in text: + try: + keys.remove(i) + except ValueError: + pass + + def test_fields_func(self): + fields_func(self.lib, [], []) + items = library.Item.all_keys() + albums = library.Album.all_keys() + + output = self.io.stdout.get().split() + self.remove_keys(items, output) + self.remove_keys(albums, output) + + assert len(items) == 0 + assert len(albums) == 0 diff --git a/test/ui/commands/test_import.py b/test/ui/commands/test_import.py new file mode 100644 index 000000000..d74d2d816 --- /dev/null +++ b/test/ui/commands/test_import.py @@ -0,0 +1,256 @@ +import os +import re +import unittest +from unittest.mock import Mock, patch + +import pytest + +from beets import autotag, config, library, ui +from beets.autotag.match import distance +from beets.test import _common +from beets.test.helper import BeetsTestCase, IOMixin +from beets.ui.commands.import_ import import_files, paths_from_logfile +from beets.ui.commands.import_.display import show_change +from beets.ui.commands.import_.session import summarize_items + + +class ImportTest(BeetsTestCase): + def test_quiet_timid_disallowed(self): + config["import"]["quiet"] = True + config["import"]["timid"] = True + with pytest.raises(ui.UserError): + import_files(None, [], None) + + def test_parse_paths_from_logfile(self): + if os.path.__name__ == "ntpath": + logfile_content = ( + "import started Wed Jun 15 23:08:26 2022\n" + "asis C:\\music\\Beatles, The\\The Beatles; C:\\music\\Beatles, The\\The Beatles\\CD 01; C:\\music\\Beatles, The\\The Beatles\\CD 02\n" # noqa: E501 + "duplicate-replace C:\\music\\Bill Evans\\Trio '65\n" + "skip C:\\music\\Michael Jackson\\Bad\n" + "skip C:\\music\\Soulwax\\Any Minute Now\n" + ) + expected_paths = [ + "C:\\music\\Beatles, The\\The Beatles", + "C:\\music\\Michael Jackson\\Bad", + "C:\\music\\Soulwax\\Any Minute Now", + ] + else: + logfile_content = ( + "import started Wed Jun 15 23:08:26 2022\n" + "asis /music/Beatles, The/The Beatles; /music/Beatles, The/The Beatles/CD 01; /music/Beatles, The/The Beatles/CD 02\n" # noqa: E501 + "duplicate-replace /music/Bill Evans/Trio '65\n" + "skip /music/Michael Jackson/Bad\n" + "skip /music/Soulwax/Any Minute Now\n" + ) + expected_paths = [ + "/music/Beatles, The/The Beatles", + "/music/Michael Jackson/Bad", + "/music/Soulwax/Any Minute Now", + ] + + logfile = os.path.join(self.temp_dir, b"logfile.log") + with open(logfile, mode="w") as fp: + fp.write(logfile_content) + actual_paths = list(paths_from_logfile(logfile)) + assert actual_paths == expected_paths + + +class ShowChangeTest(IOMixin, unittest.TestCase): + def setUp(self): + super().setUp() + + self.items = [_common.item()] + self.items[0].track = 1 + self.items[0].path = b"/path/to/file.mp3" + self.info = autotag.AlbumInfo( + album="the album", + album_id="album id", + artist="the artist", + artist_id="artist id", + tracks=[ + autotag.TrackInfo( + title="the title", track_id="track id", index=1 + ) + ], + ) + + def _show_change( + self, + items=None, + info=None, + color=False, + cur_artist="the artist", + cur_album="the album", + dist=0.1, + ): + """Return an unicode string representing the changes""" + items = items or self.items + info = info or self.info + mapping = dict(zip(items, info.tracks)) + config["ui"]["color"] = color + config["import"]["detail"] = True + change_dist = distance(items, info, mapping) + change_dist._penalties = {"album": [dist], "artist": [dist]} + show_change( + cur_artist, + cur_album, + autotag.AlbumMatch(change_dist, info, mapping, set(), set()), + ) + return self.io.getoutput().lower() + + def test_null_change(self): + msg = self._show_change() + assert "match (90.0%)" in msg + assert "album, artist" in msg + + def test_album_data_change(self): + msg = self._show_change( + cur_artist="another artist", cur_album="another album" + ) + assert "another artist -> the artist" in msg + assert "another album -> the album" in msg + + def test_item_data_change(self): + self.items[0].title = "different" + msg = self._show_change() + assert "different" in msg + assert "the title" in msg + + def test_item_data_change_with_unicode(self): + self.items[0].title = "caf\xe9" + msg = self._show_change() + assert "caf\xe9" in msg + assert "the title" in msg + + def test_album_data_change_with_unicode(self): + msg = self._show_change(cur_artist="caf\xe9", cur_album="another album") + assert "caf\xe9" in msg + assert "the artist" in msg + + def test_item_data_change_title_missing(self): + self.items[0].title = "" + msg = re.sub(r" +", " ", self._show_change()) + assert "file.mp3" in msg + assert "the title" in msg + + def test_item_data_change_title_missing_with_unicode_filename(self): + self.items[0].title = "" + self.items[0].path = "/path/to/caf\xe9.mp3".encode() + msg = re.sub(r" +", " ", self._show_change()) + assert "caf\xe9.mp3" in msg or "caf.mp3" in msg + + def test_colorize(self): + assert "test" == ui.uncolorize("test") + txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m") + assert "test" == txt + txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m test") + assert "test test" == txt + txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00mtest") + assert "testtest" == txt + txt = ui.uncolorize("test \x1b[31mtest\x1b[39;49;00m test") + assert "test test test" == txt + + def test_color_split(self): + exp = ("test", "") + res = ui.color_split("test", 5) + assert exp == res + exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m") + res = ui.color_split("\x1b[31mtest\x1b[39;49;00m", 3) + assert exp == res + + def test_split_into_lines(self): + # Test uncolored text + txt = ui.split_into_lines("test test test", [5, 5, 5]) + assert txt == ["test", "test", "test"] + # Test multiple colored texts + colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3 + split_txt = [ + "\x1b[31mtest\x1b[39;49;00m", + "\x1b[31mtest\x1b[39;49;00m", + "\x1b[31mtest\x1b[39;49;00m", + ] + txt = ui.split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt + # Test single color, multi space text + colored_text = "\x1b[31m test test test \x1b[39;49;00m" + txt = ui.split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt + # Test single color, different spacing + colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test" + # ToDo: fix color_len to handle mid-text color escapes, and thus + # split colored texts over newlines (potentially with dashes?) + split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"] + txt = ui.split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt + + def test_album_data_change_wrap_newline(self): + # Patch ui.term_width to force wrapping + with patch("beets.ui.term_width", return_value=30): + # Test newline layout + config["ui"]["import"]["layout"] = "newline" + long_name = f"another artist with a{' very' * 10} long name" + msg = self._show_change( + cur_artist=long_name, cur_album="another album" + ) + assert "artist: another artist" in msg + assert " -> the artist" in msg + assert "another album -> the album" not in msg + + def test_item_data_change_wrap_column(self): + # Patch ui.term_width to force wrapping + with patch("beets.ui.term_width", return_value=54): + # Test Column layout + config["ui"]["import"]["layout"] = "column" + long_title = f"a track with a{' very' * 10} long name" + self.items[0].title = long_title + msg = self._show_change() + assert "(#1) a track (1:00) -> (#1) the title (0:00)" in msg + + def test_item_data_change_wrap_newline(self): + # Patch ui.term_width to force wrapping + with patch("beets.ui.term_width", return_value=30): + config["ui"]["import"]["layout"] = "newline" + long_title = f"a track with a{' very' * 10} long name" + self.items[0].title = long_title + msg = self._show_change() + assert "(#1) a track with" in msg + assert " -> (#1) the title (0:00)" in msg + + +@patch("beets.library.Item.try_filesize", Mock(return_value=987)) +class SummarizeItemsTest(unittest.TestCase): + def setUp(self): + super().setUp() + item = library.Item() + item.bitrate = 4321 + item.length = 10 * 60 + 54 + item.format = "F" + self.item = item + + def test_summarize_item(self): + summary = summarize_items([], True) + assert summary == "" + + summary = summarize_items([self.item], True) + assert summary == "F, 4kbps, 10:54, 987.0 B" + + def test_summarize_items(self): + summary = summarize_items([], False) + assert summary == "0 items" + + summary = summarize_items([self.item], False) + assert summary == "1 items, F, 4kbps, 10:54, 987.0 B" + + # make a copy of self.item + i2 = self.item.copy() + + summary = summarize_items([self.item, i2], False) + assert summary == "2 items, F, 4kbps, 21:48, 1.9 KiB" + + i2.format = "G" + summary = summarize_items([self.item, i2], False) + assert summary == "2 items, F 1, G 1, 4kbps, 21:48, 1.9 KiB" + + summary = summarize_items([self.item, i2, i2], False) + assert summary == "3 items, G 2, F 1, 4kbps, 32:42, 2.9 KiB" diff --git a/test/ui/commands/test_list.py b/test/ui/commands/test_list.py new file mode 100644 index 000000000..a63a56ad1 --- /dev/null +++ b/test/ui/commands/test_list.py @@ -0,0 +1,69 @@ +from beets.test import _common +from beets.test.helper import BeetsTestCase, capture_stdout +from beets.ui.commands.list import list_items + + +class ListTest(BeetsTestCase): + def setUp(self): + super().setUp() + self.item = _common.item() + self.item.path = "xxx/yyy" + self.lib.add(self.item) + self.lib.add_album([self.item]) + + def _run_list(self, query="", album=False, path=False, fmt=""): + with capture_stdout() as stdout: + list_items(self.lib, query, album, fmt) + return stdout + + def test_list_outputs_item(self): + stdout = self._run_list() + assert "the title" in stdout.getvalue() + + def test_list_unicode_query(self): + self.item.title = "na\xefve" + self.item.store() + self.lib._connection().commit() + + stdout = self._run_list(["na\xefve"]) + out = stdout.getvalue() + assert "na\xefve" in out + + def test_list_item_path(self): + stdout = self._run_list(fmt="$path") + assert stdout.getvalue().strip() == "xxx/yyy" + + def test_list_album_outputs_something(self): + stdout = self._run_list(album=True) + assert len(stdout.getvalue()) > 0 + + def test_list_album_path(self): + stdout = self._run_list(album=True, fmt="$path") + assert stdout.getvalue().strip() == "xxx" + + def test_list_album_omits_title(self): + stdout = self._run_list(album=True) + assert "the title" not in stdout.getvalue() + + def test_list_uses_track_artist(self): + stdout = self._run_list() + assert "the artist" in stdout.getvalue() + assert "the album artist" not in stdout.getvalue() + + def test_list_album_uses_album_artist(self): + stdout = self._run_list(album=True) + assert "the artist" not in stdout.getvalue() + assert "the album artist" in stdout.getvalue() + + def test_list_item_format_artist(self): + stdout = self._run_list(fmt="$artist") + assert "the artist" in stdout.getvalue() + + def test_list_item_format_multiple(self): + stdout = self._run_list(fmt="$artist - $album - $year") + assert "the artist - the album - 0001" == stdout.getvalue().strip() + + def test_list_album_format(self): + stdout = self._run_list(album=True, fmt="$genre") + assert "the genre" in stdout.getvalue() + assert "the album" not in stdout.getvalue() diff --git a/test/ui/commands/test_modify.py b/test/ui/commands/test_modify.py new file mode 100644 index 000000000..b9cc1524d --- /dev/null +++ b/test/ui/commands/test_modify.py @@ -0,0 +1,216 @@ +import unittest + +from mediafile import MediaFile + +from beets.test.helper import BeetsTestCase, control_stdin +from beets.ui.commands.modify import modify_parse_args +from beets.util import syspath + + +class ModifyTest(BeetsTestCase): + def setUp(self): + super().setUp() + self.album = self.add_album_fixture() + [self.item] = self.album.items() + + def modify_inp(self, inp, *args): + with control_stdin(inp): + self.run_command("modify", *args) + + def modify(self, *args): + self.modify_inp("y", *args) + + # Item tests + + def test_modify_item(self): + self.modify("title=newTitle") + item = self.lib.items().get() + assert item.title == "newTitle" + + def test_modify_item_abort(self): + item = self.lib.items().get() + title = item.title + self.modify_inp("n", "title=newTitle") + item = self.lib.items().get() + assert item.title == title + + def test_modify_item_no_change(self): + title = "Tracktitle" + item = self.add_item_fixture(title=title) + self.modify_inp("y", "title", f"title={title}") + item = self.lib.items(title).get() + assert item.title == title + + def test_modify_write_tags(self): + self.modify("title=newTitle") + item = self.lib.items().get() + item.read() + assert item.title == "newTitle" + + def test_modify_dont_write_tags(self): + self.modify("--nowrite", "title=newTitle") + item = self.lib.items().get() + item.read() + assert item.title != "newTitle" + + def test_move(self): + self.modify("title=newTitle") + item = self.lib.items().get() + assert b"newTitle" in item.path + + def test_not_move(self): + self.modify("--nomove", "title=newTitle") + item = self.lib.items().get() + assert b"newTitle" not in item.path + + def test_no_write_no_move(self): + self.modify("--nomove", "--nowrite", "title=newTitle") + item = self.lib.items().get() + item.read() + assert b"newTitle" not in item.path + assert item.title != "newTitle" + + def test_update_mtime(self): + item = self.item + old_mtime = item.mtime + + self.modify("title=newTitle") + item.load() + assert old_mtime != item.mtime + assert item.current_mtime() == item.mtime + + def test_reset_mtime_with_no_write(self): + item = self.item + + self.modify("--nowrite", "title=newTitle") + item.load() + assert 0 == item.mtime + + def test_selective_modify(self): + title = "Tracktitle" + album = "album" + original_artist = "composer" + new_artist = "coverArtist" + for i in range(0, 10): + self.add_item_fixture( + title=f"{title}{i}", artist=original_artist, album=album + ) + self.modify_inp( + "s\ny\ny\ny\nn\nn\ny\ny\ny\ny\nn", title, f"artist={new_artist}" + ) + original_items = self.lib.items(f"artist:{original_artist}") + new_items = self.lib.items(f"artist:{new_artist}") + assert len(list(original_items)) == 3 + assert len(list(new_items)) == 7 + + def test_modify_formatted(self): + for i in range(0, 3): + self.add_item_fixture( + title=f"title{i}", artist="artist", album="album" + ) + items = list(self.lib.items()) + self.modify("title=${title} - append") + for item in items: + orig_title = item.title + item.load() + assert item.title == f"{orig_title} - append" + + # Album Tests + + def test_modify_album(self): + self.modify("--album", "album=newAlbum") + album = self.lib.albums().get() + assert album.album == "newAlbum" + + def test_modify_album_write_tags(self): + self.modify("--album", "album=newAlbum") + item = self.lib.items().get() + item.read() + assert item.album == "newAlbum" + + def test_modify_album_dont_write_tags(self): + self.modify("--album", "--nowrite", "album=newAlbum") + item = self.lib.items().get() + item.read() + assert item.album == "the album" + + def test_album_move(self): + self.modify("--album", "album=newAlbum") + item = self.lib.items().get() + item.read() + assert b"newAlbum" in item.path + + def test_album_not_move(self): + self.modify("--nomove", "--album", "album=newAlbum") + item = self.lib.items().get() + item.read() + assert b"newAlbum" not in item.path + + def test_modify_album_formatted(self): + item = self.lib.items().get() + orig_album = item.album + self.modify("--album", "album=${album} - append") + item.load() + assert item.album == f"{orig_album} - append" + + # Misc + + def test_write_initial_key_tag(self): + self.modify("initial_key=C#m") + item = self.lib.items().get() + mediafile = MediaFile(syspath(item.path)) + assert mediafile.initial_key == "C#m" + + def test_set_flexattr(self): + self.modify("flexattr=testAttr") + item = self.lib.items().get() + assert item.flexattr == "testAttr" + + def test_remove_flexattr(self): + item = self.lib.items().get() + item.flexattr = "testAttr" + item.store() + + self.modify("flexattr!") + item = self.lib.items().get() + assert "flexattr" not in item + + @unittest.skip("not yet implemented") + def test_delete_initial_key_tag(self): + item = self.lib.items().get() + item.initial_key = "C#m" + item.write() + item.store() + + mediafile = MediaFile(syspath(item.path)) + assert mediafile.initial_key == "C#m" + + self.modify("initial_key!") + mediafile = MediaFile(syspath(item.path)) + assert mediafile.initial_key is None + + def test_arg_parsing_colon_query(self): + (query, mods, dels) = modify_parse_args( + ["title:oldTitle", "title=newTitle"] + ) + assert query == ["title:oldTitle"] + assert mods == {"title": "newTitle"} + + def test_arg_parsing_delete(self): + (query, mods, dels) = modify_parse_args(["title:oldTitle", "title!"]) + assert query == ["title:oldTitle"] + assert dels == ["title"] + + def test_arg_parsing_query_with_exclaimation(self): + (query, mods, dels) = modify_parse_args( + ["title:oldTitle!", "title=newTitle!"] + ) + assert query == ["title:oldTitle!"] + assert mods == {"title": "newTitle!"} + + def test_arg_parsing_equals_in_value(self): + (query, mods, dels) = modify_parse_args( + ["title:foo=bar", "title=newTitle"] + ) + assert query == ["title:foo=bar"] + assert mods == {"title": "newTitle"} diff --git a/test/ui/commands/test_move.py b/test/ui/commands/test_move.py new file mode 100644 index 000000000..5c65f1475 --- /dev/null +++ b/test/ui/commands/test_move.py @@ -0,0 +1,102 @@ +import shutil + +from beets import library +from beets.test.helper import BeetsTestCase +from beets.ui.commands.move import move_items + + +class MoveTest(BeetsTestCase): + def setUp(self): + super().setUp() + + self.initial_item_path = self.lib_path / "srcfile" + shutil.copy(self.resource_path, self.initial_item_path) + + # Add a file to the library but don't copy it in yet. + self.i = library.Item.from_path(self.initial_item_path) + self.lib.add(self.i) + self.album = self.lib.add_album([self.i]) + + # Alternate destination directory. + self.otherdir = self.temp_dir_path / "testotherdir" + + def _move( + self, + query=(), + dest=None, + copy=False, + album=False, + pretend=False, + export=False, + ): + move_items(self.lib, dest, query, copy, album, pretend, export=export) + + def test_move_item(self): + self._move() + self.i.load() + assert b"libdir" in self.i.path + assert self.i.filepath.exists() + assert not self.initial_item_path.exists() + + def test_copy_item(self): + self._move(copy=True) + self.i.load() + assert b"libdir" in self.i.path + assert self.i.filepath.exists() + assert self.initial_item_path.exists() + + def test_move_album(self): + self._move(album=True) + self.i.load() + assert b"libdir" in self.i.path + assert self.i.filepath.exists() + assert not self.initial_item_path.exists() + + def test_copy_album(self): + self._move(copy=True, album=True) + self.i.load() + assert b"libdir" in self.i.path + assert self.i.filepath.exists() + assert self.initial_item_path.exists() + + def test_move_item_custom_dir(self): + self._move(dest=self.otherdir) + self.i.load() + assert b"testotherdir" in self.i.path + assert self.i.filepath.exists() + assert not self.initial_item_path.exists() + + def test_move_album_custom_dir(self): + self._move(dest=self.otherdir, album=True) + self.i.load() + assert b"testotherdir" in self.i.path + assert self.i.filepath.exists() + assert not self.initial_item_path.exists() + + def test_pretend_move_item(self): + self._move(dest=self.otherdir, pretend=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + + def test_pretend_move_album(self): + self._move(album=True, pretend=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + + def test_export_item_custom_dir(self): + self._move(dest=self.otherdir, export=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + assert self.otherdir.exists() + + def test_export_album_custom_dir(self): + self._move(dest=self.otherdir, album=True, export=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + assert self.otherdir.exists() + + def test_pretend_export_item(self): + self._move(dest=self.otherdir, pretend=True, export=True) + self.i.load() + assert self.i.filepath == self.initial_item_path + assert not self.otherdir.exists() diff --git a/test/ui/commands/test_remove.py b/test/ui/commands/test_remove.py new file mode 100644 index 000000000..e42bb7630 --- /dev/null +++ b/test/ui/commands/test_remove.py @@ -0,0 +1,80 @@ +import os + +from beets import library +from beets.test.helper import BeetsTestCase, IOMixin +from beets.ui.commands.remove import remove_items +from beets.util import MoveOperation, syspath + + +class RemoveTest(IOMixin, BeetsTestCase): + def setUp(self): + super().setUp() + + # Copy a file into the library. + self.i = library.Item.from_path(self.resource_path) + self.lib.add(self.i) + self.i.move(operation=MoveOperation.COPY) + + def test_remove_items_no_delete(self): + self.io.addinput("y") + remove_items(self.lib, "", False, False, False) + items = self.lib.items() + assert len(list(items)) == 0 + assert self.i.filepath.exists() + + def test_remove_items_with_delete(self): + self.io.addinput("y") + remove_items(self.lib, "", False, True, False) + items = self.lib.items() + assert len(list(items)) == 0 + assert not self.i.filepath.exists() + + def test_remove_items_with_force_no_delete(self): + remove_items(self.lib, "", False, False, True) + items = self.lib.items() + assert len(list(items)) == 0 + assert self.i.filepath.exists() + + def test_remove_items_with_force_delete(self): + remove_items(self.lib, "", False, True, True) + items = self.lib.items() + assert len(list(items)) == 0 + assert not self.i.filepath.exists() + + def test_remove_items_select_with_delete(self): + i2 = library.Item.from_path(self.resource_path) + self.lib.add(i2) + i2.move(operation=MoveOperation.COPY) + + for s in ("s", "y", "n"): + self.io.addinput(s) + remove_items(self.lib, "", False, True, False) + items = self.lib.items() + assert len(list(items)) == 1 + # There is probably no guarantee that the items are queried in any + # spcecific order, thus just ensure that exactly one was removed. + # To improve upon this, self.io would need to have the capability to + # generate input that depends on previous output. + num_existing = 0 + num_existing += 1 if os.path.exists(syspath(self.i.path)) else 0 + num_existing += 1 if os.path.exists(syspath(i2.path)) else 0 + assert num_existing == 1 + + def test_remove_albums_select_with_delete(self): + a1 = self.add_album_fixture() + a2 = self.add_album_fixture() + path1 = a1.items()[0].path + path2 = a2.items()[0].path + items = self.lib.items() + assert len(list(items)) == 3 + + for s in ("s", "y", "n"): + self.io.addinput(s) + remove_items(self.lib, "", True, True, False) + items = self.lib.items() + assert len(list(items)) == 2 # incl. the item from setUp() + # See test_remove_items_select_with_delete() + num_existing = 0 + num_existing += 1 if os.path.exists(syspath(path1)) else 0 + num_existing += 1 if os.path.exists(syspath(path2)) else 0 + assert num_existing == 1 diff --git a/test/ui/commands/test_update.py b/test/ui/commands/test_update.py new file mode 100644 index 000000000..3fb687418 --- /dev/null +++ b/test/ui/commands/test_update.py @@ -0,0 +1,205 @@ +import os + +from mediafile import MediaFile + +from beets import library +from beets.test import _common +from beets.test.helper import BeetsTestCase, IOMixin +from beets.ui.commands.update import update_items +from beets.util import MoveOperation, remove, syspath + + +class UpdateTest(IOMixin, BeetsTestCase): + def setUp(self): + super().setUp() + + # Copy a file into the library. + item_path = os.path.join(_common.RSRC, b"full.mp3") + item_path_two = os.path.join(_common.RSRC, b"full.flac") + self.i = library.Item.from_path(item_path) + self.i2 = library.Item.from_path(item_path_two) + self.lib.add(self.i) + self.lib.add(self.i2) + self.i.move(operation=MoveOperation.COPY) + self.i2.move(operation=MoveOperation.COPY) + self.album = self.lib.add_album([self.i, self.i2]) + + # Album art. + artfile = os.path.join(self.temp_dir, b"testart.jpg") + _common.touch(artfile) + self.album.set_art(artfile) + self.album.store() + remove(artfile) + + def _update( + self, + query=(), + album=False, + move=False, + reset_mtime=True, + fields=None, + exclude_fields=None, + ): + self.io.addinput("y") + if reset_mtime: + self.i.mtime = 0 + self.i.store() + update_items( + self.lib, + query, + album, + move, + False, + fields=fields, + exclude_fields=exclude_fields, + ) + + def test_delete_removes_item(self): + assert list(self.lib.items()) + remove(self.i.path) + remove(self.i2.path) + self._update() + assert not list(self.lib.items()) + + def test_delete_removes_album(self): + assert self.lib.albums() + remove(self.i.path) + remove(self.i2.path) + self._update() + assert not self.lib.albums() + + def test_delete_removes_album_art(self): + art_filepath = self.album.art_filepath + assert art_filepath.exists() + remove(self.i.path) + remove(self.i2.path) + self._update() + assert not art_filepath.exists() + + def test_modified_metadata_detected(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.save() + self._update() + item = self.lib.items().get() + assert item.title == "differentTitle" + + def test_modified_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.save() + self._update(move=True) + item = self.lib.items().get() + assert b"differentTitle" in item.path + + def test_modified_metadata_not_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.save() + self._update(move=False) + item = self.lib.items().get() + assert b"differentTitle" not in item.path + + def test_selective_modified_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.genre = "differentGenre" + mf.save() + self._update(move=True, fields=["title"]) + item = self.lib.items().get() + assert b"differentTitle" in item.path + assert item.genre != "differentGenre" + + def test_selective_modified_metadata_not_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.genre = "differentGenre" + mf.save() + self._update(move=False, fields=["title"]) + item = self.lib.items().get() + assert b"differentTitle" not in item.path + assert item.genre != "differentGenre" + + def test_modified_album_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.album = "differentAlbum" + mf.save() + self._update(move=True) + item = self.lib.items().get() + assert b"differentAlbum" in item.path + + def test_modified_album_metadata_art_moved(self): + artpath = self.album.artpath + mf = MediaFile(syspath(self.i.path)) + mf.album = "differentAlbum" + mf.save() + self._update(move=True) + album = self.lib.albums()[0] + assert artpath != album.artpath + assert album.artpath is not None + + def test_selective_modified_album_metadata_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.album = "differentAlbum" + mf.genre = "differentGenre" + mf.save() + self._update(move=True, fields=["album"]) + item = self.lib.items().get() + assert b"differentAlbum" in item.path + assert item.genre != "differentGenre" + + def test_selective_modified_album_metadata_not_moved(self): + mf = MediaFile(syspath(self.i.path)) + mf.album = "differentAlbum" + mf.genre = "differentGenre" + mf.save() + self._update(move=True, fields=["genre"]) + item = self.lib.items().get() + assert b"differentAlbum" not in item.path + assert item.genre == "differentGenre" + + def test_mtime_match_skips_update(self): + mf = MediaFile(syspath(self.i.path)) + mf.title = "differentTitle" + mf.save() + + # Make in-memory mtime match on-disk mtime. + self.i.mtime = os.path.getmtime(syspath(self.i.path)) + self.i.store() + + self._update(reset_mtime=False) + item = self.lib.items().get() + assert item.title == "full" + + def test_multivalued_albumtype_roundtrip(self): + # https://github.com/beetbox/beets/issues/4528 + + # albumtypes is empty for our test fixtures, so populate it first + album = self.album + correct_albumtypes = ["album", "live"] + + # Setting albumtypes does not set albumtype, currently. + # Using x[0] mirrors https://github.com/beetbox/mediafile/blob/057432ad53b3b84385e5582f69f44dc00d0a725d/mediafile.py#L1928 # noqa: E501 + correct_albumtype = correct_albumtypes[0] + + album.albumtype = correct_albumtype + album.albumtypes = correct_albumtypes + album.try_sync(write=True, move=False) + + album.load() + assert album.albumtype == correct_albumtype + assert album.albumtypes == correct_albumtypes + + self._update() + + album.load() + assert album.albumtype == correct_albumtype + assert album.albumtypes == correct_albumtypes + + def test_modified_metadata_excluded(self): + mf = MediaFile(syspath(self.i.path)) + mf.lyrics = "new lyrics" + mf.save() + self._update(exclude_fields=["lyrics"]) + item = self.lib.items().get() + assert item.lyrics != "new lyrics" diff --git a/test/test_ui_commands.py b/test/ui/commands/test_utils.py similarity index 50% rename from test/test_ui_commands.py rename to test/ui/commands/test_utils.py index 412ddc2b7..bd07a27c7 100644 --- a/test/test_ui_commands.py +++ b/test/ui/commands/test_utils.py @@ -1,19 +1,3 @@ -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Test module for file ui/commands.py""" - import os import shutil @@ -21,8 +5,8 @@ import pytest from beets import library, ui from beets.test import _common -from beets.test.helper import BeetsTestCase, IOMixin, ItemInDBTestCase -from beets.ui import commands +from beets.test.helper import BeetsTestCase +from beets.ui.commands.utils import do_query from beets.util import syspath @@ -44,17 +28,17 @@ class QueryTest(BeetsTestCase): def check_do_query( self, num_items, num_albums, q=(), album=False, also_items=True ): - items, albums = commands._do_query(self.lib, q, album, also_items) + items, albums = do_query(self.lib, q, album, also_items) assert len(items) == num_items assert len(albums) == num_albums def test_query_empty(self): with pytest.raises(ui.UserError): - commands._do_query(self.lib, (), False) + do_query(self.lib, (), False) def test_query_empty_album(self): with pytest.raises(ui.UserError): - commands._do_query(self.lib, (), True) + do_query(self.lib, (), True) def test_query_item(self): self.add_item() @@ -73,24 +57,3 @@ class QueryTest(BeetsTestCase): self.add_album([item, item2]) self.check_do_query(3, 2, album=True) self.check_do_query(0, 2, album=True, also_items=False) - - -class FieldsTest(IOMixin, ItemInDBTestCase): - def remove_keys(self, keys, text): - for i in text: - try: - keys.remove(i) - except ValueError: - pass - - def test_fields_func(self): - commands.fields_func(self.lib, [], []) - items = library.Item.all_keys() - albums = library.Album.all_keys() - - output = self.io.stdout.get().split() - self.remove_keys(items, output) - self.remove_keys(albums, output) - - assert len(items) == 0 - assert len(albums) == 0 diff --git a/test/ui/commands/test_write.py b/test/ui/commands/test_write.py new file mode 100644 index 000000000..312b51dd2 --- /dev/null +++ b/test/ui/commands/test_write.py @@ -0,0 +1,46 @@ +from beets.test.helper import BeetsTestCase + + +class WriteTest(BeetsTestCase): + def write_cmd(self, *args): + return self.run_with_output("write", *args) + + def test_update_mtime(self): + item = self.add_item_fixture() + item["title"] = "a new title" + item.store() + + item = self.lib.items().get() + assert item.mtime == 0 + + self.write_cmd() + item = self.lib.items().get() + assert item.mtime == item.current_mtime() + + def test_non_metadata_field_unchanged(self): + """Changing a non-"tag" field like `bitrate` and writing should + have no effect. + """ + # An item that starts out "clean". + item = self.add_item_fixture() + item.read() + + # ... but with a mismatched bitrate. + item.bitrate = 123 + item.store() + + output = self.write_cmd() + + assert output == "" + + def test_write_metadata_field(self): + item = self.add_item_fixture() + item.read() + old_title = item.title + + item.title = "new title" + item.store() + + output = self.write_cmd() + + assert f"{old_title} -> new title" in output diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py new file mode 100644 index 000000000..a37d4bb29 --- /dev/null +++ b/test/ui/test_ui.py @@ -0,0 +1,590 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Tests for the command-line interface.""" + +import os +import platform +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +import pytest +from confuse import ConfigError + +from beets import config, plugins, ui +from beets.test import _common +from beets.test.helper import BeetsTestCase, IOMixin, PluginTestCase +from beets.ui import commands +from beets.util import syspath + + +class PrintTest(IOMixin, unittest.TestCase): + def test_print_without_locale(self): + lang = os.environ.get("LANG") + if lang: + del os.environ["LANG"] + + try: + ui.print_("something") + except TypeError: + self.fail("TypeError during print") + finally: + if lang: + os.environ["LANG"] = lang + + def test_print_with_invalid_locale(self): + old_lang = os.environ.get("LANG") + os.environ["LANG"] = "" + old_ctype = os.environ.get("LC_CTYPE") + os.environ["LC_CTYPE"] = "UTF-8" + + try: + ui.print_("something") + except ValueError: + self.fail("ValueError during print") + finally: + if old_lang: + os.environ["LANG"] = old_lang + else: + del os.environ["LANG"] + if old_ctype: + os.environ["LC_CTYPE"] = old_ctype + else: + del os.environ["LC_CTYPE"] + + +@_common.slow_test() +class TestPluginTestCase(PluginTestCase): + plugin = "test" + + def setUp(self): + super().setUp() + config["pluginpath"] = [_common.PLUGINPATH] + + +class ConfigTest(TestPluginTestCase): + def setUp(self): + super().setUp() + + # Don't use the BEETSDIR from `helper`. Instead, we point the home + # directory there. Some tests will set `BEETSDIR` themselves. + del os.environ["BEETSDIR"] + + # Also set APPDATA, the Windows equivalent of setting $HOME. + appdata_dir = self.temp_dir_path / "AppData" / "Roaming" + + self._orig_cwd = os.getcwd() + self.test_cmd = self._make_test_cmd() + commands.default_commands.append(self.test_cmd) + + # Default user configuration + if platform.system() == "Windows": + self.user_config_dir = appdata_dir / "beets" + else: + self.user_config_dir = self.temp_dir_path / ".config" / "beets" + self.user_config_dir.mkdir(parents=True, exist_ok=True) + self.user_config_path = self.user_config_dir / "config.yaml" + + # Custom BEETSDIR + self.beetsdir = self.temp_dir_path / "beetsdir" + self.beetsdir.mkdir(parents=True, exist_ok=True) + + self.env_config_path = str(self.beetsdir / "config.yaml") + self.cli_config_path = str(self.temp_dir_path / "config.yaml") + self.env_patcher = patch( + "os.environ", + {"HOME": str(self.temp_dir_path), "APPDATA": str(appdata_dir)}, + ) + self.env_patcher.start() + + self._reset_config() + + def tearDown(self): + self.env_patcher.stop() + commands.default_commands.pop() + os.chdir(syspath(self._orig_cwd)) + super().tearDown() + + def _make_test_cmd(self): + test_cmd = ui.Subcommand("test", help="test") + + def run(lib, options, args): + test_cmd.lib = lib + test_cmd.options = options + test_cmd.args = args + + test_cmd.func = run + return test_cmd + + def _reset_config(self): + # Config should read files again on demand + config.clear() + config._materialized = False + + def write_config_file(self): + return open(self.user_config_path, "w") + + def test_paths_section_respected(self): + with self.write_config_file() as config: + config.write("paths: {x: y}") + + self.run_command("test", lib=None) + key, template = self.test_cmd.lib.path_formats[0] + assert key == "x" + assert template.original == "y" + + def test_default_paths_preserved(self): + default_formats = ui.get_path_formats() + + self._reset_config() + with self.write_config_file() as config: + config.write("paths: {x: y}") + self.run_command("test", lib=None) + key, template = self.test_cmd.lib.path_formats[0] + assert key == "x" + assert template.original == "y" + assert self.test_cmd.lib.path_formats[1:] == default_formats + + def test_nonexistant_db(self): + with self.write_config_file() as config: + config.write("library: /xxx/yyy/not/a/real/path") + + with pytest.raises(ui.UserError): + self.run_command("test", lib=None) + + def test_user_config_file(self): + with self.write_config_file() as file: + file.write("anoption: value") + + self.run_command("test", lib=None) + assert config["anoption"].get() == "value" + + def test_replacements_parsed(self): + with self.write_config_file() as config: + config.write("replace: {'[xy]': z}") + + self.run_command("test", lib=None) + replacements = self.test_cmd.lib.replacements + repls = [(p.pattern, s) for p, s in replacements] # Compare patterns. + assert repls == [("[xy]", "z")] + + def test_multiple_replacements_parsed(self): + with self.write_config_file() as config: + config.write("replace: {'[xy]': z, foo: bar}") + self.run_command("test", lib=None) + replacements = self.test_cmd.lib.replacements + repls = [(p.pattern, s) for p, s in replacements] + assert repls == [("[xy]", "z"), ("foo", "bar")] + + def test_cli_config_option(self): + with open(self.cli_config_path, "w") as file: + file.write("anoption: value") + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["anoption"].get() == "value" + + def test_cli_config_file_overwrites_user_defaults(self): + with open(self.user_config_path, "w") as file: + file.write("anoption: value") + + with open(self.cli_config_path, "w") as file: + file.write("anoption: cli overwrite") + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["anoption"].get() == "cli overwrite" + + def test_cli_config_file_overwrites_beetsdir_defaults(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + with open(self.env_config_path, "w") as file: + file.write("anoption: value") + + with open(self.cli_config_path, "w") as file: + file.write("anoption: cli overwrite") + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["anoption"].get() == "cli overwrite" + + # @unittest.skip('Difficult to implement with optparse') + # def test_multiple_cli_config_files(self): + # cli_config_path_1 = os.path.join(self.temp_dir, b'config.yaml') + # cli_config_path_2 = os.path.join(self.temp_dir, b'config_2.yaml') + # + # with open(cli_config_path_1, 'w') as file: + # file.write('first: value') + # + # with open(cli_config_path_2, 'w') as file: + # file.write('second: value') + # + # self.run_command('--config', cli_config_path_1, + # '--config', cli_config_path_2, 'test', lib=None) + # assert config['first'].get() == 'value' + # assert config['second'].get() == 'value' + # + # @unittest.skip('Difficult to implement with optparse') + # def test_multiple_cli_config_overwrite(self): + # cli_overwrite_config_path = os.path.join(self.temp_dir, + # b'overwrite_config.yaml') + # + # with open(self.cli_config_path, 'w') as file: + # file.write('anoption: value') + # + # with open(cli_overwrite_config_path, 'w') as file: + # file.write('anoption: overwrite') + # + # self.run_command('--config', self.cli_config_path, + # '--config', cli_overwrite_config_path, 'test') + # assert config['anoption'].get() == 'cli overwrite' + + # FIXME: fails on windows + @unittest.skipIf(sys.platform == "win32", "win32") + def test_cli_config_paths_resolve_relative_to_user_dir(self): + with open(self.cli_config_path, "w") as file: + file.write("library: beets.db\n") + file.write("statefile: state") + + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["library"].as_path() == self.user_config_dir / "beets.db" + assert config["statefile"].as_path() == self.user_config_dir / "state" + + def test_cli_config_paths_resolve_relative_to_beetsdir(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + with open(self.cli_config_path, "w") as file: + file.write("library: beets.db\n") + file.write("statefile: state") + + self.run_command("--config", self.cli_config_path, "test", lib=None) + assert config["library"].as_path() == self.beetsdir / "beets.db" + assert config["statefile"].as_path() == self.beetsdir / "state" + + def test_command_line_option_relative_to_working_dir(self): + config.read() + os.chdir(syspath(self.temp_dir)) + self.run_command("--library", "foo.db", "test", lib=None) + assert config["library"].as_path() == Path.cwd() / "foo.db" + + def test_cli_config_file_loads_plugin_commands(self): + with open(self.cli_config_path, "w") as file: + file.write(f"pluginpath: {_common.PLUGINPATH}\n") + file.write("plugins: test") + + self.run_command("--config", self.cli_config_path, "plugin", lib=None) + plugs = plugins.find_plugins() + assert len(plugs) == 1 + assert plugs[0].is_test_plugin + self.unload_plugins() + + def test_beetsdir_config(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + with open(self.env_config_path, "w") as file: + file.write("anoption: overwrite") + + config.read() + assert config["anoption"].get() == "overwrite" + + def test_beetsdir_points_to_file_error(self): + beetsdir = str(self.temp_dir_path / "beetsfile") + open(beetsdir, "a").close() + os.environ["BEETSDIR"] = beetsdir + with pytest.raises(ConfigError): + self.run_command("test") + + def test_beetsdir_config_does_not_load_default_user_config(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + with open(self.user_config_path, "w") as file: + file.write("anoption: value") + + config.read() + assert not config["anoption"].exists() + + def test_default_config_paths_resolve_relative_to_beetsdir(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + config.read() + assert config["library"].as_path() == self.beetsdir / "library.db" + assert config["statefile"].as_path() == self.beetsdir / "state.pickle" + + def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): + os.environ["BEETSDIR"] = str(self.beetsdir) + + with open(self.env_config_path, "w") as file: + file.write("library: beets.db\n") + file.write("statefile: state") + + config.read() + assert config["library"].as_path() == self.beetsdir / "beets.db" + assert config["statefile"].as_path() == self.beetsdir / "state" + + +class ShowModelChangeTest(IOMixin, unittest.TestCase): + def setUp(self): + super().setUp() + self.a = _common.item() + self.b = _common.item() + self.a.path = self.b.path + + def _show(self, **kwargs): + change = ui.show_model_changes(self.a, self.b, **kwargs) + out = self.io.getoutput() + return change, out + + def test_identical(self): + change, out = self._show() + assert not change + assert out == "" + + def test_string_fixed_field_change(self): + self.b.title = "x" + change, out = self._show() + assert change + assert "title" in out + + def test_int_fixed_field_change(self): + self.b.track = 9 + change, out = self._show() + assert change + assert "track" in out + + def test_floats_close_to_identical(self): + self.a.length = 1.00001 + self.b.length = 1.00005 + change, out = self._show() + assert not change + assert out == "" + + def test_floats_different(self): + self.a.length = 1.00001 + self.b.length = 2.00001 + change, out = self._show() + assert change + assert "length" in out + + def test_both_values_shown(self): + self.a.title = "foo" + self.b.title = "bar" + change, out = self._show() + assert "foo" in out + assert "bar" in out + + +class PathFormatTest(unittest.TestCase): + def test_custom_paths_prepend(self): + default_formats = ui.get_path_formats() + + config["paths"] = {"foo": "bar"} + pf = ui.get_path_formats() + key, tmpl = pf[0] + assert key == "foo" + assert tmpl.original == "bar" + assert pf[1:] == default_formats + + +@_common.slow_test() +class PluginTest(TestPluginTestCase): + def test_plugin_command_from_pluginpath(self): + self.run_command("test", lib=None) + + +class CommonOptionsParserCliTest(BeetsTestCase): + """Test CommonOptionsParser and formatting LibModel formatting on 'list' + command. + """ + + def setUp(self): + super().setUp() + self.item = _common.item() + self.item.path = b"xxx/yyy" + self.lib.add(self.item) + self.lib.add_album([self.item]) + + def test_base(self): + output = self.run_with_output("ls") + assert output == "the artist - the album - the title\n" + + output = self.run_with_output("ls", "-a") + assert output == "the album artist - the album\n" + + def test_path_option(self): + output = self.run_with_output("ls", "-p") + assert output == "xxx/yyy\n" + + output = self.run_with_output("ls", "-a", "-p") + assert output == "xxx\n" + + def test_format_option(self): + output = self.run_with_output("ls", "-f", "$artist") + assert output == "the artist\n" + + output = self.run_with_output("ls", "-a", "-f", "$albumartist") + assert output == "the album artist\n" + + def test_format_option_unicode(self): + output = self.run_with_output("ls", "-f", "caf\xe9") + assert output == "caf\xe9\n" + + def test_root_format_option(self): + output = self.run_with_output( + "--format-item", "$artist", "--format-album", "foo", "ls" + ) + assert output == "the artist\n" + + output = self.run_with_output( + "--format-item", "foo", "--format-album", "$albumartist", "ls", "-a" + ) + assert output == "the album artist\n" + + def test_help(self): + output = self.run_with_output("help") + assert "Usage:" in output + + output = self.run_with_output("help", "list") + assert "Usage:" in output + + with pytest.raises(ui.UserError): + self.run_command("help", "this.is.not.a.real.command") + + def test_stats(self): + output = self.run_with_output("stats") + assert "Approximate total size:" in output + + # # Need to have more realistic library setup for this to work + # output = self.run_with_output('stats', '-e') + # assert 'Total size:' in output + + def test_version(self): + output = self.run_with_output("version") + assert "Python version" in output + assert "no plugins loaded" in output + + # # Need to have plugin loaded + # output = self.run_with_output('version') + # assert 'plugins: ' in output + + +class CommonOptionsParserTest(unittest.TestCase): + def test_album_option(self): + parser = ui.CommonOptionsParser() + assert not parser._album_flags + parser.add_album_option() + assert bool(parser._album_flags) + + assert parser.parse_args([]) == ({"album": None}, []) + assert parser.parse_args(["-a"]) == ({"album": True}, []) + assert parser.parse_args(["--album"]) == ({"album": True}, []) + + def test_path_option(self): + parser = ui.CommonOptionsParser() + parser.add_path_option() + assert not parser._album_flags + + config["format_item"].set("$foo") + assert parser.parse_args([]) == ({"path": None}, []) + assert config["format_item"].as_str() == "$foo" + + assert parser.parse_args(["-p"]) == ( + {"path": True, "format": "$path"}, + [], + ) + assert parser.parse_args(["--path"]) == ( + {"path": True, "format": "$path"}, + [], + ) + + assert config["format_item"].as_str() == "$path" + assert config["format_album"].as_str() == "$path" + + def test_format_option(self): + parser = ui.CommonOptionsParser() + parser.add_format_option() + assert not parser._album_flags + + config["format_item"].set("$foo") + assert parser.parse_args([]) == ({"format": None}, []) + assert config["format_item"].as_str() == "$foo" + + assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) + assert parser.parse_args(["--format", "$baz"]) == ( + {"format": "$baz"}, + [], + ) + + assert config["format_item"].as_str() == "$baz" + assert config["format_album"].as_str() == "$baz" + + def test_format_option_with_target(self): + with pytest.raises(KeyError): + ui.CommonOptionsParser().add_format_option(target="thingy") + + parser = ui.CommonOptionsParser() + parser.add_format_option(target="item") + + config["format_item"].set("$item") + config["format_album"].set("$album") + + assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) + + assert config["format_item"].as_str() == "$bar" + assert config["format_album"].as_str() == "$album" + + def test_format_option_with_album(self): + parser = ui.CommonOptionsParser() + parser.add_album_option() + parser.add_format_option() + + config["format_item"].set("$item") + config["format_album"].set("$album") + + parser.parse_args(["-f", "$bar"]) + assert config["format_item"].as_str() == "$bar" + assert config["format_album"].as_str() == "$album" + + parser.parse_args(["-a", "-f", "$foo"]) + assert config["format_item"].as_str() == "$bar" + assert config["format_album"].as_str() == "$foo" + + parser.parse_args(["-f", "$foo2", "-a"]) + assert config["format_album"].as_str() == "$foo2" + + def test_add_all_common_options(self): + parser = ui.CommonOptionsParser() + parser.add_all_common_options() + assert parser.parse_args([]) == ( + {"album": None, "path": None, "format": None}, + [], + ) + + +class EncodingTest(unittest.TestCase): + """Tests for the `terminal_encoding` config option and our + `_in_encoding` and `_out_encoding` utility functions. + """ + + def out_encoding_overridden(self): + config["terminal_encoding"] = "fake_encoding" + assert ui._out_encoding() == "fake_encoding" + + def in_encoding_overridden(self): + config["terminal_encoding"] = "fake_encoding" + assert ui._in_encoding() == "fake_encoding" + + def out_encoding_default_utf8(self): + with patch("sys.stdout") as stdout: + stdout.encoding = None + assert ui._out_encoding() == "utf-8" + + def in_encoding_default_utf8(self): + with patch("sys.stdin") as stdin: + stdin.encoding = None + assert ui._in_encoding() == "utf-8" diff --git a/test/test_ui_importer.py b/test/ui/test_ui_importer.py similarity index 100% rename from test/test_ui_importer.py rename to test/ui/test_ui_importer.py diff --git a/test/test_ui_init.py b/test/ui/test_ui_init.py similarity index 100% rename from test/test_ui_init.py rename to test/ui/test_ui_init.py From 25ae330044abf04045e3f378f72bbaed739fb30d Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 3 Nov 2025 14:03:25 +0100 Subject: [PATCH 046/274] refactor: moved some more imports that are only used in the commands in their respective files. Also fixed some imports --- beets/ui/__init__.py | 69 +------------------------ beets/ui/commands/__init__.py | 15 ++++++ beets/ui/commands/_utils.py | 67 ------------------------ beets/ui/commands/completion.py | 2 +- beets/ui/commands/config.py | 9 ++-- beets/ui/commands/import_/__init__.py | 64 ++++++++++++++++++++++- beets/ui/commands/import_/display.py | 5 +- beets/ui/commands/import_/session.py | 11 ++-- beets/ui/commands/modify.py | 2 +- beets/ui/commands/move.py | 74 ++++++++++++++++++++++----- beets/ui/commands/remove.py | 2 +- beets/ui/commands/update.py | 2 +- beets/ui/commands/utils.py | 29 +++++++++++ beets/ui/commands/write.py | 2 +- beetsplug/edit.py | 5 +- 15 files changed, 186 insertions(+), 172 deletions(-) delete mode 100644 beets/ui/commands/_utils.py create mode 100644 beets/ui/commands/utils.py diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index fe980bb5c..cf2162337 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1111,76 +1111,9 @@ def show_model_changes( return bool(changes) -def show_path_changes(path_changes): - """Given a list of tuples (source, destination) that indicate the - path changes, log the changes as INFO-level output to the beets log. - The output is guaranteed to be unicode. - - Every pair is shown on a single line if the terminal width permits it, - else it is split over two lines. E.g., - - Source -> Destination - - vs. - - Source - -> Destination - """ - sources, destinations = zip(*path_changes) - - # Ensure unicode output - sources = list(map(util.displayable_path, sources)) - destinations = list(map(util.displayable_path, destinations)) - - # Calculate widths for terminal split - col_width = (term_width() - len(" -> ")) // 2 - max_width = len(max(sources + destinations, key=len)) - - if max_width > col_width: - # Print every change over two lines - for source, dest in zip(sources, destinations): - color_source, color_dest = colordiff(source, dest) - print_(f"{color_source} \n -> {color_dest}") - else: - # Print every change on a single line, and add a header - title_pad = max_width - len("Source ") + len(" -> ") - - print_(f"Source {' ' * title_pad} Destination") - for source, dest in zip(sources, destinations): - pad = max_width - len(source) - color_source, color_dest = colordiff(source, dest) - print_(f"{color_source} {' ' * pad} -> {color_dest}") - - # Helper functions for option parsing. -def _store_dict(option, opt_str, value, parser): - """Custom action callback to parse options which have ``key=value`` - pairs as values. All such pairs passed for this option are - aggregated into a dictionary. - """ - dest = option.dest - option_values = getattr(parser.values, dest, None) - - if option_values is None: - # This is the first supplied ``key=value`` pair of option. - # Initialize empty dictionary and get a reference to it. - setattr(parser.values, dest, {}) - option_values = getattr(parser.values, dest) - - try: - key, value = value.split("=", 1) - if not (key and value): - raise ValueError - except ValueError: - raise UserError( - f"supplied argument `{value}' is not of the form `key=value'" - ) - - option_values[key] = value - - class CommonOptionsParser(optparse.OptionParser): """Offers a simple way to add common formatting options. @@ -1666,7 +1599,7 @@ def _raw_main(args: list[str], lib=None) -> None: and subargs[0] == "config" and ("-e" in subargs or "--edit" in subargs) ): - from beets.ui.commands import config_edit + from beets.ui.commands.config import config_edit return config_edit() diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index 0691be045..214bcfbd0 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -32,6 +32,21 @@ from .update import update_cmd from .version import version_cmd from .write import write_cmd + +def __getattr__(name: str): + """Handle deprecated imports.""" + return deprecate_imports( + old_module=__name__, + new_module_by_name={ + "TerminalImportSession": "beets.ui.commands.import_.session", + "PromptChoice": "beets.ui.commands.import_.session", + # TODO: We might want to add more deprecated imports here + }, + name=name, + version="3.0.0", + ) + + # The list of default subcommands. This is populated with Subcommand # objects that can be fed to a SubcommandsOptionParser. default_commands = [ diff --git a/beets/ui/commands/_utils.py b/beets/ui/commands/_utils.py deleted file mode 100644 index 17e2f34c8..000000000 --- a/beets/ui/commands/_utils.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Utility functions for beets UI commands.""" - -import os - -from beets import ui -from beets.util import displayable_path, normpath, syspath - - -def do_query(lib, query, album, also_items=True): - """For commands that operate on matched items, performs a query - and returns a list of matching items and a list of matching - albums. (The latter is only nonempty when album is True.) Raises - a UserError if no items match. also_items controls whether, when - fetching albums, the associated items should be fetched also. - """ - if album: - albums = list(lib.albums(query)) - items = [] - if also_items: - for al in albums: - items += al.items() - - else: - albums = [] - items = list(lib.items(query)) - - if album and not albums: - raise ui.UserError("No matching albums found.") - elif not album and not items: - raise ui.UserError("No matching items found.") - - return items, albums - - -def paths_from_logfile(path): - """Parse the logfile and yield skipped paths to pass to the `import` - command. - """ - with open(path, encoding="utf-8") as fp: - for i, line in enumerate(fp, start=1): - verb, sep, paths = line.rstrip("\n").partition(" ") - if not sep: - raise ValueError(f"line {i} is invalid") - - # Ignore informational lines that don't need to be re-imported. - if verb in {"import", "duplicate-keep", "duplicate-replace"}: - continue - - if verb not in {"asis", "skip", "duplicate-skip"}: - raise ValueError(f"line {i} contains unknown verb {verb}") - - yield os.path.commonpath(paths.split("; ")) - - -def parse_logfiles(logfiles): - """Parse all `logfiles` and yield paths from it.""" - for logfile in logfiles: - try: - yield from paths_from_logfile(syspath(normpath(logfile))) - except ValueError as err: - raise ui.UserError( - f"malformed logfile {displayable_path(logfile)}: {err}" - ) from err - except OSError as err: - raise ui.UserError( - f"unreadable logfile {displayable_path(logfile)}: {err}" - ) from err diff --git a/beets/ui/commands/completion.py b/beets/ui/commands/completion.py index 266b2740a..776c389b4 100644 --- a/beets/ui/commands/completion.py +++ b/beets/ui/commands/completion.py @@ -48,7 +48,7 @@ def completion_script(commands): completion data for. """ base_script = os.path.join( - os.path.dirname(__file__), "../completion_base.sh" + os.path.dirname(__file__), "./completion_base.sh" ) with open(base_script) as base_script: yield base_script.read() diff --git a/beets/ui/commands/config.py b/beets/ui/commands/config.py index 81cc2851a..3581c6647 100644 --- a/beets/ui/commands/config.py +++ b/beets/ui/commands/config.py @@ -2,7 +2,8 @@ import os -from beets import config, ui, util +from beets import config, ui +from beets.util import displayable_path, editor_command, interactive_open def config_func(lib, opts, args): @@ -25,7 +26,7 @@ def config_func(lib, opts, args): filenames.insert(0, user_path) for filename in filenames: - ui.print_(util.displayable_path(filename)) + ui.print_(displayable_path(filename)) # Open in editor. elif opts.edit: @@ -45,11 +46,11 @@ def config_edit(): An empty config file is created if no existing config file exists. """ path = config.user_config_path() - editor = util.editor_command() + editor = editor_command() try: if not os.path.isfile(path): open(path, "w+").close() - util.interactive_open([path], editor) + interactive_open([path], editor) except OSError as exc: message = f"Could not edit configuration: {exc}" if not editor: diff --git a/beets/ui/commands/import_/__init__.py b/beets/ui/commands/import_/__init__.py index 6940528ad..5dba71fa8 100644 --- a/beets/ui/commands/import_/__init__.py +++ b/beets/ui/commands/import_/__init__.py @@ -5,13 +5,47 @@ import os from beets import config, logging, plugins, ui from beets.util import displayable_path, normpath, syspath -from .._utils import parse_logfiles from .session import TerminalImportSession # Global logger. log = logging.getLogger("beets") +def paths_from_logfile(path): + """Parse the logfile and yield skipped paths to pass to the `import` + command. + """ + with open(path, encoding="utf-8") as fp: + for i, line in enumerate(fp, start=1): + verb, sep, paths = line.rstrip("\n").partition(" ") + if not sep: + raise ValueError(f"line {i} is invalid") + + # Ignore informational lines that don't need to be re-imported. + if verb in {"import", "duplicate-keep", "duplicate-replace"}: + continue + + if verb not in {"asis", "skip", "duplicate-skip"}: + raise ValueError(f"line {i} contains unknown verb {verb}") + + yield os.path.commonpath(paths.split("; ")) + + +def parse_logfiles(logfiles): + """Parse all `logfiles` and yield paths from it.""" + for logfile in logfiles: + try: + yield from paths_from_logfile(syspath(normpath(logfile))) + except ValueError as err: + raise ui.UserError( + f"malformed logfile {displayable_path(logfile)}: {err}" + ) from err + except OSError as err: + raise ui.UserError( + f"unreadable logfile {displayable_path(logfile)}: {err}" + ) from err + + def import_files(lib, paths: list[bytes], query): """Import the files in the given list of paths or matching the query. @@ -97,6 +131,32 @@ def import_func(lib, opts, args: list[str]): import_files(lib, byte_paths, query) +def _store_dict(option, opt_str, value, parser): + """Custom action callback to parse options which have ``key=value`` + pairs as values. All such pairs passed for this option are + aggregated into a dictionary. + """ + dest = option.dest + option_values = getattr(parser.values, dest, None) + + if option_values is None: + # This is the first supplied ``key=value`` pair of option. + # Initialize empty dictionary and get a reference to it. + setattr(parser.values, dest, {}) + option_values = getattr(parser.values, dest) + + try: + key, value = value.split("=", 1) + if not (key and value): + raise ValueError + except ValueError: + raise ui.UserError( + f"supplied argument `{value}' is not of the form `key=value'" + ) + + option_values[key] = value + + import_cmd = ui.Subcommand( "import", help="import new music", aliases=("imp", "im") ) @@ -274,7 +334,7 @@ import_cmd.parser.add_option( "--set", dest="set_fields", action="callback", - callback=ui._store_dict, + callback=_store_dict, metavar="FIELD=VALUE", help="set the given fields to the supplied values", ) diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index b6617d487..a12f1f8d3 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -2,16 +2,13 @@ import os from collections.abc import Sequence from functools import cached_property -from beets import autotag, config, logging, ui +from beets import autotag, config, ui from beets.autotag import hooks from beets.util import displayable_path from beets.util.units import human_seconds_short VARIOUS_ARTISTS = "Various Artists" -# Global logger. -log = logging.getLogger("beets") - class ChangeRepresentation: """Keeps track of all information needed to generate a (colored) text diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 6608705a8..27562664e 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -6,7 +6,6 @@ from beets import autotag, config, importer, logging, plugins, ui from beets.autotag import Recommendation from beets.util import displayable_path from beets.util.units import human_bytes, human_seconds_short -from beetsplug.bareasc import print_ from .display import ( disambig_string, @@ -415,8 +414,8 @@ def choose_candidate( if singleton: ui.print_("No matching recordings found.") else: - print_(f"No matching release found for {itemcount} tracks.") - print_( + ui.print_(f"No matching release found for {itemcount} tracks.") + ui.print_( "For help, see: " "https://beets.readthedocs.org/en/latest/faq.html#nomatch" ) @@ -461,17 +460,17 @@ def choose_candidate( else: metadata = ui.colorize("text_highlight_minor", metadata) line1 = [index, distance, metadata] - print_(f" {' '.join(line1)}") + ui.print_(f" {' '.join(line1)}") # Penalties. penalties = penalty_string(match.distance, 3) if penalties: - print_(f"{' ' * 13}{penalties}") + ui.print_(f"{' ' * 13}{penalties}") # Disambiguation disambig = disambig_string(match.info) if disambig: - print_(f"{' ' * 13}{disambig}") + ui.print_(f"{' ' * 13}{disambig}") # Ask the user for a choice. sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index dab68a3fc..186bfb6dd 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -3,7 +3,7 @@ from beets import library, ui from beets.util import functemplate -from ._utils import do_query +from .utils import do_query def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): diff --git a/beets/ui/commands/move.py b/beets/ui/commands/move.py index 6d6f4f16a..40a9d1b83 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -2,17 +2,65 @@ import os -from beets import logging, ui, util +from beets import logging, ui +from beets.util import ( + MoveOperation, + PathLike, + displayable_path, + normpath, + syspath, +) -from ._utils import do_query +from .utils import do_query # Global logger. log = logging.getLogger("beets") +def show_path_changes(path_changes): + """Given a list of tuples (source, destination) that indicate the + path changes, log the changes as INFO-level output to the beets log. + The output is guaranteed to be unicode. + + Every pair is shown on a single line if the terminal width permits it, + else it is split over two lines. E.g., + + Source -> Destination + + vs. + + Source + -> Destination + """ + sources, destinations = zip(*path_changes) + + # Ensure unicode output + sources = list(map(displayable_path, sources)) + destinations = list(map(displayable_path, destinations)) + + # Calculate widths for terminal split + col_width = (ui.term_width() - len(" -> ")) // 2 + max_width = len(max(sources + destinations, key=len)) + + if max_width > col_width: + # Print every change over two lines + for source, dest in zip(sources, destinations): + color_source, color_dest = ui.colordiff(source, dest) + ui.print_(f"{color_source} \n -> {color_dest}") + else: + # Print every change on a single line, and add a header + title_pad = max_width - len("Source ") + len(" -> ") + + ui.print_(f"Source {' ' * title_pad} Destination") + for source, dest in zip(sources, destinations): + pad = max_width - len(source) + color_source, color_dest = ui.colordiff(source, dest) + ui.print_(f"{color_source} {' ' * pad} -> {color_dest}") + + def move_items( lib, - dest_path: util.PathLike, + dest_path: PathLike, query, copy, album, @@ -60,7 +108,7 @@ def move_items( if pretend: if album: - ui.show_path_changes( + show_path_changes( [ (item.path, item.destination(basedir=dest)) for obj in objs @@ -68,7 +116,7 @@ def move_items( ] ) else: - ui.show_path_changes( + show_path_changes( [(obj.path, obj.destination(basedir=dest)) for obj in objs] ) else: @@ -76,7 +124,7 @@ def move_items( objs = ui.input_select_objects( f"Really {act}", objs, - lambda o: ui.show_path_changes( + lambda o: show_path_changes( [(o.path, o.destination(basedir=dest))] ), ) @@ -87,24 +135,22 @@ def move_items( if export: # Copy without affecting the database. obj.move( - operation=util.MoveOperation.COPY, basedir=dest, store=False + operation=MoveOperation.COPY, basedir=dest, store=False ) else: # Ordinary move/copy: store the new path. if copy: - obj.move(operation=util.MoveOperation.COPY, basedir=dest) + obj.move(operation=MoveOperation.COPY, basedir=dest) else: - obj.move(operation=util.MoveOperation.MOVE, basedir=dest) + obj.move(operation=MoveOperation.MOVE, basedir=dest) def move_func(lib, opts, args): dest = opts.dest if dest is not None: - dest = util.normpath(dest) - if not os.path.isdir(util.syspath(dest)): - raise ui.UserError( - f"no such directory: {util.displayable_path(dest)}" - ) + dest = normpath(dest) + if not os.path.isdir(syspath(dest)): + raise ui.UserError(f"no such directory: {displayable_path(dest)}") move_items( lib, diff --git a/beets/ui/commands/remove.py b/beets/ui/commands/remove.py index 574f0c4d4..997a4b48c 100644 --- a/beets/ui/commands/remove.py +++ b/beets/ui/commands/remove.py @@ -2,7 +2,7 @@ from beets import ui -from ._utils import do_query +from .utils import do_query def remove_items(lib, query, album, delete, force): diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py index 71be6bbd9..9286bf12b 100644 --- a/beets/ui/commands/update.py +++ b/beets/ui/commands/update.py @@ -5,7 +5,7 @@ import os from beets import library, logging, ui from beets.util import ancestry, syspath -from ._utils import do_query +from .utils import do_query # Global logger. log = logging.getLogger("beets") diff --git a/beets/ui/commands/utils.py b/beets/ui/commands/utils.py new file mode 100644 index 000000000..71c104d07 --- /dev/null +++ b/beets/ui/commands/utils.py @@ -0,0 +1,29 @@ +"""Utility functions for beets UI commands.""" + +from beets import ui + + +def do_query(lib, query, album, also_items=True): + """For commands that operate on matched items, performs a query + and returns a list of matching items and a list of matching + albums. (The latter is only nonempty when album is True.) Raises + a UserError if no items match. also_items controls whether, when + fetching albums, the associated items should be fetched also. + """ + if album: + albums = list(lib.albums(query)) + items = [] + if also_items: + for al in albums: + items += al.items() + + else: + albums = [] + items = list(lib.items(query)) + + if album and not albums: + raise ui.UserError("No matching albums found.") + elif not album and not items: + raise ui.UserError("No matching items found.") + + return items, albums diff --git a/beets/ui/commands/write.py b/beets/ui/commands/write.py index 84f2fb5b6..05c3c7565 100644 --- a/beets/ui/commands/write.py +++ b/beets/ui/commands/write.py @@ -5,7 +5,7 @@ import os from beets import library, logging, ui from beets.util import syspath -from ._utils import do_query +from .utils import do_query # Global logger. log = logging.getLogger("beets") diff --git a/beetsplug/edit.py b/beetsplug/edit.py index f6fadefd0..188afed1f 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -25,7 +25,8 @@ import yaml from beets import plugins, ui, util from beets.dbcore import types from beets.importer import Action -from beets.ui.commands import PromptChoice, _do_query +from beets.ui.commands.import_.session import PromptChoice +from beets.ui.commands.utils import do_query # These "safe" types can avoid the format/parse cycle that most fields go # through: they are safe to edit with native YAML types. @@ -176,7 +177,7 @@ class EditPlugin(plugins.BeetsPlugin): def _edit_command(self, lib, opts, args): """The CLI command function for the `beet edit` command.""" # Get the objects to edit. - items, albums = _do_query(lib, args, opts.album, False) + items, albums = do_query(lib, args, opts.album, False) objs = albums if opts.album else items if not objs: ui.print_("Nothing to edit.") From b3b7dc33165e16bc0905055e5d2f75343a5a4a5a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 3 Nov 2025 14:04:22 +0100 Subject: [PATCH 047/274] Added changelog entry and git blame ignore revs. --- .git-blame-ignore-revs | 4 ++++ docs/changelog.rst | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 14b50859f..310759857 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -73,3 +73,7 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 33f1a5d0bef8ca08be79ee7a0d02a018d502680d # Moved art.py utility module from beets into beetsplug 28aee0fde463f1e18dfdba1994e2bdb80833722f +# Refactor `ui/commands.py` into multiple modules +59c93e70139f70e9fd1c6f3c1bceb005945bec33 +a59e41a88365e414db3282658d2aa456e0b3468a +25ae330044abf04045e3f378f72bbaed739fb30d \ No newline at end of file diff --git a/docs/changelog.rst b/docs/changelog.rst index 5ebf3f53e..d4a2a31d4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -43,6 +43,9 @@ Other changes: - The documentation chapter :doc:`dev/paths` has been moved to the "For Developers" section and revised to reflect current best practices (pathlib usage). +- Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into + multiple modules within the ``beets/ui/commands`` directory for better + maintainability. 2.5.1 (October 14, 2025) ------------------------ From f495a9e18d9b5e7061a91c4108326e7fb80aa956 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 5 Nov 2025 15:54:35 +0100 Subject: [PATCH 048/274] Added more descriptions to git-blame-ignore-revs file. --- .git-blame-ignore-revs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 310759857..2eee8c5c3 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -75,5 +75,7 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 28aee0fde463f1e18dfdba1994e2bdb80833722f # Refactor `ui/commands.py` into multiple modules 59c93e70139f70e9fd1c6f3c1bceb005945bec33 -a59e41a88365e414db3282658d2aa456e0b3468a -25ae330044abf04045e3f378f72bbaed739fb30d \ No newline at end of file +# Moved ui.commands._utils into ui.commands.utils +25ae330044abf04045e3f378f72bbaed739fb30d +# Refactor test_ui_command.py into multiple modules +a59e41a88365e414db3282658d2aa456e0b3468a \ No newline at end of file From e9afe069bc75bb18334bc7ab4710c7746fd90770 Mon Sep 17 00:00:00 2001 From: Ratiq Narwal Date: Thu, 6 Nov 2025 17:18:29 -0800 Subject: [PATCH 049/274] Accept lyrics source as a string --- beetsplug/lyrics.py | 2 +- docs/changelog.rst | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 4c35d8a2e..76854f0e9 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -958,7 +958,7 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin): @cached_property def backends(self) -> list[Backend]: - user_sources = self.config["sources"].get() + user_sources = self.config["sources"].as_str_seq() chosen = sanitize_choices(user_sources, self.BACKEND_BY_NAME) if "google" in chosen and not self.config["google_API_key"].get(): diff --git a/docs/changelog.rst b/docs/changelog.rst index d4a2a31d4..d9112d883 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,6 +30,9 @@ Bug fixes: features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. +- :doc:`plugins/lyrics`: Accepts strings (used to only accept list of strings) + :bug:`5962` + For plugin developers: - A new plugin event, ``album_matched``, is sent when an album that is being From a7becf8490398d46019a5260b395428fee9bdea6 Mon Sep 17 00:00:00 2001 From: Ratiq Narwal Date: Thu, 6 Nov 2025 17:29:33 -0800 Subject: [PATCH 050/274] Improve changelog --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d9112d883..22c81830f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,8 +30,8 @@ Bug fixes: features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. -- :doc:`plugins/lyrics`: Accepts strings (used to only accept list of strings) - :bug:`5962` +- :doc:`plugins/lyrics`: Accepts strings for lyrics sources (used to only accept + list of strings). :bug:`5962` For plugin developers: From 60ad6dc503e633b15b57318e16a9f368ea8d37d5 Mon Sep 17 00:00:00 2001 From: Ratiq Narwal Date: Thu, 6 Nov 2025 17:41:21 -0800 Subject: [PATCH 051/274] Fix changelog formatting --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 22c81830f..eb9f5d97d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,8 +30,8 @@ Bug fixes: features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. -- :doc:`plugins/lyrics`: Accepts strings for lyrics sources (used to only accept - list of strings). :bug:`5962` +- :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only + accepted a list of strings). :bug:`5962` For plugin developers: From f77c03ed90224b67519fc0d4b3c643cc8426e875 Mon Sep 17 00:00:00 2001 From: Ratiq Narwal Date: Thu, 6 Nov 2025 17:58:25 -0800 Subject: [PATCH 052/274] Remove unnecessary space --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index eb9f5d97d..9fa451cf2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,8 +30,8 @@ Bug fixes: features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. -- :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only - accepted a list of strings). :bug:`5962` +- :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only + accepted a list of strings). :bug:`5962` For plugin developers: From 26a8e164d50dd3a78a7ca6c01537cd044f50d2ed Mon Sep 17 00:00:00 2001 From: Ratiq Narwal Date: Thu, 6 Nov 2025 18:10:48 -0800 Subject: [PATCH 053/274] Remove newline character between list points --- docs/changelog.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9fa451cf2..fbb7573f5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,7 +29,6 @@ Bug fixes: audio-features endpoint, the plugin logs a warning once and skips audio features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. - - :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only accepted a list of strings). :bug:`5962` From b405d2fded8a7bb0591c0cd6ff7a9fd1c8c9bc18 Mon Sep 17 00:00:00 2001 From: Emi Katagiri-Simpson Date: Fri, 7 Nov 2025 15:05:56 -0500 Subject: [PATCH 054/274] Migrate `os` calls to `pathlib` calls in hardlink util function See discussion here: https://github.com/beetbox/beets/pull/5684#discussion_r2502432781 --- beets/util/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index b053e9c73..c95c2e523 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -578,12 +578,14 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False): if samefile(path, dest): return - if os.path.exists(syspath(dest)) and not replace: + # Dereference symlinks, expand "~", and convert relative paths to absolute + origin_path = Path(os.fsdecode(path)).expanduser().resolve() + dest_path = Path(os.fsdecode(dest)).expanduser().resolve() + + if dest_path.exists() and not replace: raise FilesystemError("file exists", "rename", (path, dest)) try: - # This step dereferences any symlinks and converts to an absolute path - resolved_origin = Path(syspath(path)).resolve() - os.link(resolved_origin, syspath(dest)) + dest_path.hardlink_to(origin_path) except NotImplementedError: raise FilesystemError( "OS does not support hard links.link", From d64efbb6c1c3436a81d255eef63685aa200f1f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 5 Nov 2025 09:04:54 +0000 Subject: [PATCH 055/274] Upgrade deps before upgrade --- poetry.lock | 2301 ++++++++++++++++++++++++++++----------------------- 1 file changed, 1275 insertions(+), 1026 deletions(-) diff --git a/poetry.lock b/poetry.lock index 568b20d7d..0ac2434f2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,13 +31,13 @@ files = [ [[package]] name = "anyio" -version = "4.9.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, ] [package.dependencies] @@ -47,9 +47,7 @@ sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] -trio = ["trio (>=0.26.1)"] +trio = ["trio (>=0.31.0)"] [[package]] name = "appdirs" @@ -156,13 +154,13 @@ dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest [[package]] name = "beautifulsoup4" -version = "4.13.4" +version = "4.14.2" description = "Screen-scraping library" optional = false python-versions = ">=3.7.0" files = [ - {file = "beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b"}, - {file = "beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195"}, + {file = "beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515"}, + {file = "beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e"}, ] [package.dependencies] @@ -178,33 +176,33 @@ lxml = ["lxml"] [[package]] name = "black" -version = "24.10.0" +version = "25.9.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, + {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, + {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, + {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, + {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, + {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, + {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, + {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, + {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, + {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, + {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, + {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, + {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, + {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, + {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, + {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, + {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, + {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, + {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, + {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, + {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, + {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, + {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, ] [package.dependencies] @@ -213,6 +211,7 @@ mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" +pytokens = ">=0.1.10" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} @@ -408,193 +407,231 @@ cffi = ">=1.0.0" [[package]] name = "certifi" -version = "2025.7.14" +version = "2025.10.5" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, - {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, + {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, + {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, ] [[package]] name = "cffi" -version = "1.17.1" +version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] [package.dependencies] -pycparser = "*" +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.2" +version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" files = [ - {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, - {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, - {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] [[package]] @@ -653,78 +690,115 @@ pyyaml = "*" [[package]] name = "coverage" -version = "7.9.2" +version = "7.10.7" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" files = [ - {file = "coverage-7.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66283a192a14a3854b2e7f3418d7db05cdf411012ab7ff5db98ff3b181e1f912"}, - {file = "coverage-7.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e01d138540ef34fcf35c1aa24d06c3de2a4cffa349e29a10056544f35cca15f"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f22627c1fe2745ee98d3ab87679ca73a97e75ca75eb5faee48660d060875465f"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b1c2d8363247b46bd51f393f86c94096e64a1cf6906803fa8d5a9d03784bdbf"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c10c882b114faf82dbd33e876d0cbd5e1d1ebc0d2a74ceef642c6152f3f4d547"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de3c0378bdf7066c3988d66cd5232d161e933b87103b014ab1b0b4676098fa45"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1e2f097eae0e5991e7623958a24ced3282676c93c013dde41399ff63e230fcf2"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28dc1f67e83a14e7079b6cea4d314bc8b24d1aed42d3582ff89c0295f09b181e"}, - {file = "coverage-7.9.2-cp310-cp310-win32.whl", hash = "sha256:bf7d773da6af9e10dbddacbf4e5cab13d06d0ed93561d44dae0188a42c65be7e"}, - {file = "coverage-7.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:0c0378ba787681ab1897f7c89b415bd56b0b2d9a47e5a3d8dc0ea55aac118d6c"}, - {file = "coverage-7.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7a56a2964a9687b6aba5b5ced6971af308ef6f79a91043c05dd4ee3ebc3e9ba"}, - {file = "coverage-7.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123d589f32c11d9be7fe2e66d823a236fe759b0096f5db3fb1b75b2fa414a4fa"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:333b2e0ca576a7dbd66e85ab402e35c03b0b22f525eed82681c4b866e2e2653a"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:326802760da234baf9f2f85a39e4a4b5861b94f6c8d95251f699e4f73b1835dc"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19e7be4cfec248df38ce40968c95d3952fbffd57b400d4b9bb580f28179556d2"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b4a4cb73b9f2b891c1788711408ef9707666501ba23684387277ededab1097c"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2c8937fa16c8c9fbbd9f118588756e7bcdc7e16a470766a9aef912dd3f117dbd"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42da2280c4d30c57a9b578bafd1d4494fa6c056d4c419d9689e66d775539be74"}, - {file = "coverage-7.9.2-cp311-cp311-win32.whl", hash = "sha256:14fa8d3da147f5fdf9d298cacc18791818f3f1a9f542c8958b80c228320e90c6"}, - {file = "coverage-7.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:549cab4892fc82004f9739963163fd3aac7a7b0df430669b75b86d293d2df2a7"}, - {file = "coverage-7.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2667a2b913e307f06aa4e5677f01a9746cd08e4b35e14ebcde6420a9ebb4c62"}, - {file = "coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0"}, - {file = "coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d"}, - {file = "coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355"}, - {file = "coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0"}, - {file = "coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b"}, - {file = "coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038"}, - {file = "coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868"}, - {file = "coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a"}, - {file = "coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b"}, - {file = "coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694"}, - {file = "coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5"}, - {file = "coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac"}, - {file = "coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926"}, - {file = "coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd"}, - {file = "coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb"}, - {file = "coverage-7.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ddc39510ac922a5c4c27849b739f875d3e1d9e590d1e7b64c98dadf037a16cce"}, - {file = "coverage-7.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a535c0c7364acd55229749c2b3e5eebf141865de3a8f697076a3291985f02d30"}, - {file = "coverage-7.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df0f9ef28e0f20c767ccdccfc5ae5f83a6f4a2fbdfbcbcc8487a8a78771168c8"}, - {file = "coverage-7.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f3da12e0ccbcb348969221d29441ac714bbddc4d74e13923d3d5a7a0bebef7a"}, - {file = "coverage-7.9.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a17eaf46f56ae0f870f14a3cbc2e4632fe3771eab7f687eda1ee59b73d09fe4"}, - {file = "coverage-7.9.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:669135a9d25df55d1ed56a11bf555f37c922cf08d80799d4f65d77d7d6123fcf"}, - {file = "coverage-7.9.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9d3a700304d01a627df9db4322dc082a0ce1e8fc74ac238e2af39ced4c083193"}, - {file = "coverage-7.9.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:71ae8b53855644a0b1579d4041304ddc9995c7b21c8a1f16753c4d8903b4dfed"}, - {file = "coverage-7.9.2-cp39-cp39-win32.whl", hash = "sha256:dd7a57b33b5cf27acb491e890720af45db05589a80c1ffc798462a765be6d4d7"}, - {file = "coverage-7.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f65bb452e579d5540c8b37ec105dd54d8b9307b07bcaa186818c104ffda22441"}, - {file = "coverage-7.9.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:8a1166db2fb62473285bcb092f586e081e92656c7dfa8e9f62b4d39d7e6b5050"}, - {file = "coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4"}, - {file = "coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, + {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, + {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, + {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, + {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, + {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, + {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, + {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, + {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, + {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, + {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] [package.dependencies] @@ -789,13 +863,13 @@ test = ["pytest", "pytest-aiohttp"] [[package]] name = "docutils" -version = "0.20.1" +version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" files = [ - {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, - {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, + {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, + {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] [[package]] @@ -828,13 +902,13 @@ files = [ [[package]] name = "flask" -version = "3.1.1" +version = "3.1.2" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.9" files = [ - {file = "flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c"}, - {file = "flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e"}, + {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, + {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, ] [package.dependencies] @@ -923,13 +997,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" -version = "3.10" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] @@ -1059,86 +1133,96 @@ files = [ [[package]] name = "jellyfish" -version = "1.2.0" -description = "Approximate and phonetic matching of strings." +version = "1.2.1" +description = "" optional = false python-versions = ">=3.9" files = [ - {file = "jellyfish-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:50b6d2a123d3e0929cf136c6c26a6774338be7eb9d140a94223f56e3339b2f80"}, - {file = "jellyfish-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:baa1e44244cba9da6d67a50d3076dd7567e3b91caa9887bb68e20f321e0d2500"}, - {file = "jellyfish-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65082288f76b3821e5cfeae6c2290621f9e8eff75e3fe2d90817dcd068c5bf36"}, - {file = "jellyfish-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4309d536a5427c008bab096fd38cb2d44c7e475c494b23f2554cfdcf8a19f7fb"}, - {file = "jellyfish-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa0ac18693162b751bdd010a2c35136500a326b6a0bd0b18e6d973c524048ac7"}, - {file = "jellyfish-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c19d70cfbfe4eb9c7c6a1df848cfa48f6e5166a3f23362a2c1d7a2e763674113"}, - {file = "jellyfish-1.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5616698941afc730aa6cc162a4686bda29aa8127d70bcb939321143b1170238a"}, - {file = "jellyfish-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b28ad8da20561f07ca4bf1297bd5519d8cd947b8b5593e00cc7ceb255b2a1d84"}, - {file = "jellyfish-1.2.0-cp310-cp310-win32.whl", hash = "sha256:6ec6db8301bf91de85ee5084a44f0b5d399cc030c86910730d5ae69f1e712438"}, - {file = "jellyfish-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:8ed2393f6d0c7e8ec53ab0627dc81e1613bc2e76a9c30332197d0a1df5e39356"}, - {file = "jellyfish-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b4f8ff3cda0e00f6f62fe98ffce28bd7f21d1d55875470f8275a2fdbd84cfb6a"}, - {file = "jellyfish-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792cb481816626396892bccf53643ccc55a7f7c2b129de61360d01044a539afd"}, - {file = "jellyfish-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ca2c84d3aaeea4bd7c9bdb174229789e69c7dd58916b47813f52db3a1b62495"}, - {file = "jellyfish-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ebc962fd90b2dcb33eb308e70c3a356a931c4b10c76d8d9d63df1d5dac42be4"}, - {file = "jellyfish-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0d765888bf186b75bf16b3d9a1b7f088f5f5ccbf62b414c25d92b404aad9c2a"}, - {file = "jellyfish-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:85c5eca0d56241d07a0a89f2896bc7d1ec66ee72ffa801847c70f404b0214fad"}, - {file = "jellyfish-1.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:13d7d925760bd8c3fd8831fcc0ad5a32ceae82c66e8aa19df45082afe5c4be2a"}, - {file = "jellyfish-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ccc330b6104c87e22dbb22c2578abcf0e36d1346c1810eec3f67571089b36874"}, - {file = "jellyfish-1.2.0-cp311-cp311-win32.whl", hash = "sha256:75d131a51202e679b653507f99634bc13c4aa6a4afabe06a1c3d200f72e18b9b"}, - {file = "jellyfish-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:63f58a0a7c9c0bb9a69562d2b9dd1a3f6729e94b0dcb6adf54b45b4da853eb94"}, - {file = "jellyfish-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:088c9b7e7077802ce2254b876486ae3b49d81f4f07f6c692c612ba40e1a42177"}, - {file = "jellyfish-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:393664438fbb98886f9c97675179d4b552b68c3d0099d4df3cdec6412deaeea0"}, - {file = "jellyfish-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a54a83905596dd712948b6af7fccc2b28d37624bfc9eab4868518c3f8106c739"}, - {file = "jellyfish-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f68cdb689b59653fa70345c8fcb09bfee12d34c0f7ae223ce70fa5175cb2ee"}, - {file = "jellyfish-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:019542af342973c537275b289c1e891fb2b62b011bfdb68c816da4527477b74d"}, - {file = "jellyfish-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:079ec6fceb5336e7c2f99b43ee035f85b39022db897c70e736439ed1d4fc8462"}, - {file = "jellyfish-1.2.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a5ddd20e6d87c7dc173717ffe0df0bba50aa0b0c51e3d57d6cce1706ea6a1167"}, - {file = "jellyfish-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:08a1a3f6adc033eb3735a8ba121188a5d3fdc6630eec6a946c30260c1ac680ac"}, - {file = "jellyfish-1.2.0-cp312-cp312-win32.whl", hash = "sha256:65ec39cfed29e475df33c9d7fc70d76eb39ce6dfb7fedf19599caff497a9b3c7"}, - {file = "jellyfish-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:199baa59412723ef76126303fc236728b2613a4723fba83eede942c89e1dad1c"}, - {file = "jellyfish-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:8b995bdf97d43cdca1e6bd5375f351bcb85c7f5e8760fe4a28c63eb0e6104075"}, - {file = "jellyfish-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:559c1d6f17ba51639843b958a0d57ece5c4155e6b820c4acb3f3437437625ef3"}, - {file = "jellyfish-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4439f4066ccc5dd6a7a15cb06941f5150bab646201e9e014a7d34d65cbe89fe"}, - {file = "jellyfish-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbf866d2b967fd2d5380134fdcb47d4f113e24d659b46c38e55da80c215d2042"}, - {file = "jellyfish-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9290b82276bba1941ad0f98226f51b43aeef7bdedb927b9266516b4519b9012"}, - {file = "jellyfish-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:052345ded2b00104a50acbab35c671efe06f40790202f6a2fc279ad645f31ab2"}, - {file = "jellyfish-1.2.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:465dcf8b17162e3dae2cae0072b22ea9637e6ce8ddd8294181758437cd9c0673"}, - {file = "jellyfish-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ae5f2e3c5ef14cb5b86afe7ed4078e504f75dd61ca9d9560bef597f9d2237c63"}, - {file = "jellyfish-1.2.0-cp313-cp313-win32.whl", hash = "sha256:13ee212b6fa294a1b6306693a1553b760d876780e757b9f016010748fe811b4d"}, - {file = "jellyfish-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:c8089e918ddb1abae946e92d053f646a7f686d0d051ef69cdfaa28b37352bbdf"}, - {file = "jellyfish-1.2.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:50a0c79a663ccb919ba0b36af726aeefb72538481aca45b4f0708e104d2ef8af"}, - {file = "jellyfish-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c84fac793e43567c0c0361a6ad1bf5bc7126c2d130d5bdb5e0dffec72e805605"}, - {file = "jellyfish-1.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c68a293baa2748a484345c34bd97edaa83cd4a52d09204b1eecfbce177f3db01"}, - {file = "jellyfish-1.2.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:52a612f91bf979b188c46535218ac633ef62c9dab24b92324f181f985c9260a8"}, - {file = "jellyfish-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:057849127aa217601186eaec36a3ca5ed0bc86424c88bedc6c3ae2bf3c0c7616"}, - {file = "jellyfish-1.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4c8d6c28fb631c3d501b3c9ce1f7a729827b8dcd98e0f8599748f446a8ab94db"}, - {file = "jellyfish-1.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d2f87d8621c0c3122fa4307b247a258dbffd83cc30575e38497b869dcc711cf6"}, - {file = "jellyfish-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7ccb573b94a60792144946a9dcd47f7d8b7b605a100c3d4b359d2d0aa17ee554"}, - {file = "jellyfish-1.2.0-cp39-cp39-win32.whl", hash = "sha256:83b0c09808be387e10172cb3dfc7229fc395a92db02bf7fbcd01e5546340a7ae"}, - {file = "jellyfish-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:59dc22ebda3a55701f422222b6e1afa75a83d2195accb7f42f7a796a1725fc1b"}, - {file = "jellyfish-1.2.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3c0d9d4136341c34ed0fb3fb406801d7a2724a1fa4996577822bc5e5b27870b4"}, - {file = "jellyfish-1.2.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:36e53729ac0bd387c8f7fd60c8727e6784113976058d8aa2f62398fcdfe209f1"}, - {file = "jellyfish-1.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10b178b8351e90e463d937e53c8b5525627a0bb2ca6f7e49ac7452b0608a1020"}, - {file = "jellyfish-1.2.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e79225fba34bb082d2f21fa5cd7dc015c856ff04b8340e98ce122ab71a445cad"}, - {file = "jellyfish-1.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a1ea2d395a4c9c00224b3abd57b73bd2cb4ca17fc6e2024c8433b31e1b1061"}, - {file = "jellyfish-1.2.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b27ff486175b9c8fecdc9147c7dde1d835675bd76df12c1521f378a2fab70493"}, - {file = "jellyfish-1.2.0-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:4b6b1109174e6ae780f6c2b8454b2fcfc7efeb8fae767f757c481ccca16fb842"}, - {file = "jellyfish-1.2.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dfb4e76c2b6c3e94ea3778510c94609893ddc7172255838b3221eba1ec9aa5cc"}, - {file = "jellyfish-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:07384e33e5f9bfd3d1356cf73d94388af295ed8f196a1d9f09bc381c5ea79be8"}, - {file = "jellyfish-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:03754194fc2f5cf65136f2b5f2aeacf48a805ddf21f4ff9f1a6cffc67756d937"}, - {file = "jellyfish-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57a0c408c588c4477bdcd82c0c1c33f08900aca5c2dfc9d5e78f2e0919294a68"}, - {file = "jellyfish-1.2.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72062c9772ff672535463954828e9921fb1bf1d63c66602db2956567e9e50aa8"}, - {file = "jellyfish-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb3b464faeb8e4f4f6f7987fbd3f5de759fc0d460bbe4768b446e3f1c003026a"}, - {file = "jellyfish-1.2.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33c5d80209b278807a770a463f39d0b6a3f95dacf9a64fd322ad4add63a52516"}, - {file = "jellyfish-1.2.0-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:0787a5fef60aa838732f325064cc4401425e450023bb8fc8d3b2bd2ee75df57d"}, - {file = "jellyfish-1.2.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dee4cc60f2b342f3f62784787f1ba811e505b9a8d8f68cc7505d496c563143b5"}, - {file = "jellyfish-1.2.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b726088637c1fef88a9cbcd91ba9960dfd58d9604506083c4902092db13f71c"}, - {file = "jellyfish-1.2.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c3f9413fadf4bc9d453822c4e13ccf15a5d831857b387cfbb85ba153afbe974a"}, - {file = "jellyfish-1.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04162e0803029146eb102b2fb11e1be6a966fdd48ea5bd22399b75e5edfbd73e"}, - {file = "jellyfish-1.2.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5dbbbd69091969b605aaf317cda8e11413732934c471f9ba82aceedb9d83333"}, - {file = "jellyfish-1.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03ae5049f8ba478e1acb1a2cbaa4062d45c2a76d32d66d52260f979c0c0701c7"}, - {file = "jellyfish-1.2.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:07d9b9708c2b91059ba635cfdb04665cf7ca9055a70c9a2b590da4755395aba0"}, - {file = "jellyfish-1.2.0-pp39-pypy39_pp73-musllinux_1_1_i686.whl", hash = "sha256:cc2190b04a7759c1f5d2095aa711d99ff803b22d6c863b6e60b4fb531d2aed7b"}, - {file = "jellyfish-1.2.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:8c984b6f7ccc682f5e6211b2e722a8cac2ccca0dae312df83fa70a341302f6ee"}, - {file = "jellyfish-1.2.0.tar.gz", hash = "sha256:5c7d73db4045dcc53b6efbfea21f3d3da432d3e052dc51827574d1a447fc23b4"}, + {file = "jellyfish-1.2.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b35d4b5b688f759ffd075190a9850b04671bad14c5b37124eb43e99306ec16ea"}, + {file = "jellyfish-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b37b76ea338c4a473c34a9b9e1e033a78aafb9040a8c0eea579fc5805d8e4b46"}, + {file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:137cfcc26396d0f2e1265ac61f800bb921921ea722a43dd897e58190f767c474"}, + {file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab1bfea271ce4bda09d975080d5465cf5a8b127e7c0ea61ea3f972417a7a2193"}, + {file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2348f698f9c1d72023afc8d39939045421a01da9b7e3078e3029227e35f28419"}, + {file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4072e21ad4036af41bd57b447b1dda64fe60aa679cfa8854ba0a0338152439f1"}, + {file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cf6cd68921f2bacc547ba1cf64ad0e76bc1727f3bab13bba2e5f5869aba038b1"}, + {file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:01647c12261bc1f7b102e918e7665497176d87f6fc96271439c8855872bc2606"}, + {file = "jellyfish-1.2.1-cp310-cp310-win32.whl", hash = "sha256:ddf05ea471da2808d77ecfa425d8884124b4754f4d483afa7703b6655530cf5c"}, + {file = "jellyfish-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:e4a210a960f3917da757b0581750b6e0a8db9acef68dafbc1b6e2ae39e847ba8"}, + {file = "jellyfish-1.2.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9913789a98ccf49213fbb1dabc597847a0ec33d3b0e151689498f4b38ba9be0f"}, + {file = "jellyfish-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e36d9000d4f7e1a35689a74ec7749d27a216dfa6c47cac2e5ad3de8a523bd69"}, + {file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7853d2ed7d6929c029312ec849410f1ea7ae76ce72ad1140fb73f6e8a1e6aa4f"}, + {file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68080af234256ef943f0add6fc79816b0c643d8df291c17a85c1b6e45bdfbb96"}, + {file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c5acb213aa75a61bcfc176566e20f2503069667e760d83d403b59e115fef0dd"}, + {file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4b28fcefc0c3534277ff0306e6c10672fb050f4784b5f3be7037e80801569fb5"}, + {file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f69aeb08659a6c81d559bbe319075e3417434ae5b3a5e4a758d1c4055a03497a"}, + {file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:63770120cc3386dcc13bcc4df508ab281a6b14c3b2c0e33586439a6c40ee122f"}, + {file = "jellyfish-1.2.1-cp311-cp311-win32.whl", hash = "sha256:ecf62d4aad0baa8832ab60f96e7baedbe6558bd292597503d927e9c5bce745d8"}, + {file = "jellyfish-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:bd186c041d9be86c4fa5e2490943ce5d7f05b472f45d7f49426f259f3dd20bc4"}, + {file = "jellyfish-1.2.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:32a85b752cb51463face13e2b1797cfa617cd7fb7073f15feaa4020a86a346ce"}, + {file = "jellyfish-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:675ab43840488944899ca87f02d4813c1e32107e56afaba7489705a70214e8aa"}, + {file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c888f624d03e55e501bc438906505c79fb307d8da37a6dda18dd1ac2e6d5ea9c"}, + {file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2b56a1fd2c5126c4a3362ec4470291cdd3c7daa22f583da67e75e30dc425ce6"}, + {file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a3ccff843822e7f3ad6f91662488a3630724c8587976bce114f3c7238e8ffa1"}, + {file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10da696747e2de0336180fd5ba77ef769a7c80f9743123545f7fc0251efbbcec"}, + {file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c3c18f13175a9c90f3abd8805720b0eb3e10eca1d5d4e0cf57722b2a62d62016"}, + {file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0368596e176bf548b3be2979ff33e274fb6d5e13b2cebe85137b8b698b002a85"}, + {file = "jellyfish-1.2.1-cp312-cp312-win32.whl", hash = "sha256:451ddf4094e108e33d3b86d7817a7e20a2c5e6812d08c34ee22f6a595f38dcca"}, + {file = "jellyfish-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:15318c13070fe6d9caeb7e10f9cdf89ff47c9d20f05a9a2c0d3b5cb8062a7033"}, + {file = "jellyfish-1.2.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4b3e3223aaad74e18aacc74775e01815e68af810258ceea6fa6a81b19f384312"}, + {file = "jellyfish-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e967e67058b78189d2b20a9586c7720a05ec4a580d6a98c796cd5cd2b7b11303"}, + {file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32581c50b34a09889b2d96796170e53da313a1e7fde32be63c82e50e7e791e3c"}, + {file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07b022412ebece96759006cb015d46b8218d7f896d8b327c6bbee784ddf38ed9"}, + {file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80a49eb817eaa6591f43a31e5c93d79904de62537f029907ef88c050d781a638"}, + {file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e1b990fb15985571616f7f40a12d6fa062897b19fb5359b6dec3cd811d802c24"}, + {file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:dd895cf63fac0a9f11b524fff810d9a6081dcf3c518b34172ac8684eb504dd43"}, + {file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:6d2bac5982d7a08759ea487bfa00149e6aa8a3be7cd43c4ed1be1e3505425c69"}, + {file = "jellyfish-1.2.1-cp313-cp313-win32.whl", hash = "sha256:509355ebedec69a8bf0cc113a6bf9c01820d12fe2eea44f47dfa809faf2d5463"}, + {file = "jellyfish-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:9c747ae5c0fb4bd519f6abbfe4bd704b2f1c63fd4dd3dbb8d8864478974e1571"}, + {file = "jellyfish-1.2.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:212aaf177236192a735bbbf5938717aa8518d14a25b08b015e47e783e70be060"}, + {file = "jellyfish-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b8986d9768daddd5e87abf513ae168ea0afe690a444d4c82d5b1b14b0d045820"}, + {file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fa0ba0946f3c274f6a87aaa3c631dc70a363bd46cceea828ce777e8db653b6f"}, + {file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6e76b23431a667cd485fb562428d1ad29bae9fdd0fcdfb5a51cc8087bae0e88c"}, + {file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a058f4c6a591d5e5a47569f5648a26303ba19c76a960fef7e0beba2aa959e52e"}, + {file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6a49ce2a580edd3b16b69421137deef464e2f8907f9ef906d49950b1a52908c1"}, + {file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:c85aa2bc76a36d92a3197f406f86636664d5b323727dfec4fa2842a8a24a06ae"}, + {file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:29cfa8bfb72aacf2d611a3313b358ed4d4140fa3d3efcffea750c8e7f8acb1aa"}, + {file = "jellyfish-1.2.1-cp314-cp314-win32.whl", hash = "sha256:f121218dc33fb318c34ddd889dc7362606ce1316af2bb63b73cc1df81523ca34"}, + {file = "jellyfish-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:9a73b5c6425a70ebd440579a677eb4f03b327b2f59090db34e6c937aeea5aabd"}, + {file = "jellyfish-1.2.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5335f622458aa105289a8e358bc32ecd1b9634b6ffec3e77ea3577e49c297171"}, + {file = "jellyfish-1.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c51e565f85ce38cf9388c4f916d53888b0fa34788fcebe3aff3db24948e0960"}, + {file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14bbb30d988dec1d12183cf5d4621c908f98add2009c72a185e8c3e8d00b804f"}, + {file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9930e20f0e9f65ad1d57d98290c2be3abd75812d058815605f44a56056fb9a66"}, + {file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0028857c5381c9d55e21cc6cb0d7f9545c3a9a7bb7dbca3960fe0a898c691ac2"}, + {file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:56da7632e029912af25e25422fae3b6df318400297d552791f4b21da6d815ed6"}, + {file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a3cab91020e3ff7565e55a611ec3e3257c093ac950d55778a48bfc8c57562b6e"}, + {file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0b21c1596ce283fd7ee954eb0eeb007d59e480364324bcd91ad55146e91f3936"}, + {file = "jellyfish-1.2.1-cp39-cp39-win32.whl", hash = "sha256:1098ce1f84ae3f147f0a18a6803ffb09b9c8cd5fedce42465643ca0b5c9d0224"}, + {file = "jellyfish-1.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:4b013876109d91fa6fc871ffa4e0dbfda11820c33dc4ad0e2967b3fc1187f804"}, + {file = "jellyfish-1.2.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c499ea3a134130797c50e367687a6a46a12653c59af381bee92c41a5ab0bd55d"}, + {file = "jellyfish-1.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:91cad49a4fb731b726afc5ae385a3217a7016ed88a04da40c131cff8136a5db5"}, + {file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bda2275f31a64adf3483e39f7a4e2107f7dfe3a3f85f0d2c0cb6ae5fbe4a443"}, + {file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98a133b40dc00cfda6609e1b0cb0ab0b77796fc2719aae886a12009514f73499"}, + {file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa30c7b59bd1c5e105693108a6d7a98f3e7a1a59e23e15bc5897b91fd5849f5"}, + {file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:db97d873f23b0c15b4ed911ece10e5cc0bb96cdc53666d5c3788bd0af81807f1"}, + {file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:393f609fd6139ce782e747e22c399483ffc58341009e6a97e39ffe5f5b2c674c"}, + {file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fb3c6e537cb4605c22895a8d4a10cdb26611ba2bbfc7f0b4c1d06bb9d8aad648"}, + {file = "jellyfish-1.2.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:748dc45a0394fbe9120b8b3b9a39fab0967c7e2d6ecdd5304af018e774f80f96"}, + {file = "jellyfish-1.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:13f1ac9caba22af10bfe42f674822643c0266009f882e0fe652079706dc5d13a"}, + {file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ffeeb6c78c45fbb6d2a22b0173fb8a6af849001d6c26fab49c525136dbd9734"}, + {file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1354b558a0a16597b6032dd0af64bebd24994f7e7484cf14993320eb764b06cb"}, + {file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5977810972c6f0b2e61252c4758fd5aee21abf663ff309881195a99d37daa94"}, + {file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:536c80d8d4ec7f39cbb10b85d926ff96cef3cde4a83ca0991c07cd9835d5dc13"}, + {file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:21baa92d4a5112167721156f6d061c2ae105f2995b3a5e19cec6662928f0c439"}, + {file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68ea3ddd4dae1152a7f7155ef02a7bfad919611158d71b301f9aa167685819af"}, + {file = "jellyfish-1.2.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d7be8021658b46b22500a77f1707901bd98fc210f185c229b81c74efd3c1baf2"}, + {file = "jellyfish-1.2.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:bcdcd603a7737cd3f5a2ab10ce9b49844329deb81c2daafcd8131e54fc730205"}, + {file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c28a4ae3e201e1c1b7bacacd40e2e76c4068b90c9ae3a0d525e0ac98206f1cc"}, + {file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bebccd0652ac1c7e438ae1f451edefde63d14b3af6f6daa30c599919dcb92886"}, + {file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05be396aebe3dce7a8cb2f97727ecdf99e86457c48e97190775dce33f8b7e39d"}, + {file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:9d4448c874959ae012cda0f6d570ac0bd7f0fcf12007714eaebf86b86919b66f"}, + {file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_i686.whl", hash = "sha256:4a21d7eda5e6996772055f798e3fe1de1b33b3edad7f6cf0567097a21585a812"}, + {file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:a0ef6f0ecc085c1f8fddb048f538c8bb89989e5d470eab45d4e9bd48ee73a40d"}, + {file = "jellyfish-1.2.1.tar.gz", hash = "sha256:72d2fda61b23babe862018729be73c8b0dc12e3e6601f36f6e65d905e249f4db"}, ] [[package]] @@ -1270,77 +1354,84 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "libcst" -version = "1.8.2" -description = "A concrete syntax tree with AST-like properties for Python 3.0 through 3.13 programs." +version = "1.8.6" +description = "A concrete syntax tree with AST-like properties for Python 3.0 through 3.14 programs." optional = false python-versions = ">=3.9" files = [ - {file = "libcst-1.8.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:67d9720d91f507c87b3e5f070627ad640a00bc6cfdf5635f8c6ee9f2964cf71c"}, - {file = "libcst-1.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:94b7c032b72566077614a02baab1929739fd0af0cc1d46deaba4408b870faef2"}, - {file = "libcst-1.8.2-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:11ea148902e3e1688afa392087c728ac3a843e54a87d334d1464d2097d3debb7"}, - {file = "libcst-1.8.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:22c9473a2cc53faabcc95a0ac6ca4e52d127017bf34ba9bc0f8e472e44f7b38e"}, - {file = "libcst-1.8.2-cp310-cp310-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b5269b96367e65793a7714608f6d906418eb056d59eaac9bba980486aabddbed"}, - {file = "libcst-1.8.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d20e932ddd9a389da57b060c26e84a24118c96ff6fc5dcc7b784da24e823b694"}, - {file = "libcst-1.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a553d452004e44b841788f6faa7231a02157527ddecc89dbbe5b689b74822226"}, - {file = "libcst-1.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fe762c4c390039b79b818cbc725d8663586b25351dc18a2704b0e357d69b924"}, - {file = "libcst-1.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:5c513e64eff0f7bf2a908e2d987a98653eb33e1062ce2afd3a84af58159a24f9"}, - {file = "libcst-1.8.2-cp310-cp310-win_arm64.whl", hash = "sha256:41613fe08e647213546c7c59a5a1fc5484666e7d4cab6e80260c612acbb20e8c"}, - {file = "libcst-1.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:688a03bac4dfb9afc5078ec01d53c21556381282bdf1a804dd0dbafb5056de2a"}, - {file = "libcst-1.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c34060ff2991707c710250463ae9f415ebb21653f2f5b013c61c9c376ff9b715"}, - {file = "libcst-1.8.2-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f54f5c4176d60e7cd6b0880e18fb3fa8501ae046069151721cab457c7c538a3d"}, - {file = "libcst-1.8.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d11992561de0ad29ec2800230fbdcbef9efaa02805d5c633a73ab3cf2ba51bf1"}, - {file = "libcst-1.8.2-cp311-cp311-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fa3b807c2d2b34397c135d19ad6abb20c47a2ddb7bf65d90455f2040f7797e1e"}, - {file = "libcst-1.8.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b0110140738be1287e3724080a101e7cec6ae708008b7650c9d8a1c1788ec03a"}, - {file = "libcst-1.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a50618f4819a97ef897e055ac7aaf1cad5df84c206f33be35b0759d671574197"}, - {file = "libcst-1.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9bb599c175dc34a4511f0e26d5b5374fbcc91ea338871701a519e95d52f3c28"}, - {file = "libcst-1.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:96e2363e1f6e44bd7256bbbf3a53140743f821b5133046e6185491e0d9183447"}, - {file = "libcst-1.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:f5391d71bd7e9e6c73dcb3ee8d8c63b09efc14ce6e4dad31568d4838afc9aae0"}, - {file = "libcst-1.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2e8c1dfa854e700fcf6cd79b2796aa37d55697a74646daf5ea47c7c764bac31c"}, - {file = "libcst-1.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b5c57a3c1976c365678eb0730bcb140d40510990cb77df9a91bb5c41d587ba6"}, - {file = "libcst-1.8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:0f23409add2aaebbb6d8e881babab43c2d979f051b8bd8aed5fe779ea180a4e8"}, - {file = "libcst-1.8.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b88e9104c456590ad0ef0e82851d4fc03e9aa9d621fa8fdd4cd0907152a825ae"}, - {file = "libcst-1.8.2-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5ba3ea570c8fb6fc44f71aa329edc7c668e2909311913123d0d7ab8c65fc357"}, - {file = "libcst-1.8.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:460fcf3562f078781e1504983cb11909eb27a1d46eaa99e65c4b0fafdc298298"}, - {file = "libcst-1.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1381ddbd1066d543e05d580c15beacf671e1469a0b2adb6dba58fec311f4eed"}, - {file = "libcst-1.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a70e40ce7600e1b32e293bb9157e9de3b69170e2318ccb219102f1abb826c94a"}, - {file = "libcst-1.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:3ece08ba778b6eeea74d9c705e9af2d1b4e915e9bc6de67ad173b962e575fcc0"}, - {file = "libcst-1.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:5efd1bf6ee5840d1b0b82ec8e0b9c64f182fa5a7c8aad680fbd918c4fa3826e0"}, - {file = "libcst-1.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08e9dca4ab6f8551794ce7ec146f86def6a82da41750cbed2c07551345fa10d3"}, - {file = "libcst-1.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8310521f2ccb79b5c4345750d475b88afa37bad930ab5554735f85ad5e3add30"}, - {file = "libcst-1.8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:da2d8b008aff72acd5a4a588491abdda1b446f17508e700f26df9be80d8442ae"}, - {file = "libcst-1.8.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:be821d874ce8b26cbadd7277fa251a9b37f6d2326f8b5682b6fc8966b50a3a59"}, - {file = "libcst-1.8.2-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f74b0bc7378ad5afcf25ac9d0367b4dbba50f6f6468faa41f5dfddcf8bf9c0f8"}, - {file = "libcst-1.8.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b68ea4a6018abfea1f68d50f74de7d399172684c264eb09809023e2c8696fc23"}, - {file = "libcst-1.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e264307ec49b2c72480422abafe80457f90b4e6e693b7ddf8a23d24b5c24001"}, - {file = "libcst-1.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5d5519962ce7c72d81888fb0c09e58e308ba4c376e76bcd853b48151063d6a8"}, - {file = "libcst-1.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:b62aa11d6b74ed5545e58ac613d3f63095e5fd0254b3e0d1168fda991b9a6b41"}, - {file = "libcst-1.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9c2bd4ac288a9cdb7ffc3229a9ce8027a66a3fd3f2ab9e13da60f5fbfe91f3b2"}, - {file = "libcst-1.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:08a8c7d9922ca6eed24e2c13a3c552b3c186af8fc78e5d4820b58487d780ec19"}, - {file = "libcst-1.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bba7c2b5063e8ada5a5477f9fa0c01710645426b5a8628ec50d558542a0a292e"}, - {file = "libcst-1.8.2-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d97c9fe13aacfbefded6861f5200dcb8e837da7391a9bdeb44ccb133705990af"}, - {file = "libcst-1.8.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d2194ae959630aae4176a4b75bd320b3274c20bef2a5ca6b8d6fc96d3c608edf"}, - {file = "libcst-1.8.2-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0be639f5b2e1999a4b4a82a0f4633969f97336f052d0c131627983589af52f56"}, - {file = "libcst-1.8.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6753e50904e05c27915933da41518ecd7a8ca4dd3602112ba44920c6e353a455"}, - {file = "libcst-1.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:706d07106af91c343150be86caeae1ea3851b74aa0730fcbbf8cd089e817f818"}, - {file = "libcst-1.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd4310ea8ddc49cc8872e083737cf806299b17f93159a1f354d59aa08993e876"}, - {file = "libcst-1.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:51bbafdd847529e8a16d1965814ed17831af61452ee31943c414cb23451de926"}, - {file = "libcst-1.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f14f5045766646ed9e8826b959c6d07194788babed1e0ba08c94ea4f39517e3"}, - {file = "libcst-1.8.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:f69582e24667715e3860d80d663f1caeb2398110077e23cc0a1e0066a851f5ab"}, - {file = "libcst-1.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ba85f9e6a7f37ef998168aa3fd28d263d7f83016bd306a4508a2394e5e793b4"}, - {file = "libcst-1.8.2-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:43ccaa6c54daa1749cec53710c70d47150965574d4c6d4c4f2e3f87b9bf9f591"}, - {file = "libcst-1.8.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8a81d816c2088d2055112af5ecd82fdfbe8ff277600e94255e2639b07de10234"}, - {file = "libcst-1.8.2-cp39-cp39-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:449f9ff8a5025dcd5c8d4ad28f6c291de5de89e4c044b0bda96b45bef8999b75"}, - {file = "libcst-1.8.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:36d5ab95f39f855521585b0e819dc2d4d1b2a4080bad04c2f3de1e387a5d2233"}, - {file = "libcst-1.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:207575dec2dae722acf6ab39b4b361151c65f8f895fd37edf9d384f5541562e1"}, - {file = "libcst-1.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52a1067cf31d9e9e4be514b253bea6276f1531dd7de6ab0917df8ce5b468a820"}, - {file = "libcst-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:59e8f611c977206eba294c296c2d29a1c1b1b88206cb97cd0d4847c1a3d923e7"}, - {file = "libcst-1.8.2-cp39-cp39-win_arm64.whl", hash = "sha256:ae22376633cfa3db21c4eed2870d1c36b5419289975a41a45f34a085b2d9e6ea"}, - {file = "libcst-1.8.2.tar.gz", hash = "sha256:66e82cedba95a6176194a817be4232c720312f8be6d2c8f3847f3317d95a0c7f"}, + {file = "libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9"}, + {file = "libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6"}, + {file = "libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58"}, + {file = "libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8"}, + {file = "libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba"}, + {file = "libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b"}, + {file = "libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073"}, + {file = "libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100"}, + {file = "libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073"}, + {file = "libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6"}, + {file = "libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978"}, + {file = "libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532"}, + {file = "libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64"}, + {file = "libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b"}, + {file = "libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f"}, + {file = "libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c"}, + {file = "libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9"}, + {file = "libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09"}, + {file = "libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d"}, + {file = "libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5"}, + {file = "libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1"}, + {file = "libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86"}, + {file = "libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d"}, + {file = "libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7"}, + {file = "libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb"}, + {file = "libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196"}, + {file = "libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105"}, + {file = "libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d"}, + {file = "libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786"}, + {file = "libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30"}, + {file = "libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde"}, + {file = "libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf"}, + {file = "libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e"}, + {file = "libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58"}, + {file = "libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f"}, + {file = "libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93"}, + {file = "libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012"}, + {file = "libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4"}, + {file = "libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330"}, + {file = "libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42"}, + {file = "libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c"}, + {file = "libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661"}, + {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474"}, + {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8"}, + {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a"}, + {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47"}, + {file = "libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4"}, + {file = "libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9"}, + {file = "libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1"}, + {file = "libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4"}, + {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28"}, + {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa"}, + {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1"}, + {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996"}, + {file = "libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82"}, + {file = "libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f"}, + {file = "libcst-1.8.6-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cb2679ef532f9fa5be5c5a283b6357cb6e9888a8dd889c4bb2b01845a29d8c0b"}, + {file = "libcst-1.8.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:203ec2a83f259baf686b9526268cd23d048d38be5589594ef143aee50a4faf7e"}, + {file = "libcst-1.8.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6366ab2107425bf934b0c83311177f2a371bfc757ee8c6ad4a602d7cbcc2f363"}, + {file = "libcst-1.8.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6aa11df6c58812f731172b593fcb485d7ba09ccc3b52fea6c7f26a43377dc748"}, + {file = "libcst-1.8.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:351ab879c2fd20d9cb2844ed1ea3e617ed72854d3d1e2b0880ede9c3eea43ba8"}, + {file = "libcst-1.8.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98fa1ca321c81fb1f02e5c43f956ca543968cc1a30b264fd8e0a2e1b0b0bf106"}, + {file = "libcst-1.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:25fc7a1303cad7639ad45ec38c06789b4540b7258e9a108924aaa2c132af4aca"}, + {file = "libcst-1.8.6-cp39-cp39-win_arm64.whl", hash = "sha256:4d7bbdd35f3abdfb5ac5d1a674923572dab892b126a58da81ff2726102d6ec2e"}, + {file = "libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b"}, ] [package.dependencies] -pyyaml = {version = ">=5.2", markers = "python_version < \"3.13\""} -pyyaml-ft = {version = ">=8.0.0", markers = "python_version >= \"3.13\""} +pyyaml = [ + {version = ">=5.2", markers = "python_version < \"3.13\""}, + {version = ">=6.0.3", markers = "python_version >= \"3.14\""}, +] +pyyaml-ft = {version = ">=8.0.0", markers = "python_version == \"3.13\""} typing-extensions = {version = "*", markers = "python_version < \"3.10\""} [[package]] @@ -1438,105 +1529,151 @@ files = [ [[package]] name = "lxml" -version = "6.0.0" +version = "6.0.2" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = true python-versions = ">=3.8" files = [ - {file = "lxml-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:35bc626eec405f745199200ccb5c6b36f202675d204aa29bb52e27ba2b71dea8"}, - {file = "lxml-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:246b40f8a4aec341cbbf52617cad8ab7c888d944bfe12a6abd2b1f6cfb6f6082"}, - {file = "lxml-6.0.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2793a627e95d119e9f1e19720730472f5543a6d84c50ea33313ce328d870f2dd"}, - {file = "lxml-6.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:46b9ed911f36bfeb6338e0b482e7fe7c27d362c52fde29f221fddbc9ee2227e7"}, - {file = "lxml-6.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b4790b558bee331a933e08883c423f65bbcd07e278f91b2272489e31ab1e2b4"}, - {file = "lxml-6.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2030956cf4886b10be9a0285c6802e078ec2391e1dd7ff3eb509c2c95a69b76"}, - {file = "lxml-6.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d23854ecf381ab1facc8f353dcd9adeddef3652268ee75297c1164c987c11dc"}, - {file = "lxml-6.0.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:43fe5af2d590bf4691531b1d9a2495d7aab2090547eaacd224a3afec95706d76"}, - {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74e748012f8c19b47f7d6321ac929a9a94ee92ef12bc4298c47e8b7219b26541"}, - {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:43cfbb7db02b30ad3926e8fceaef260ba2fb7df787e38fa2df890c1ca7966c3b"}, - {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34190a1ec4f1e84af256495436b2d196529c3f2094f0af80202947567fdbf2e7"}, - {file = "lxml-6.0.0-cp310-cp310-win32.whl", hash = "sha256:5967fe415b1920a3877a4195e9a2b779249630ee49ece22021c690320ff07452"}, - {file = "lxml-6.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:f3389924581d9a770c6caa4df4e74b606180869043b9073e2cec324bad6e306e"}, - {file = "lxml-6.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:522fe7abb41309e9543b0d9b8b434f2b630c5fdaf6482bee642b34c8c70079c8"}, - {file = "lxml-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ee56288d0df919e4aac43b539dd0e34bb55d6a12a6562038e8d6f3ed07f9e36"}, - {file = "lxml-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8dd6dd0e9c1992613ccda2bcb74fc9d49159dbe0f0ca4753f37527749885c25"}, - {file = "lxml-6.0.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:d7ae472f74afcc47320238b5dbfd363aba111a525943c8a34a1b657c6be934c3"}, - {file = "lxml-6.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5592401cdf3dc682194727c1ddaa8aa0f3ddc57ca64fd03226a430b955eab6f6"}, - {file = "lxml-6.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58ffd35bd5425c3c3b9692d078bf7ab851441434531a7e517c4984d5634cd65b"}, - {file = "lxml-6.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f720a14aa102a38907c6d5030e3d66b3b680c3e6f6bc95473931ea3c00c59967"}, - {file = "lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2a5e8d207311a0170aca0eb6b160af91adc29ec121832e4ac151a57743a1e1e"}, - {file = "lxml-6.0.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:2dd1cc3ea7e60bfb31ff32cafe07e24839df573a5e7c2d33304082a5019bcd58"}, - {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cfcf84f1defed7e5798ef4f88aa25fcc52d279be731ce904789aa7ccfb7e8d2"}, - {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a52a4704811e2623b0324a18d41ad4b9fabf43ce5ff99b14e40a520e2190c851"}, - {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c16304bba98f48a28ae10e32a8e75c349dd742c45156f297e16eeb1ba9287a1f"}, - {file = "lxml-6.0.0-cp311-cp311-win32.whl", hash = "sha256:f8d19565ae3eb956d84da3ef367aa7def14a2735d05bd275cd54c0301f0d0d6c"}, - {file = "lxml-6.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b2d71cdefda9424adff9a3607ba5bbfc60ee972d73c21c7e3c19e71037574816"}, - {file = "lxml-6.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:8a2e76efbf8772add72d002d67a4c3d0958638696f541734304c7f28217a9cab"}, - {file = "lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108"}, - {file = "lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be"}, - {file = "lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab"}, - {file = "lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563"}, - {file = "lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7"}, - {file = "lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7"}, - {file = "lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991"}, - {file = "lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da"}, - {file = "lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e"}, - {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741"}, - {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3"}, - {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16"}, - {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0"}, - {file = "lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a"}, - {file = "lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3"}, - {file = "lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb"}, - {file = "lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da"}, - {file = "lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7"}, - {file = "lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3"}, - {file = "lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81"}, - {file = "lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1"}, - {file = "lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24"}, - {file = "lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a"}, - {file = "lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29"}, - {file = "lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4"}, - {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca"}, - {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf"}, - {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f"}, - {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef"}, - {file = "lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181"}, - {file = "lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e"}, - {file = "lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03"}, - {file = "lxml-6.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4eb114a0754fd00075c12648d991ec7a4357f9cb873042cc9a77bf3a7e30c9db"}, - {file = "lxml-6.0.0-cp38-cp38-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:7da298e1659e45d151b4028ad5c7974917e108afb48731f4ed785d02b6818994"}, - {file = "lxml-6.0.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bf61bc4345c1895221357af8f3e89f8c103d93156ef326532d35c707e2fb19d"}, - {file = "lxml-6.0.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63b634facdfbad421d4b61c90735688465d4ab3a8853ac22c76ccac2baf98d97"}, - {file = "lxml-6.0.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e380e85b93f148ad28ac15f8117e2fd8e5437aa7732d65e260134f83ce67911b"}, - {file = "lxml-6.0.0-cp38-cp38-win32.whl", hash = "sha256:185efc2fed89cdd97552585c624d3c908f0464090f4b91f7d92f8ed2f3b18f54"}, - {file = "lxml-6.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:f97487996a39cb18278ca33f7be98198f278d0bc3c5d0fd4d7b3d63646ca3c8a"}, - {file = "lxml-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85b14a4689d5cff426c12eefe750738648706ea2753b20c2f973b2a000d3d261"}, - {file = "lxml-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f64ccf593916e93b8d36ed55401bb7fe9c7d5de3180ce2e10b08f82a8f397316"}, - {file = "lxml-6.0.0-cp39-cp39-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:b372d10d17a701b0945f67be58fae4664fd056b85e0ff0fbc1e6c951cdbc0512"}, - {file = "lxml-6.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a674c0948789e9136d69065cc28009c1b1874c6ea340253db58be7622ce6398f"}, - {file = "lxml-6.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:edf6e4c8fe14dfe316939711e3ece3f9a20760aabf686051b537a7562f4da91a"}, - {file = "lxml-6.0.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:048a930eb4572829604982e39a0c7289ab5dc8abc7fc9f5aabd6fbc08c154e93"}, - {file = "lxml-6.0.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0b5fa5eda84057a4f1bbb4bb77a8c28ff20ae7ce211588d698ae453e13c6281"}, - {file = "lxml-6.0.0-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:c352fc8f36f7e9727db17adbf93f82499457b3d7e5511368569b4c5bd155a922"}, - {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8db5dc617cb937ae17ff3403c3a70a7de9df4852a046f93e71edaec678f721d0"}, - {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:2181e4b1d07dde53986023482673c0f1fba5178ef800f9ab95ad791e8bdded6a"}, - {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3c98d5b24c6095e89e03d65d5c574705be3d49c0d8ca10c17a8a4b5201b72f5"}, - {file = "lxml-6.0.0-cp39-cp39-win32.whl", hash = "sha256:04d67ceee6db4bcb92987ccb16e53bef6b42ced872509f333c04fb58a3315256"}, - {file = "lxml-6.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:e0b1520ef900e9ef62e392dd3d7ae4f5fa224d1dd62897a792cf353eb20b6cae"}, - {file = "lxml-6.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:e35e8aaaf3981489f42884b59726693de32dabfc438ac10ef4eb3409961fd402"}, - {file = "lxml-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:dbdd7679a6f4f08152818043dbb39491d1af3332128b3752c3ec5cebc0011a72"}, - {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40442e2a4456e9910875ac12951476d36c0870dcb38a68719f8c4686609897c4"}, - {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db0efd6bae1c4730b9c863fc4f5f3c0fa3e8f05cae2c44ae141cb9dfc7d091dc"}, - {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ab542c91f5a47aaa58abdd8ea84b498e8e49fe4b883d67800017757a3eb78e8"}, - {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:013090383863b72c62a702d07678b658fa2567aa58d373d963cca245b017e065"}, - {file = "lxml-6.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c86df1c9af35d903d2b52d22ea3e66db8058d21dc0f59842ca5deb0595921141"}, - {file = "lxml-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4337e4aec93b7c011f7ee2e357b0d30562edd1955620fdd4aeab6aacd90d43c5"}, - {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ae74f7c762270196d2dda56f8dd7309411f08a4084ff2dfcc0b095a218df2e06"}, - {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:059c4cbf3973a621b62ea3132934ae737da2c132a788e6cfb9b08d63a0ef73f9"}, - {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f090a9bc0ce8da51a5632092f98a7e7f84bca26f33d161a98b57f7fb0004ca"}, - {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9da022c14baeec36edfcc8daf0e281e2f55b950249a455776f0d1adeeada4734"}, - {file = "lxml-6.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a55da151d0b0c6ab176b4e761670ac0e2667817a1e0dadd04a01d0561a219349"}, - {file = "lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72"}, + {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388"}, + {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c"}, + {file = "lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b"}, + {file = "lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0"}, + {file = "lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5"}, + {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607"}, + {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7"}, + {file = "lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46"}, + {file = "lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078"}, + {file = "lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285"}, + {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456"}, + {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322"}, + {file = "lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849"}, + {file = "lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f"}, + {file = "lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6"}, + {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77"}, + {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314"}, + {file = "lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2"}, + {file = "lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7"}, + {file = "lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf"}, + {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe"}, + {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c"}, + {file = "lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b"}, + {file = "lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed"}, + {file = "lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8"}, + {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d"}, + {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f"}, + {file = "lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312"}, + {file = "lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca"}, + {file = "lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c"}, + {file = "lxml-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a656ca105115f6b766bba324f23a67914d9c728dafec57638e2b92a9dcd76c62"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c54d83a2188a10ebdba573f16bd97135d06c9ef60c3dc495315c7a28c80a263f"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:1ea99340b3c729beea786f78c38f60f4795622f36e305d9c9be402201efdc3b7"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af85529ae8d2a453feee4c780d9406a5e3b17cee0dd75c18bd31adcd584debc3"}, + {file = "lxml-6.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fe659f6b5d10fb5a17f00a50eb903eb277a71ee35df4615db573c069bcf967ac"}, + {file = "lxml-6.0.2-cp38-cp38-win32.whl", hash = "sha256:5921d924aa5468c939d95c9814fa9f9b5935a6ff4e679e26aaf2951f74043512"}, + {file = "lxml-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:0aa7070978f893954008ab73bb9e3c24a7c56c054e00566a21b553dc18105fca"}, + {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2c8458c2cdd29589a8367c09c8f030f1d202be673f0ca224ec18590b3b9fb694"}, + {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fee0851639d06276e6b387f1c190eb9d7f06f7f53514e966b26bae46481ec90"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2142a376b40b6736dfc214fd2902409e9e3857eff554fed2d3c60f097e62a62"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6b5b39cc7e2998f968f05309e666103b53e2edd01df8dc51b90d734c0825444"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4aec24d6b72ee457ec665344a29acb2d35937d5192faebe429ea02633151aad"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:b42f4d86b451c2f9d06ffb4f8bbc776e04df3ba070b9fe2657804b1b40277c48"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cdaefac66e8b8f30e37a9b4768a391e1f8a16a7526d5bc77a7928408ef68e93"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:b738f7e648735714bbb82bdfd030203360cfeab7f6e8a34772b3c8c8b820568c"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daf42de090d59db025af61ce6bdb2521f0f102ea0e6ea310f13c17610a97da4c"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:66328dabea70b5ba7e53d94aa774b733cf66686535f3bc9250a7aab53a91caaf"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:e237b807d68a61fc3b1e845407e27e5eb8ef69bc93fe8505337c1acb4ee300b6"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ac02dc29fd397608f8eb15ac1610ae2f2f0154b03f631e6d724d9e2ad4ee2c84"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:817ef43a0c0b4a77bd166dc9a09a555394105ff3374777ad41f453526e37f9cb"}, + {file = "lxml-6.0.2-cp39-cp39-win32.whl", hash = "sha256:bc532422ff26b304cfb62b328826bd995c96154ffd2bac4544f37dbb95ecaa8f"}, + {file = "lxml-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:995e783eb0374c120f528f807443ad5a83a656a8624c467ea73781fc5f8a8304"}, + {file = "lxml-6.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:08b9d5e803c2e4725ae9e8559ee880e5328ed61aa0935244e0515d7d9dbec0aa"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e"}, + {file = "lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62"}, ] [package.extras] @@ -1547,72 +1684,100 @@ htmlsoup = ["BeautifulSoup4"] [[package]] name = "markupsafe" -version = "3.0.2" +version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] [[package]] @@ -1760,43 +1925,49 @@ files = [ [[package]] name = "mypy" -version = "1.17.0" +version = "1.18.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" files = [ - {file = "mypy-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8e08de6138043108b3b18f09d3f817a4783912e48828ab397ecf183135d84d6"}, - {file = "mypy-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce4a17920ec144647d448fc43725b5873548b1aae6c603225626747ededf582d"}, - {file = "mypy-1.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff25d151cc057fdddb1cb1881ef36e9c41fa2a5e78d8dd71bee6e4dcd2bc05b"}, - {file = "mypy-1.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93468cf29aa9a132bceb103bd8475f78cacde2b1b9a94fd978d50d4bdf616c9a"}, - {file = "mypy-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:98189382b310f16343151f65dd7e6867386d3e35f7878c45cfa11383d175d91f"}, - {file = "mypy-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:c004135a300ab06a045c1c0d8e3f10215e71d7b4f5bb9a42ab80236364429937"}, - {file = "mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be"}, - {file = "mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61"}, - {file = "mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f"}, - {file = "mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d"}, - {file = "mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3"}, - {file = "mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70"}, - {file = "mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb"}, - {file = "mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d"}, - {file = "mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8"}, - {file = "mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e"}, - {file = "mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8"}, - {file = "mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d"}, - {file = "mypy-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c41aa59211e49d717d92b3bb1238c06d387c9325d3122085113c79118bebb06"}, - {file = "mypy-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e69db1fb65b3114f98c753e3930a00514f5b68794ba80590eb02090d54a5d4a"}, - {file = "mypy-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03ba330b76710f83d6ac500053f7727270b6b8553b0423348ffb3af6f2f7b889"}, - {file = "mypy-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037bc0f0b124ce46bfde955c647f3e395c6174476a968c0f22c95a8d2f589bba"}, - {file = "mypy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c38876106cb6132259683632b287238858bd58de267d80defb6f418e9ee50658"}, - {file = "mypy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:d30ba01c0f151998f367506fab31c2ac4527e6a7b2690107c7a7f9e3cb419a9c"}, - {file = "mypy-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:63e751f1b5ab51d6f3d219fe3a2fe4523eaa387d854ad06906c63883fde5b1ab"}, - {file = "mypy-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f7fb09d05e0f1c329a36dcd30e27564a3555717cde87301fae4fb542402ddfad"}, - {file = "mypy-1.17.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b72c34ce05ac3a1361ae2ebb50757fb6e3624032d91488d93544e9f82db0ed6c"}, - {file = "mypy-1.17.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434ad499ad8dde8b2f6391ddfa982f41cb07ccda8e3c67781b1bfd4e5f9450a8"}, - {file = "mypy-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f105f61a5eff52e137fd73bee32958b2add9d9f0a856f17314018646af838e97"}, - {file = "mypy-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:ba06254a5a22729853209550d80f94e28690d5530c661f9416a68ac097b13fc4"}, - {file = "mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496"}, - {file = "mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03"}, + {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, + {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, + {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, + {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, + {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, + {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, + {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, + {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, + {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, + {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, + {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, + {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, + {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, + {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, + {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, + {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, ] [package.dependencies] @@ -2192,13 +2363,13 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.3.8" +version = "4.4.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" files = [ - {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, - {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, ] [package.extras] @@ -2255,26 +2426,35 @@ xxhash = ["xxhash (>=1.4.3)"] [[package]] name = "psutil" -version = "7.0.0" -description = "Cross-platform lib for process and system monitoring in Python. NOTE: the syntax of this script MUST be kept compatible with Python 2.7." +version = "7.1.3" +description = "Cross-platform lib for process and system monitoring." optional = false python-versions = ">=3.6" files = [ - {file = "psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25"}, - {file = "psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993"}, - {file = "psutil-7.0.0-cp36-cp36m-win32.whl", hash = "sha256:84df4eb63e16849689f76b1ffcb36db7b8de703d1bc1fe41773db487621b6c17"}, - {file = "psutil-7.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1e744154a6580bc968a0195fd25e80432d3afec619daf145b9e5ba16cc1d688e"}, - {file = "psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99"}, - {file = "psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553"}, - {file = "psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456"}, + {file = "psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc"}, + {file = "psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0"}, + {file = "psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7"}, + {file = "psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251"}, + {file = "psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa"}, + {file = "psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee"}, + {file = "psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353"}, + {file = "psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b"}, + {file = "psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9"}, + {file = "psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f"}, + {file = "psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7"}, + {file = "psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264"}, + {file = "psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab"}, + {file = "psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880"}, + {file = "psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3"}, + {file = "psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b"}, + {file = "psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd"}, + {file = "psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1"}, + {file = "psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74"}, ] [package.extras] -dev = ["abi3audit", "black (==24.10.0)", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest", "pytest-cov", "pytest-xdist", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] -test = ["pytest", "pytest-xdist", "setuptools"] +dev = ["abi3audit", "black", "check-manifest", "colorama", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel", "wmi"] +test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32", "setuptools", "wheel", "wmi"] [[package]] name = "py7zr" @@ -2406,13 +2586,13 @@ files = [ [[package]] name = "pycparser" -version = "2.22" +version = "2.23" description = "C parser in Python" optional = false python-versions = ">=3.8" files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] [[package]] @@ -2508,12 +2688,12 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pygobject" -version = "3.52.3" +version = "3.54.5" description = "Python bindings for GObject Introspection" optional = true -python-versions = "<4.0,>=3.9" +python-versions = ">=3.9" files = [ - {file = "pygobject-3.52.3.tar.gz", hash = "sha256:00e427d291e957462a8fad659a9f9c8be776ff82a8b76bdf402f1eaeec086d82"}, + {file = "pygobject-3.54.5.tar.gz", hash = "sha256:b6656f6348f5245606cf15ea48c384c7f05156c75ead206c1b246c80a22fb585"}, ] [package.dependencies] @@ -2610,13 +2790,13 @@ test = ["coverage[toml] (>=5.2)", "hypothesis", "pytest (>=6.0)", "pytest-benchm [[package]] name = "pytest" -version = "8.4.1" +version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" files = [ - {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, - {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] @@ -2633,22 +2813,22 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-cov" -version = "6.2.1" +version = "7.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" files = [ - {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, - {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, ] [package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} +coverage = {version = ">=7.10.6", extras = ["toml"]} pluggy = ">=1.2" -pytest = ">=6.2.5" +pytest = ">=7" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-flask" @@ -2716,6 +2896,20 @@ requests = "*" [package.extras] docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +[[package]] +name = "pytokens" +version = "0.2.0" +description = "A Fast, spec compliant Python 3.13+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8"}, + {file = "pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + [[package]] name = "pyxdg" version = "0.28" @@ -2729,64 +2923,77 @@ files = [ [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] @@ -2817,105 +3024,105 @@ files = [ [[package]] name = "pyzstd" -version = "0.17.0" +version = "0.18.0" description = "Python bindings to Zstandard (zstd) compression library." optional = false python-versions = ">=3.5" files = [ - {file = "pyzstd-0.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ac857abb4c4daea71f134e74af7fe16bcfeec40911d13cf9128ddc600d46d92"}, - {file = "pyzstd-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2d84e8d1cbecd3b661febf5ca8ce12c5e112cfeb8401ceedfb84ab44365298ac"}, - {file = "pyzstd-0.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f829fa1e7daac2e45b46656bdee13923150f329e53554aeaef75cceec706dd8c"}, - {file = "pyzstd-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:994de7a13bb683c190a1b2a0fb99fe0c542126946f0345360582d7d5e8ce8cda"}, - {file = "pyzstd-0.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3eb213a22823e2155aa252d9093c62ac12d7a9d698a4b37c5613f99cb9de327"}, - {file = "pyzstd-0.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c451cfa31e70860334cc7dffe46e5178de1756642d972bc3a570fc6768673868"}, - {file = "pyzstd-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d66dc6f15249625e537ea4e5e64c195f50182556c3731f260b13c775b7888d6b"}, - {file = "pyzstd-0.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:308d4888083913fac2b7b6f4a88f67c0773d66db37e6060971c3f173cfa92d1e"}, - {file = "pyzstd-0.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a3b636f37af9de52efb7dd2d2f15deaeabdeeacf8e69c29bf3e7e731931e6d66"}, - {file = "pyzstd-0.17.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4c07391c67b496d851b18aa29ff552a552438187900965df57f64d5cf2100c40"}, - {file = "pyzstd-0.17.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e8bd12a13313ffa27347d7abe20840dcd2092852ab835a8e86008f38f11bd5ac"}, - {file = "pyzstd-0.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e27bfab45f9cdab0c336c747f493a00680a52a018a8bb7a1f787ddde4b29410"}, - {file = "pyzstd-0.17.0-cp310-cp310-win32.whl", hash = "sha256:7370c0978edfcb679419f43ec504c128463858a7ea78cf6d0538c39dfb36fce3"}, - {file = "pyzstd-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:564f7aa66cda4acd9b2a8461ff0c6a6e39a977be3e2e7317411a9f7860d7338d"}, - {file = "pyzstd-0.17.0-cp310-cp310-win_arm64.whl", hash = "sha256:fccff3a37fa4c513fe1ebf94cb9dc0369c714da22b5671f78ddcbc7ec8f581cc"}, - {file = "pyzstd-0.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06d1e7afafe86b90f3d763f83d2f6b6a437a8d75119fe1ff52b955eb9df04eaa"}, - {file = "pyzstd-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc827657f644e4510211b49f5dab6b04913216bc316206d98f9a75214361f16e"}, - {file = "pyzstd-0.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecffadaa2ee516ecea3e432ebf45348fa8c360017f03b88800dd312d62ecb063"}, - {file = "pyzstd-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:596de361948d3aad98a837c98fcee4598e51b608f7e0912e0e725f82e013f00f"}, - {file = "pyzstd-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd3a8d0389c103e93853bf794b9a35ac5d0d11ca3e7e9f87e3305a10f6dfa6b2"}, - {file = "pyzstd-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1356f72c7b8bb99b942d582b61d1a93c5065e66b6df3914dac9f2823136c3228"}, - {file = "pyzstd-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f514c339b013b0b0a2ed8ea6e44684524223bd043267d7644d7c3a70e74a0dd"}, - {file = "pyzstd-0.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4de16306821021c2d82a45454b612e2a8683d99bfb98cff51a883af9334bea0"}, - {file = "pyzstd-0.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:aeb9759c04b6a45c1b56be21efb0a738e49b0b75c4d096a38707497a7ff2be82"}, - {file = "pyzstd-0.17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a5b31ddeada0027e67464d99f09167cf08bab5f346c3c628b2d3c84e35e239a"}, - {file = "pyzstd-0.17.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8338e4e91c52af839abcf32f1f65f3b21e2597ffe411609bdbdaf10274991bd0"}, - {file = "pyzstd-0.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:628e93862feb372b4700085ec4d1d389f1283ac31900af29591ae01019910ff3"}, - {file = "pyzstd-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c27773f9c95ebc891cfcf1ef282584d38cde0a96cb8d64127953ad752592d3d7"}, - {file = "pyzstd-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:c043a5766e00a2b7844705c8fa4563b7c195987120afee8f4cf594ecddf7e9ac"}, - {file = "pyzstd-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:efd371e41153ef55bf51f97e1ce4c1c0b05ceb59ed1d8972fc9aa1e9b20a790f"}, - {file = "pyzstd-0.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ac330fc4f64f97a411b6f3fc179d2fe3050b86b79140e75a9a6dd9d6d82087f"}, - {file = "pyzstd-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:725180c0c4eb2e643b7048ebfb45ddf43585b740535907f70ff6088f5eda5096"}, - {file = "pyzstd-0.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c20fe0a60019685fa1f7137cb284f09e3f64680a503d9c0d50be4dd0a3dc5ec"}, - {file = "pyzstd-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d97f7aaadc3b6e2f8e51bfa6aa203ead9c579db36d66602382534afaf296d0db"}, - {file = "pyzstd-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42dcb34c5759b59721997036ff2d94210515d3ef47a9de84814f1c51a1e07e8a"}, - {file = "pyzstd-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6bf05e18be6f6c003c7129e2878cffd76fcbebda4e7ebd7774e34ae140426cbf"}, - {file = "pyzstd-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c40f7c3a5144aa4fbccf37c30411f6b1db4c0f2cb6ad4df470b37929bffe6ca0"}, - {file = "pyzstd-0.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9efd4007f8369fd0890701a4fc77952a0a8c4cb3bd30f362a78a1adfb3c53c12"}, - {file = "pyzstd-0.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f8add139b5fd23b95daa844ca13118197f85bd35ce7507e92fcdce66286cc34"}, - {file = "pyzstd-0.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:259a60e8ce9460367dcb4b34d8b66e44ca3d8c9c30d53ed59ae7037622b3bfc7"}, - {file = "pyzstd-0.17.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:86011a93cc3455c5d2e35988feacffbf2fa106812a48e17eb32c2a52d25a95b3"}, - {file = "pyzstd-0.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:425c31bc3de80313054e600398e4f1bd229ee61327896d5d015e2cd0283c9012"}, - {file = "pyzstd-0.17.0-cp312-cp312-win32.whl", hash = "sha256:7c4b88183bb36eb2cebbc0352e6e9fe8e2d594f15859ae1ef13b63ebc58be158"}, - {file = "pyzstd-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c31947e0120468342d74e0fa936d43f7e1dad66a2262f939735715aa6c730e8"}, - {file = "pyzstd-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:1d0346418abcef11507356a31bef5470520f6a5a786d4e2c69109408361b1020"}, - {file = "pyzstd-0.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6cd1a1d37a7abe9c01d180dad699e3ac3889e4f48ac5dcca145cc46b04e9abd2"}, - {file = "pyzstd-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a44fd596eda06b6265dc0358d5b309715a93f8e96e8a4b5292c2fe0e14575b3"}, - {file = "pyzstd-0.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a99b37453f92f0691b2454d0905bbf2f430522612f6f12bbc81133ad947eb97"}, - {file = "pyzstd-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63d864e9f9e624a466070a121ace9d9cbf579eac4ed575dee3b203ab1b3cbeee"}, - {file = "pyzstd-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e58bc02b055f96d1f83c791dd197d8c80253275a56cd84f917a006e9f528420d"}, - {file = "pyzstd-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e62df7c0ba74618481149c849bc3ed7d551b9147e1274b4b3170bbcc0bfcc0a"}, - {file = "pyzstd-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42ecdd7136294f1becb8e57441df00eaa6dfd7444a8b0c96a1dfba5c81b066e7"}, - {file = "pyzstd-0.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:be07a57af75f99fc39b8e2d35f8fb823ecd7ef099cd1f6203829a5094a991ae2"}, - {file = "pyzstd-0.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0d41e6f7ec2a70dab4982157a099562de35a6735c890945b4cebb12fb7eb0be0"}, - {file = "pyzstd-0.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f482d906426756e7cc9a43f500fee907e1b3b4e9c04d42d58fb1918c6758759b"}, - {file = "pyzstd-0.17.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:827327b35605265e1d05a2f6100244415e8f2728bb75c951736c9288415908d7"}, - {file = "pyzstd-0.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a55008f80e3390e4f37bd9353830f1675f271d13d6368d2f1dc413b7c6022b3"}, - {file = "pyzstd-0.17.0-cp313-cp313-win32.whl", hash = "sha256:a4be186c0df86d4d95091c759a06582654f2b93690503b1c24d77f537d0cf5d0"}, - {file = "pyzstd-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:251a0b599bd224ec66f39165ddb2f959d0a523938e3bbfa82d8188dc03a271a2"}, - {file = "pyzstd-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce6d5fd908fd3ddec32d1c1a5a7a15b9d7737d0ef2ab20fe1e8261da61395017"}, - {file = "pyzstd-0.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d5cb23c3c4ba4105a518cfbe8a566f9482da26f4bc8c1c865fd66e8e266be071"}, - {file = "pyzstd-0.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:10b5d9215890a24f22505b68add26beeb2e3858bbe738a7ee339f0db8e29d033"}, - {file = "pyzstd-0.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db1cff52fd24caf42a2cfb7e5d8dc822b93e9fac5dab505d0bd22e302061e2d2"}, - {file = "pyzstd-0.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3caad3106e0e80f76acbb19c15e1e834ba6fd44dd4c82719ef8e3374f7fafd3"}, - {file = "pyzstd-0.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e52e1de31b935e27568742145d8b4d0f204a1605e36f4e1e2846e0d39bed98"}, - {file = "pyzstd-0.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaa046bc9e751c4083102f3624a52bbb66e20e7aa3e28673543b22e69d9b57cd"}, - {file = "pyzstd-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cc9310bdb7cf2c70098aab40fb6bf68faaf0149110c6ef668996e7957e0147a"}, - {file = "pyzstd-0.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3619075966456783818904f9d9e213c6fe2e583d5beb545fa1968b1848781e0f"}, - {file = "pyzstd-0.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3844f8c7d7850580423b1b33601b016b3b913d18deb6fe14a7641b9c2714275c"}, - {file = "pyzstd-0.17.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab53f91280b7b639c47bb2048e01182230e7cf3f0f0980bdb405b4241cfb705e"}, - {file = "pyzstd-0.17.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:75252ee53e53a819ea7ac4271f66686018bc8b98ef12628269f099c10d881077"}, - {file = "pyzstd-0.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0795afdaa34e1ed7f3d7552100cd57a1cef9d7310b386a893e0890e9a585b427"}, - {file = "pyzstd-0.17.0-cp39-cp39-win32.whl", hash = "sha256:f7316be5a5246b6bbdd807c7a4f10382b6b02c3afc5ae6acd2e266a84f715493"}, - {file = "pyzstd-0.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:121e8fac3e24b881fed59d638100b80c34f6347c02d2f24580f633451939f2d7"}, - {file = "pyzstd-0.17.0-cp39-cp39-win_arm64.whl", hash = "sha256:fe36ccda67f73e909ac305984fe13b7b5a79296706d095a80472ada4413174c2"}, - {file = "pyzstd-0.17.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c56f99c697130f39702e07ab9fa0bb4c929c7bfe47c0a488dea732bd8a8752a"}, - {file = "pyzstd-0.17.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:152bae1b2197bcd41fc143f93acd23d474f590162547484ca04ce5874c4847de"}, - {file = "pyzstd-0.17.0-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2ddbbd7614922e52018ba3e7bb4cbe6f25b230096831d97916b8b89be8cd0cb"}, - {file = "pyzstd-0.17.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f6f3f152888825f71fd2cf2499f093fac252a5c1fa15ab8747110b3dc095f6b"}, - {file = "pyzstd-0.17.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d00a2d2bddf51c7bf32c17dc47f0f49f47ebae07c2528b9ee8abf1f318ac193"}, - {file = "pyzstd-0.17.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d79e3eff07217707a92c1a6a9841c2466bfcca4d00fea0bea968f4034c27a256"}, - {file = "pyzstd-0.17.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3ce6bac0c4c032c5200647992a8efcb9801c918633ebe11cceba946afea152d9"}, - {file = "pyzstd-0.17.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:a00998144b35be7c485a383f739fe0843a784cd96c3f1f2f53f1a249545ce49a"}, - {file = "pyzstd-0.17.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8521d7bbd00e0e1c1fd222c1369a7600fba94d24ba380618f9f75ee0c375c277"}, - {file = "pyzstd-0.17.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da65158c877eac78dcc108861d607c02fb3703195c3a177f2687e0bcdfd519d0"}, - {file = "pyzstd-0.17.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:226ca0430e2357abae1ade802585231a2959b010ec9865600e416652121ba80b"}, - {file = "pyzstd-0.17.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e3a19e8521c145a0e2cd87ca464bf83604000c5454f7e0746092834fd7de84d1"}, - {file = "pyzstd-0.17.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:56ed2de4717844ffdebb5c312ec7e7b8eb2b69eb72883bbfe472ba2c872419e6"}, - {file = "pyzstd-0.17.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc61c47ca631241081c0c99895a1feb56dab4beab37cac7d1f9f18aff06962eb"}, - {file = "pyzstd-0.17.0-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd61757a4020590dad6c20fdbf37c054ed9f349591a0d308c3c03c0303ce221"}, - {file = "pyzstd-0.17.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d6cce91a8ac8ae1aab06684a8bf0dee088405de7f451e1e89776ddc1f40074"}, - {file = "pyzstd-0.17.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc668b67a13bf6213d0a9c09edc1f4842ed680b92fc3c9361f55a904903bfd1f"}, - {file = "pyzstd-0.17.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a67d7ef18715875b31127eb90075c03ced722fd87902b34bca4b807a2ce1e4d9"}, - {file = "pyzstd-0.17.0.tar.gz", hash = "sha256:d84271f8baa66c419204c1dd115a4dec8b266f8a2921da21b81764fa208c1db6"}, + {file = "pyzstd-0.18.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:79bb84d866bf57ad2c4bc6b8247628b38e965c4f66288f887bf90f546a42ae04"}, + {file = "pyzstd-0.18.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0576c48e2f7a2c457538414a6197397c343b1bf5bfe9332b049afd0366c0c92"}, + {file = "pyzstd-0.18.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7702484795ee3c16c48a03d990123e833f1e1d6baabbe9a53256238eb04cbc"}, + {file = "pyzstd-0.18.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c412ac29a9ebb76c8c40f2df146327b460ce184bbbdaa5bc9257317dce4caa8"}, + {file = "pyzstd-0.18.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:36baae4201196c2ec6567faf4a3f19c68211efc2fca30836c885b848ed057f66"}, + {file = "pyzstd-0.18.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f6d9c8a535af243c5a19f2d66c3733595ab633e00b97237d877e70e8389edc5"}, + {file = "pyzstd-0.18.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a533550740ce8c721aae27b377fb1160df68a9f457f16015ec8e47547a033dfc"}, + {file = "pyzstd-0.18.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdd76049c8ccbb98276cfa78d807b4a497ec6bad2603361eceae993c6130e5bf"}, + {file = "pyzstd-0.18.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:09b73fe07a8d81898ef1575cb3063816168abb3305c1a9f30110383b61a4ee92"}, + {file = "pyzstd-0.18.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6baf9fd75d0af4f5d677b6e2d8dd3deb359c4ec2250c8536fe5ea48fd9305199"}, + {file = "pyzstd-0.18.0-cp310-cp310-win32.whl", hash = "sha256:c0634ab42226d2ad96c94d57fd242df2ca9417350c2969eb97c8c61d9574ba69"}, + {file = "pyzstd-0.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:ec99569321a99b9868666c85a5846151f9a16b6a222b59b2570e2ddeefd4d80c"}, + {file = "pyzstd-0.18.0-cp310-cp310-win_arm64.whl", hash = "sha256:85371149cc1d8168461981084438b9f2f139c1699e989fef44562f7504ba0632"}, + {file = "pyzstd-0.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:848914835a8a984d4c5fad2355dc66f0aca979b35ec22753c9e694be8e98403c"}, + {file = "pyzstd-0.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3938fea87fe83113b5d8ec2925bb265b4c540e374bb0ec73e5528de58d68c393"}, + {file = "pyzstd-0.18.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9af4bcde7dde46ca7e82a4c6f5fda1760bcbfd15525dbea36fe625263ef06b5e"}, + {file = "pyzstd-0.18.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d9419d173d26de25342235256aba363190e48e3fd8a8988420a26221b45320"}, + {file = "pyzstd-0.18.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b84f75f0494087afad31363e80a3463d1f32a0a6265f1a24660e6422b2b6fa6"}, + {file = "pyzstd-0.18.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cfcdf0e46020bda2e98814464ca3ae830da83937c4c61776bf8835c7094214e"}, + {file = "pyzstd-0.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8551b6bc3690fb76e730967a628b6aab0d9331c38a41f5cddb546be994771191"}, + {file = "pyzstd-0.18.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6883b47a4d5d5489890e24e74ef14c1f16dcd68bb326b86911ae0e254e33e4b7"}, + {file = "pyzstd-0.18.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929dec930296362ce03fee81877fa93a68ca4de3af75fdfa96ecbe0e366b2ee3"}, + {file = "pyzstd-0.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:278c80fdeaf857b620295cc815a31f6478fcb217d476ac889985a43b2b67e9bd"}, + {file = "pyzstd-0.18.0-cp311-cp311-win32.whl", hash = "sha256:0d1b678644894e49b5a448f02eebe0ac31bde6f51813168f5ff223d7212e1974"}, + {file = "pyzstd-0.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:8285a464aed201b166bb0d2f4667485b61b607cf89f12943b1f21f7e84cb4550"}, + {file = "pyzstd-0.18.0-cp311-cp311-win_arm64.whl", hash = "sha256:942badf996589e5ab6cbdd0f7dd33f5dc2cd7ed0b65441c96b9a12ffa7700d51"}, + {file = "pyzstd-0.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5eef13ee3e230e50c01b288d581664e8758f7b831271f6f32cfc29823a6ab365"}, + {file = "pyzstd-0.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f78d6ef80d2f355b5bc1a897e9aa58659e85170b3fa268f3211c4979c768264c"}, + {file = "pyzstd-0.18.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:394175aeeb4e2255ff5340b32f6db79375b3ffb25514fe4c1439015a7f335ec2"}, + {file = "pyzstd-0.18.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3250c551f526d3b966cf4a2199a8d9538dc5c7083b7a26a45f305f8f2ab20a06"}, + {file = "pyzstd-0.18.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a99ca80053ca37be21f05f6c4152c70777e0eface72b08277cb4b10b6d286e79"}, + {file = "pyzstd-0.18.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5dc4488536e87ff0aac698b9cd65f2913ac87417b3952d80be32463c8e95cc35"}, + {file = "pyzstd-0.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12da158f6ec1180be0a3d6f531050dfc1357a25e5d0fd8dd99d4506d2a3f448"}, + {file = "pyzstd-0.18.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f9a7d6bff36dfbe87dce1730e4b70d6ab49058a6f8ea22e85b33642491a2d053"}, + {file = "pyzstd-0.18.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0f56086bf8019f7c809a406dcc182ce0fb0d3623a9edf351ed80dbb484514613"}, + {file = "pyzstd-0.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eb69217ad9b760537e93f2d578c7927b788a9cac0e2104e536855a2797b5b09"}, + {file = "pyzstd-0.18.0-cp312-cp312-win32.whl", hash = "sha256:05ce49412c7aef970e0a6be8e9add4748bc474a7f13533a14555642022f871e9"}, + {file = "pyzstd-0.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:e951c3013b9df479cff758d578b83837b2531d02fb6c3e59166a756795697e19"}, + {file = "pyzstd-0.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:33b54781c66a86e33c93c89ae426811d0aa35a216a23116fc5d5162449284305"}, + {file = "pyzstd-0.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:65117997d1e10e9b41336c90c2c4877c8d27533f753272805ff39df15fd5298a"}, + {file = "pyzstd-0.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8550efbfb5944343666d0e79d6a3687adcbeb4dbf17aa743146a25e72d12d47f"}, + {file = "pyzstd-0.18.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac61854c4a77df66695540549a89f4c67039e4181a9158b8646425f1d56d947a"}, + {file = "pyzstd-0.18.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c453369483f67480f86d67a7b63ef22827db65e7f0d4bec7992bb81751a94b9"}, + {file = "pyzstd-0.18.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ef4b757b2df808ac15058fc2aa41e07d93843ee5a95629ff51eb6e8f1950951"}, + {file = "pyzstd-0.18.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b42529770febd331e23c5e8a68e9899acb0cc0806ee4c970354806c0ceeec6c7"}, + {file = "pyzstd-0.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7f54d13c269cdc37d2f73c9b3e70c6d2bb168dec768a472d54c2ed830bb19fb9"}, + {file = "pyzstd-0.18.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6686460ca4be536dca1b6f2f80055f383a78e92e68e03a14806428572c4fdba"}, + {file = "pyzstd-0.18.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8da3978d7de9095cacc5089bd0c435ab84ebd127e0979cd31fa1b216111644af"}, + {file = "pyzstd-0.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc87e6e50547cff97e07c3fed9999d79b6327c9c4143c3049a7cfeacb2cdba"}, + {file = "pyzstd-0.18.0-cp313-cp313-win32.whl", hash = "sha256:2dd203f2534b16dea2761394fda4e0f3c465a5109ae6450bdaada67e6ac14a45"}, + {file = "pyzstd-0.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:98f43488f88b859291d6bdc51cc7793d1eab17aa9382b17d762944bbb8567c98"}, + {file = "pyzstd-0.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:cff8922e25e19d8fbd95b53f451e637bc80e826ab53c8777a885d4e99d1c0c2d"}, + {file = "pyzstd-0.18.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:67f795ec745cfd6930cdaf5118fcdd8d87ce02b07b254d37efe75afd33ce9917"}, + {file = "pyzstd-0.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a8a589673b9b417a084e393f18d09a16b67b87a80f80da6d3b4f84dd983c9b3d"}, + {file = "pyzstd-0.18.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdaee8c33f96a6568225e821e6cc33045917628ae0bc7d8d3855332085c1aa7c"}, + {file = "pyzstd-0.18.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42bf45d8e835d7c9c0bef98ff703143a5129edf09ef6c3b757037cbf79eabcaa"}, + {file = "pyzstd-0.18.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f4dff2a15e2047baea9359d3a547dee80f61887f17e0f23190b4b932fd617e4"}, + {file = "pyzstd-0.18.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ed87932d6c534fc8921f7d44a4dadb32881e10ebc68935175a2cba254f5cc83"}, + {file = "pyzstd-0.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7d08a372b2b7fa1fd24217424e13d3d794e01299c43c8bd55f50934ef0785779"}, + {file = "pyzstd-0.18.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:e8403108172e24622f51732a336a89fe32bf3842965e0dc677c65df3a562f3ad"}, + {file = "pyzstd-0.18.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5604eeb7f00ec308b7e878dae92abfc4eee2e5d238765a62d4fadc0d57bbbff3"}, + {file = "pyzstd-0.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6b300c5240409f1e7ab9972ab2a880a1949447d8414dbc11d89c10bfcb31aa5"}, + {file = "pyzstd-0.18.0-cp314-cp314-win32.whl", hash = "sha256:83f4fe1409a59c45a5e6fccb4d451e1e3dd03a5fabebd2dd6ba651468f54025e"}, + {file = "pyzstd-0.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:73c3dcd9a16f1669ed6eef0dad1d840b7dd6070ab7d48719171ca691101e7975"}, + {file = "pyzstd-0.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:61333bbb337b9746284624ed14f6238838dfae1e395691ba49f227015374f760"}, + {file = "pyzstd-0.18.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9bccd16621016b83c2d5d40408806a841bbca2860370dca5ef0e3db005417aca"}, + {file = "pyzstd-0.18.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c7ee6747541594a5851bae720d5ab070ba9ef644df779507f35819ea61fd83fd"}, + {file = "pyzstd-0.18.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea0d70b4ec72b9d5feae4ec665ef8a4cd48f442921f2100117229c900a5a713"}, + {file = "pyzstd-0.18.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d581aeeba9a3ed13e304b0efc27efdf310b58c1e69ebb99a08e0eeea3a392310"}, + {file = "pyzstd-0.18.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d582d2fab7cc3e7606c2b09093f914e6e8b942ec52aa992a3a25d9d3ed7ba295"}, + {file = "pyzstd-0.18.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a25a72afa7d66d47a881e475ffe88d9961b36052bf6a512af3b84de22b20d41f"}, + {file = "pyzstd-0.18.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5b4feed895f32b314f2b3aa3ba6a4e0ce903c6764f31ad78e68b6c3fa31415ac"}, + {file = "pyzstd-0.18.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:20d9524adbc4efc8a1680e59cc325bc73ff56bf70bb54d233c3540efcb7bf476"}, + {file = "pyzstd-0.18.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:72c25d14217854883b571f101253d39443ea2f226f85cf3223b4d4a4d644618d"}, + {file = "pyzstd-0.18.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c335605ac7d018ca2d4d68cc0bac10e3c4ccf8e9686972dfc569a4df53f7a8d3"}, + {file = "pyzstd-0.18.0-cp39-cp39-win32.whl", hash = "sha256:64ebf9bd8065388d778c4ab6d9c4e913c00633abcfbf55236202dd0398520cc0"}, + {file = "pyzstd-0.18.0-cp39-cp39-win_amd64.whl", hash = "sha256:4a32751ac634eb685bec42935b0f6e494f018843da09596da3f2a0072ae8273b"}, + {file = "pyzstd-0.18.0-cp39-cp39-win_arm64.whl", hash = "sha256:6b64efb254fdc3c90ed4c74185beee62c24e517288aacfb3abd95c127e6f8f52"}, + {file = "pyzstd-0.18.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:35934369fcdfde6fb932f88fa441337c8ddaf4b08e7b0b12952010f0ba2082f7"}, + {file = "pyzstd-0.18.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:55b8e12c9657359a697440e88a8535d1a771025e5d8f1c3087ad69ba11bee6d2"}, + {file = "pyzstd-0.18.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134d33d3e56b5083c8f827b63254c2abf85d6ace2b323e69d28e3954b5b71883"}, + {file = "pyzstd-0.18.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6c4bffa0157ef9e5cfa32413a5a79448e5affadece4982df274f1b5aae3a680"}, + {file = "pyzstd-0.18.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8c36824d94cf77997a899b60886cc2be3ac969083f1d74eb4dd4127234ba50a4"}, + {file = "pyzstd-0.18.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:788e0889db436cd6d16a3b490006ab80a913d8ce6f46db127f1888066ff4560b"}, + {file = "pyzstd-0.18.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e70b7c36a40d7f946bf6391a206374b057299735d366fad6524d3b9f392441f"}, + {file = "pyzstd-0.18.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:571c5f71622943387370f76de8cc0de3d5c6217ab0f38386cb127665e4e09275"}, + {file = "pyzstd-0.18.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de0b730f374b583894d58b79cff76569540baf1e84bc493be191d3128b58e559"}, + {file = "pyzstd-0.18.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b32184013f33dba2fabcdda89f2a83289f5b717a0c2477cda764e53fdafec7ee"}, + {file = "pyzstd-0.18.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:27c281abfc2f13f19df92793f66e12cd0a19038ccbc02684af2a14bce664fdc4"}, + {file = "pyzstd-0.18.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7313f3a9bd2cb11158e5eaab3d5d2cd6b4582702e383a08ebb8273d0d45c3e49"}, + {file = "pyzstd-0.18.0-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec4ae014abf835bd9995ee1b318fdf4e955ffb8439838373bdc19c80d51a541"}, + {file = "pyzstd-0.18.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94c2f15f0e67acf89bec97ea276f7a5ad4e6d0267f62f12424bf044a0de280a0"}, + {file = "pyzstd-0.18.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:898e41170fde5aa73105a0262572c286bafc5f24c7b4cf131168d9b198e4c586"}, + {file = "pyzstd-0.18.0.tar.gz", hash = "sha256:81b6851ab1ca2e5f2c709e896a1362e3065a64f271f43db77fb7d5e4a78e9861"}, ] [package.dependencies] @@ -2949,109 +3156,137 @@ cffi = "*" [[package]] name = "regex" -version = "2025.7.34" +version = "2025.11.3" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.9" files = [ - {file = "regex-2025.7.34-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d856164d25e2b3b07b779bfed813eb4b6b6ce73c2fd818d46f47c1eb5cd79bd6"}, - {file = "regex-2025.7.34-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d15a9da5fad793e35fb7be74eec450d968e05d2e294f3e0e77ab03fa7234a83"}, - {file = "regex-2025.7.34-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:95b4639c77d414efa93c8de14ce3f7965a94d007e068a94f9d4997bb9bd9c81f"}, - {file = "regex-2025.7.34-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7de1ceed5a5f84f342ba4a9f4ae589524adf9744b2ee61b5da884b5b659834"}, - {file = "regex-2025.7.34-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02e5860a250cd350c4933cf376c3bc9cb28948e2c96a8bc042aee7b985cfa26f"}, - {file = "regex-2025.7.34-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a5966220b9a1a88691282b7e4350e9599cf65780ca60d914a798cb791aa1177"}, - {file = "regex-2025.7.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48fb045bbd4aab2418dc1ba2088a5e32de4bfe64e1457b948bb328a8dc2f1c2e"}, - {file = "regex-2025.7.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20ff8433fa45e131f7316594efe24d4679c5449c0ca69d91c2f9d21846fdf064"}, - {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c436fd1e95c04c19039668cfb548450a37c13f051e8659f40aed426e36b3765f"}, - {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0b85241d3cfb9f8a13cefdfbd58a2843f208f2ed2c88181bf84e22e0c7fc066d"}, - {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:075641c94126b064c65ab86e7e71fc3d63e7ff1bea1fb794f0773c97cdad3a03"}, - {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70645cad3407d103d1dbcb4841839d2946f7d36cf38acbd40120fee1682151e5"}, - {file = "regex-2025.7.34-cp310-cp310-win32.whl", hash = "sha256:3b836eb4a95526b263c2a3359308600bd95ce7848ebd3c29af0c37c4f9627cd3"}, - {file = "regex-2025.7.34-cp310-cp310-win_amd64.whl", hash = "sha256:cbfaa401d77334613cf434f723c7e8ba585df162be76474bccc53ae4e5520b3a"}, - {file = "regex-2025.7.34-cp310-cp310-win_arm64.whl", hash = "sha256:bca11d3c38a47c621769433c47f364b44e8043e0de8e482c5968b20ab90a3986"}, - {file = "regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8"}, - {file = "regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a"}, - {file = "regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68"}, - {file = "regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78"}, - {file = "regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719"}, - {file = "regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33"}, - {file = "regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083"}, - {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3"}, - {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d"}, - {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd"}, - {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a"}, - {file = "regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1"}, - {file = "regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a"}, - {file = "regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0"}, - {file = "regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50"}, - {file = "regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f"}, - {file = "regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130"}, - {file = "regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46"}, - {file = "regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4"}, - {file = "regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0"}, - {file = "regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b"}, - {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01"}, - {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77"}, - {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da"}, - {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282"}, - {file = "regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588"}, - {file = "regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62"}, - {file = "regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176"}, - {file = "regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5"}, - {file = "regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd"}, - {file = "regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b"}, - {file = "regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad"}, - {file = "regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59"}, - {file = "regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415"}, - {file = "regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f"}, - {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1"}, - {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c"}, - {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a"}, - {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0"}, - {file = "regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1"}, - {file = "regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997"}, - {file = "regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f"}, - {file = "regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a"}, - {file = "regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435"}, - {file = "regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac"}, - {file = "regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72"}, - {file = "regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e"}, - {file = "regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751"}, - {file = "regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4"}, - {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98"}, - {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7"}, - {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47"}, - {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e"}, - {file = "regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb"}, - {file = "regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae"}, - {file = "regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64"}, - {file = "regex-2025.7.34-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fd5edc3f453de727af267c7909d083e19f6426fc9dd149e332b6034f2a5611e6"}, - {file = "regex-2025.7.34-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa1cdfb8db96ef20137de5587954c812821966c3e8b48ffc871e22d7ec0a4938"}, - {file = "regex-2025.7.34-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:89c9504fc96268e8e74b0283e548f53a80c421182a2007e3365805b74ceef936"}, - {file = "regex-2025.7.34-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33be70d75fa05a904ee0dc43b650844e067d14c849df7e82ad673541cd465b5f"}, - {file = "regex-2025.7.34-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57d25b6732ea93eeb1d090e8399b6235ca84a651b52d52d272ed37d3d2efa0f1"}, - {file = "regex-2025.7.34-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:baf2fe122a3db1c0b9f161aa44463d8f7e33eeeda47bb0309923deb743a18276"}, - {file = "regex-2025.7.34-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a764a83128af9c1a54be81485b34dca488cbcacefe1e1d543ef11fbace191e1"}, - {file = "regex-2025.7.34-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7f663ccc4093877f55b51477522abd7299a14c5bb7626c5238599db6a0cb95d"}, - {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4913f52fbc7a744aaebf53acd8d3dc1b519e46ba481d4d7596de3c862e011ada"}, - {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:efac4db9e044d47fd3b6b0d40b6708f4dfa2d8131a5ac1d604064147c0f552fd"}, - {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7373afae7cfb716e3b8e15d0184510d518f9d21471f2d62918dbece85f2c588f"}, - {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9960d162f3fecf6af252534a1ae337e9c2e20d74469fed782903b24e2cc9d3d7"}, - {file = "regex-2025.7.34-cp39-cp39-win32.whl", hash = "sha256:95d538b10eb4621350a54bf14600cc80b514211d91a019dc74b8e23d2159ace5"}, - {file = "regex-2025.7.34-cp39-cp39-win_amd64.whl", hash = "sha256:f7f3071b5faa605b0ea51ec4bb3ea7257277446b053f4fd3ad02b1dcb4e64353"}, - {file = "regex-2025.7.34-cp39-cp39-win_arm64.whl", hash = "sha256:716a47515ba1d03f8e8a61c5013041c8c90f2e21f055203498105d7571b44531"}, - {file = "regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5"}, + {file = "regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec"}, + {file = "regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd"}, + {file = "regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e"}, + {file = "regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf"}, + {file = "regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a"}, + {file = "regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0"}, + {file = "regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204"}, + {file = "regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9"}, + {file = "regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7"}, + {file = "regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c"}, + {file = "regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5"}, + {file = "regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2"}, + {file = "regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a"}, + {file = "regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c"}, + {file = "regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed"}, + {file = "regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4"}, + {file = "regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad"}, + {file = "regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379"}, + {file = "regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38"}, + {file = "regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de"}, + {file = "regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:81519e25707fc076978c6143b81ea3dc853f176895af05bf7ec51effe818aeec"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3bf28b1873a8af8bbb58c26cc56ea6e534d80053b41fb511a35795b6de507e6a"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:856a25c73b697f2ce2a24e7968285579e62577a048526161a2c0f53090bea9f9"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a3d571bd95fade53c86c0517f859477ff3a93c3fde10c9e669086f038e0f207"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:732aea6de26051af97b94bc98ed86448821f839d058e5d259c72bf6d73ad0fc0"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51c1c1847128238f54930edb8805b660305dca164645a9fd29243f5610beea34"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22dd622a402aad4558277305350699b2be14bc59f64d64ae1d928ce7d072dced"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f3b5a391c7597ffa96b41bd5cbd2ed0305f515fcbb367dfa72735679d5502364"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cc4076a5b4f36d849fd709284b4a3b112326652f3b0466f04002a6c15a0c96c1"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a295ca2bba5c1c885826ce3125fa0b9f702a1be547d821c01d65f199e10c01e2"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b4774ff32f18e0504bfc4e59a3e71e18d83bc1e171a3c8ed75013958a03b2f14"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e7d1cdfa88ef33a2ae6aa0d707f9255eb286ffbd90045f1088246833223aee"}, + {file = "regex-2025.11.3-cp39-cp39-win32.whl", hash = "sha256:74d04244852ff73b32eeede4f76f51c5bcf44bc3c207bc3e6cf1c5c45b890708"}, + {file = "regex-2025.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:7a50cd39f73faa34ec18d6720ee25ef10c4c1839514186fcda658a06c06057a2"}, + {file = "regex-2025.11.3-cp39-cp39-win_arm64.whl", hash = "sha256:43b4fb020e779ca81c1b5255015fe2b82816c76ec982354534ad9ec09ad7c9e3"}, + {file = "regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01"}, ] [[package]] name = "requests" -version = "2.32.4" +version = "2.32.5" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] [package.dependencies] @@ -3121,13 +3356,13 @@ tests = ["pytest (<8)", "pytest-cov", "scipy (>=1.1)"] [[package]] name = "responses" -version = "0.25.7" +version = "0.25.8" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" files = [ - {file = "responses-0.25.7-py3-none-any.whl", hash = "sha256:92ca17416c90fe6b35921f52179bff29332076bb32694c0df02dcac2c6bc043c"}, - {file = "responses-0.25.7.tar.gz", hash = "sha256:8ebae11405d7a5df79ab6fd54277f6f2bc29b2d002d0dd2d5c632594d1ddcedb"}, + {file = "responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c"}, + {file = "responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4"}, ] [package.dependencies] @@ -3151,29 +3386,30 @@ files = [ [[package]] name = "ruff" -version = "0.12.3" +version = "0.14.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2"}, - {file = "ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041"}, - {file = "ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311"}, - {file = "ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07"}, - {file = "ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12"}, - {file = "ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b"}, - {file = "ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f"}, - {file = "ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d"}, - {file = "ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7"}, - {file = "ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1"}, - {file = "ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77"}, + {file = "ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371"}, + {file = "ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654"}, + {file = "ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14"}, + {file = "ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed"}, + {file = "ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc"}, + {file = "ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd"}, + {file = "ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb"}, + {file = "ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20"}, + {file = "ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0"}, + {file = "ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e"}, + {file = "ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5"}, + {file = "ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e"}, + {file = "ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e"}, + {file = "ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa"}, + {file = "ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f"}, + {file = "ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7"}, + {file = "ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f"}, + {file = "ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1"}, + {file = "ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153"}, ] [[package]] @@ -3274,72 +3510,72 @@ test = ["array-api-strict", "asv", "gmpy2", "hypothesis (>=6.30)", "mpmath", "po [[package]] name = "scipy" -version = "1.16.2" +version = "1.16.3" description = "Fundamental algorithms for scientific computing in Python" optional = true python-versions = ">=3.11" files = [ - {file = "scipy-1.16.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6ab88ea43a57da1af33292ebd04b417e8e2eaf9d5aa05700be8d6e1b6501cd92"}, - {file = "scipy-1.16.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c95e96c7305c96ede73a7389f46ccd6c659c4da5ef1b2789466baeaed3622b6e"}, - {file = "scipy-1.16.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:87eb178db04ece7c698220d523c170125dbffebb7af0345e66c3554f6f60c173"}, - {file = "scipy-1.16.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:4e409eac067dcee96a57fbcf424c13f428037827ec7ee3cb671ff525ca4fc34d"}, - {file = "scipy-1.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e574be127bb760f0dad24ff6e217c80213d153058372362ccb9555a10fc5e8d2"}, - {file = "scipy-1.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5db5ba6188d698ba7abab982ad6973265b74bb40a1efe1821b58c87f73892b9"}, - {file = "scipy-1.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec6e74c4e884104ae006d34110677bfe0098203a3fec2f3faf349f4cb05165e3"}, - {file = "scipy-1.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:912f46667d2d3834bc3d57361f854226475f695eb08c08a904aadb1c936b6a88"}, - {file = "scipy-1.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:91e9e8a37befa5a69e9cacbe0bcb79ae5afb4a0b130fd6db6ee6cc0d491695fa"}, - {file = "scipy-1.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:f3bf75a6dcecab62afde4d1f973f1692be013110cad5338007927db8da73249c"}, - {file = "scipy-1.16.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:89d6c100fa5c48472047632e06f0876b3c4931aac1f4291afc81a3644316bb0d"}, - {file = "scipy-1.16.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ca748936cd579d3f01928b30a17dc474550b01272d8046e3e1ee593f23620371"}, - {file = "scipy-1.16.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:fac4f8ce2ddb40e2e3d0f7ec36d2a1e7f92559a2471e59aec37bd8d9de01fec0"}, - {file = "scipy-1.16.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:033570f1dcefd79547a88e18bccacff025c8c647a330381064f561d43b821232"}, - {file = "scipy-1.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea3421209bf00c8a5ef2227de496601087d8f638a2363ee09af059bd70976dc1"}, - {file = "scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f66bd07ba6f84cd4a380b41d1bf3c59ea488b590a2ff96744845163309ee8e2f"}, - {file = "scipy-1.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e9feab931bd2aea4a23388c962df6468af3d808ddf2d40f94a81c5dc38f32ef"}, - {file = "scipy-1.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03dfc75e52f72cf23ec2ced468645321407faad8f0fe7b1f5b49264adbc29cb1"}, - {file = "scipy-1.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:0ce54e07bbb394b417457409a64fd015be623f36e330ac49306433ffe04bc97e"}, - {file = "scipy-1.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a8ffaa4ac0df81a0b94577b18ee079f13fecdb924df3328fc44a7dc5ac46851"}, - {file = "scipy-1.16.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:84f7bf944b43e20b8a894f5fe593976926744f6c185bacfcbdfbb62736b5cc70"}, - {file = "scipy-1.16.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5c39026d12edc826a1ef2ad35ad1e6d7f087f934bb868fc43fa3049c8b8508f9"}, - {file = "scipy-1.16.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e52729ffd45b68777c5319560014d6fd251294200625d9d70fd8626516fc49f5"}, - {file = "scipy-1.16.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:024dd4a118cccec09ca3209b7e8e614931a6ffb804b2a601839499cb88bdf925"}, - {file = "scipy-1.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a5dc7ee9c33019973a470556081b0fd3c9f4c44019191039f9769183141a4d9"}, - {file = "scipy-1.16.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c2275ff105e508942f99d4e3bc56b6ef5e4b3c0af970386ca56b777608ce95b7"}, - {file = "scipy-1.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af80196eaa84f033e48444d2e0786ec47d328ba00c71e4299b602235ffef9acb"}, - {file = "scipy-1.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9fb1eb735fe3d6ed1f89918224e3385fbf6f9e23757cacc35f9c78d3b712dd6e"}, - {file = "scipy-1.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:fda714cf45ba43c9d3bae8f2585c777f64e3f89a2e073b668b32ede412d8f52c"}, - {file = "scipy-1.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:2f5350da923ccfd0b00e07c3e5cfb316c1c0d6c1d864c07a72d092e9f20db104"}, - {file = "scipy-1.16.2-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:53d8d2ee29b925344c13bda64ab51785f016b1b9617849dac10897f0701b20c1"}, - {file = "scipy-1.16.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:9e05e33657efb4c6a9d23bd8300101536abd99c85cca82da0bffff8d8764d08a"}, - {file = "scipy-1.16.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:7fe65b36036357003b3ef9d37547abeefaa353b237e989c21027b8ed62b12d4f"}, - {file = "scipy-1.16.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6406d2ac6d40b861cccf57f49592f9779071655e9f75cd4f977fa0bdd09cb2e4"}, - {file = "scipy-1.16.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff4dc42bd321991fbf611c23fc35912d690f731c9914bf3af8f417e64aca0f21"}, - {file = "scipy-1.16.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:654324826654d4d9133e10675325708fb954bc84dae6e9ad0a52e75c6b1a01d7"}, - {file = "scipy-1.16.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63870a84cd15c44e65220eaed2dac0e8f8b26bbb991456a033c1d9abfe8a94f8"}, - {file = "scipy-1.16.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fa01f0f6a3050fa6a9771a95d5faccc8e2f5a92b4a2e5440a0fa7264a2398472"}, - {file = "scipy-1.16.2-cp313-cp313t-win_amd64.whl", hash = "sha256:116296e89fba96f76353a8579820c2512f6e55835d3fad7780fece04367de351"}, - {file = "scipy-1.16.2-cp313-cp313t-win_arm64.whl", hash = "sha256:98e22834650be81d42982360382b43b17f7ba95e0e6993e2a4f5b9ad9283a94d"}, - {file = "scipy-1.16.2-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:567e77755019bb7461513c87f02bb73fb65b11f049aaaa8ca17cfaa5a5c45d77"}, - {file = "scipy-1.16.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:17d9bb346194e8967296621208fcdfd39b55498ef7d2f376884d5ac47cec1a70"}, - {file = "scipy-1.16.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0a17541827a9b78b777d33b623a6dcfe2ef4a25806204d08ead0768f4e529a88"}, - {file = "scipy-1.16.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:d7d4c6ba016ffc0f9568d012f5f1eb77ddd99412aea121e6fa8b4c3b7cbad91f"}, - {file = "scipy-1.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9702c4c023227785c779cba2e1d6f7635dbb5b2e0936cdd3a4ecb98d78fd41eb"}, - {file = "scipy-1.16.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1cdf0ac28948d225decdefcc45ad7dd91716c29ab56ef32f8e0d50657dffcc7"}, - {file = "scipy-1.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70327d6aa572a17c2941cdfb20673f82e536e91850a2e4cb0c5b858b690e1548"}, - {file = "scipy-1.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5221c0b2a4b58aa7c4ed0387d360fd90ee9086d383bb34d9f2789fafddc8a936"}, - {file = "scipy-1.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:f5a85d7b2b708025af08f060a496dd261055b617d776fc05a1a1cc69e09fe9ff"}, - {file = "scipy-1.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:2cc73a33305b4b24556957d5857d6253ce1e2dcd67fa0ff46d87d1670b3e1e1d"}, - {file = "scipy-1.16.2-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:9ea2a3fed83065d77367775d689401a703d0f697420719ee10c0780bcab594d8"}, - {file = "scipy-1.16.2-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7280d926f11ca945c3ef92ba960fa924e1465f8d07ce3a9923080363390624c4"}, - {file = "scipy-1.16.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8afae1756f6a1fe04636407ef7dbece33d826a5d462b74f3d0eb82deabefd831"}, - {file = "scipy-1.16.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:5c66511f29aa8d233388e7416a3f20d5cae7a2744d5cee2ecd38c081f4e861b3"}, - {file = "scipy-1.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efe6305aeaa0e96b0ccca5ff647a43737d9a092064a3894e46c414db84bc54ac"}, - {file = "scipy-1.16.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f3a337d9ae06a1e8d655ee9d8ecb835ea5ddcdcbd8d23012afa055ab014f374"}, - {file = "scipy-1.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bab3605795d269067d8ce78a910220262711b753de8913d3deeaedb5dded3bb6"}, - {file = "scipy-1.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b0348d8ddb55be2a844c518cd8cc8deeeb8aeba707cf834db5758fc89b476a2c"}, - {file = "scipy-1.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:26284797e38b8a75e14ea6631d29bda11e76ceaa6ddb6fdebbfe4c4d90faf2f9"}, - {file = "scipy-1.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d2a4472c231328d4de38d5f1f68fdd6d28a615138f842580a8a321b5845cf779"}, - {file = "scipy-1.16.2.tar.gz", hash = "sha256:af029b153d243a80afb6eabe40b0a07f8e35c9adc269c019f364ad747f826a6b"}, + {file = "scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97"}, + {file = "scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511"}, + {file = "scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005"}, + {file = "scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb"}, + {file = "scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876"}, + {file = "scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2"}, + {file = "scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e"}, + {file = "scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733"}, + {file = "scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78"}, + {file = "scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184"}, + {file = "scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6"}, + {file = "scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07"}, + {file = "scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9"}, + {file = "scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686"}, + {file = "scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203"}, + {file = "scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1"}, + {file = "scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe"}, + {file = "scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70"}, + {file = "scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc"}, + {file = "scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2"}, + {file = "scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c"}, + {file = "scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d"}, + {file = "scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9"}, + {file = "scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4"}, + {file = "scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959"}, + {file = "scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88"}, + {file = "scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234"}, + {file = "scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d"}, + {file = "scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304"}, + {file = "scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2"}, + {file = "scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b"}, + {file = "scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079"}, + {file = "scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a"}, + {file = "scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119"}, + {file = "scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c"}, + {file = "scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e"}, + {file = "scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135"}, + {file = "scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6"}, + {file = "scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc"}, + {file = "scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a"}, + {file = "scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6"}, + {file = "scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657"}, + {file = "scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26"}, + {file = "scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc"}, + {file = "scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22"}, + {file = "scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc"}, + {file = "scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0"}, + {file = "scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800"}, + {file = "scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d"}, + {file = "scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f"}, + {file = "scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c"}, + {file = "scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40"}, + {file = "scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d"}, + {file = "scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa"}, + {file = "scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8"}, + {file = "scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353"}, + {file = "scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146"}, + {file = "scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d"}, + {file = "scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7"}, + {file = "scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562"}, + {file = "scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb"}, ] [package.dependencies] @@ -3385,13 +3621,13 @@ files = [ [[package]] name = "soco" -version = "0.30.10" +version = "0.30.12" description = "SoCo (Sonos Controller) is a simple library to control Sonos speakers." optional = true python-versions = ">=3.6" files = [ - {file = "soco-0.30.10-py2.py3-none-any.whl", hash = "sha256:f62ea676e4457223a8fc5192ffe91f795f6a4a18da8aa686ef20ce6657056a0f"}, - {file = "soco-0.30.10.tar.gz", hash = "sha256:a9c8ddb53836d18a0bbb881224cc6818e1ef1b28791637378ab25ff1eb1a87c3"}, + {file = "soco-0.30.12-py2.py3-none-any.whl", hash = "sha256:bb6c3bc7d5dd25cce77f76ff2da4df6dc17e387ff7c713f60205092f43da8766"}, + {file = "soco-0.30.12.tar.gz", hash = "sha256:9c5ee8191e0fbb2c79b8992931a2d38fb4360097a190d0aca20fef330138af51"}, ] [package.dependencies] @@ -3428,13 +3664,13 @@ numpy = "*" [[package]] name = "soupsieve" -version = "2.7" +version = "2.8" description = "A modern CSS selector implementation for Beautiful Soup." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4"}, - {file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"}, + {file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"}, + {file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"}, ] [[package]] @@ -3759,43 +3995,53 @@ files = [ [[package]] name = "tomli" -version = "2.2.1" +version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] [[package]] @@ -3825,13 +4071,13 @@ files = [ [[package]] name = "types-flask-cors" -version = "6.0.0.20250520" +version = "6.0.0.20250809" description = "Typing stubs for Flask-Cors" optional = false python-versions = ">=3.9" files = [ - {file = "types_flask_cors-6.0.0.20250520-py3-none-any.whl", hash = "sha256:8898ed43a6b68d0b3b499e1d2f7aa696a99a001610de44e09fc6f404d16eb704"}, - {file = "types_flask_cors-6.0.0.20250520.tar.gz", hash = "sha256:9357c21be733f65e568ff27e816426832f3e3fd906eedbb896bcc6b1cfa026e6"}, + {file = "types_flask_cors-6.0.0.20250809-py3-none-any.whl", hash = "sha256:f6d660dddab946779f4263cb561bffe275d86cb8747ce02e9fec8d340780131b"}, + {file = "types_flask_cors-6.0.0.20250809.tar.gz", hash = "sha256:24380a2b82548634c0931d50b9aafab214eea9f85dcc04f15ab1518752a7e6aa"}, ] [package.dependencies] @@ -3839,24 +4085,24 @@ Flask = ">=2.0.0" [[package]] name = "types-html5lib" -version = "1.1.11.20250708" +version = "1.1.11.20251014" description = "Typing stubs for html5lib" optional = false python-versions = ">=3.9" files = [ - {file = "types_html5lib-1.1.11.20250708-py3-none-any.whl", hash = "sha256:bb898066b155de7081cb182179e2ded31b9e0e234605e2cb46536894e68a6954"}, - {file = "types_html5lib-1.1.11.20250708.tar.gz", hash = "sha256:24321720fdbac71cee50d5a4bec9b7448495b7217974cffe3fcf1ede4eef7afe"}, + {file = "types_html5lib-1.1.11.20251014-py3-none-any.whl", hash = "sha256:4ff2cf18dfc547009ab6fa4190fc3de464ba815c9090c3dd4a5b65f664bfa76c"}, + {file = "types_html5lib-1.1.11.20251014.tar.gz", hash = "sha256:cc628d626e0111a2426a64f5f061ecfd113958b69ff6b3dc0eaaed2347ba9455"}, ] [[package]] name = "types-mock" -version = "5.2.0.20250516" +version = "5.2.0.20250924" description = "Typing stubs for mock" optional = false python-versions = ">=3.9" files = [ - {file = "types_mock-5.2.0.20250516-py3-none-any.whl", hash = "sha256:e50fbd0c3be8bcea25c30a47fac0b7a6ca22f630ef2f53416a73b319b39dfde1"}, - {file = "types_mock-5.2.0.20250516.tar.gz", hash = "sha256:aab7d3d9ad3814f2f8da12cc8e42d9be7d38200c5f214e3c0278c38fa01299d7"}, + {file = "types_mock-5.2.0.20250924-py3-none-any.whl", hash = "sha256:23617ffb4cf948c085db69ec90bd474afbce634ef74995045ae0a5748afbe57d"}, + {file = "types_mock-5.2.0.20250924.tar.gz", hash = "sha256:953197543b4183f00363e8e626f6c7abea1a3f7a4dd69d199addb70b01b6bb35"}, ] [[package]] @@ -3872,24 +4118,24 @@ files = [ [[package]] name = "types-pyyaml" -version = "6.0.12.20250516" +version = "6.0.12.20250915" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.9" files = [ - {file = "types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530"}, - {file = "types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba"}, + {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, + {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, ] [[package]] name = "types-requests" -version = "2.32.4.20250611" +version = "2.32.4.20250913" description = "Typing stubs for requests" optional = false python-versions = ">=3.9" files = [ - {file = "types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072"}, - {file = "types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826"}, + {file = "types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1"}, + {file = "types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d"}, ] [package.dependencies] @@ -3964,15 +4210,18 @@ watchdog = ["watchdog (>=2.3)"] [[package]] name = "xmltodict" -version = "0.14.2" +version = "1.0.2" description = "Makes working with XML feel like you are working with JSON" optional = true -python-versions = ">=3.6" +python-versions = ">=3.9" files = [ - {file = "xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac"}, - {file = "xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553"}, + {file = "xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d"}, + {file = "xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649"}, ] +[package.extras] +test = ["pytest", "pytest-cov"] + [[package]] name = "zipp" version = "3.23.0" From a7830bebae459dda4a4ec8c9eb0b5b122dd63434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 5 Nov 2025 09:11:27 +0000 Subject: [PATCH 056/274] Update python requirement and dependencies --- poetry.lock | 869 ++++++++++++++++++++++--------------------------- pyproject.toml | 3 +- 2 files changed, 383 insertions(+), 489 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0ac2434f2..9426ad659 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,13 +20,13 @@ tests = ["hypothesis", "pytest"] [[package]] name = "alabaster" -version = "0.7.16" +version = "1.0.0" description = "A light, configurable Sphinx theme" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, - {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, + {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, + {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, ] [[package]] @@ -636,13 +636,13 @@ files = [ [[package]] name = "click" -version = "8.1.8" +version = "8.3.0" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, + {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, + {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, ] [package.dependencies] @@ -690,115 +690,103 @@ pyyaml = "*" [[package]] name = "coverage" -version = "7.10.7" +version = "7.11.0" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, - {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, - {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, - {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, - {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, - {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, - {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, - {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, - {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, - {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, - {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, - {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, - {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, - {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, - {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, - {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, - {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, - {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, - {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, - {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, - {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, - {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, - {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, - {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, - {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, - {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, - {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, - {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, - {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, - {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, - {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, - {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, - {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, - {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, - {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, - {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, - {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, - {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, - {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, - {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, + {file = "coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31"}, + {file = "coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075"}, + {file = "coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab"}, + {file = "coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0"}, + {file = "coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785"}, + {file = "coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591"}, + {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088"}, + {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f"}, + {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866"}, + {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841"}, + {file = "coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf"}, + {file = "coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969"}, + {file = "coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847"}, + {file = "coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc"}, + {file = "coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0"}, + {file = "coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7"}, + {file = "coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623"}, + {file = "coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287"}, + {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552"}, + {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de"}, + {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601"}, + {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e"}, + {file = "coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c"}, + {file = "coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9"}, + {file = "coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745"}, + {file = "coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1"}, + {file = "coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007"}, + {file = "coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46"}, + {file = "coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893"}, + {file = "coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115"}, + {file = "coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415"}, + {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186"}, + {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d"}, + {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d"}, + {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2"}, + {file = "coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5"}, + {file = "coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0"}, + {file = "coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad"}, + {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, + {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, + {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, + {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, + {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, + {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, + {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, + {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, + {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, + {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, + {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, + {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, + {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, + {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, + {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, + {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, ] [package.dependencies] @@ -914,7 +902,6 @@ files = [ [package.dependencies] blinker = ">=1.9.0" click = ">=8.1.3" -importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} itsdangerous = ">=2.2.0" jinja2 = ">=3.1.2" markupsafe = ">=2.1.1" @@ -1031,29 +1018,6 @@ files = [ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - [[package]] name = "inflate64" version = "1.0.1" @@ -1111,13 +1075,13 @@ test = ["pytest"] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] [[package]] @@ -1432,7 +1396,6 @@ pyyaml = [ {version = ">=6.0.3", markers = "python_version >= \"3.14\""}, ] pyyaml-ft = {version = ">=8.0.0", markers = "python_version == \"3.13\""} -typing-extensions = {version = "*", markers = "python_version < \"3.10\""} [[package]] name = "librosa" @@ -1467,36 +1430,6 @@ display = ["matplotlib (>=3.5.0)"] docs = ["ipython (>=7.0)", "matplotlib (>=3.5.0)", "mir_eval (>=0.5)", "numba (>=0.51)", "numpydoc", "presets", "sphinx (!=1.3.1)", "sphinx-copybutton (>=0.5.2)", "sphinx-gallery (>=0.7)", "sphinx-multiversion (>=0.2.3)", "sphinx_rtd_theme (>=1.2.0)", "sphinxcontrib-googleanalytics (>=0.4)", "sphinxcontrib-svg2pdfconverter"] tests = ["matplotlib (>=3.5.0)", "packaging (>=20.0)", "pytest", "pytest-cov", "pytest-mpl", "resampy (>=0.2.2)", "samplerate", "types-decorator"] -[[package]] -name = "llvmlite" -version = "0.43.0" -description = "lightweight wrapper around basic LLVM functionality" -optional = true -python-versions = ">=3.9" -files = [ - {file = "llvmlite-0.43.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a289af9a1687c6cf463478f0fa8e8aa3b6fb813317b0d70bf1ed0759eab6f761"}, - {file = "llvmlite-0.43.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d4fd101f571a31acb1559ae1af30f30b1dc4b3186669f92ad780e17c81e91bc"}, - {file = "llvmlite-0.43.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d434ec7e2ce3cc8f452d1cd9a28591745de022f931d67be688a737320dfcead"}, - {file = "llvmlite-0.43.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6912a87782acdff6eb8bf01675ed01d60ca1f2551f8176a300a886f09e836a6a"}, - {file = "llvmlite-0.43.0-cp310-cp310-win_amd64.whl", hash = "sha256:14f0e4bf2fd2d9a75a3534111e8ebeb08eda2f33e9bdd6dfa13282afacdde0ed"}, - {file = "llvmlite-0.43.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e8d0618cb9bfe40ac38a9633f2493d4d4e9fcc2f438d39a4e854f39cc0f5f98"}, - {file = "llvmlite-0.43.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0a9a1a39d4bf3517f2af9d23d479b4175ead205c592ceeb8b89af48a327ea57"}, - {file = "llvmlite-0.43.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1da416ab53e4f7f3bc8d4eeba36d801cc1894b9fbfbf2022b29b6bad34a7df2"}, - {file = "llvmlite-0.43.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977525a1e5f4059316b183fb4fd34fa858c9eade31f165427a3977c95e3ee749"}, - {file = "llvmlite-0.43.0-cp311-cp311-win_amd64.whl", hash = "sha256:d5bd550001d26450bd90777736c69d68c487d17bf371438f975229b2b8241a91"}, - {file = "llvmlite-0.43.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f99b600aa7f65235a5a05d0b9a9f31150c390f31261f2a0ba678e26823ec38f7"}, - {file = "llvmlite-0.43.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:35d80d61d0cda2d767f72de99450766250560399edc309da16937b93d3b676e7"}, - {file = "llvmlite-0.43.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eccce86bba940bae0d8d48ed925f21dbb813519169246e2ab292b5092aba121f"}, - {file = "llvmlite-0.43.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df6509e1507ca0760787a199d19439cc887bfd82226f5af746d6977bd9f66844"}, - {file = "llvmlite-0.43.0-cp312-cp312-win_amd64.whl", hash = "sha256:7a2872ee80dcf6b5dbdc838763d26554c2a18aa833d31a2635bff16aafefb9c9"}, - {file = "llvmlite-0.43.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cd2a7376f7b3367019b664c21f0c61766219faa3b03731113ead75107f3b66c"}, - {file = "llvmlite-0.43.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:18e9953c748b105668487b7c81a3e97b046d8abf95c4ddc0cd3c94f4e4651ae8"}, - {file = "llvmlite-0.43.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74937acd22dc11b33946b67dca7680e6d103d6e90eeaaaf932603bec6fe7b03a"}, - {file = "llvmlite-0.43.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc9efc739cc6ed760f795806f67889923f7274276f0eb45092a1473e40d9b867"}, - {file = "llvmlite-0.43.0-cp39-cp39-win_amd64.whl", hash = "sha256:47e147cdda9037f94b399bf03bfd8a6b6b1f2f90be94a454e3386f006455a9b4"}, - {file = "llvmlite-0.43.0.tar.gz", hash = "sha256:ae2b5b5c3ef67354824fb75517c8db5fbe93bc02cd9671f3c62271626bc041d5"}, -] - [[package]] name = "llvmlite" version = "0.45.1" @@ -1994,40 +1927,6 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] -[[package]] -name = "numba" -version = "0.60.0" -description = "compiling Python code using LLVM" -optional = true -python-versions = ">=3.9" -files = [ - {file = "numba-0.60.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d761de835cd38fb400d2c26bb103a2726f548dc30368853121d66201672e651"}, - {file = "numba-0.60.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:159e618ef213fba758837f9837fb402bbe65326e60ba0633dbe6c7f274d42c1b"}, - {file = "numba-0.60.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1527dc578b95c7c4ff248792ec33d097ba6bef9eda466c948b68dfc995c25781"}, - {file = "numba-0.60.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe0b28abb8d70f8160798f4de9d486143200f34458d34c4a214114e445d7124e"}, - {file = "numba-0.60.0-cp310-cp310-win_amd64.whl", hash = "sha256:19407ced081d7e2e4b8d8c36aa57b7452e0283871c296e12d798852bc7d7f198"}, - {file = "numba-0.60.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a17b70fc9e380ee29c42717e8cc0bfaa5556c416d94f9aa96ba13acb41bdece8"}, - {file = "numba-0.60.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fb02b344a2a80efa6f677aa5c40cd5dd452e1b35f8d1c2af0dfd9ada9978e4b"}, - {file = "numba-0.60.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f4fde652ea604ea3c86508a3fb31556a6157b2c76c8b51b1d45eb40c8598703"}, - {file = "numba-0.60.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4142d7ac0210cc86432b818338a2bc368dc773a2f5cf1e32ff7c5b378bd63ee8"}, - {file = "numba-0.60.0-cp311-cp311-win_amd64.whl", hash = "sha256:cac02c041e9b5bc8cf8f2034ff6f0dbafccd1ae9590dc146b3a02a45e53af4e2"}, - {file = "numba-0.60.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7da4098db31182fc5ffe4bc42c6f24cd7d1cb8a14b59fd755bfee32e34b8404"}, - {file = "numba-0.60.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38d6ea4c1f56417076ecf8fc327c831ae793282e0ff51080c5094cb726507b1c"}, - {file = "numba-0.60.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:62908d29fb6a3229c242e981ca27e32a6e606cc253fc9e8faeb0e48760de241e"}, - {file = "numba-0.60.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0ebaa91538e996f708f1ab30ef4d3ddc344b64b5227b67a57aa74f401bb68b9d"}, - {file = "numba-0.60.0-cp312-cp312-win_amd64.whl", hash = "sha256:f75262e8fe7fa96db1dca93d53a194a38c46da28b112b8a4aca168f0df860347"}, - {file = "numba-0.60.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:01ef4cd7d83abe087d644eaa3d95831b777aa21d441a23703d649e06b8e06b74"}, - {file = "numba-0.60.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:819a3dfd4630d95fd574036f99e47212a1af41cbcb019bf8afac63ff56834449"}, - {file = "numba-0.60.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b983bd6ad82fe868493012487f34eae8bf7dd94654951404114f23c3466d34b"}, - {file = "numba-0.60.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c151748cd269ddeab66334bd754817ffc0cabd9433acb0f551697e5151917d25"}, - {file = "numba-0.60.0-cp39-cp39-win_amd64.whl", hash = "sha256:3031547a015710140e8c87226b4cfe927cac199835e5bf7d4fe5cb64e814e3ab"}, - {file = "numba-0.60.0.tar.gz", hash = "sha256:5df6158e5584eece5fc83294b949fd30b9f1125df7708862205217e068aabf16"}, -] - -[package.dependencies] -llvmlite = "==0.43.*" -numpy = ">=1.22,<2.1" - [[package]] name = "numba" version = "0.62.1" @@ -2064,56 +1963,66 @@ numpy = ">=1.22,<2.4" [[package]] name = "numpy" -version = "2.0.2" +version = "2.2.6" description = "Fundamental package for array computing in Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"}, - {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"}, - {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"}, - {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"}, - {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"}, - {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"}, - {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"}, - {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"}, - {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"}, - {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"}, - {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"}, - {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"}, - {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"}, - {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"}, - {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"}, - {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"}, - {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"}, - {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"}, - {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"}, - {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"}, - {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"}, - {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"}, - {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"}, - {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"}, - {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"}, - {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, ] [[package]] @@ -2239,143 +2148,127 @@ files = [ [[package]] name = "pillow" -version = "11.3.0" -description = "Python Imaging Library (Fork)" +version = "12.0.0" +description = "Python Imaging Library (fork)" optional = true -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, - {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, - {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, - {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, - {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, - {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, - {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, - {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, - {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, - {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, - {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, - {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, - {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, - {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, - {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, - {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, - {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, - {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, - {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, - {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, - {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, - {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, - {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, - {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, - {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, - {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, - {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, - {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, - {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, - {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, - {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, - {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, - {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, - {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, - {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, - {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, - {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, - {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, - {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, - {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, - {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, - {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, - {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, - {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, - {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, - {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, - {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, - {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, - {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, - {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, - {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, - {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, - {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, - {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, - {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, - {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, - {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, + {file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"}, + {file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"}, + {file = "pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363"}, + {file = "pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca"}, + {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e"}, + {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782"}, + {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10"}, + {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa"}, + {file = "pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275"}, + {file = "pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d"}, + {file = "pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7"}, + {file = "pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc"}, + {file = "pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257"}, + {file = "pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642"}, + {file = "pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3"}, + {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c"}, + {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227"}, + {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b"}, + {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e"}, + {file = "pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739"}, + {file = "pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e"}, + {file = "pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d"}, + {file = "pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371"}, + {file = "pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082"}, + {file = "pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f"}, + {file = "pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d"}, + {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953"}, + {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8"}, + {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79"}, + {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba"}, + {file = "pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0"}, + {file = "pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a"}, + {file = "pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad"}, + {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643"}, + {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4"}, + {file = "pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399"}, + {file = "pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5"}, + {file = "pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b"}, + {file = "pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3"}, + {file = "pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07"}, + {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e"}, + {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344"}, + {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27"}, + {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79"}, + {file = "pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098"}, + {file = "pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905"}, + {file = "pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a"}, + {file = "pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3"}, + {file = "pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced"}, + {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b"}, + {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d"}, + {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a"}, + {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe"}, + {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee"}, + {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef"}, + {file = "pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9"}, + {file = "pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b"}, + {file = "pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47"}, + {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9"}, + {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2"}, + {file = "pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a"}, + {file = "pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b"}, + {file = "pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad"}, + {file = "pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01"}, + {file = "pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c"}, + {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e"}, + {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e"}, + {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9"}, + {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab"}, + {file = "pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b"}, + {file = "pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b"}, + {file = "pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0"}, + {file = "pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6"}, + {file = "pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6"}, + {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"}, + {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e"}, + {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca"}, + {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925"}, + {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8"}, + {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4"}, + {file = "pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52"}, + {file = "pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a"}, + {file = "pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76"}, + {file = "pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5"}, + {file = "pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353"}, ] [package.extras] docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] -test-arrow = ["pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.4.0" +version = "4.5.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, - {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, + {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, + {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, ] [package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.14.1)"] +docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] +type = ["mypy (>=1.18.2)"] [[package]] name = "pluggy" @@ -2701,17 +2594,17 @@ pycairo = ">=1.16" [[package]] name = "pylast" -version = "5.5.0" +version = "6.0.0" description = "A Python interface to Last.fm and Libre.fm" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "pylast-5.5.0-py3-none-any.whl", hash = "sha256:a28b5dbf69ef71b868e42ce27c74e4feea5277fbee26960549604ce34d631bbe"}, - {file = "pylast-5.5.0.tar.gz", hash = "sha256:b6e95cf11fb99779cd451afd5dd68c4036c44f88733cf2346ba27317c1869da4"}, + {file = "pylast-6.0.0-py3-none-any.whl", hash = "sha256:8570017a955a04c5694e7ad38b13081b6119531b4a10bfc771ccd0b9d4f900ee"}, + {file = "pylast-6.0.0.tar.gz", hash = "sha256:09748dcdb97ddc812c65460bea73f7cce578b2b8ed4d9f6a0d1da122f8b05c5c"}, ] [package.dependencies] -httpx = "*" +httpx = ">=0.26" [package.extras] tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] @@ -3414,99 +3307,121 @@ files = [ [[package]] name = "scikit-learn" -version = "1.6.1" +version = "1.7.2" description = "A set of python modules for machine learning and data mining" optional = true -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "scikit_learn-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d056391530ccd1e501056160e3c9673b4da4805eb67eb2bdf4e983e1f9c9204e"}, - {file = "scikit_learn-1.6.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0c8d036eb937dbb568c6242fa598d551d88fb4399c0344d95c001980ec1c7d36"}, - {file = "scikit_learn-1.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8634c4bd21a2a813e0a7e3900464e6d593162a29dd35d25bdf0103b3fce60ed5"}, - {file = "scikit_learn-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:775da975a471c4f6f467725dff0ced5c7ac7bda5e9316b260225b48475279a1b"}, - {file = "scikit_learn-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:8a600c31592bd7dab31e1c61b9bbd6dea1b3433e67d264d17ce1017dbdce8002"}, - {file = "scikit_learn-1.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72abc587c75234935e97d09aa4913a82f7b03ee0b74111dcc2881cba3c5a7b33"}, - {file = "scikit_learn-1.6.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b3b00cdc8f1317b5f33191df1386c0befd16625f49d979fe77a8d44cae82410d"}, - {file = "scikit_learn-1.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc4765af3386811c3ca21638f63b9cf5ecf66261cc4815c1db3f1e7dc7b79db2"}, - {file = "scikit_learn-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25fc636bdaf1cc2f4a124a116312d837148b5e10872147bdaf4887926b8c03d8"}, - {file = "scikit_learn-1.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:fa909b1a36e000a03c382aade0bd2063fd5680ff8b8e501660c0f59f021a6415"}, - {file = "scikit_learn-1.6.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:926f207c804104677af4857b2c609940b743d04c4c35ce0ddc8ff4f053cddc1b"}, - {file = "scikit_learn-1.6.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2c2cae262064e6a9b77eee1c8e768fc46aa0b8338c6a8297b9b6759720ec0ff2"}, - {file = "scikit_learn-1.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1061b7c028a8663fb9a1a1baf9317b64a257fcb036dae5c8752b2abef31d136f"}, - {file = "scikit_learn-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e69fab4ebfc9c9b580a7a80111b43d214ab06250f8a7ef590a4edf72464dd86"}, - {file = "scikit_learn-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:70b1d7e85b1c96383f872a519b3375f92f14731e279a7b4c6cfd650cf5dffc52"}, - {file = "scikit_learn-1.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ffa1e9e25b3d93990e74a4be2c2fc61ee5af85811562f1288d5d055880c4322"}, - {file = "scikit_learn-1.6.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dc5cf3d68c5a20ad6d571584c0750ec641cc46aeef1c1507be51300e6003a7e1"}, - {file = "scikit_learn-1.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c06beb2e839ecc641366000ca84f3cf6fa9faa1777e29cf0c04be6e4d096a348"}, - {file = "scikit_learn-1.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8ca8cb270fee8f1f76fa9bfd5c3507d60c6438bbee5687f81042e2bb98e5a97"}, - {file = "scikit_learn-1.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:7a1c43c8ec9fde528d664d947dc4c0789be4077a3647f232869f41d9bf50e0fb"}, - {file = "scikit_learn-1.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a17c1dea1d56dcda2fac315712f3651a1fea86565b64b48fa1bc090249cbf236"}, - {file = "scikit_learn-1.6.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a7aa5f9908f0f28f4edaa6963c0a6183f1911e63a69aa03782f0d924c830a35"}, - {file = "scikit_learn-1.6.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0650e730afb87402baa88afbf31c07b84c98272622aaba002559b614600ca691"}, - {file = "scikit_learn-1.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:3f59fe08dc03ea158605170eb52b22a105f238a5d512c4470ddeca71feae8e5f"}, - {file = "scikit_learn-1.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6849dd3234e87f55dce1db34c89a810b489ead832aaf4d4550b7ea85628be6c1"}, - {file = "scikit_learn-1.6.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e7be3fa5d2eb9be7d77c3734ff1d599151bb523674be9b834e8da6abe132f44e"}, - {file = "scikit_learn-1.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44a17798172df1d3c1065e8fcf9019183f06c87609b49a124ebdf57ae6cb0107"}, - {file = "scikit_learn-1.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b7a3b86e411e4bce21186e1c180d792f3d99223dcfa3b4f597ecc92fa1a422"}, - {file = "scikit_learn-1.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7a73d457070e3318e32bdb3aa79a8d990474f19035464dfd8bede2883ab5dc3b"}, - {file = "scikit_learn-1.6.1.tar.gz", hash = "sha256:b4fc2525eca2c69a59260f583c56a7557c6ccdf8deafdba6e060f94c1c59738e"}, + {file = "scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f"}, + {file = "scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c"}, + {file = "scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8"}, + {file = "scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18"}, + {file = "scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5"}, + {file = "scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e"}, + {file = "scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1"}, + {file = "scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d"}, + {file = "scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1"}, + {file = "scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1"}, + {file = "scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96"}, + {file = "scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476"}, + {file = "scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b"}, + {file = "scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44"}, + {file = "scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290"}, + {file = "scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7"}, + {file = "scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe"}, + {file = "scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f"}, + {file = "scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0"}, + {file = "scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c"}, + {file = "scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8"}, + {file = "scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a"}, + {file = "scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c"}, + {file = "scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c"}, + {file = "scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973"}, + {file = "scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33"}, + {file = "scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615"}, + {file = "scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106"}, + {file = "scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61"}, + {file = "scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8"}, + {file = "scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda"}, ] [package.dependencies] joblib = ">=1.2.0" -numpy = ">=1.19.5" -scipy = ">=1.6.0" +numpy = ">=1.22.0" +scipy = ">=1.8.0" threadpoolctl = ">=3.1.0" [package.extras] -benchmark = ["matplotlib (>=3.3.4)", "memory_profiler (>=0.57.0)", "pandas (>=1.1.5)"] -build = ["cython (>=3.0.10)", "meson-python (>=0.16.0)", "numpy (>=1.19.5)", "scipy (>=1.6.0)"] -docs = ["Pillow (>=7.1.2)", "matplotlib (>=3.3.4)", "memory_profiler (>=0.57.0)", "numpydoc (>=1.2.0)", "pandas (>=1.1.5)", "plotly (>=5.14.0)", "polars (>=0.20.30)", "pooch (>=1.6.0)", "pydata-sphinx-theme (>=0.15.3)", "scikit-image (>=0.17.2)", "seaborn (>=0.9.0)", "sphinx (>=7.3.7)", "sphinx-copybutton (>=0.5.2)", "sphinx-design (>=0.5.0)", "sphinx-design (>=0.6.0)", "sphinx-gallery (>=0.17.1)", "sphinx-prompt (>=1.4.0)", "sphinx-remove-toctrees (>=1.0.0.post1)", "sphinxcontrib-sass (>=0.3.4)", "sphinxext-opengraph (>=0.9.1)", "towncrier (>=24.8.0)"] -examples = ["matplotlib (>=3.3.4)", "pandas (>=1.1.5)", "plotly (>=5.14.0)", "pooch (>=1.6.0)", "scikit-image (>=0.17.2)", "seaborn (>=0.9.0)"] -install = ["joblib (>=1.2.0)", "numpy (>=1.19.5)", "scipy (>=1.6.0)", "threadpoolctl (>=3.1.0)"] -maintenance = ["conda-lock (==2.5.6)"] -tests = ["black (>=24.3.0)", "matplotlib (>=3.3.4)", "mypy (>=1.9)", "numpydoc (>=1.2.0)", "pandas (>=1.1.5)", "polars (>=0.20.30)", "pooch (>=1.6.0)", "pyamg (>=4.0.0)", "pyarrow (>=12.0.0)", "pytest (>=7.1.2)", "pytest-cov (>=2.9.0)", "ruff (>=0.5.1)", "scikit-image (>=0.17.2)"] +benchmark = ["matplotlib (>=3.5.0)", "memory_profiler (>=0.57.0)", "pandas (>=1.4.0)"] +build = ["cython (>=3.0.10)", "meson-python (>=0.17.1)", "numpy (>=1.22.0)", "scipy (>=1.8.0)"] +docs = ["Pillow (>=8.4.0)", "matplotlib (>=3.5.0)", "memory_profiler (>=0.57.0)", "numpydoc (>=1.2.0)", "pandas (>=1.4.0)", "plotly (>=5.14.0)", "polars (>=0.20.30)", "pooch (>=1.6.0)", "pydata-sphinx-theme (>=0.15.3)", "scikit-image (>=0.19.0)", "seaborn (>=0.9.0)", "sphinx (>=7.3.7)", "sphinx-copybutton (>=0.5.2)", "sphinx-design (>=0.5.0)", "sphinx-design (>=0.6.0)", "sphinx-gallery (>=0.17.1)", "sphinx-prompt (>=1.4.0)", "sphinx-remove-toctrees (>=1.0.0.post1)", "sphinxcontrib-sass (>=0.3.4)", "sphinxext-opengraph (>=0.9.1)", "towncrier (>=24.8.0)"] +examples = ["matplotlib (>=3.5.0)", "pandas (>=1.4.0)", "plotly (>=5.14.0)", "pooch (>=1.6.0)", "scikit-image (>=0.19.0)", "seaborn (>=0.9.0)"] +install = ["joblib (>=1.2.0)", "numpy (>=1.22.0)", "scipy (>=1.8.0)", "threadpoolctl (>=3.1.0)"] +maintenance = ["conda-lock (==3.0.1)"] +tests = ["matplotlib (>=3.5.0)", "mypy (>=1.15)", "numpydoc (>=1.2.0)", "pandas (>=1.4.0)", "polars (>=0.20.30)", "pooch (>=1.6.0)", "pyamg (>=4.2.1)", "pyarrow (>=12.0.0)", "pytest (>=7.1.2)", "pytest-cov (>=2.9.0)", "ruff (>=0.11.7)", "scikit-image (>=0.19.0)"] [[package]] name = "scipy" -version = "1.13.1" +version = "1.15.3" description = "Fundamental algorithms for scientific computing in Python" optional = true -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "scipy-1.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:20335853b85e9a49ff7572ab453794298bcf0354d8068c5f6775a0eabf350aca"}, - {file = "scipy-1.13.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d605e9c23906d1994f55ace80e0125c587f96c020037ea6aa98d01b4bd2e222f"}, - {file = "scipy-1.13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfa31f1def5c819b19ecc3a8b52d28ffdcc7ed52bb20c9a7589669dd3c250989"}, - {file = "scipy-1.13.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26264b282b9da0952a024ae34710c2aff7d27480ee91a2e82b7b7073c24722f"}, - {file = "scipy-1.13.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eccfa1906eacc02de42d70ef4aecea45415f5be17e72b61bafcfd329bdc52e94"}, - {file = "scipy-1.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:2831f0dc9c5ea9edd6e51e6e769b655f08ec6db6e2e10f86ef39bd32eb11da54"}, - {file = "scipy-1.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:27e52b09c0d3a1d5b63e1105f24177e544a222b43611aaf5bc44d4a0979e32f9"}, - {file = "scipy-1.13.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:54f430b00f0133e2224c3ba42b805bfd0086fe488835effa33fa291561932326"}, - {file = "scipy-1.13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e89369d27f9e7b0884ae559a3a956e77c02114cc60a6058b4e5011572eea9299"}, - {file = "scipy-1.13.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a78b4b3345f1b6f68a763c6e25c0c9a23a9fd0f39f5f3d200efe8feda560a5fa"}, - {file = "scipy-1.13.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45484bee6d65633752c490404513b9ef02475b4284c4cfab0ef946def50b3f59"}, - {file = "scipy-1.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:5713f62f781eebd8d597eb3f88b8bf9274e79eeabf63afb4a737abc6c84ad37b"}, - {file = "scipy-1.13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5d72782f39716b2b3509cd7c33cdc08c96f2f4d2b06d51e52fb45a19ca0c86a1"}, - {file = "scipy-1.13.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:017367484ce5498445aade74b1d5ab377acdc65e27095155e448c88497755a5d"}, - {file = "scipy-1.13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:949ae67db5fa78a86e8fa644b9a6b07252f449dcf74247108c50e1d20d2b4627"}, - {file = "scipy-1.13.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de3ade0e53bc1f21358aa74ff4830235d716211d7d077e340c7349bc3542e884"}, - {file = "scipy-1.13.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2ac65fb503dad64218c228e2dc2d0a0193f7904747db43014645ae139c8fad16"}, - {file = "scipy-1.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:cdd7dacfb95fea358916410ec61bbc20440f7860333aee6d882bb8046264e949"}, - {file = "scipy-1.13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:436bbb42a94a8aeef855d755ce5a465479c721e9d684de76bf61a62e7c2b81d5"}, - {file = "scipy-1.13.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8335549ebbca860c52bf3d02f80784e91a004b71b059e3eea9678ba994796a24"}, - {file = "scipy-1.13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d533654b7d221a6a97304ab63c41c96473ff04459e404b83275b60aa8f4b7004"}, - {file = "scipy-1.13.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637e98dcf185ba7f8e663e122ebf908c4702420477ae52a04f9908707456ba4d"}, - {file = "scipy-1.13.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a014c2b3697bde71724244f63de2476925596c24285c7a637364761f8710891c"}, - {file = "scipy-1.13.1-cp39-cp39-win_amd64.whl", hash = "sha256:392e4ec766654852c25ebad4f64e4e584cf19820b980bc04960bca0b0cd6eaa2"}, - {file = "scipy-1.13.1.tar.gz", hash = "sha256:095a87a0312b08dfd6a6155cbbd310a8c51800fc931b8c0b84003014b874ed3c"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92"}, + {file = "scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82"}, + {file = "scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40"}, + {file = "scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e"}, + {file = "scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c"}, + {file = "scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1"}, + {file = "scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889"}, + {file = "scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982"}, + {file = "scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9"}, + {file = "scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594"}, + {file = "scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c"}, + {file = "scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45"}, + {file = "scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49"}, + {file = "scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e"}, + {file = "scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539"}, + {file = "scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730"}, + {file = "scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825"}, + {file = "scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7"}, + {file = "scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11"}, + {file = "scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126"}, + {file = "scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb"}, + {file = "scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723"}, + {file = "scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb"}, + {file = "scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4"}, + {file = "scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5"}, + {file = "scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca"}, + {file = "scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf"}, ] [package.dependencies] -numpy = ">=1.22.4,<2.3" +numpy = ">=1.23.5,<2.5" [package.extras] -dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy", "pycodestyle", "pydevtool", "rich-click", "ruff", "types-psutil", "typing_extensions"] -doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.12.0)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0)", "sphinx-design (>=0.4.0)"] -test = ["array-api-strict", "asv", "gmpy2", "hypothesis (>=6.30)", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] +doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.0.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] +test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] [[package]] name = "scipy" @@ -3717,38 +3632,37 @@ test = ["pytest"] [[package]] name = "sphinx" -version = "7.4.7" +version = "8.1.3" description = "Python documentation generator" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"}, - {file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"}, + {file = "sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2"}, + {file = "sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927"}, ] [package.dependencies] -alabaster = ">=0.7.14,<0.8.0" +alabaster = ">=0.7.14" babel = ">=2.13" colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} docutils = ">=0.20,<0.22" imagesize = ">=1.3" -importlib-metadata = {version = ">=6.0", markers = "python_version < \"3.10\""} Jinja2 = ">=3.1" packaging = ">=23.0" Pygments = ">=2.17" requests = ">=2.30.0" snowballstemmer = ">=2.2" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = ">=2.0.0" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" +sphinxcontrib-applehelp = ">=1.0.7" +sphinxcontrib-devhelp = ">=1.0.6" +sphinxcontrib-htmlhelp = ">=2.0.6" +sphinxcontrib-jsmath = ">=1.0.1" +sphinxcontrib-qthelp = ">=1.0.6" sphinxcontrib-serializinghtml = ">=1.1.9" tomli = {version = ">=2", markers = "python_version < \"3.11\""} [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=6.0)", "importlib-metadata (>=6.0)", "mypy (==1.10.1)", "pytest (>=6.0)", "ruff (==0.5.2)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-docutils (==0.21.0.20240711)", "types-requests (>=2.30.0)"] +lint = ["flake8 (>=6.0)", "mypy (==1.11.1)", "pyright (==1.1.384)", "pytest (>=6.0)", "ruff (==0.6.9)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.18.0.20240506)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241005)", "types-requests (==2.32.0.20240914)", "types-urllib3 (==1.26.25.14)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] [[package]] @@ -3796,13 +3710,13 @@ theme-sbt = ["sphinx-book-theme (>=1.1,<2.0)"] [[package]] name = "sphinx-lint" -version = "1.0.0" +version = "1.0.1" description = "Check for stylistic and formal issues in .rst and .py files included in the documentation." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "sphinx_lint-1.0.0-py3-none-any.whl", hash = "sha256:6117a0f340b2dc73eadfc57db7531d4477e0929f92a0c1a2f61e6edbc272f0bc"}, - {file = "sphinx_lint-1.0.0.tar.gz", hash = "sha256:6eafdb44172ce526f405bf36c713eb246f1340ec2d667e7298e2487ed76decd2"}, + {file = "sphinx_lint-1.0.1-py3-none-any.whl", hash = "sha256:914648e4cc6e677df3a09a8d72a33c8dfa0c2618c857e415ec3ebdc219ff0af1"}, + {file = "sphinx_lint-1.0.1.tar.gz", hash = "sha256:2b054ff3270fce56a8a18737665dd8ab04f45d8bfdf3a57a2313970b527ad612"}, ] [package.dependencies] @@ -4222,25 +4136,6 @@ files = [ [package.extras] test = ["pytest", "pytest-cov"] -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [extras] absubmit = ["requests"] aura = ["Pillow", "flask", "flask-cors"] @@ -4270,5 +4165,5 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" -python-versions = ">=3.9,<4" -content-hash = "be135ccdcad615804f5fc96290d5d8e6ad51a244599356133c2b68bb030f640f" +python-versions = ">=3.10,<4" +content-hash = "10a60daf371ba5d2c3d62ab0da7be81af40890517f9f60ed4a2cee1835eea6ae" diff --git a/pyproject.toml b/pyproject.toml index 78e85286b..09e32aa75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ classifiers = [ "Environment :: Web Environment", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -42,7 +41,7 @@ Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" "Bug Tracker" = "https://github.com/beetbox/beets/issues" [tool.poetry.dependencies] -python = ">=3.9,<4" +python = ">=3.10,<4" colorama = { version = "*", markers = "sys_platform == 'win32'" } confuse = ">=2.1.0" From dc339328719b58eea98565194cb5b2c169979fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 5 Nov 2025 11:18:15 +0000 Subject: [PATCH 057/274] Update python version references --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/integration_test.yaml | 6 +++++- .github/workflows/{lint.yml => lint.yaml} | 2 +- .github/workflows/make_release.yaml | 2 +- CONTRIBUTING.rst | 4 ++-- beets/plugins.py | 4 ---- beets/util/functemplate.py | 2 -- docs/changelog.rst | 5 +++++ docs/guides/installation.rst | 4 ++-- 9 files changed, 18 insertions(+), 15 deletions(-) rename .github/workflows/{lint.yml => lint.yaml} (99%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e8a532956..bfd05c718 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,10 +20,10 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.platform }} env: - IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} + IS_MAIN_PYTHON: ${{ matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' }} steps: - uses: actions/checkout@v5 - name: Install Python tools diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 8c7e44d7a..375968571 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -3,6 +3,10 @@ on: workflow_dispatch: schedule: - cron: "0 0 * * SUN" # run every Sunday at midnight + +env: + PYTHON_VERSION: "3.10" + jobs: test_integration: runs-on: ubuntu-latest @@ -12,7 +16,7 @@ jobs: uses: BrandonLWhite/pipx-install-action@v1.0.3 - uses: actions/setup-python@v6 with: - python-version: 3.9 + python-version: ${{ env.PYTHON_VERSION }} cache: poetry - name: Install dependencies diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yaml similarity index 99% rename from .github/workflows/lint.yml rename to .github/workflows/lint.yaml index dcc5d0f12..bb54c8875 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yaml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: - PYTHON_VERSION: 3.9 + PYTHON_VERSION: "3.10" jobs: changed-files: diff --git a/.github/workflows/make_release.yaml b/.github/workflows/make_release.yaml index 5a8abe5bb..571b50970 100644 --- a/.github/workflows/make_release.yaml +++ b/.github/workflows/make_release.yaml @@ -8,7 +8,7 @@ on: required: true env: - PYTHON_VERSION: 3.9 + PYTHON_VERSION: "3.10" NEW_VERSION: ${{ inputs.version }} NEW_TAG: v${{ inputs.version }} diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index d19a376b3..c2cde4ed4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -124,12 +124,12 @@ command. Instead, you can activate the virtual environment in your shell with: $ poetry shell -You should see ``(beets-py3.9)`` prefix in your shell prompt. Now you can run +You should see ``(beets-py3.10)`` prefix in your shell prompt. Now you can run commands directly, for example: :: - $ (beets-py3.9) pytest + $ (beets-py3.10) pytest Additionally, poethepoet_ task runner assists us with the most common operations. Formatting, linting, testing are defined as ``poe`` tasks in diff --git a/beets/plugins.py b/beets/plugins.py index 4fdad9807..810df3a45 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -25,7 +25,6 @@ from collections import defaultdict from functools import cached_property, wraps from importlib import import_module from pathlib import Path -from types import GenericAlias from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar import mediafile @@ -450,9 +449,6 @@ def _get_plugin(name: str) -> BeetsPlugin | None: for obj in reversed(namespace.__dict__.values()): if ( inspect.isclass(obj) - and not isinstance( - obj, GenericAlias - ) # seems to be needed for python <= 3.9 only and issubclass(obj, BeetsPlugin) and obj != BeetsPlugin and not inspect.isabstract(obj) diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 5d85530a1..739196cef 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -105,8 +105,6 @@ def compile_func(arg_names, statements, name="_the_func", debug=False): decorator_list=[], ) - # The ast.Module signature changed in 3.8 to accept a list of types to - # ignore. mod = ast.Module([func_def], []) ast.fix_missing_locations(mod) diff --git a/docs/changelog.rst b/docs/changelog.rst index 64f69b792..906897015 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,9 @@ below! Unreleased ---------- +Beets now requires Python 3.10 or later since support for EOL Python 3.9 has +been dropped. + New features: - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. @@ -42,6 +45,8 @@ For plugin developers: For packagers: +- The minimum supported Python version is now 3.10. + Other changes: - The documentation chapter :doc:`dev/paths` has been moved to the "For diff --git a/docs/guides/installation.rst b/docs/guides/installation.rst index 648a72d0b..bd634c4c5 100644 --- a/docs/guides/installation.rst +++ b/docs/guides/installation.rst @@ -1,10 +1,10 @@ Installation ============ -Beets requires `Python 3.9 or later`_. You can install it using package +Beets requires `Python 3.10 or later`_. You can install it using package managers, pipx_, pip_ or by using package managers. -.. _python 3.9 or later: https://python.org/download/ +.. _python 3.10 or later: https://python.org/download/ Using ``pipx`` or ``pip`` ------------------------- From d486885af31075d277cda74e57cf756e9f5a259d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 5 Nov 2025 11:43:51 +0000 Subject: [PATCH 058/274] pyupgrade Python 3.10 --- beets/autotag/__init__.py | 6 +++--- beets/dbcore/db.py | 11 +++++++++-- beets/importer/session.py | 4 +++- beets/importer/stages.py | 4 +++- beets/importer/tasks.py | 3 ++- beets/logging.py | 4 +++- beets/metadata_plugins.py | 4 ++-- beets/ui/__init__.py | 5 ++++- beets/util/__init__.py | 3 +-- beets/util/artresizer.py | 5 ++++- beets/util/hidden.py | 3 +-- beets/util/pipeline.py | 5 ++++- beetsplug/beatport.py | 11 +++-------- beetsplug/bpd/__init__.py | 2 +- beetsplug/chroma.py | 2 +- beetsplug/deezer.py | 4 +++- beetsplug/discogs.py | 4 ++-- beetsplug/fetchart.py | 8 ++++---- beetsplug/importsource.py | 2 +- beetsplug/lastgenre/__init__.py | 4 ++-- beetsplug/lyrics.py | 4 +++- beetsplug/mbpseudo.py | 4 +++- beetsplug/musicbrainz.py | 3 ++- beetsplug/replaygain.py | 4 ++-- beetsplug/spotify.py | 4 +++- extra/release.py | 4 ++-- test/plugins/test_aura.py | 6 ++---- test/plugins/test_ftintitle.py | 20 ++++++++++---------- test/plugins/test_hook.py | 4 ++-- test/plugins/test_importfeeds.py | 1 - test/test_sort.py | 2 +- 31 files changed, 86 insertions(+), 64 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 319f7f522..8fa5a6864 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -18,7 +18,7 @@ from __future__ import annotations import warnings from importlib import import_module -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from beets import config, logging @@ -117,8 +117,8 @@ SPECIAL_FIELDS = { def _apply_metadata( - info: Union[AlbumInfo, TrackInfo], - db_obj: Union[Album, Item], + info: AlbumInfo | TrackInfo, + db_obj: Album | Item, nullable_fields: Sequence[str] = [], ): """Set the db_obj's metadata to match the info.""" diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index afae6e906..cc172d0d8 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -26,9 +26,16 @@ import threading import time from abc import ABC from collections import defaultdict -from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence +from collections.abc import ( + Callable, + Generator, + Iterable, + Iterator, + Mapping, + Sequence, +) from sqlite3 import Connection, sqlite_version_info -from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic +from typing import TYPE_CHECKING, Any, AnyStr, Generic from typing_extensions import TypeVar # default value support from unidecode import unidecode diff --git a/beets/importer/session.py b/beets/importer/session.py index 46277837e..83c5ad4e3 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -15,7 +15,7 @@ from __future__ import annotations import os import time -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING from beets import config, dbcore, library, logging, plugins, util from beets.importer.tasks import Action @@ -25,6 +25,8 @@ from . import stages as stagefuncs from .state import ImportState if TYPE_CHECKING: + from collections.abc import Sequence + from beets.util import PathBytes from .tasks import ImportTask diff --git a/beets/importer/stages.py b/beets/importer/stages.py index d99b742a2..5474053d0 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -16,7 +16,7 @@ from __future__ import annotations import itertools import logging -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from beets import config, plugins from beets.util import MoveOperation, displayable_path, pipeline @@ -30,6 +30,8 @@ from .tasks import ( ) if TYPE_CHECKING: + from collections.abc import Callable + from beets import library from .session import ImportSession diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 710f4da50..9f60d7619 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -20,9 +20,10 @@ import re import shutil import time from collections import defaultdict +from collections.abc import Callable, Iterable, Sequence from enum import Enum from tempfile import mkdtemp -from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence +from typing import TYPE_CHECKING, Any import mediafile diff --git a/beets/logging.py b/beets/logging.py index 3ed5e5a84..8dab1cea6 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -37,7 +37,7 @@ from logging import ( RootLogger, StreamHandler, ) -from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload +from typing import TYPE_CHECKING, Any, TypeVar, Union, overload __all__ = [ "DEBUG", @@ -54,6 +54,8 @@ __all__ = [ ] if TYPE_CHECKING: + from collections.abc import Mapping + T = TypeVar("T") from types import TracebackType diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index b865167e4..f42e8f690 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -10,7 +10,7 @@ from __future__ import annotations import abc import re from functools import cache, cached_property -from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar +from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar import unidecode from confuse import NotFoundError @@ -22,7 +22,7 @@ from beets.util.id_extractors import extract_release_id from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Sequence from .autotag.hooks import AlbumInfo, Item, TrackInfo diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 294b466d6..12eb6d005 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -32,7 +32,7 @@ import warnings from difflib import SequenceMatcher from functools import cache from itertools import chain -from typing import Any, Callable, Literal +from typing import TYPE_CHECKING, Any, Literal import confuse @@ -42,6 +42,9 @@ from beets.dbcore import query as db_query from beets.util import as_string from beets.util.functemplate import template +if TYPE_CHECKING: + from collections.abc import Callable + # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == "win32": try: diff --git a/beets/util/__init__.py b/beets/util/__init__.py index fc05e4997..892c11167 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -29,7 +29,7 @@ import tempfile import traceback import warnings from collections import Counter -from collections.abc import Sequence +from collections.abc import Callable, Sequence from contextlib import suppress from enum import Enum from functools import cache @@ -41,7 +41,6 @@ from typing import ( TYPE_CHECKING, Any, AnyStr, - Callable, ClassVar, Generic, NamedTuple, diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 5ecde5140..72007d0b5 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -26,7 +26,7 @@ import subprocess from abc import ABC, abstractmethod from enum import Enum from itertools import chain -from typing import Any, ClassVar, Mapping +from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import urlencode from beets import logging, util @@ -37,6 +37,9 @@ from beets.util import ( syspath, ) +if TYPE_CHECKING: + from collections.abc import Mapping + PROXY_URL = "https://images.weserv.nl/" log = logging.getLogger("beets") diff --git a/beets/util/hidden.py b/beets/util/hidden.py index d2c66fac0..0a71c91fd 100644 --- a/beets/util/hidden.py +++ b/beets/util/hidden.py @@ -20,10 +20,9 @@ import os import stat import sys from pathlib import Path -from typing import Union -def is_hidden(path: Union[bytes, Path]) -> bool: +def is_hidden(path: bytes | Path) -> bool: """ Determine whether the given path is treated as a 'hidden file' by the OS. """ diff --git a/beets/util/pipeline.py b/beets/util/pipeline.py index bd2c49316..2ed593904 100644 --- a/beets/util/pipeline.py +++ b/beets/util/pipeline.py @@ -36,10 +36,13 @@ from __future__ import annotations import queue import sys from threading import Lock, Thread -from typing import Callable, Generator, TypeVar +from typing import TYPE_CHECKING, TypeVar from typing_extensions import TypeVarTuple, Unpack +if TYPE_CHECKING: + from collections.abc import Callable, Generator + BUBBLE = "__PIPELINE_BUBBLE__" POISON = "__PIPELINE_POISON__" diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index c07cce72f..718e0730e 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -19,14 +19,7 @@ from __future__ import annotations import json import re from datetime import datetime, timedelta -from typing import ( - TYPE_CHECKING, - Iterable, - Iterator, - Literal, - Sequence, - overload, -) +from typing import TYPE_CHECKING, Literal, overload import confuse from requests_oauthlib import OAuth1Session @@ -42,6 +35,8 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin if TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Sequence + from beets.importer import ImportSession from beets.library import Item diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 1a4f505dd..0359259b7 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -283,7 +283,7 @@ class BaseServer: if not self.ctrl_sock: self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port)) - self.ctrl_sock.sendall((f"{message}\n").encode("utf-8")) + self.ctrl_sock.sendall((f"{message}\n").encode()) def _send_event(self, event): """Notify subscribed connections of an event.""" diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 192310fb8..1e9835789 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -18,8 +18,8 @@ autotagger. Requires the pyacoustid library. import re from collections import defaultdict +from collections.abc import Iterable from functools import cached_property, partial -from typing import Iterable import acoustid import confuse diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 3eaca1e05..ef27dddc7 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -18,7 +18,7 @@ from __future__ import annotations import collections import time -from typing import TYPE_CHECKING, Literal, Sequence +from typing import TYPE_CHECKING, Literal import requests @@ -32,6 +32,8 @@ from beets.metadata_plugins import ( ) if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Item, Library from ._typing import JSONDict diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index be1cf97fa..29600a676 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -27,7 +27,7 @@ import time import traceback from functools import cache from string import ascii_lowercase -from typing import TYPE_CHECKING, Sequence, cast +from typing import TYPE_CHECKING, cast import confuse from discogs_client import Client, Master, Release @@ -43,7 +43,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin if TYPE_CHECKING: - from collections.abc import Callable, Iterable + from collections.abc import Callable, Iterable, Sequence from beets.library import Item diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 37e7426f6..e6bd05119 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -23,7 +23,7 @@ from collections import OrderedDict from contextlib import closing from enum import Enum from functools import cached_property -from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal, Tuple, Type +from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal import confuse import requests @@ -86,7 +86,7 @@ class Candidate: path: None | bytes = None, url: None | str = None, match: None | MetadataMatch = None, - size: None | Tuple[int, int] = None, + size: None | tuple[int, int] = None, ): self._log = log self.path = path @@ -682,7 +682,7 @@ class GoogleImages(RemoteArtSource): """ if not (album.albumartist and album.album): return - search_string = f"{album.albumartist},{album.album}".encode("utf-8") + search_string = f"{album.albumartist},{album.album}".encode() try: response = self.request( @@ -1293,7 +1293,7 @@ class CoverArtUrl(RemoteArtSource): # All art sources. The order they will be tried in is specified by the config. -ART_SOURCES: set[Type[ArtSource]] = { +ART_SOURCES: set[type[ArtSource]] = { FileSystem, CoverArtArchive, ITunesStore, diff --git a/beetsplug/importsource.py b/beetsplug/importsource.py index 1c686d334..19b2530ba 100644 --- a/beetsplug/importsource.py +++ b/beetsplug/importsource.py @@ -19,7 +19,7 @@ class ImportSourcePlugin(BeetsPlugin): def __init__(self): """Initialize the plugin and read configuration.""" - super(ImportSourcePlugin, self).__init__() + super().__init__() self.config.add( { "suggest_removal": False, diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 3b04e65d6..ea0ab951a 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -28,7 +28,7 @@ import os import traceback from functools import singledispatchmethod from pathlib import Path -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING import pylast import yaml @@ -352,7 +352,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): combined = old + new return self._resolve_genres(combined) - def _get_genre(self, obj: LibModel) -> tuple[Union[str, None], ...]: + def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]: """Get the final genre string for an Album or Item object. `self.sources` specifies allowed genre sources. Starting with the first diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 76854f0e9..677467776 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -28,7 +28,7 @@ from html import unescape from http import HTTPStatus from itertools import groupby from pathlib import Path -from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple +from typing import TYPE_CHECKING, NamedTuple from urllib.parse import quote, quote_plus, urlencode, urlparse import langdetect @@ -42,6 +42,8 @@ from beets.autotag.distance import string_dist from beets.util.config import sanitize_choices if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from beets.importer import ImportTask from beets.library import Item, Library from beets.logging import BeetsLogger as Logger diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 448aef365..9cfa99969 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -19,7 +19,7 @@ from __future__ import annotations import itertools import traceback from copy import deepcopy -from typing import TYPE_CHECKING, Any, Iterable, Sequence +from typing import TYPE_CHECKING, Any import mediafile import musicbrainzngs @@ -40,6 +40,8 @@ from beetsplug.musicbrainz import ( ) if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from beets.autotag import AlbumMatch from beets.library import Item from beetsplug._typing import JSONDict diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 29bbc26d0..3b49107ad 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -21,7 +21,7 @@ from collections import Counter from contextlib import suppress from functools import cached_property from itertools import product -from typing import TYPE_CHECKING, Any, Iterable, Sequence +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin import musicbrainzngs @@ -34,6 +34,7 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.id_extractors import extract_release_id if TYPE_CHECKING: + from collections.abc import Iterable, Sequence from typing import Literal from beets.library import Item diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 3e777d977..a8c887caa 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -28,7 +28,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from multiprocessing.pool import ThreadPool from threading import Event, Thread -from typing import TYPE_CHECKING, Any, Callable, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from beets import ui from beets.plugins import BeetsPlugin @@ -36,7 +36,7 @@ from beets.util import command_output, displayable_path, syspath if TYPE_CHECKING: import optparse - from collections.abc import Sequence + from collections.abc import Callable, Sequence from logging import Logger from confuse import ConfigView diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index a8126b852..b3c653682 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -27,7 +27,7 @@ import re import threading import time import webbrowser -from typing import TYPE_CHECKING, Any, Literal, Sequence, Union +from typing import TYPE_CHECKING, Any, Literal, Union import confuse import requests @@ -43,6 +43,8 @@ from beets.metadata_plugins import ( ) if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Library from beetsplug._typing import JSONDict diff --git a/extra/release.py b/extra/release.py index e16814960..0c11415a9 100755 --- a/extra/release.py +++ b/extra/release.py @@ -6,18 +6,18 @@ from __future__ import annotations import re import subprocess +from collections.abc import Callable from contextlib import redirect_stdout from datetime import datetime, timezone from functools import partial from io import StringIO from pathlib import Path -from typing import Callable, NamedTuple +from typing import NamedTuple, TypeAlias import click import tomli from packaging.version import Version, parse from sphinx.ext import intersphinx -from typing_extensions import TypeAlias from docs.conf import rst_epilog diff --git a/test/plugins/test_aura.py b/test/plugins/test_aura.py index f4535c738..7e840008e 100644 --- a/test/plugins/test_aura.py +++ b/test/plugins/test_aura.py @@ -1,7 +1,7 @@ import os from http import HTTPStatus from pathlib import Path -from typing import Any, Optional +from typing import Any import pytest from flask.testing import Client @@ -58,9 +58,7 @@ class TestAuraResponse: def get_response_data(self, client: Client, item): """Return a callback accepting `endpoint` and `params` parameters.""" - def get( - endpoint: str, params: dict[str, str] - ) -> Optional[dict[str, Any]]: + def get(endpoint: str, params: dict[str, str]) -> dict[str, Any] | None: """Add additional `params` and GET the given endpoint. `include` parameter is added to every call to check that the diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 56c82b9d2..b4259666d 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -14,7 +14,7 @@ """Tests for the 'ftintitle' plugin.""" -from typing import Dict, Generator, Optional, Tuple, Union +from collections.abc import Generator import pytest @@ -39,7 +39,7 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]: def set_config( env: FtInTitlePluginFunctional, - cfg: Optional[Dict[str, Union[str, bool, list[str]]]], + cfg: dict[str, str | bool | list[str]] | None, ) -> None: cfg = {} if cfg is None else cfg defaults = { @@ -57,7 +57,7 @@ def add_item( path: str, artist: str, title: str, - albumartist: Optional[str], + albumartist: str | None, ) -> Item: return env.add_item( path=path, @@ -250,10 +250,10 @@ def add_item( ) def test_ftintitle_functional( env: FtInTitlePluginFunctional, - cfg: Optional[Dict[str, Union[str, bool, list[str]]]], - cmd_args: Tuple[str, ...], - given: Tuple[str, str, Optional[str]], - expected: Tuple[str, str], + cfg: dict[str, str | bool | list[str]] | None, + cmd_args: tuple[str, ...], + given: tuple[str, str, str | None], + expected: tuple[str, str], ) -> None: set_config(env, cfg) ftintitle.FtInTitlePlugin() @@ -287,7 +287,7 @@ def test_ftintitle_functional( def test_find_feat_part( artist: str, albumartist: str, - expected: Optional[str], + expected: str | None, ) -> None: assert ftintitle.find_feat_part(artist, albumartist) == expected @@ -307,7 +307,7 @@ def test_find_feat_part( ) def test_split_on_feat( given: str, - expected: Tuple[str, Optional[str]], + expected: tuple[str, str | None], ) -> None: assert ftintitle.split_on_feat(given) == expected @@ -359,7 +359,7 @@ def test_contains_feat(given: str, expected: bool) -> None: ], ) def test_custom_words( - given: str, custom_words: Optional[list[str]], expected: bool + given: str, custom_words: list[str] | None, expected: bool ) -> None: if custom_words is None: custom_words = [] diff --git a/test/plugins/test_hook.py b/test/plugins/test_hook.py index 3392d6881..033e1ea64 100644 --- a/test/plugins/test_hook.py +++ b/test/plugins/test_hook.py @@ -19,13 +19,13 @@ import os import sys import unittest from contextlib import contextmanager -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from beets import plugins from beets.test.helper import PluginTestCase, capture_log if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Callable, Iterator class HookTestCase(PluginTestCase): diff --git a/test/plugins/test_importfeeds.py b/test/plugins/test_importfeeds.py index 53da87172..3f51eca76 100644 --- a/test/plugins/test_importfeeds.py +++ b/test/plugins/test_importfeeds.py @@ -1,6 +1,5 @@ import datetime import os -import os.path from beets.library import Album, Item from beets.test.helper import PluginTestCase diff --git a/test/test_sort.py b/test/test_sort.py index 25d993e30..460aa07b8 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -14,7 +14,7 @@ """Various tests for querying the library database.""" -from mock import patch +from unittest.mock import patch import beets.library from beets import config, dbcore From ffa70acad94644bc78e2bbbba4947ff8f826d5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 5 Nov 2025 11:44:48 +0000 Subject: [PATCH 059/274] Ignore pyupgrade blame --- .git-blame-ignore-revs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 2eee8c5c3..11842573f 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -78,4 +78,6 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 # Moved ui.commands._utils into ui.commands.utils 25ae330044abf04045e3f378f72bbaed739fb30d # Refactor test_ui_command.py into multiple modules -a59e41a88365e414db3282658d2aa456e0b3468a \ No newline at end of file +a59e41a88365e414db3282658d2aa456e0b3468a +# pyupgrade Python 3.10 +301637a1609831947cb5dd90270ed46c24b1ab1b From 881549e83cef6d850283fbf3c960c0a10d3f9df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 8 Nov 2025 11:52:12 +0000 Subject: [PATCH 060/274] Enable all pyupgrade lint rules --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 09e32aa75..85a542282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -304,9 +304,7 @@ select = [ "N", # pep8-naming "PT", # flake8-pytest-style # "RUF", # ruff - # "UP", # pyupgrade - "UP031", # do not use percent formatting - "UP032", # use f-string instead of format call + "UP", # pyupgrade "TCH", # flake8-type-checking "W", # pycodestyle ] From dd824e69b2138af611efac8e9a34d772ffb01921 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 10 Nov 2025 19:13:25 +0100 Subject: [PATCH 061/274] Clearart: Do not update files without an embedded image --- beetsplug/_utils/art.py | 7 +++++-- beetsplug/embedart.py | 1 + test/plugins/test_embedart.py | 13 +++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/beetsplug/_utils/art.py b/beetsplug/_utils/art.py index 656c303ce..264802ba5 100644 --- a/beetsplug/_utils/art.py +++ b/beetsplug/_utils/art.py @@ -210,5 +210,8 @@ def clear(log, lib, query): items = lib.items(query) log.info("Clearing album art from {} items", len(items)) for item in items: - log.debug("Clearing art for {}", item) - item.try_write(tags={"images": None}) + if mediafile.MediaFile(syspath(item.path)).images: + log.debug("Clearing art for {}", item) + item.try_write(tags={"images": None}) + else: + log.debug("No art to clean for {}", item) diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index cbf40f570..ab02f13b5 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -17,6 +17,7 @@ import os.path import tempfile from mimetypes import guess_extension +from unittest import mock import requests diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index d40025374..3b07a6a72 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -17,6 +17,7 @@ import os import os.path import shutil import tempfile +import time import unittest from unittest.mock import MagicMock, patch @@ -225,10 +226,22 @@ class EmbedartCliTest(IOMixin, PluginMixin, FetchImageHelper, BeetsTestCase): item = album.items()[0] self.io.addinput("y") self.run_command("embedart", "-f", self.small_artpath) + embedded_time = item.current_mtime() + time.sleep(1) + self.io.addinput("y") self.run_command("clearart") mediafile = MediaFile(syspath(item.path)) assert not mediafile.images + clear_time = item.current_mtime() + assert clear_time > embedded_time + time.sleep(1) + + # A run on a file without an image should not be modified + self.io.addinput("y") + self.run_command("clearart") + no_clear_time = item.current_mtime() + assert no_clear_time == clear_time def test_clear_art_with_no_input(self): self._setup_data() From 85168ba7fc0342dd3da551e3f8d3139b1c88e809 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 10 Nov 2025 19:20:42 +0100 Subject: [PATCH 062/274] Remove wrong import --- beetsplug/embedart.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index ab02f13b5..cbf40f570 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -17,7 +17,6 @@ import os.path import tempfile from mimetypes import guess_extension -from unittest import mock import requests From 8889c4ab47f3bbe4f9fe1b4051bd13a88bbae22d Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 10 Nov 2025 22:38:37 +0100 Subject: [PATCH 063/274] Clear art on import --- beets/test/helper.py | 6 ++++-- beetsplug/_utils/art.py | 14 +++++++++----- beetsplug/embedart.py | 10 ++++++++++ test/plugins/test_embedart.py | 31 ++++++++++++++++++++++++++++++- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/beets/test/helper.py b/beets/test/helper.py index 3cb1e4c3c..20ba4f4ab 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -364,15 +364,17 @@ class TestHelper(ConfigMixin): items.append(item) return self.lib.add_album(items) - def create_mediafile_fixture(self, ext="mp3", images=[]): + def create_mediafile_fixture(self, ext="mp3", images=[], target_dir=None): """Copy a fixture mediafile with the extension to `temp_dir`. `images` is a subset of 'png', 'jpg', and 'tiff'. For each specified extension a cover art image is added to the media file. """ + if not target_dir: + target_dir = self.temp_dir src = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}")) - handle, path = mkstemp(dir=self.temp_dir) + handle, path = mkstemp(dir=target_dir) path = bytestring_path(path) os.close(handle) shutil.copyfile(syspath(src), syspath(path)) diff --git a/beetsplug/_utils/art.py b/beetsplug/_utils/art.py index 264802ba5..b11b30b95 100644 --- a/beetsplug/_utils/art.py +++ b/beetsplug/_utils/art.py @@ -206,12 +206,16 @@ def extract_first(log, outpath, items): return real_path +def clear_item(item, log): + if mediafile.MediaFile(syspath(item.path)).images: + log.debug("Clearing art for {}", item) + item.try_write(tags={"images": None}) + else: + log.debug("No art to clean for {}", item) + + def clear(log, lib, query): items = lib.items(query) log.info("Clearing album art from {} items", len(items)) for item in items: - if mediafile.MediaFile(syspath(item.path)).images: - log.debug("Clearing art for {}", item) - item.try_write(tags={"images": None}) - else: - log.debug("No art to clean for {}", item) + clear_item(item, log) diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index cbf40f570..08e63836c 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -62,6 +62,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): "ifempty": False, "remove_art_file": False, "quality": 0, + "clearart_on_import": False, } ) @@ -82,6 +83,9 @@ class EmbedCoverArtPlugin(BeetsPlugin): self.register_listener("art_set", self.process_album) + if self.config["clearart_on_import"].get(bool): + self.register_listener("import_task_files", self.import_task_files) + def commands(self): # Embed command. embed_cmd = ui.Subcommand( @@ -278,3 +282,9 @@ class EmbedCoverArtPlugin(BeetsPlugin): os.remove(syspath(album.artpath)) album.artpath = None album.store() + + def import_task_files(self, session, task): + """Automatically clearart of imported files.""" + for item in task.imported_items(): + self._log.debug("clearart-on-import {.filepath}", item) + art.clear_item(item, self._log) diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index 3b07a6a72..ae66fbc6f 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -27,6 +27,7 @@ from mediafile import MediaFile from beets import config, logging, ui from beets.test import _common from beets.test.helper import ( + ImportHelper, BeetsTestCase, FetchImageHelper, IOMixin, @@ -76,7 +77,9 @@ def require_artresizer_compare(test): return wrapper -class EmbedartCliTest(IOMixin, PluginMixin, FetchImageHelper, BeetsTestCase): +class EmbedartCliTest( + ImportHelper, IOMixin, PluginMixin, FetchImageHelper, BeetsTestCase +): plugin = "embedart" small_artpath = os.path.join(_common.RSRC, b"image-2x3.jpg") abbey_artpath = os.path.join(_common.RSRC, b"abbey.jpg") @@ -286,6 +289,32 @@ class EmbedartCliTest(IOMixin, PluginMixin, FetchImageHelper, BeetsTestCase): mediafile = MediaFile(syspath(item.path)) assert not mediafile.images + def test_clearart_on_import_disabled(self): + file_path = self.create_mediafile_fixture( + images=["jpg"], target_dir=self.import_path + ) + self.import_media.append(file_path) + with self.configure_plugin({"clearart_on_import": False}): + importer = self.setup_importer(autotag=False, write=True) + importer.run() + + item = self.lib.items()[0] + assert MediaFile(os.path.join(item.path)).images + + def test_clearart_on_import_enabled(self): + file_path = self.create_mediafile_fixture( + images=["jpg"], target_dir=self.import_path + ) + self.import_media.append(file_path) + # Force re-init the plugin to register the listener + self.unload_plugins() + with self.configure_plugin({"clearart_on_import": True}): + importer = self.setup_importer(autotag=False, write=True) + importer.run() + + item = self.lib.items()[0] + assert not MediaFile(os.path.join(item.path)).images + class DummyArtResizer(ArtResizer): """An `ArtResizer` which pretends that ImageMagick is available, and has From 95f21b6e429ecbf68f5a399cf6498256aa3f544d Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 10 Nov 2025 22:38:58 +0100 Subject: [PATCH 064/274] Remove log if no art to clear --- beetsplug/_utils/art.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/beetsplug/_utils/art.py b/beetsplug/_utils/art.py index b11b30b95..fce650c5b 100644 --- a/beetsplug/_utils/art.py +++ b/beetsplug/_utils/art.py @@ -210,8 +210,6 @@ def clear_item(item, log): if mediafile.MediaFile(syspath(item.path)).images: log.debug("Clearing art for {}", item) item.try_write(tags={"images": None}) - else: - log.debug("No art to clean for {}", item) def clear(log, lib, query): From d11c074a859b6bcef05cad8a3947bd09651608ec Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 10 Nov 2025 23:44:02 +0100 Subject: [PATCH 065/274] Improve test to not sleep --- test/plugins/test_embedart.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index ae66fbc6f..2b6f59e26 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -17,7 +17,6 @@ import os import os.path import shutil import tempfile -import time import unittest from unittest.mock import MagicMock, patch @@ -229,21 +228,19 @@ class EmbedartCliTest( item = album.items()[0] self.io.addinput("y") self.run_command("embedart", "-f", self.small_artpath) - embedded_time = item.current_mtime() - time.sleep(1) + embedded_time = os.path.getmtime(syspath(item.path)) self.io.addinput("y") self.run_command("clearart") mediafile = MediaFile(syspath(item.path)) assert not mediafile.images - clear_time = item.current_mtime() + clear_time = os.path.getmtime(syspath(item.path)) assert clear_time > embedded_time - time.sleep(1) # A run on a file without an image should not be modified self.io.addinput("y") self.run_command("clearart") - no_clear_time = item.current_mtime() + no_clear_time = os.path.getmtime(syspath(item.path)) assert no_clear_time == clear_time def test_clear_art_with_no_input(self): From bef249e61607d7add10774c0b9472a3aa5028a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 11 Nov 2025 04:03:52 +0000 Subject: [PATCH 066/274] Fix format-docs command --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 85a542282..e4b69b7f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,7 +227,7 @@ cmd = "ruff format" [tool.poe.tasks.format-docs] help = "Format the documentation" -cmd = "docstrfmt" +cmd = "docstrfmt docs *.rst" [tool.poe.tasks.lint] help = "Check the code for linting issues. Accepts ruff options." @@ -285,7 +285,6 @@ extend-exclude = [ "docs/api/**/*", "README_kr.rst", ] -files = ["docs", "*.rst"] [tool.ruff] target-version = "py39" From 9e7d5debdc408ed15bf35832103d3f8801e14567 Mon Sep 17 00:00:00 2001 From: Aidan Epstein Date: Tue, 15 Jul 2025 09:11:28 -0700 Subject: [PATCH 067/274] Allow selecting either tags or genres in the includes, defaulting to genres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Genres is a filtered list based on what musicbrainz considers a genre, tags are all the user-submitted tags. [1] 1. https://musicbrainz.org/doc/MusicBrainz_API#:~:text=Since%20genres%20are,!). Also apply suggestions from code review Co-authored-by: Šarūnas Nejus --- beetsplug/musicbrainz.py | 10 ++++++++-- docs/changelog.rst | 2 ++ docs/plugins/musicbrainz.rst | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 3b49107ad..57656b956 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -90,6 +90,7 @@ RELEASE_INCLUDES = list( "isrcs", "url-rels", "release-rels", + "genres", "tags", } & set(musicbrainzngs.VALID_INCLUDES["release"]) @@ -370,6 +371,10 @@ def _merge_pseudo_and_actual_album( class MusicBrainzPlugin(MetadataSourcePlugin): + @cached_property + def genres_field(self) -> str: + return f"{config['musicbrainz']['genres_tag'].get()}-list" + def __init__(self): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. @@ -382,6 +387,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): "ratelimit": 1, "ratelimit_interval": 1, "genres": False, + "genres_tag": "genre", "external_ids": { "discogs": False, "bandcamp": False, @@ -723,8 +729,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin): if self.config["genres"]: sources = [ - release["release-group"].get("tag-list", []), - release.get("tag-list", []), + release["release-group"].get(self.genres_field, []), + release.get(self.genres_field, []), ] genres: Counter[str] = Counter() for source in sources: diff --git a/docs/changelog.rst b/docs/changelog.rst index e6821327e..1225f4f9f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,8 @@ been dropped. New features: - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. +- :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the + genres tag. - :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and album artist are the same in ftintitle. - :doc:`plugins/play`: Added `$playlist` marker to precisely edit the playlist diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 00c553d8b..ac6d7a7d6 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -32,6 +32,7 @@ Default ratelimit_interval: 1.0 extra_tags: [] genres: no + genres_tag: genre external_ids: discogs: no bandcamp: no @@ -136,6 +137,12 @@ Default ``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports existing data will be overwritten. +.. conf:: _genres_tag + :default: genres + + Either ``genres`` or ``tags``. Specify ``genres`` to use just musicbrainz genres and + ``tags`` to use all user-supplied musicbrainz tags. + .. include:: ./shared_metadata_source_config.rst .. _building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup From d7636fb0c3b34ea90e4296039208bd09203c9992 Mon Sep 17 00:00:00 2001 From: Aidan Epstein Date: Tue, 11 Nov 2025 13:18:51 -0800 Subject: [PATCH 068/274] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus --- beetsplug/musicbrainz.py | 2 +- docs/plugins/musicbrainz.rst | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 57656b956..2b9d5e9c2 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -373,7 +373,7 @@ def _merge_pseudo_and_actual_album( class MusicBrainzPlugin(MetadataSourcePlugin): @cached_property def genres_field(self) -> str: - return f"{config['musicbrainz']['genres_tag'].get()}-list" + return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}-list" def __init__(self): """Set up the python-musicbrainz-ngs module according to settings diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index ac6d7a7d6..7fe436c2c 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -137,11 +137,11 @@ Default ``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports existing data will be overwritten. -.. conf:: _genres_tag - :default: genres +.. conf:: genres_tag + :default: genre - Either ``genres`` or ``tags``. Specify ``genres`` to use just musicbrainz genres and - ``tags`` to use all user-supplied musicbrainz tags. + Either ``genre`` or ``tag``. Specify ``genre`` to use just musicbrainz genre and + ``tag`` to use all user-supplied musicbrainz tags. .. include:: ./shared_metadata_source_config.rst From 672bf0bf41304e20bb8f9b1b2da78937e8087474 Mon Sep 17 00:00:00 2001 From: Aidan Epstein Date: Tue, 11 Nov 2025 16:24:32 -0800 Subject: [PATCH 069/274] Add tests. --- test/plugins/test_musicbrainz.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 844b2ad4e..9e271a481 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -65,6 +65,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ], "date": "3001", "medium-list": [], + "genre-list": [{"count": 1, "name": "GENRE"}], + "tag-list": [{"count": 1, "name": "TAG"}], "label-info-list": [ { "catalog-number": "CATALOG NUMBER", @@ -515,6 +517,26 @@ class MBAlbumInfoTest(MusicBrainzTestCase): d = self.mb.album_info(release) assert d.data_source == "MusicBrainz" + def test_genres(self): + config["musicbrainz"]["genres"] = True + config["musicbrainz"]["genres_tag"] = "genre" + release = self._make_release() + d = self.mb.album_info(release) + assert d.genre == "GENRE" + + def test_tags(self): + config["musicbrainz"]["genres"] = True + config["musicbrainz"]["genres_tag"] = "tag" + release = self._make_release() + d = self.mb.album_info(release) + assert d.genre == "TAG" + + def test_no_genres(self): + config["musicbrainz"]["genres"] = False + release = self._make_release() + d = self.mb.album_info(release) + assert d.genre is None + def test_ignored_media(self): config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"] tracks = [ From 2ef77852b714a6dedfe2f3d7baac687006864c68 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 12 Nov 2025 20:47:36 +0100 Subject: [PATCH 070/274] Fix import --from-logfile Fixes "none of the paths are importable" error with any valid import log file that was accidentally introduced in commit 4260162d4 --- beets/ui/commands/import_/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/ui/commands/import_/__init__.py b/beets/ui/commands/import_/__init__.py index 5dba71fa8..b2991f183 100644 --- a/beets/ui/commands/import_/__init__.py +++ b/beets/ui/commands/import_/__init__.py @@ -125,7 +125,7 @@ def import_func(lib, opts, args: list[str]): # If all paths were read from a logfile, and none of them exist, throw # an error - if not paths: + if not byte_paths: raise ui.UserError("none of the paths are importable") import_files(lib, byte_paths, query) From 97bc0b3b8c7cf554ade7a9fbfa51b70cda529246 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 12 Nov 2025 21:05:15 +0100 Subject: [PATCH 071/274] Changelog for #6161 --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1225f4f9f..366af9ff0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -41,6 +41,8 @@ Bug fixes: the default config path. :bug:`5652` - :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only accepted a list of strings). :bug:`5962` +- Fix a bug introduced in release 2.4.0 where import from any valid + import-log-file always threw a "none of the paths are importable" error. For plugin developers: From 666c412b0ee91b25b08757bc28bbffd0fd249b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20Bastian?= Date: Tue, 11 Nov 2025 21:04:30 +0100 Subject: [PATCH 072/274] =?UTF-8?q?plugins/web:=20fix=20endpoints=20`/?= =?UTF-8?q?=E2=80=A6/values/=E2=80=A6`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following #4709 and #5447, the web plugin used single-quotes (ie. string litteral) in the SQL query for table columns. Thus, for instance, the query `GET /item/values/albumartist` would return the litteral "albumartist" instead of a list of unique album artists. --- beetsplug/web/__init__.py | 2 +- docs/changelog.rst | 4 ++++ test/plugins/test_web.py | 7 +++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 7b13cf016..1fbb3b0f3 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -232,7 +232,7 @@ def _get_unique_table_field_values(model, field, sort_field): raise KeyError with g.lib.transaction() as tx: rows = tx.query( - f"SELECT DISTINCT '{field}' FROM '{model._table}' ORDER BY '{sort_field}'" + f"SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}" ) return [row[0] for row in rows] diff --git a/docs/changelog.rst b/docs/changelog.rst index 366af9ff0..c5a0dab53 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -43,6 +43,10 @@ Bug fixes: accepted a list of strings). :bug:`5962` - Fix a bug introduced in release 2.4.0 where import from any valid import-log-file always threw a "none of the paths are importable" error. +- :doc:`/plugins/web`: repair broken `/item/values/…` and `/albums/values/…` + endpoints. Previously, due to single-quotes (ie. string literal) in the SQL + query, the query eg. `GET /item/values/albumartist` would return the literal + "albumartist" instead of a list of unique album artists. For plugin developers: diff --git a/test/plugins/test_web.py b/test/plugins/test_web.py index 9fc3d109d..4a532e02c 100644 --- a/test/plugins/test_web.py +++ b/test/plugins/test_web.py @@ -118,6 +118,13 @@ class WebPluginTest(ItemInDBTestCase): assert response.status_code == 200 assert len(res_json["items"]) == 3 + def test_get_unique_item_artist(self): + response = self.client.get("/item/values/artist") + res_json = json.loads(response.data.decode("utf-8")) + + assert response.status_code == 200 + assert res_json["values"] == ["", "AAA Singers"] + def test_get_single_item_by_id(self): response = self.client.get("/item/1") res_json = json.loads(response.data.decode("utf-8")) From 189fedb0089ba2bc2fc08fc16dcdca3ce9ac0d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20Bastian?= Date: Sat, 15 Nov 2025 21:00:02 +0100 Subject: [PATCH 073/274] Web plugin: add type hint for g.lib --- beetsplug/web/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 1fbb3b0f3..28bc20152 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -17,9 +17,10 @@ import base64 import json import os +import typing as t import flask -from flask import g, jsonify +from flask import jsonify from unidecode import unidecode from werkzeug.routing import BaseConverter, PathConverter @@ -28,6 +29,17 @@ from beets import ui, util from beets.dbcore.query import PathQuery from beets.plugins import BeetsPlugin +# Type checking hacks + +if t.TYPE_CHECKING: + + class LibraryCtx(flask.ctx._AppCtxGlobals): + lib: beets.library.Library + + g = LibraryCtx() +else: + from flask import g + # Utilities. From 1d239d6e27c4f4ce4ab36566af4569cf294042dd Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Wed, 12 Nov 2025 07:03:30 -0600 Subject: [PATCH 074/274] feat(ftintitle): Insert featured artist before track variant --- beetsplug/ftintitle.py | 106 ++++++++++++++++++++++++++++++++- docs/changelog.rst | 7 +++ test/plugins/test_ftintitle.py | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index dd681a972..cec22af3f 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -59,6 +59,89 @@ def contains_feat(title: str, custom_words: list[str] | None = None) -> bool: ) +# Default keywords that indicate remix/edit/version content +DEFAULT_BRACKET_KEYWORDS = [ + "abridged", + "acapella", + "club", + "demo", + "edit", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", +] + + +def find_bracket_position( + title: str, keywords: list[str] | None = None +) -> int | None: + """Find the position of the first opening bracket that contains + remix/edit-related keywords and has a matching closing bracket. + + Args: + title: The title to search in. + keywords: List of keywords to match. If None, uses DEFAULT_BRACKET_KEYWORDS. + If an empty list, matches any bracket content (not just keywords). + + Returns: + The position of the opening bracket, or None if no match found. + """ + if keywords is None: + keywords = DEFAULT_BRACKET_KEYWORDS + + # If keywords is empty, match any bracket content + if not keywords: + pattern = None + else: + # Build regex pattern with word boundaries + keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords) + pattern = re.compile(keyword_pattern, re.IGNORECASE) + + # Bracket pairs (opening, closing) + bracket_pairs = [("(", ")"), ("[", "]"), ("<", ">"), ("{", "}")] + + # Track the earliest valid bracket position + earliest_pos = None + + for open_char, close_char in bracket_pairs: + pos = 0 + while True: + # Find next opening bracket + open_pos = title.find(open_char, pos) + if open_pos == -1: + break + + # Find matching closing bracket + close_pos = title.find(close_char, open_pos + 1) + if close_pos == -1: + break + + # Extract content between brackets + content = title[open_pos + 1 : close_pos] + + # Check if content matches: if pattern is None (empty keywords), + # match any content; otherwise check for keywords + if pattern is None or pattern.search(content): + if earliest_pos is None or open_pos < earliest_pos: + earliest_pos = open_pos + + # Continue searching from after this closing bracket + pos = close_pos + 1 + + return earliest_pos + + def find_feat_part( artist: str, albumartist: str | None, @@ -110,6 +193,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], + "bracket_keywords": DEFAULT_BRACKET_KEYWORDS, } ) @@ -138,6 +222,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): bool ) custom_words = self.config["custom_words"].get(list) + bracket_keywords = self.config["bracket_keywords"].get(list) write = ui.should_write() for item in lib.items(args): @@ -147,6 +232,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, + bracket_keywords, ): item.store() if write: @@ -161,6 +247,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field = self.config["keep_in_artist"].get(bool) preserve_album_artist = self.config["preserve_album_artist"].get(bool) custom_words = self.config["custom_words"].get(list) + bracket_keywords = self.config["bracket_keywords"].get(list) for item in task.imported_items(): if self.ft_in_title( @@ -169,6 +256,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, + bracket_keywords, ): item.store() @@ -179,6 +267,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat: bool, keep_in_artist_field: bool, custom_words: list[str], + bracket_keywords: list[str] | None = None, ) -> None: """Choose how to add new artists to the title and set the new metadata. Also, print out messages about any changes that are made. @@ -208,7 +297,14 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() new_format = feat_format.format(feat_part) - new_title = f"{item.title} {new_format}" + # Insert before the first bracket containing remix/edit keywords + bracket_pos = find_bracket_position(item.title, bracket_keywords) + if bracket_pos is not None: + title_before = item.title[:bracket_pos].rstrip() + title_after = item.title[bracket_pos:] + new_title = f"{title_before} {new_format} {title_after}" + else: + new_title = f"{item.title} {new_format}" self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -219,6 +315,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field: bool, preserve_album_artist: bool, custom_words: list[str], + bracket_keywords: list[str] | None = None, ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -250,6 +347,11 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have a featuring artist, move it to the title. self.update_metadata( - item, feat_part, drop_feat, keep_in_artist_field, custom_words + item, + feat_part, + drop_feat, + keep_in_artist_field, + custom_words, + bracket_keywords, ) return True diff --git a/docs/changelog.rst b/docs/changelog.rst index c5a0dab53..c3591b80e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,13 @@ New features: - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. +- :doc:`plugins/ftintitle`: Featured artists are now inserted before brackets + containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead + of being appended at the end. This improves formatting for titles like "Song 1 + (Carol Remix) ft. Bob" which becomes "Song 1 ft. Bob (Carol Remix)". A variety + of brackets are supported and a new ``bracket_keywords`` configuration option + allows customizing the keywords. Setting ``bracket_keywords`` to an empty list + matches any bracket content regardless of keywords. Bug fixes: diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index b4259666d..b2e2bad9b 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -246,6 +246,49 @@ def add_item( ("Alice", "Song 1 feat. Bob"), id="skip-if-artist-and-album-artists-is-the-same-matching-match-b", ), + # ---- titles with brackets/parentheses ---- + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), + ("Alice", "Song 1 ft. Bob (Carol Remix)"), + id="title-with-brackets-insert-before", + ), + pytest.param( + {"format": "ft. {}", "keep_in_artist": True}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), + ("Alice ft. Bob", "Song 1 ft. Bob (Carol Remix)"), + id="title-with-brackets-keep-in-artist", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Remix) (Live)", "Alice"), + ("Alice", "Song 1 ft. Bob (Remix) (Live)"), + id="title-with-multiple-brackets-uses-first-with-keyword", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Arbitrary)", "Alice"), + ("Alice", "Song 1 (Arbitrary) ft. Bob"), + id="title-with-brackets-no-keyword-appends", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 [Edit]", "Alice"), + ("Alice", "Song 1 ft. Bob [Edit]"), + id="title-with-square-brackets-keyword", + ), + pytest.param( + {"format": "ft. {}"}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 ", "Alice"), + ("Alice", "Song 1 ft. Bob "), + id="title-with-angle-brackets-keyword", + ), ], ) def test_ftintitle_functional( @@ -312,6 +355,69 @@ def test_split_on_feat( assert ftintitle.split_on_feat(given) == expected +@pytest.mark.parametrize( + "given,expected", + [ + # different braces and keywords + ("Song (Remix)", 5), + ("Song [Version]", 5), + ("Song {Extended Mix}", 5), + ("Song ", 5), + # two keyword clauses + ("Song (Remix) (Live)", 5), + # brace insensitivity + ("Song (Live) [Remix]", 5), + ("Song [Edit] (Remastered)", 5), + # negative cases + ("Song", None), # no clause + ("Song (Arbitrary)", None), # no keyword + ("Song (", None), # no matching brace or keyword + ("Song (Live", None), # no matching brace with keyword + # one keyword clause, one non-keyword clause + ("Song (Live) (Arbitrary)", 5), + ("Song (Arbitrary) (Remix)", 17), + # nested brackets - same type + ("Song (Remix (Extended))", 5), + ("Song [Arbitrary [Description]]", None), + # nested brackets - different types + ("Song (Remix [Extended])", 5), + # nested - returns outer start position despite inner keyword + ("Song [Arbitrary {Extended}]", 5), + ("Song {Live }", 5), + ("Song ", 5), + ("Song [Live]", 5), + ("Song (Version) ", 5), + ("Song (Arbitrary [Description])", None), + ("Song [Description (Arbitrary)]", None), + ], +) +def test_find_bracket_position(given: str, expected: int | None) -> None: + assert ftintitle.find_bracket_position(given) == expected + + +@pytest.mark.parametrize( + "given,keywords,expected", + [ + ("Song (Live)", ["live"], 5), + ("Song (Live)", None, 5), + ("Song (Arbitrary)", None, None), + ("Song (Concert)", ["concert"], 5), + ("Song (Concert)", None, None), + ("Song (Remix)", ["custom"], None), + ("Song (Custom)", ["custom"], 5), + ("Song (Live)", [], 5), + ("Song (Anything)", [], 5), + ("Song (Remix)", [], 5), + ("Song", [], None), + ("Song (", [], None), + ], +) +def test_find_bracket_position_custom_keywords( + given: str, keywords: list[str] | None, expected: int | None +) -> None: + assert ftintitle.find_bracket_position(given, keywords) == expected + + @pytest.mark.parametrize( "given,expected", [ From 62e1a41ff2e6242cdae54ce63236af0e6c105514 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:19:12 -0600 Subject: [PATCH 075/274] chore(ftintitle): add 'edition' to keyword defaults --- beetsplug/ftintitle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index cec22af3f..9702bf9a5 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -66,6 +66,7 @@ DEFAULT_BRACKET_KEYWORDS = [ "club", "demo", "edit", + "edition", "extended", "instrumental", "live", From 15daebb55f9b356cdd2ac5687d7af5c4ef53f67b Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:31:40 -0600 Subject: [PATCH 076/274] test(ftintitle): mock import task to exercise import hooks --- test/plugins/test_ftintitle.py | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index b2e2bad9b..a6be02b3b 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,9 +15,11 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator +from typing import cast import pytest +from beets.importer import ImportSession, ImportTask from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -68,6 +70,16 @@ def add_item( ) +class DummyImportTask: + """Minimal stand-in for ImportTask used to exercise import hooks.""" + + def __init__(self, items: list[Item]) -> None: + self._items = items + + def imported_items(self) -> list[Item]: + return self._items + + @pytest.mark.parametrize( "cfg, cmd_args, given, expected", [ @@ -312,6 +324,31 @@ def test_ftintitle_functional( assert item["title"] == expected_title +def test_imported_stage_moves_featured_artist( + env: FtInTitlePluginFunctional, +) -> None: + """The import-stage hook should fetch config settings and process items.""" + set_config(env, None) + plugin = ftintitle.FtInTitlePlugin() + item = add_item( + env, + "/imported-hook", + "Alice feat. Bob", + "Song 1 (Carol Remix)", + "Various Artists", + ) + task = DummyImportTask([item]) + + plugin.imported( + cast(ImportSession, None), + cast(ImportTask, task), + ) + item.load() + + assert item["artist"] == "Alice" + assert item["title"] == "Song 1 feat. Bob (Carol Remix)" + + @pytest.mark.parametrize( "artist,albumartist,expected", [ From a9ed637c407c6788e0510e75fa98261c9afb31ad Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 17:31:49 -0600 Subject: [PATCH 077/274] docs(ftintitle): add bracket_keywords --- docs/plugins/ftintitle.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 1d2ec5c20..fef1bc9bf 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -32,6 +32,18 @@ file. The available options are: skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. +- **bracket_keywords**: Controls where the featuring text is inserted when the + title includes bracketed qualifiers such as ``(Remix)`` or ``[Live]``. + FtInTitle inserts the new text before the first bracket whose contents match + any of these keywords. Supply a list of words to fine-tune the behavior or set + the list to ``[]`` to match *any* bracket regardless of its contents. Default: + + :: + + ["abridged", "acapella", "club", "demo", "edit", "edition", "extended", + "instrumental", "live", "mix", "radio", "release", "remastered" + "remastered", "remix", "rmx", "unabridged", "unreleased", + "version", "vip"] Running Manually ---------------- From 3dd3bf5640eebb0da282edae4169e8a636b1aeac Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 15 Nov 2025 22:34:43 -0600 Subject: [PATCH 078/274] fix: address sourcery comments --- beetsplug/ftintitle.py | 13 ++++++++----- docs/plugins/ftintitle.rst | 2 +- test/plugins/test_ftintitle.py | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 9702bf9a5..d3d600958 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -105,7 +105,9 @@ def find_bracket_position( if not keywords: pattern = None else: - # Build regex pattern with word boundaries + # Build regex pattern to support multi-word keywords/phrases. + # Each keyword/phrase is escaped and surrounded by word boundaries at + # start and end, matching phrases like "club mix" as a whole. keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords) pattern = re.compile(keyword_pattern, re.IGNORECASE) @@ -133,9 +135,10 @@ def find_bracket_position( # Check if content matches: if pattern is None (empty keywords), # match any content; otherwise check for keywords - if pattern is None or pattern.search(content): - if earliest_pos is None or open_pos < earliest_pos: - earliest_pos = open_pos + if (pattern is None or pattern.search(content)) and ( + earliest_pos is None or open_pos < earliest_pos + ): + earliest_pos = open_pos # Continue searching from after this closing bracket pos = close_pos + 1 @@ -194,7 +197,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": DEFAULT_BRACKET_KEYWORDS, + "bracket_keywords": DEFAULT_BRACKET_KEYWORDS.copy(), } ) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index fef1bc9bf..347e2792d 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -41,7 +41,7 @@ file. The available options are: :: ["abridged", "acapella", "club", "demo", "edit", "edition", "extended", - "instrumental", "live", "mix", "radio", "release", "remastered" + "instrumental", "live", "mix", "radio", "release", "remaster", "remastered", "remix", "rmx", "unabridged", "unreleased", "version", "vip"] diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index a6be02b3b..065f23ade 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -301,6 +301,21 @@ class DummyImportTask: ("Alice", "Song 1 ft. Bob "), id="title-with-angle-brackets-keyword", ), + # multi-word keyword + pytest.param( + {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), + ("Alice", "Song 1 ft. Bob (Club Mix)"), + id="multi-word-keyword-positive-match", + ), + pytest.param( + {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), + ("Alice", "Song 1 (Club Remix) ft. Bob"), + id="multi-word-keyword-negative-no-match", + ), ], ) def test_ftintitle_functional( @@ -447,6 +462,9 @@ def test_find_bracket_position(given: str, expected: int | None) -> None: ("Song (Remix)", [], 5), ("Song", [], None), ("Song (", [], None), + # Multi-word keyword tests + ("Song (Club Mix)", ["club mix"], 5), # Positive: matches multi-word + ("Song (Club Remix)", ["club mix"], None), # Negative: no match ], ) def test_find_bracket_position_custom_keywords( From 2aa949e5a0f9da3e079296b376f0b8e72d3da70b Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 16 Nov 2025 19:48:46 -0600 Subject: [PATCH 079/274] fix(fitintitle): simplify keyword_pattern using map() instead of list comprehension --- beetsplug/ftintitle.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index d3d600958..4d0821593 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -106,9 +106,7 @@ def find_bracket_position( pattern = None else: # Build regex pattern to support multi-word keywords/phrases. - # Each keyword/phrase is escaped and surrounded by word boundaries at - # start and end, matching phrases like "club mix" as a whole. - keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords) + keyword_pattern = rf"\b{'|'.join(map(re.escape, keywords))}\b" pattern = re.compile(keyword_pattern, re.IGNORECASE) # Bracket pairs (opening, closing) From 50e55f85f4fd231d0023353355faed32cb007611 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 16 Nov 2025 19:54:27 -0600 Subject: [PATCH 080/274] fix(ftintitle): prune find_bracket_position docstring --- beetsplug/ftintitle.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 4d0821593..629e58f17 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -89,14 +89,6 @@ def find_bracket_position( ) -> int | None: """Find the position of the first opening bracket that contains remix/edit-related keywords and has a matching closing bracket. - - Args: - title: The title to search in. - keywords: List of keywords to match. If None, uses DEFAULT_BRACKET_KEYWORDS. - If an empty list, matches any bracket content (not just keywords). - - Returns: - The position of the opening bracket, or None if no match found. """ if keywords is None: keywords = DEFAULT_BRACKET_KEYWORDS From 3051af9eb64c60f7334c29fdf58013055cb245b3 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Mon, 17 Nov 2025 12:56:19 -0600 Subject: [PATCH 081/274] fix: abstract insert_ft_into_title, move bracket_keywords and find_bracket_position inside plugin --- beetsplug/ftintitle.py | 117 ++++++++++++++------------------- test/plugins/test_ftintitle.py | 37 +++++++++-- 2 files changed, 79 insertions(+), 75 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 629e58f17..06c5e69be 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -17,6 +17,7 @@ from __future__ import annotations import re +from functools import cached_property from typing import TYPE_CHECKING from beets import plugins, ui @@ -84,58 +85,6 @@ DEFAULT_BRACKET_KEYWORDS = [ ] -def find_bracket_position( - title: str, keywords: list[str] | None = None -) -> int | None: - """Find the position of the first opening bracket that contains - remix/edit-related keywords and has a matching closing bracket. - """ - if keywords is None: - keywords = DEFAULT_BRACKET_KEYWORDS - - # If keywords is empty, match any bracket content - if not keywords: - pattern = None - else: - # Build regex pattern to support multi-word keywords/phrases. - keyword_pattern = rf"\b{'|'.join(map(re.escape, keywords))}\b" - pattern = re.compile(keyword_pattern, re.IGNORECASE) - - # Bracket pairs (opening, closing) - bracket_pairs = [("(", ")"), ("[", "]"), ("<", ">"), ("{", "}")] - - # Track the earliest valid bracket position - earliest_pos = None - - for open_char, close_char in bracket_pairs: - pos = 0 - while True: - # Find next opening bracket - open_pos = title.find(open_char, pos) - if open_pos == -1: - break - - # Find matching closing bracket - close_pos = title.find(close_char, open_pos + 1) - if close_pos == -1: - break - - # Extract content between brackets - content = title[open_pos + 1 : close_pos] - - # Check if content matches: if pattern is None (empty keywords), - # match any content; otherwise check for keywords - if (pattern is None or pattern.search(content)) and ( - earliest_pos is None or open_pos < earliest_pos - ): - earliest_pos = open_pos - - # Continue searching from after this closing bracket - pos = close_pos + 1 - - return earliest_pos - - def find_feat_part( artist: str, albumartist: str | None, @@ -176,6 +125,10 @@ def find_feat_part( class FtInTitlePlugin(plugins.BeetsPlugin): + @cached_property + def bracket_keywords(self) -> list[str] | None: + return self.config["bracket_keywords"].as_str_seq() + def __init__(self) -> None: super().__init__() @@ -216,7 +169,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): bool ) custom_words = self.config["custom_words"].get(list) - bracket_keywords = self.config["bracket_keywords"].get(list) write = ui.should_write() for item in lib.items(args): @@ -226,7 +178,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, - bracket_keywords, ): item.store() if write: @@ -241,7 +192,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field = self.config["keep_in_artist"].get(bool) preserve_album_artist = self.config["preserve_album_artist"].get(bool) custom_words = self.config["custom_words"].get(list) - bracket_keywords = self.config["bracket_keywords"].get(list) for item in task.imported_items(): if self.ft_in_title( @@ -250,7 +200,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field, preserve_album_artist, custom_words, - bracket_keywords, ): item.store() @@ -261,7 +210,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat: bool, keep_in_artist_field: bool, custom_words: list[str], - bracket_keywords: list[str] | None = None, ) -> None: """Choose how to add new artists to the title and set the new metadata. Also, print out messages about any changes that are made. @@ -290,15 +238,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # artist and if we do not drop featuring information. if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() - new_format = feat_format.format(feat_part) - # Insert before the first bracket containing remix/edit keywords - bracket_pos = find_bracket_position(item.title, bracket_keywords) - if bracket_pos is not None: - title_before = item.title[:bracket_pos].rstrip() - title_after = item.title[bracket_pos:] - new_title = f"{title_before} {new_format} {title_after}" - else: - new_title = f"{item.title} {new_format}" + formatted = feat_format.format(feat_part) + new_title = self.insert_ft_into_title(item.title, formatted) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -309,7 +250,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): keep_in_artist_field: bool, preserve_album_artist: bool, custom_words: list[str], - bracket_keywords: list[str] | None = None, ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -346,6 +286,47 @@ class FtInTitlePlugin(plugins.BeetsPlugin): drop_feat, keep_in_artist_field, custom_words, - bracket_keywords, ) return True + + def find_bracket_position( + self, + title: str, + ) -> int | None: + """Find the position of the first opening bracket that contains + remix/edit-related keywords and has a matching closing bracket. + """ + keywords = self.bracket_keywords + + # If keywords is empty, match any bracket content + if not keywords: + keyword_ptn = ".*?" + else: + # Build regex supporting keywords/multi-word phrases. + keyword_ptn = rf"\b{'|'.join(map(re.escape, keywords))}\b" + + pattern = re.compile( + rf""" + \(.*?({keyword_ptn}).*?\) | + \[.*?({keyword_ptn}).*?\] | + <.*?({keyword_ptn}).*?> | + \{{.*?({keyword_ptn}).*?}} + """, + re.IGNORECASE | re.VERBOSE, + ) + + return m.start() if (m := pattern.search(title)) else None + + def insert_ft_into_title( + self, + title: str, + feat_part: str, + ) -> str: + """Insert featured artist before the first bracket containing + remix/edit keywords if present. + """ + if (bracket_pos := self.find_bracket_position(title)) is not None: + title_before = title[:bracket_pos].rstrip() + title_after = title[bracket_pos:] + return f"{title_before} {feat_part} {title_after}" + return f"{title} {feat_part}" diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 065f23ade..73853e6c3 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,7 +15,7 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator -from typing import cast +from typing import TypeAlias, cast import pytest @@ -24,6 +24,8 @@ from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle +ConfigValue: TypeAlias = str | bool | list[str] + class FtInTitlePluginFunctional(PluginTestCase): plugin = "ftintitle" @@ -41,7 +43,7 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]: def set_config( env: FtInTitlePluginFunctional, - cfg: dict[str, str | bool | list[str]] | None, + cfg: dict[str, ConfigValue] | None, ) -> None: cfg = {} if cfg is None else cfg defaults = { @@ -49,11 +51,21 @@ def set_config( "auto": True, "keep_in_artist": False, "custom_words": [], + "bracket_keywords": ftintitle.DEFAULT_BRACKET_KEYWORDS.copy(), } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) +def build_plugin( + env: FtInTitlePluginFunctional, + cfg: dict[str, ConfigValue] | None = None, +) -> ftintitle.FtInTitlePlugin: + """Instantiate plugin with provided config applied first.""" + set_config(env, cfg) + return ftintitle.FtInTitlePlugin() + + def add_item( env: FtInTitlePluginFunctional, path: str, @@ -427,7 +439,7 @@ def test_split_on_feat( ("Song (Live", None), # no matching brace with keyword # one keyword clause, one non-keyword clause ("Song (Live) (Arbitrary)", 5), - ("Song (Arbitrary) (Remix)", 17), + ("Song (Arbitrary) (Remix)", 5), # nested brackets - same type ("Song (Remix (Extended))", 5), ("Song [Arbitrary [Description]]", None), @@ -443,8 +455,13 @@ def test_split_on_feat( ("Song [Description (Arbitrary)]", None), ], ) -def test_find_bracket_position(given: str, expected: int | None) -> None: - assert ftintitle.find_bracket_position(given) == expected +def test_find_bracket_position( + env: FtInTitlePluginFunctional, + given: str, + expected: int | None, +) -> None: + plugin = build_plugin(env) + assert plugin.find_bracket_position(given) == expected @pytest.mark.parametrize( @@ -468,9 +485,15 @@ def test_find_bracket_position(given: str, expected: int | None) -> None: ], ) def test_find_bracket_position_custom_keywords( - given: str, keywords: list[str] | None, expected: int | None + env: FtInTitlePluginFunctional, + given: str, + keywords: list[str] | None, + expected: int | None, ) -> None: - assert ftintitle.find_bracket_position(given, keywords) == expected + cfg: dict[str, ConfigValue] | None + cfg = None if keywords is None else {"bracket_keywords": keywords} + plugin = build_plugin(env, cfg) + assert plugin.find_bracket_position(given) == expected @pytest.mark.parametrize( From aa2dc9005f356b707bc5854848afd23fda0f4f5a Mon Sep 17 00:00:00 2001 From: Ognyan Moore Date: Tue, 18 Nov 2025 23:00:42 +0300 Subject: [PATCH 082/274] Catch ValueError when setting gst required version pytest.importskip is used to catch the case when beetsplug.bpd cannot be imported. On macOS, the gi module was able to be imported, but when trying to specify `gi.require_version`, a ValueError is raised about Gst being unavailable. pytest does not catch this ValueError during importskip as it is not an ImportError, and thus the test suite errors during the test collection phase. With this change, we catch the ValueError, and re-raise it as an ImportError and pytest gracefully skips those tests. --- beetsplug/bpd/gstplayer.py | 11 ++++++++++- docs/changelog.rst | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index fa23f2b0e..f356b3066 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -27,7 +27,16 @@ import gi from beets import ui -gi.require_version("Gst", "1.0") +try: + gi.require_version("Gst", "1.0") +except ValueError as e: + # on some scenarios, gi may be importable, but we get a ValueError when + # trying to specify the required version. This is problematic in the test + # suite where test_bpd.py has a call to + # pytest.importorskip("beetsplug.bpd"). Re-raising as an ImportError + # makes it so the test collector functions as inteded. + raise ImportError from e + from gi.repository import GLib, Gst # noqa: E402 Gst.init(None) diff --git a/docs/changelog.rst b/docs/changelog.rst index c5a0dab53..2f618103f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,6 +66,8 @@ Other changes: - Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into multiple modules within the ``beets/ui/commands`` directory for better maintainability. +- :doc:`plugins/bpd`: Raise ImportError instead of ValueError when GStreamer is + unavailable, enabling ``importorskip`` usage in pytest setup. 2.5.1 (October 14, 2025) ------------------------ From 16c4f6e4331099ffa05d5fc9bbf4f15456bba611 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Thu, 20 Nov 2025 18:48:37 +0100 Subject: [PATCH 083/274] Fix lint --- test/plugins/test_embedart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py index 2b6f59e26..a7038b152 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -26,9 +26,9 @@ from mediafile import MediaFile from beets import config, logging, ui from beets.test import _common from beets.test.helper import ( - ImportHelper, BeetsTestCase, FetchImageHelper, + ImportHelper, IOMixin, PluginMixin, ) From aced802c5644722c8ca87f3419f463541d22a0a8 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Thu, 20 Nov 2025 15:57:22 -0500 Subject: [PATCH 084/274] Fix recursion in inline plugin when item_fields shadow DB fields (#6115) --- beetsplug/inline.py | 13 +++++++++---- test/plugins/test_plugins.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 test/plugins/test_plugins.py diff --git a/beetsplug/inline.py b/beetsplug/inline.py index e9a94ac38..860a205ee 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -61,18 +61,18 @@ class InlinePlugin(BeetsPlugin): config["item_fields"].items(), config["pathfields"].items() ): self._log.debug("adding item field {}", key) - func = self.compile_inline(view.as_str(), False) + func = self.compile_inline(view.as_str(), False, key) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config["album_fields"].items(): self._log.debug("adding album field {}", key) - func = self.compile_inline(view.as_str(), True) + func = self.compile_inline(view.as_str(), True, key) if func is not None: self.album_template_fields[key] = func - def compile_inline(self, python_code, album): + def compile_inline(self, python_code, album, field_name): """Given a Python expression or function body, compile it as a path field function. The returned function takes a single argument, an Item, and returns a Unicode string. If the expression cannot be @@ -97,7 +97,12 @@ class InlinePlugin(BeetsPlugin): is_expr = True def _dict_for(obj): - out = dict(obj) + out = {} + for key in obj.keys(computed=False): + if key == field_name: + continue + out[key] = obj._get(key) + if album: out["items"] = list(obj.items()) return out diff --git a/test/plugins/test_plugins.py b/test/plugins/test_plugins.py new file mode 100644 index 000000000..a606f16ca --- /dev/null +++ b/test/plugins/test_plugins.py @@ -0,0 +1,31 @@ +# test/plugins/test_plugins.py + +from beets import config, plugins +from beets.test.helper import PluginTestCase + +class TestInlineRecursion(PluginTestCase): + def test_no_recursion_when_inline_shadows_fixed_field(self): + config['plugins'] = ['inline'] + + config['item_fields'] = { + 'track_no': ( + "f'{disc:02d}-{track:02d}' if disctotal > 1 " + "else f'{track:02d}'" + ) + } + + plugins._instances.clear() + plugins.load_plugins() + + item = self.add_item_fixture( + artist='Artist', + album='Album', + title='Title', + track=1, + disc=1, + disctotal=1, + ) + + out = item.evaluate_template('$track_no') + + assert out == '01' \ No newline at end of file From ba45fedde581dfe7a0c848bcc1f3c0ef3b5a826d Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Thu, 20 Nov 2025 16:09:01 -0500 Subject: [PATCH 085/274] Fix inline recursion test formatting --- test/plugins/test_plugins.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/plugins/test_plugins.py b/test/plugins/test_plugins.py index a606f16ca..f4baf3663 100644 --- a/test/plugins/test_plugins.py +++ b/test/plugins/test_plugins.py @@ -3,14 +3,14 @@ from beets import config, plugins from beets.test.helper import PluginTestCase + class TestInlineRecursion(PluginTestCase): def test_no_recursion_when_inline_shadows_fixed_field(self): - config['plugins'] = ['inline'] + config["plugins"] = ["inline"] - config['item_fields'] = { - 'track_no': ( - "f'{disc:02d}-{track:02d}' if disctotal > 1 " - "else f'{track:02d}'" + config["item_fields"] = { + "track_no": ( + "f'{disc:02d}-{track:02d}' if disctotal > 1 else f'{track:02d}'" ) } @@ -18,14 +18,14 @@ class TestInlineRecursion(PluginTestCase): plugins.load_plugins() item = self.add_item_fixture( - artist='Artist', - album='Album', - title='Title', + artist="Artist", + album="Album", + title="Title", track=1, disc=1, disctotal=1, ) - out = item.evaluate_template('$track_no') + out = item.evaluate_template("$track_no") - assert out == '01' \ No newline at end of file + assert out == "01" From 9c37f94171ab9a9f180c9206a619ad9d0939de0f Mon Sep 17 00:00:00 2001 From: asardaes Date: Sat, 15 Nov 2025 15:59:35 +0100 Subject: [PATCH 086/274] Add album template value in ftintitle plugin --- beetsplug/ftintitle.py | 13 +++++++++++-- docs/changelog.rst | 1 + docs/plugins/ftintitle.rst | 8 ++++++++ docs/reference/pathformat.rst | 2 ++ test/plugins/test_ftintitle.py | 11 ++++++++++- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index dd681a972..ab841a12c 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -19,11 +19,11 @@ from __future__ import annotations import re from typing import TYPE_CHECKING -from beets import plugins, ui +from beets import config, plugins, ui if TYPE_CHECKING: from beets.importer import ImportSession, ImportTask - from beets.library import Item + from beets.library import Album, Item def split_on_feat( @@ -98,6 +98,11 @@ def find_feat_part( return feat_part +def _album_artist_no_feat(album: Album) -> str: + custom_words = config["ftintitle"]["custom_words"].as_str_seq() + return split_on_feat(album["albumartist"], False, list(custom_words))[0] + + class FtInTitlePlugin(plugins.BeetsPlugin): def __init__(self) -> None: super().__init__() @@ -129,6 +134,10 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if self.config["auto"]: self.import_stages = [self.imported] + self.album_template_fields["album_artist_no_feat"] = ( + _album_artist_no_feat + ) + def commands(self) -> list[ui.Subcommand]: def func(lib, opts, args): self.config.set_args(opts) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2f618103f..d95de38c5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,7 @@ been dropped. New features: - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. +- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``. - :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the genres tag. - :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 1d2ec5c20..3dfbfca27 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -33,6 +33,14 @@ file. The available options are: - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. +Path Template Values +-------------------- + +This plugin provides the ``album_artist_no_feat`` :ref:`template value +` that you can use in your :ref:`path-format-config` in +``paths.default``. Any ``custom_words`` in the configuration are taken into +account. + Running Manually ---------------- diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 30871cf55..10dd3ae05 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -281,6 +281,8 @@ constructs include: - ``$missing`` by :doc:`/plugins/missing`: The number of missing tracks per album. +- ``$album_artist_no_feat`` by :doc:`/plugins/ftintitle`: The album artist + without any featured artists - ``%bucket{text}`` by :doc:`/plugins/bucket`: Substitute a string by the range it belongs to. - ``%the{text}`` by :doc:`/plugins/the`: Moves English articles to ends of diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index b4259666d..6f01601e0 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -18,7 +18,7 @@ from collections.abc import Generator import pytest -from beets.library.models import Item +from beets.library.models import Album, Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -364,3 +364,12 @@ def test_custom_words( if custom_words is None: custom_words = [] assert ftintitle.contains_feat(given, custom_words) is expected + + +def test_album_template_value(): + album = Album() + album["albumartist"] = "Foo ft. Bar" + assert ftintitle._album_artist_no_feat(album) == "Foo" + + album["albumartist"] = "Foobar" + assert ftintitle._album_artist_no_feat(album) == "Foobar" From 2eff2d25f580364b17bdac4d2c75cce6b7e39ecc Mon Sep 17 00:00:00 2001 From: asardaes Date: Sat, 15 Nov 2025 16:31:20 +0100 Subject: [PATCH 087/274] Improve typing for template fields and funcs --- beets/plugins.py | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 810df3a45..b5c3d421b 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -151,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - template_funcs: TFuncMap[str] | None = None - template_fields: TFuncMap[Item] | None = None - album_template_fields: TFuncMap[Album] | None = None + template_funcs: ClassVar[TFuncMap[str]] = {} + template_fields: ClassVar[TFuncMap[Item]] = {} + album_template_fields: ClassVar[TFuncMap[Album]] = {} name: str config: ConfigView @@ -222,11 +222,11 @@ class BeetsPlugin(metaclass=abc.ABCMeta): # Set class attributes if they are not already set # for the type of plugin. if not self.template_funcs: - self.template_funcs = {} + self.template_funcs = {} # type: ignore[misc] if not self.template_fields: - self.template_fields = {} + self.template_fields = {} # type: ignore[misc] if not self.album_template_fields: - self.album_template_fields = {} + self.album_template_fields = {} # type: ignore[misc] self.early_import_stages = [] self.import_stages = [] @@ -368,8 +368,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta): """ def helper(func: TFunc[str]) -> TFunc[str]: - if cls.template_funcs is None: - cls.template_funcs = {} cls.template_funcs[name] = func return func @@ -384,8 +382,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta): """ def helper(func: TFunc[Item]) -> TFunc[Item]: - if cls.template_fields is None: - cls.template_fields = {} cls.template_fields[name] = func return func @@ -565,8 +561,7 @@ def template_funcs() -> TFuncMap[str]: """ funcs: TFuncMap[str] = {} for plugin in find_plugins(): - if plugin.template_funcs: - funcs.update(plugin.template_funcs) + funcs.update(plugin.template_funcs) return funcs @@ -592,21 +587,20 @@ F = TypeVar("F") def _check_conflicts_and_merge( - plugin: BeetsPlugin, plugin_funcs: dict[str, F] | None, funcs: dict[str, F] + plugin: BeetsPlugin, plugin_funcs: dict[str, F], funcs: dict[str, F] ) -> None: """Check the provided template functions for conflicts and merge into funcs. Raises a `PluginConflictError` if a plugin defines template functions for fields that another plugin has already defined template functions for. """ - if plugin_funcs: - if not plugin_funcs.keys().isdisjoint(funcs.keys()): - conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys()) - raise PluginConflictError( - f"Plugin {plugin.name} defines template functions for " - f"{conflicted_fields} that conflict with another plugin." - ) - funcs.update(plugin_funcs) + if not plugin_funcs.keys().isdisjoint(funcs.keys()): + conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys()) + raise PluginConflictError( + f"Plugin {plugin.name} defines template functions for " + f"{conflicted_fields} that conflict with another plugin." + ) + funcs.update(plugin_funcs) def item_field_getters() -> TFuncMap[Item]: From 23a19e94097d40748420c1c13c5078e3d57f73fd Mon Sep 17 00:00:00 2001 From: asardaes Date: Thu, 20 Nov 2025 20:23:30 +0100 Subject: [PATCH 088/274] Remove class variables for template fields and funcs --- beets/plugins.py | 45 +++++++-------------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index b5c3d421b..990fe0874 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -151,9 +151,10 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - template_funcs: ClassVar[TFuncMap[str]] = {} - template_fields: ClassVar[TFuncMap[Item]] = {} - album_template_fields: ClassVar[TFuncMap[Album]] = {} + + template_funcs: TFuncMap[str] + template_fields: TFuncMap[Item] + album_template_fields: TFuncMap[Album] name: str config: ConfigView @@ -219,14 +220,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self.name = name or self.__module__.split(".")[-1] self.config = beets.config[self.name] - # Set class attributes if they are not already set - # for the type of plugin. - if not self.template_funcs: - self.template_funcs = {} # type: ignore[misc] - if not self.template_fields: - self.template_fields = {} # type: ignore[misc] - if not self.album_template_fields: - self.album_template_fields = {} # type: ignore[misc] + self.template_funcs = {} + self.template_fields = {} + self.album_template_fields = {} self.early_import_stages = [] self.import_stages = [] @@ -360,33 +356,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self._set_log_level_and_params(logging.WARNING, func) ) - @classmethod - def template_func(cls, name: str) -> Callable[[TFunc[str]], TFunc[str]]: - """Decorator that registers a path template function. The - function will be invoked as ``%name{}`` from path format - strings. - """ - - def helper(func: TFunc[str]) -> TFunc[str]: - cls.template_funcs[name] = func - return func - - return helper - - @classmethod - def template_field(cls, name: str) -> Callable[[TFunc[Item]], TFunc[Item]]: - """Decorator that registers a path template field computation. - The value will be referenced as ``$name`` from path format - strings. The function must accept a single parameter, the Item - being formatted. - """ - - def helper(func: TFunc[Item]) -> TFunc[Item]: - cls.template_fields[name] = func - return func - - return helper - def get_plugin_names() -> list[str]: """Discover and return the set of plugin names to be loaded. From be0b71043cb0f0fa1cd58555bb2f3efb4f7739a8 Mon Sep 17 00:00:00 2001 From: asardaes Date: Thu, 20 Nov 2025 21:54:25 +0100 Subject: [PATCH 089/274] Revert "Remove class variables for template fields and funcs" This reverts commit a7033fe63b3e039f6ebf23238e9b2257adb0f352. --- beets/plugins.py | 45 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 990fe0874..b5c3d421b 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -151,10 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - - template_funcs: TFuncMap[str] - template_fields: TFuncMap[Item] - album_template_fields: TFuncMap[Album] + template_funcs: ClassVar[TFuncMap[str]] = {} + template_fields: ClassVar[TFuncMap[Item]] = {} + album_template_fields: ClassVar[TFuncMap[Album]] = {} name: str config: ConfigView @@ -220,9 +219,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self.name = name or self.__module__.split(".")[-1] self.config = beets.config[self.name] - self.template_funcs = {} - self.template_fields = {} - self.album_template_fields = {} + # Set class attributes if they are not already set + # for the type of plugin. + if not self.template_funcs: + self.template_funcs = {} # type: ignore[misc] + if not self.template_fields: + self.template_fields = {} # type: ignore[misc] + if not self.album_template_fields: + self.album_template_fields = {} # type: ignore[misc] self.early_import_stages = [] self.import_stages = [] @@ -356,6 +360,33 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self._set_log_level_and_params(logging.WARNING, func) ) + @classmethod + def template_func(cls, name: str) -> Callable[[TFunc[str]], TFunc[str]]: + """Decorator that registers a path template function. The + function will be invoked as ``%name{}`` from path format + strings. + """ + + def helper(func: TFunc[str]) -> TFunc[str]: + cls.template_funcs[name] = func + return func + + return helper + + @classmethod + def template_field(cls, name: str) -> Callable[[TFunc[Item]], TFunc[Item]]: + """Decorator that registers a path template field computation. + The value will be referenced as ``$name`` from path format + strings. The function must accept a single parameter, the Item + being formatted. + """ + + def helper(func: TFunc[Item]) -> TFunc[Item]: + cls.template_fields[name] = func + return func + + return helper + def get_plugin_names() -> list[str]: """Discover and return the set of plugin names to be loaded. From ba18ee2f1461910caed070fd5af674a953201875 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Fri, 21 Nov 2025 17:58:50 +0100 Subject: [PATCH 090/274] Added comment for deprecation in 3.0.0. --- beets/plugins.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index b5c3d421b..0c7bae234 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -151,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - template_funcs: ClassVar[TFuncMap[str]] = {} - template_fields: ClassVar[TFuncMap[Item]] = {} - album_template_fields: ClassVar[TFuncMap[Album]] = {} + template_funcs: ClassVar[TFuncMap[str]] | TFuncMap[str] = {} # type: ignore[valid-type] + template_fields: ClassVar[TFuncMap[Item]] | TFuncMap[Item] = {} # type: ignore[valid-type] + album_template_fields: ClassVar[TFuncMap[Album]] | TFuncMap[Album] = {} # type: ignore[valid-type] name: str config: ConfigView @@ -219,14 +219,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self.name = name or self.__module__.split(".")[-1] self.config = beets.config[self.name] - # Set class attributes if they are not already set - # for the type of plugin. + # If the class attributes are not set, initialize as instance attributes. + # TODO: Revise with v3.0.0, see also type: ignore[valid-type] above if not self.template_funcs: - self.template_funcs = {} # type: ignore[misc] + self.template_funcs = {} if not self.template_fields: - self.template_fields = {} # type: ignore[misc] + self.template_fields = {} if not self.album_template_fields: - self.album_template_fields = {} # type: ignore[misc] + self.album_template_fields = {} self.early_import_stages = [] self.import_stages = [] From ec95c8df2523981c38a9f7782001f10409d620ce Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sun, 23 Nov 2025 09:45:12 -0500 Subject: [PATCH 091/274] preserve the order in which queries were specified in the configuration --- beetsplug/smartplaylist.py | 50 ++++++------- docs/changelog.rst | 4 ++ test/plugins/test_smartplaylist.py | 110 ++++++++++++++++++++++++----- 3 files changed, 123 insertions(+), 41 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 8203ce4ef..c0404b7d6 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -19,8 +19,7 @@ from urllib.parse import quote from urllib.request import pathname2url from beets import ui -from beets.dbcore import OrQuery -from beets.dbcore.query import MultipleSort, ParsingError +from beets.dbcore.query import ParsingError from beets.library import Album, Item, parse_query_string from beets.plugins import BeetsPlugin from beets.plugins import send as send_event @@ -190,25 +189,12 @@ class SmartPlaylistPlugin(BeetsPlugin): elif len(qs) == 1: query_and_sort = parse_query_string(qs[0], model_cls) else: - # multiple queries and sorts - queries, sorts = zip( - *(parse_query_string(q, model_cls) for q in qs) + # multiple queries and sorts - preserve order + # Store tuple of (query, sort) tuples for hashability + queries_and_sorts = tuple( + parse_query_string(q, model_cls) for q in qs ) - query = OrQuery(queries) - final_sorts = [] - for s in sorts: - if s: - if isinstance(s, MultipleSort): - final_sorts += s.sorts - else: - final_sorts.append(s) - if not final_sorts: - sort = None - elif len(final_sorts) == 1: - (sort,) = final_sorts - else: - sort = MultipleSort(final_sorts) - query_and_sort = query, sort + query_and_sort = queries_and_sorts, None playlist_data += (query_and_sort,) @@ -221,10 +207,17 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists.add(playlist_data) def matches(self, model, query, album_query): - if album_query and isinstance(model, Album): + # Handle tuple/list of queries (multiple queries preserving order) + if isinstance(album_query, (list, tuple)) and isinstance(model, Album): + return any(q.match(model) for q, _ in album_query) + elif album_query and isinstance(model, Album): return album_query.match(model) - if query and isinstance(model, Item): + + if isinstance(query, (list, tuple)) and isinstance(model, Item): + return any(q.match(model) for q, _ in query) + elif query and isinstance(model, Item): return query.match(model) + return False def db_change(self, lib, model): @@ -270,9 +263,18 @@ class SmartPlaylistPlugin(BeetsPlugin): self._log.info("Creating playlist {}", name) items = [] - if query: + # Handle tuple/list of queries (preserves order) + if isinstance(query, (list, tuple)): + for q, sort in query: + items.extend(lib.items(q, sort)) + elif query: items.extend(lib.items(query, q_sort)) - if album_query: + + if isinstance(album_query, (list, tuple)): + for q, sort in album_query: + for album in lib.albums(q, sort): + items.extend(album.items()) + elif album_query: for album in lib.albums(album_query, a_q_sort): items.extend(album.items()) diff --git a/docs/changelog.rst b/docs/changelog.rst index 366af9ff0..af9012a23 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,6 +29,10 @@ New features: Bug fixes: +- :doc:`/plugins/smartplaylist`: Fixed an issue where multiple queries in a + playlist configuration were not preserving their order, causing items to + appear in database order rather than the order specified in the config. + :bug:`6183` - When hardlinking from a symlink (e.g. importing a symlink with hardlinking enabled), dereference the symlink then hardlink, rather than creating a new (potentially broken) symlink :bug:`5676` diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index d3569d836..a16313b28 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -22,7 +22,6 @@ from unittest.mock import MagicMock, Mock, PropertyMock import pytest from beets import config -from beets.dbcore import OrQuery from beets.dbcore.query import FixedFieldSort, MultipleSort, NullSort from beets.library import Album, Item, parse_query_string from beets.test.helper import BeetsTestCase, PluginTestCase @@ -54,16 +53,15 @@ class SmartPlaylistTest(BeetsTestCase): foo_foo = parse_query_string("FOO foo", Item) baz_baz = parse_query_string("BAZ baz", Item) baz_baz2 = parse_query_string("BAZ baz", Album) - bar_bar = OrQuery( - ( - parse_query_string("BAR bar1", Album)[0], - parse_query_string("BAR bar2", Album)[0], - ) - ) + # Multiple queries are now stored as a tuple of (query, sort) tuples + bar_queries = tuple([ + parse_query_string("BAR bar1", Album), + parse_query_string("BAR bar2", Album), + ]) assert spl._unmatched_playlists == { ("foo", foo_foo, (None, None)), ("baz", baz_baz, baz_baz2), - ("bar", (None, None), (bar_bar, None)), + ("bar", (None, None), (bar_queries, None)), } def test_build_queries_with_sorts(self): @@ -86,19 +84,28 @@ class SmartPlaylistTest(BeetsTestCase): ) spl.build_queries() - sorts = {name: sort for name, (_, sort), _ in spl._unmatched_playlists} + + # Multiple queries now return a tuple of (query, sort) tuples, not combined + sorts = {} + for name, (query_data, sort), _ in spl._unmatched_playlists: + if isinstance(query_data, tuple): + # Tuple of queries - each has its own sort + sorts[name] = [s for _, s in query_data] + else: + sorts[name] = sort sort = FixedFieldSort # short cut since we're only dealing with this assert sorts["no_sort"] == NullSort() assert sorts["one_sort"] == sort("year") - assert sorts["only_empty_sorts"] is None - assert sorts["one_non_empty_sort"] == sort("year") - assert sorts["multiple_sorts"] == MultipleSort( - [sort("year"), sort("genre", False)] - ) - assert sorts["mixed"] == MultipleSort( - [sort("year"), sort("genre"), sort("id", False)] - ) + # Multiple queries store individual sorts in the tuple + assert sorts["only_empty_sorts"] == [NullSort(), NullSort()] + assert sorts["one_non_empty_sort"] == [sort("year"), NullSort()] + assert sorts["multiple_sorts"] == [sort("year"), sort("genre", False)] + assert sorts["mixed"] == [ + sort("year"), + NullSort(), + MultipleSort([sort("genre"), sort("id", False)]), + ] def test_matches(self): spl = SmartPlaylistPlugin() @@ -122,6 +129,15 @@ class SmartPlaylistTest(BeetsTestCase): assert spl.matches(i, query, a_query) assert spl.matches(a, query, a_query) + # Test with list of queries + q1 = Mock() + q1.match.return_value = False + q2 = Mock() + q2.match.side_effect = {i: True}.__getitem__ + queries_list = [(q1, None), (q2, None)] + assert spl.matches(i, queries_list, None) + assert not spl.matches(a, queries_list, None) + def test_db_changes(self): spl = SmartPlaylistPlugin() @@ -327,6 +343,66 @@ class SmartPlaylistTest(BeetsTestCase): assert content == b"http://beets:8337/item/3/file\n" + def test_playlist_update_multiple_queries_preserve_order(self): + """Test that multiple queries preserve their order in the playlist.""" + spl = SmartPlaylistPlugin() + + # Create three mock items + i1 = Mock(path=b"/item1.mp3") + i1.evaluate_template.return_value = "ordered.m3u" + i2 = Mock(path=b"/item2.mp3") + i2.evaluate_template.return_value = "ordered.m3u" + i3 = Mock(path=b"/item3.mp3") + i3.evaluate_template.return_value = "ordered.m3u" + + lib = Mock() + lib.replacements = CHAR_REPLACE + lib.albums.return_value = [] + + # Set up lib.items to return different items for different queries + q1 = Mock() + q2 = Mock() + q3 = Mock() + + def items_side_effect(query, sort): + if query == q1: + return [i1] + elif query == q2: + return [i2] + elif query == q3: + return [i3] + return [] + + lib.items.side_effect = items_side_effect + + # Create playlist with multiple queries (stored as tuple) + queries_and_sorts = ((q1, None), (q2, None), (q3, None)) + pl = "ordered.m3u", (queries_and_sorts, None), (None, None) + spl._matched_playlists = [pl] + + dir = mkdtemp() + config["smartplaylist"]["relative_to"] = False + config["smartplaylist"]["playlist_dir"] = str(dir) + try: + spl.update_playlists(lib) + except Exception: + rmtree(syspath(dir)) + raise + + # Verify that lib.items was called with queries in the correct order + assert lib.items.call_count == 3 + lib.items.assert_any_call(q1, None) + lib.items.assert_any_call(q2, None) + lib.items.assert_any_call(q3, None) + + m3u_filepath = Path(dir, "ordered.m3u") + assert m3u_filepath.exists() + content = m3u_filepath.read_bytes() + rmtree(syspath(dir)) + + # Items should be in order: i1, i2, i3 + assert content == b"/item1.mp3\n/item2.mp3\n/item3.mp3\n" + class SmartPlaylistCLITest(PluginTestCase): plugin = "smartplaylist" From 0511c4f2021e1962c82e1a180b5342a3a22be6fe Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sun, 23 Nov 2025 09:50:53 -0500 Subject: [PATCH 092/274] cleanup --- beetsplug/smartplaylist.py | 21 ++++++++++++--------- test/plugins/test_smartplaylist.py | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index c0404b7d6..392c635f0 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -159,7 +159,7 @@ class SmartPlaylistPlugin(BeetsPlugin): """ Instantiate queries for the playlists. - Each playlist has 2 queries: one or items one for albums, each with a + Each playlist has 2 queries: one for items, one for albums, each with a sort. We must also remember its name. _unmatched_playlists is a set of tuples (name, (q, q_sort), (album_q, album_q_sort)). @@ -168,7 +168,7 @@ class SmartPlaylistPlugin(BeetsPlugin): More precisely - it will be NullSort when a playlist query ('query' or 'album_query') is a single item or a list with 1 element - - it will be None when there are multiple items i a query + - it will be None when there are multiple items in a query """ self._unmatched_playlists = set() self._matched_playlists = set() @@ -207,16 +207,19 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists.add(playlist_data) def matches(self, model, query, album_query): - # Handle tuple/list of queries (multiple queries preserving order) - if isinstance(album_query, (list, tuple)) and isinstance(model, Album): - return any(q.match(model) for q, _ in album_query) - elif album_query and isinstance(model, Album): + # Handle single query object for Album + if album_query and not isinstance(album_query, (list, tuple)) and isinstance(model, Album): return album_query.match(model) + # Handle tuple/list of queries for Album + elif isinstance(album_query, (list, tuple)) and isinstance(model, Album): + return any(q.match(model) for q, _ in album_query) - if isinstance(query, (list, tuple)) and isinstance(model, Item): - return any(q.match(model) for q, _ in query) - elif query and isinstance(model, Item): + # Handle single query object for Item + if query and not isinstance(query, (list, tuple)) and isinstance(model, Item): return query.match(model) + # Handle tuple/list of queries for Item + elif isinstance(query, (list, tuple)) and isinstance(model, Item): + return any(q.match(model) for q, _ in query) return False diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index a16313b28..79055986f 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -98,7 +98,7 @@ class SmartPlaylistTest(BeetsTestCase): assert sorts["no_sort"] == NullSort() assert sorts["one_sort"] == sort("year") # Multiple queries store individual sorts in the tuple - assert sorts["only_empty_sorts"] == [NullSort(), NullSort()] + assert all(isinstance(x, NullSort) for x in sorts["only_empty_sorts"]) assert sorts["one_non_empty_sort"] == [sort("year"), NullSort()] assert sorts["multiple_sorts"] == [sort("year"), sort("genre", False)] assert sorts["mixed"] == [ From f00bf83f05c83a72f8cbea83ec59c4a46dff1918 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sun, 23 Nov 2025 09:52:26 -0500 Subject: [PATCH 093/274] lint --- beetsplug/smartplaylist.py | 16 +++++++++++++--- test/plugins/test_smartplaylist.py | 10 ++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 392c635f0..47d7414f4 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -208,14 +208,24 @@ class SmartPlaylistPlugin(BeetsPlugin): def matches(self, model, query, album_query): # Handle single query object for Album - if album_query and not isinstance(album_query, (list, tuple)) and isinstance(model, Album): + if ( + album_query + and not isinstance(album_query, (list, tuple)) + and isinstance(model, Album) + ): return album_query.match(model) # Handle tuple/list of queries for Album - elif isinstance(album_query, (list, tuple)) and isinstance(model, Album): + elif isinstance(album_query, (list, tuple)) and isinstance( + model, Album + ): return any(q.match(model) for q, _ in album_query) # Handle single query object for Item - if query and not isinstance(query, (list, tuple)) and isinstance(model, Item): + if ( + query + and not isinstance(query, (list, tuple)) + and isinstance(model, Item) + ): return query.match(model) # Handle tuple/list of queries for Item elif isinstance(query, (list, tuple)) and isinstance(model, Item): diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index 79055986f..1b994c094 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -54,10 +54,12 @@ class SmartPlaylistTest(BeetsTestCase): baz_baz = parse_query_string("BAZ baz", Item) baz_baz2 = parse_query_string("BAZ baz", Album) # Multiple queries are now stored as a tuple of (query, sort) tuples - bar_queries = tuple([ - parse_query_string("BAR bar1", Album), - parse_query_string("BAR bar2", Album), - ]) + bar_queries = tuple( + [ + parse_query_string("BAR bar1", Album), + parse_query_string("BAR bar2", Album), + ] + ) assert spl._unmatched_playlists == { ("foo", foo_foo, (None, None)), ("baz", baz_baz, baz_baz2), From 71f4cc181435157cacae4cc5f797c8df7eb534ab Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sun, 23 Nov 2025 09:59:34 -0500 Subject: [PATCH 094/274] Remove duplicate tracks --- beetsplug/smartplaylist.py | 14 ++++++-- test/plugins/test_smartplaylist.py | 58 ++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 47d7414f4..5fbc4785b 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -277,16 +277,26 @@ class SmartPlaylistPlugin(BeetsPlugin): items = [] # Handle tuple/list of queries (preserves order) + # Track seen items to avoid duplicates when an item matches + # multiple queries + seen_ids = set() + if isinstance(query, (list, tuple)): for q, sort in query: - items.extend(lib.items(q, sort)) + for item in lib.items(q, sort): + if item.id not in seen_ids: + items.append(item) + seen_ids.add(item.id) elif query: items.extend(lib.items(query, q_sort)) if isinstance(album_query, (list, tuple)): for q, sort in album_query: for album in lib.albums(q, sort): - items.extend(album.items()) + for item in album.items(): + if item.id not in seen_ids: + items.append(item) + seen_ids.add(item.id) elif album_query: for album in lib.albums(album_query, a_q_sort): items.extend(album.items()) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index 1b994c094..ad9f056cc 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -350,11 +350,11 @@ class SmartPlaylistTest(BeetsTestCase): spl = SmartPlaylistPlugin() # Create three mock items - i1 = Mock(path=b"/item1.mp3") + i1 = Mock(path=b"/item1.mp3", id=1) i1.evaluate_template.return_value = "ordered.m3u" - i2 = Mock(path=b"/item2.mp3") + i2 = Mock(path=b"/item2.mp3", id=2) i2.evaluate_template.return_value = "ordered.m3u" - i3 = Mock(path=b"/item3.mp3") + i3 = Mock(path=b"/item3.mp3", id=3) i3.evaluate_template.return_value = "ordered.m3u" lib = Mock() @@ -405,6 +405,58 @@ class SmartPlaylistTest(BeetsTestCase): # Items should be in order: i1, i2, i3 assert content == b"/item1.mp3\n/item2.mp3\n/item3.mp3\n" + def test_playlist_update_multiple_queries_no_duplicates(self): + """Test that items matching multiple queries only appear once.""" + spl = SmartPlaylistPlugin() + + # Create two mock items + i1 = Mock(path=b"/item1.mp3", id=1) + i1.evaluate_template.return_value = "dedup.m3u" + i2 = Mock(path=b"/item2.mp3", id=2) + i2.evaluate_template.return_value = "dedup.m3u" + + lib = Mock() + lib.replacements = CHAR_REPLACE + lib.albums.return_value = [] + + # Set up lib.items so both queries return overlapping items + q1 = Mock() + q2 = Mock() + + def items_side_effect(query, sort): + if query == q1: + return [i1, i2] # Both items match q1 + elif query == q2: + return [i2] # Only i2 matches q2 + return [] + + lib.items.side_effect = items_side_effect + + # Create playlist with multiple queries (stored as tuple) + queries_and_sorts = ((q1, None), (q2, None)) + pl = "dedup.m3u", (queries_and_sorts, None), (None, None) + spl._matched_playlists = [pl] + + dir = mkdtemp() + config["smartplaylist"]["relative_to"] = False + config["smartplaylist"]["playlist_dir"] = str(dir) + try: + spl.update_playlists(lib) + except Exception: + rmtree(syspath(dir)) + raise + + m3u_filepath = Path(dir, "dedup.m3u") + assert m3u_filepath.exists() + content = m3u_filepath.read_bytes() + rmtree(syspath(dir)) + + # i2 should only appear once even though it matches both queries + # Order should be: i1 (from q1), i2 (from q1, skipped in q2) + assert content == b"/item1.mp3\n/item2.mp3\n" + # Verify i2 is not duplicated + assert content.count(b"/item2.mp3") == 1 + class SmartPlaylistCLITest(PluginTestCase): plugin = "smartplaylist" From 4a17901c1d28fb3b7cc91a941915c45d335b654e Mon Sep 17 00:00:00 2001 From: Stefano Rivera Date: Sun, 23 Nov 2025 13:50:57 -0400 Subject: [PATCH 095/274] reflink() doesn't take Path parameters Fix `test_successful_reflink`, by passing the right kinds of parameters. This was failing inside the reflink package: ``` /usr/lib/python3/dist-packages/reflink/reflink.py:34: in reflink backend.clone(oldpath, newpath) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ oldpath = PosixPath('/tmp/tmpx3jirmhp/testfile') newpath = PosixPath('/tmp/tmpx3jirmhp/testfile.dest') def clone(oldpath, newpath): if isinstance(oldpath, unicode): oldpath = oldpath.encode(sys.getfilesystemencoding()) if isinstance(newpath, unicode): newpath = newpath.encode(sys.getfilesystemencoding()) > newpath_c = ffi.new('char[]', newpath) ^^^^^^^^^^^^^^^^^^^^^^^^^^ E TypeError: expected new array length or list/tuple/str, not PosixPath ``` --- test/test_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_files.py b/test/test_files.py index 631b56b72..d0d93987c 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -579,7 +579,7 @@ class SafeMoveCopyTest(FilePathTestCase): @NEEDS_REFLINK def test_successful_reflink(self): - util.reflink(self.path, self.dest) + util.reflink(str(self.path), str(self.dest)) assert self.dest.exists() assert self.path.exists() From b9023521390aa372473d10ac28e3d6c7b93b3335 Mon Sep 17 00:00:00 2001 From: henry <137741507+henry-oberholtzer@users.noreply.github.com> Date: Sun, 23 Nov 2025 10:34:05 -0800 Subject: [PATCH 096/274] New Plugin: Titlecase (#6133) This plugin aims to address the shortcomings of the %title function, as brought up in issues #152, #3298 and an initial look to improvement with #3411. It supplies a new string format command, `%titlecase` which doesn't interfere with any prior expected behavior of the `%title` format command. It also adds the ability to apply titlecase logic to metadata fields that a user selects, which is useful if you, like me, are looking for stylistic consistency and the minor stylistic differences between Musizbrainz, Discogs, Deezer etc, with title case are slightly infuriating. This will add an optional dependency of [titlecase](https://pypi.org/project/titlecase/), which allows the titlecase core logic to be externally maintained. If there's not enough draw to have this as a core plugin, I can also spin this into an independent one, but it seemed like a recurring theme that the %title string format didn't really behave as expected, and I wanted my metadata to match too. - [x] Documentation. (If you've added a new command-line flag, for example, find the appropriate page under `docs/` to describe it.) - [x] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of one of the lists near the top of the document.) - [x] Tests. - Not 100% coverage, but didn't see a lot of other plugins with testing for import stages. --- .github/CODEOWNERS | 3 +- beetsplug/titlecase.py | 236 +++++++++++++++++++ docs/changelog.rst | 2 + docs/plugins/index.rst | 1 + docs/plugins/titlecase.rst | 200 +++++++++++++++++ poetry.lock | 25 ++- pyproject.toml | 3 + test/plugins/test_titlecase.py | 400 +++++++++++++++++++++++++++++++++ 8 files changed, 868 insertions(+), 2 deletions(-) create mode 100644 beetsplug/titlecase.py create mode 100644 docs/plugins/titlecase.rst create mode 100644 test/plugins/test_titlecase.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d014b925b..fe4ce3378 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,4 +3,5 @@ # Specific ownerships: /beets/metadata_plugins.py @semohr -/beetsplug/mbpseudo.py @asardaes \ No newline at end of file +/beetsplug/titlecase.py @henry-oberholtzer +/beetsplug/mbpseudo.py @asardaes diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py new file mode 100644 index 000000000..2482e1c34 --- /dev/null +++ b/beetsplug/titlecase.py @@ -0,0 +1,236 @@ +# This file is part of beets. +# Copyright 2025, Henry Oberholtzer +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Apply NYT manual of style title case rules, to text. +Title case logic is derived from the python-titlecase library. +Provides a template function and a tag modification function.""" + +import re +from functools import cached_property +from typing import TypedDict + +from titlecase import titlecase + +from beets import ui +from beets.autotag.hooks import AlbumInfo, Info +from beets.importer import ImportSession, ImportTask +from beets.library import Item +from beets.plugins import BeetsPlugin + +__author__ = "henryoberholtzer@gmail.com" +__version__ = "1.0" + + +class PreservedText(TypedDict): + words: dict[str, str] + phrases: dict[str, re.Pattern[str]] + + +class TitlecasePlugin(BeetsPlugin): + def __init__(self) -> None: + super().__init__() + + self.config.add( + { + "auto": True, + "preserve": [], + "fields": [], + "replace": [], + "seperators": [], + "force_lowercase": False, + "small_first_last": True, + "the_artist": True, + "after_choice": False, + } + ) + + """ + auto - Automatically apply titlecase to new import metadata. + preserve - Provide a list of strings with specific case requirements. + fields - Fields to apply titlecase to. + replace - List of pairs, first is the target, second is the replacement + seperators - Other characters to treat like periods. + force_lowercase - Lowercases the string before titlecasing. + small_first_last - If small characters should be cased at the start of strings. + the_artist - If the plugin infers the field to be an artist field + (e.g. the field contains "artist") + It will capitalize a lowercase The, helpful for the artist names + that start with 'The', like 'The Who' or 'The Talking Heads' when + they are not at the start of a string. Superceded by preserved phrases. + """ + # Register template function + self.template_funcs["titlecase"] = self.titlecase + + # Register UI subcommands + self._command = ui.Subcommand( + "titlecase", + help="Apply titlecasing to metadata specified in config.", + ) + + if self.config["auto"].get(bool): + if self.config["after_choice"].get(bool): + self.import_stages = [self.imported] + else: + self.register_listener( + "trackinfo_received", self.received_info_handler + ) + self.register_listener( + "albuminfo_received", self.received_info_handler + ) + + @cached_property + def force_lowercase(self) -> bool: + return self.config["force_lowercase"].get(bool) + + @cached_property + def replace(self) -> list[tuple[str, str]]: + return self.config["replace"].as_pairs() + + @cached_property + def the_artist(self) -> bool: + return self.config["the_artist"].get(bool) + + @cached_property + def fields_to_process(self) -> set[str]: + fields = set(self.config["fields"].as_str_seq()) + self._log.debug(f"fields: {', '.join(fields)}") + return fields + + @cached_property + def preserve(self) -> PreservedText: + strings = self.config["preserve"].as_str_seq() + preserved: PreservedText = {"words": {}, "phrases": {}} + for s in strings: + if " " in s: + preserved["phrases"][s] = re.compile( + rf"\b{re.escape(s)}\b", re.IGNORECASE + ) + else: + preserved["words"][s.upper()] = s + return preserved + + @cached_property + def seperators(self) -> re.Pattern[str] | None: + if seperators := "".join( + dict.fromkeys(self.config["seperators"].as_str_seq()) + ): + return re.compile(rf"(.*?[{re.escape(seperators)}]+)(\s*)(?=.)") + return None + + @cached_property + def small_first_last(self) -> bool: + return self.config["small_first_last"].get(bool) + + @cached_property + def the_artist_regexp(self) -> re.Pattern[str]: + return re.compile(r"\bthe\b") + + def titlecase_callback(self, word, **kwargs) -> str | None: + """Callback function for words to preserve case of.""" + if preserved_word := self.preserve["words"].get(word.upper(), ""): + return preserved_word + return None + + def received_info_handler(self, info: Info): + """Calls titlecase fields for AlbumInfo or TrackInfo + Processes the tracks field for AlbumInfo + """ + self.titlecase_fields(info) + if isinstance(info, AlbumInfo): + for track in info.tracks: + self.titlecase_fields(track) + + def commands(self) -> list[ui.Subcommand]: + def func(lib, opts, args): + write = ui.should_write() + for item in lib.items(args): + self._log.info(f"titlecasing {item.title}:") + self.titlecase_fields(item) + item.store() + if write: + item.try_write() + + self._command.func = func + return [self._command] + + def titlecase_fields(self, item: Item | Info) -> None: + """Applies titlecase to fields, except + those excluded by the default exclusions and the + set exclude lists. + """ + for field in self.fields_to_process: + init_field = getattr(item, field, "") + if init_field: + if isinstance(init_field, list) and isinstance( + init_field[0], str + ): + cased_list: list[str] = [ + self.titlecase(i, field) for i in init_field + ] + if cased_list != init_field: + setattr(item, field, cased_list) + self._log.info( + f"{field}: {', '.join(init_field)} ->", + f"{', '.join(cased_list)}", + ) + elif isinstance(init_field, str): + cased: str = self.titlecase(init_field, field) + if cased != init_field: + setattr(item, field, cased) + self._log.info(f"{field}: {init_field} -> {cased}") + else: + self._log.debug(f"{field}: no string present") + else: + self._log.debug(f"{field}: does not exist on {type(item)}") + + def titlecase(self, text: str, field: str = "") -> str: + """Titlecase the given text.""" + # Check we should split this into two substrings. + if self.seperators: + if len(splits := self.seperators.findall(text)): + split_cased = "".join( + [self.titlecase(s[0], field) + s[1] for s in splits] + ) + # Add on the remaining portion + return split_cased + self.titlecase( + text[len(split_cased) :], field + ) + # Any necessary replacements go first, mainly punctuation. + titlecased = text.lower() if self.force_lowercase else text + for pair in self.replace: + target, replacement = pair + titlecased = titlecased.replace(target, replacement) + # General titlecase operation + titlecased = titlecase( + titlecased, + small_first_last=self.small_first_last, + callback=self.titlecase_callback, + ) + # Apply "The Artist" feature + if self.the_artist and "artist" in field: + titlecased = self.the_artist_regexp.sub("The", titlecased) + # More complicated phrase replacements. + for phrase, regexp in self.preserve["phrases"].items(): + titlecased = regexp.sub(phrase, titlecased) + return titlecased + + def imported(self, session: ImportSession, task: ImportTask) -> None: + """Import hook for titlecasing on import.""" + for item in task.imported_items(): + try: + self._log.debug(f"titlecasing {item.title}:") + self.titlecase_fields(item) + item.store() + except Exception as e: + self._log.debug(f"titlecasing exception {e}") diff --git a/docs/changelog.rst b/docs/changelog.rst index d95de38c5..d1a0e8c7f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,8 @@ New features: - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. +- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to + resolve differences in metadata source styles. Bug fixes: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index c211616e4..4a2fce473 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -128,6 +128,7 @@ databases. They share the following configuration options: substitute the thumbnails + titlecase types unimported web diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst new file mode 100644 index 000000000..c35bc10a4 --- /dev/null +++ b/docs/plugins/titlecase.rst @@ -0,0 +1,200 @@ +Titlecase Plugin +================ + +The ``titlecase`` plugin lets you format tags and paths in accordance with the +titlecase guidelines in the `New York Times Manual of Style`_ and uses the +`python titlecase library`_. + +Motivation for this plugin comes from a desire to resolve differences in style +between databases sources. For example, `MusicBrainz style`_ follows standard +title case rules, except in the case of terms that are deemed generic, like +"mix" and "remix". On the other hand, `Discogs guidelines`_ recommend +capitalizing the first letter of each word, even for small words like "of" and +"a". This plugin aims to achieve a middle ground between disparate approaches to +casing, and bring more consistency to titles in your library. + +.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005006334-Database-Guidelines-1-General-Rules#Capitalization_And_Grammar + +.. _musicbrainz style: https://musicbrainz.org/doc/Style + +.. _new york times manual of style: https://search.worldcat.org/en/title/946964415 + +.. _python titlecase library: https://pypi.org/project/titlecase/ + +Installation +------------ + +To use the ``titlecase`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``titlecase`` extra: + +.. code-block:: bash + + pip install "beets[titlecase]" + +If you'd like to just use the path format expression, call ``%titlecase`` in +your path formatter, and set ``auto`` to ``no`` in the configuration. + +:: + + paths: + default: %titlecase($albumartist)/$titlecase($albumtitle)/$track $title + +You can now configure ``titlecase`` to your preference. + +Configuration +------------- + +This plugin offers several configuration options to tune its function to your +preference. + +Default +~~~~~~~ + +.. code-block:: yaml + + titlecase: + auto: yes + fields: [] + preserve: [] + replace: [] + seperators: [] + force_lowercase: no + small_first_last: yes + the_artist: yes + after_choice: no + +.. conf:: auto + :default: yes + + Whether to automatically apply titlecase to new imports. + +.. conf:: fields + :default: [] + + A list of fields to apply the titlecase logic to. You must specify the fields + you want to have modified in order for titlecase to apply changes to metadata. + + A good starting point is below, which will titlecase album titles, track titles, and all artist fields. + +.. code-block:: yaml + + titlecase: + fields: + - album + - title + - albumartist + - albumartist_credit + - albumartist_sort + - albumartists + - albumartists_credit + - albumartists_sort + - artist + - artist_credit + - artist_sort + - artists + - artists_credit + - artists_sort + +.. conf:: preserve + :default: [] + + List of words and phrases to preserve the case of. Without specifying ``DJ`` on + the list, titlecase will format it as ``Dj``, or specify ``The Beatles`` to make sure + ``With The Beatles`` is not capitalized as ``With the Beatles``. + +.. conf:: replace + :default: [] + + The replace function takes place before any titlecasing occurs, and is intended to + help normalize differences in puncuation styles. It accepts a list of tuples, with + the first being the target, and the second being the replacement. + + An example configuration that enforces one style of quotation mark is below. + +.. code-block:: yaml + + titlecase: + replace: + - "’": "'" + - "‘": "'" + - "“": '"' + - "”": '"' + +.. conf:: seperators + :default: [] + + A list of characters to treat as markers of new sentences. Helpful for split titles + that might otherwise have a lowercase letter at the start of the second string. + +.. conf:: force_lowercase + :default: no + + Force all strings to lowercase before applying titlecase, but can cause + problems with all caps acronyms titlecase would otherwise recognize. + +.. conf:: small_first_last + :default: yes + + An option from the base titlecase library. Controls capitalizing small words at the start + of a sentence. With this turned off ``a`` and similar words will not be capitalized + under any circumstance. + +.. conf:: the_artist + :default: yes + + If a field name contains ``artist``, then any lowercase ``the`` will be + capitalized. Useful for bands with `The` as part of the proper name, + like ``Amyl and The Sniffers``. + +.. conf:: after_choice + :default: no + + By default, titlecase runs on the candidates that are received, adjusting them before + you make your selection and creating different weight calculations. If you'd rather + see the data as recieved from the database, set this to true to run after you make + your tag choice. + +Dangerous Fields +~~~~~~~~~~~~~~~~ + +``titlecase`` only ever modifies string fields, however, this doesn't prevent +you from selecting a case sensitive field that another plugin or feature may +rely on. + +In particular, including any of the following in your configuration could lead +to unintended behavior: + +.. code-block:: bash + + acoustid_fingerprint + acoustid_id + artists_ids + asin + deezer_track_id + format + id + isrc + mb_workid + mb_trackid + mb_albumid + mb_artistid + mb_artistids + mb_albumartistid + mb_albumartistids + mb_releasetrackid + mb_releasegroupid + bitrate_mode + encoder_info + encoder_settings + +Running Manually +---------------- + +From the command line, type: + +:: + + $ beet titlecase [QUERY] + +Configuration is drawn from the config file. Without a query the operation will +be applied to the entire collection. diff --git a/poetry.lock b/poetry.lock index 9426ad659..ba16420c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2471,6 +2471,8 @@ files = [ {file = "pycairo-1.28.0-cp313-cp313-win32.whl", hash = "sha256:d13352429d8a08a1cb3607767d23d2fb32e4c4f9faa642155383980ec1478c24"}, {file = "pycairo-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:082aef6b3a9dcc328fa648d38ed6b0a31c863e903ead57dd184b2e5f86790140"}, {file = "pycairo-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:026afd53b75291917a7412d9fe46dcfbaa0c028febd46ff1132d44a53ac2c8b6"}, + {file = "pycairo-1.28.0-cp314-cp314-win32.whl", hash = "sha256:d0ab30585f536101ad6f09052fc3895e2a437ba57531ea07223d0e076248025d"}, + {file = "pycairo-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:94f2ed204999ab95a0671a0fa948ffbb9f3d6fb8731fe787917f6d022d9c1c0f"}, {file = "pycairo-1.28.0-cp39-cp39-win32.whl", hash = "sha256:3ed16d48b8a79cc584cb1cb0ad62dfb265f2dda6d6a19ef5aab181693e19c83c"}, {file = "pycairo-1.28.0-cp39-cp39-win_amd64.whl", hash = "sha256:da0d1e6d4842eed4d52779222c6e43d254244a486ca9fdab14e30042fd5bdf28"}, {file = "pycairo-1.28.0-cp39-cp39-win_arm64.whl", hash = "sha256:458877513eb2125513122e8aa9c938630e94bb0574f94f4fb5ab55eb23d6e9ac"}, @@ -2821,6 +2823,13 @@ description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, @@ -3896,6 +3905,19 @@ files = [ {file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"}, ] +[[package]] +name = "titlecase" +version = "2.4.1" +description = "Python Port of John Gruber's titlecase.pl" +optional = false +python-versions = ">=3.7" +files = [ + {file = "titlecase-2.4.1.tar.gz", hash = "sha256:7d83a277ccbbda11a2944e78a63e5ccaf3d32f828c594312e4862f9a07f635f5"}, +] + +[package.extras] +regex = ["regex (>=2020.4.4)"] + [[package]] name = "toml" version = "0.10.2" @@ -4161,9 +4183,10 @@ replaygain = ["PyGObject"] scrub = ["mutagen"] sonosupdate = ["soco"] thumbnails = ["Pillow", "pyxdg"] +titlecase = ["titlecase"] web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "10a60daf371ba5d2c3d62ab0da7be81af40890517f9f60ed4a2cee1835eea6ae" +content-hash = "9e154214b2f404415ef17df83f926a326ffb62a83b3901a404946110354d4067" diff --git a/pyproject.toml b/pyproject.toml index e4b69b7f3..8b33e9fcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,7 @@ pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } sphinx-design = { version = ">=0.6.1", optional = true } sphinx-copybutton = { version = ">=0.5.2", optional = true } +titlecase = {version = "^2.4.1", optional = true} [tool.poetry.group.test.dependencies] beautifulsoup4 = "*" @@ -112,6 +113,7 @@ rarfile = "*" requests-mock = ">=1.12.1" requests_oauthlib = "*" responses = ">=0.3.0" +titlecase = "^2.4.1" [tool.poetry.group.lint.dependencies] docstrfmt = ">=1.11.1" @@ -172,6 +174,7 @@ replaygain = [ ] # python-gi and GStreamer 1.0+ or mp3gain/aacgain or Python Audio Tools or ffmpeg scrub = ["mutagen"] sonosupdate = ["soco"] +titlecase = ["titlecase"] thumbnails = ["Pillow", "pyxdg"] web = ["flask", "flask-cors"] diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py new file mode 100644 index 000000000..44058780c --- /dev/null +++ b/test/plugins/test_titlecase.py @@ -0,0 +1,400 @@ +# This file is part of beets. +# Copyright 2025, Henry Oberholtzer +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Tests for the 'titlecase' plugin""" + +from unittest.mock import patch + +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.importer import ImportSession, ImportTask +from beets.library import Item +from beets.test.helper import PluginTestCase +from beetsplug.titlecase import TitlecasePlugin + +titlecase_fields_testcases = [ + ( + { + "fields": [ + "artist", + "albumartist", + "title", + "album", + "mb_albumd", + "year", + ], + "force_lowercase": True, + }, + Item( + artist="OPHIDIAN", + albumartist="ophiDIAN", + format="CD", + year=2003, + album="BLACKBOX", + title="KhAmElEoN", + ), + Item( + artist="Ophidian", + albumartist="Ophidian", + format="CD", + year=2003, + album="Blackbox", + title="Khameleon", + ), + ), +] + + +class TestTitlecasePlugin(PluginTestCase): + plugin = "titlecase" + preload_plugin = False + + def test_auto(self): + """Ensure automatic processing gets assigned""" + with self.configure_plugin({"auto": True, "after_choice": True}): + assert callable(TitlecasePlugin().import_stages[0]) + with self.configure_plugin({"auto": False, "after_choice": False}): + assert len(TitlecasePlugin().import_stages) == 0 + with self.configure_plugin({"auto": False, "after_choice": True}): + assert len(TitlecasePlugin().import_stages) == 0 + + def test_basic_titlecase(self): + """Check that default behavior is as expected.""" + testcases = [ + ("a", "A"), + ("PENDULUM", "Pendulum"), + ("Aaron-carl", "Aaron-Carl"), + ("LTJ bukem", "LTJ Bukem"), + ("(original mix)", "(Original Mix)"), + ("ALL CAPS TITLE", "All Caps Title"), + ] + for testcase in testcases: + given, expected = testcase + assert TitlecasePlugin().titlecase(given) == expected + + def test_small_first_last(self): + """Check the behavior for supporting small first last""" + testcases = [ + (True, "In a Silent Way", "In a Silent Way"), + (False, "In a Silent Way", "in a Silent Way"), + ] + for testcase in testcases: + sfl, given, expected = testcase + cfg = {"small_first_last": sfl} + with self.configure_plugin(cfg): + assert TitlecasePlugin().titlecase(given) == expected + + def test_preserve(self): + """Test using given strings to preserve case""" + preserve_list = [ + "easyFun", + "A.D.O.R", + "D'Angelo", + "ABBA", + "LaTeX", + "O.R.B", + "PinkPantheress", + "THE PSYCHIC ED RUSH", + "LTJ Bukem", + ] + for word in preserve_list: + with self.configure_plugin({"preserve": preserve_list}): + assert TitlecasePlugin().titlecase(word.upper()) == word + assert TitlecasePlugin().titlecase(word.lower()) == word + + def test_seperators(self): + testcases = [ + ([], "it / a / in / of / to / the", "It / a / in / of / to / The"), + (["/"], "it / the test", "It / The Test"), + ( + ["/"], + "it / a / in / of / to / the", + "It / A / In / Of / To / The", + ), + (["/"], "//it/a/in/of/to/the", "//It/A/In/Of/To/The"), + ( + ["/", ";", "|"], + "it ; a / in | of / to | the", + "It ; A / In | Of / To | The", + ), + ] + for testcase in testcases: + seperators, given, expected = testcase + with self.configure_plugin({"seperators": seperators}): + assert TitlecasePlugin().titlecase(given) == expected + + def test_received_info_handler(self): + testcases = [ + ( + TrackInfo( + album="test album", + artist_credit="test artist credit", + artists=["artist one", "artist two"], + ), + TrackInfo( + album="Test Album", + artist_credit="Test Artist Credit", + artists=["Artist One", "Artist Two"], + ), + ), + ( + AlbumInfo( + tracks=[ + TrackInfo( + album="test album", + artist_credit="test artist credit", + artists=["artist one", "artist two"], + ) + ], + album="test album", + artist_credit="test artist credit", + artists=["artist one", "artist two"], + ), + AlbumInfo( + tracks=[ + TrackInfo( + album="Test Album", + artist_credit="Test Artist Credit", + artists=["Artist One", "Artist Two"], + ) + ], + album="Test Album", + artist_credit="Test Artist Credit", + artists=["Artist One", "Artist Two"], + ), + ), + ] + cfg = {"fields": ["album", "artist_credit", "artists"]} + for testcase in testcases: + given, expected = testcase + with self.configure_plugin(cfg): + TitlecasePlugin().received_info_handler(given) + assert given == expected + + def test_titlecase_fields(self): + testcases = [ + # Test with preserve, replace, and mb_albumid + # Test with the_artist + ( + { + "preserve": ["D'Angelo"], + "replace": [("’", "'")], + "fields": ["artist", "albumartist", "mb_albumid"], + }, + Item( + artist="d’angelo and the vanguard", + mb_albumid="ab140e13-7b36-402a-a528-b69e3dee38a8", + albumartist="d’angelo", + format="CD", + album="the black messiah", + title="Till It's Done (Tutu)", + ), + Item( + artist="D'Angelo and The Vanguard", + mb_albumid="Ab140e13-7b36-402a-A528-B69e3dee38a8", + albumartist="D'Angelo", + format="CD", + album="the black messiah", + title="Till It's Done (Tutu)", + ), + ), + # Test with force_lowercase, preserve, and an incorrect field + ( + { + "force_lowercase": True, + "fields": [ + "artist", + "albumartist", + "format", + "title", + "year", + "label", + "format", + "INCORRECT_FIELD", + ], + "preserve": ["CD"], + }, + Item( + artist="OPHIDIAN", + albumartist="OphiDIAN", + format="cd", + year=2003, + album="BLACKBOX", + title="KhAmElEoN", + label="enzyme records", + ), + Item( + artist="Ophidian", + albumartist="Ophidian", + format="CD", + year=2003, + album="Blackbox", + title="Khameleon", + label="Enzyme Records", + ), + ), + # Test with no changes + ( + { + "fields": [ + "artist", + "artists", + "albumartist", + "format", + "title", + "year", + "label", + "format", + "INCORRECT_FIELD", + ], + "preserve": ["CD"], + }, + Item( + artist="Ophidian", + artists=["Ophidian"], + albumartist="Ophidian", + format="CD", + year=2003, + album="Blackbox", + title="Khameleon", + label="Enzyme Records", + ), + Item( + artist="Ophidian", + artists=["Ophidian"], + albumartist="Ophidian", + format="CD", + year=2003, + album="Blackbox", + title="Khameleon", + label="Enzyme Records", + ), + ), + # Test with the_artist disabled + ( + { + "the_artist": False, + "fields": [ + "artist", + "artists_sort", + ], + }, + Item( + artists_sort=["b-52s, the"], + artist="a day in the park", + ), + Item( + artists_sort=["B-52s, The"], + artist="A Day in the Park", + ), + ), + # Test to make sure preserve and the_artist + # dont target the middle of sentences + # show that The artist applies to any field + # with artist mentioned + ( + { + "preserve": ["PANTHER"], + "fields": ["artist", "artists", "artists_ids"], + }, + Item( + artist="pinkpantheress", + artists=["pinkpantheress", "artist_two"], + artists_ids=["the the", "the the"], + ), + Item( + artist="Pinkpantheress", + artists=["Pinkpantheress", "Artist_two"], + artists_ids=["The The", "The The"], + ), + ), + ] + for testcase in testcases: + cfg, given, expected = testcase + with self.configure_plugin(cfg): + TitlecasePlugin().titlecase_fields(given) + assert given.artist == expected.artist + assert given.artists == expected.artists + assert given.artists_sort == expected.artists_sort + assert given.albumartist == expected.albumartist + assert given.artists_ids == expected.artists_ids + assert given.format == expected.format + assert given.year == expected.year + assert given.title == expected.title + assert given.label == expected.label + + def test_cli_write(self): + given = Item( + album="retrodelica 2: back 2 the future", + artist="blue planet corporation", + title="generator", + ) + expected = Item( + album="Retrodelica 2: Back 2 the Future", + artist="Blue Planet Corporation", + title="Generator", + ) + cfg = {"fields": ["album", "artist", "title"]} + with self.configure_plugin(cfg): + given.add(self.lib) + self.run_command("titlecase") + assert self.lib.items().get().artist == expected.artist + assert self.lib.items().get().album == expected.album + assert self.lib.items().get().title == expected.title + self.lib.items().get().remove() + + def test_cli_no_write(self): + given = Item( + album="retrodelica 2: back 2 the future", + artist="blue planet corporation", + title="generator", + ) + expected = Item( + album="retrodelica 2: back 2 the future", + artist="blue planet corporation", + title="generator", + ) + cfg = {"fields": ["album", "artist", "title"]} + with self.configure_plugin(cfg): + given.add(self.lib) + self.run_command("-p", "titlecase") + assert self.lib.items().get().artist == expected.artist + assert self.lib.items().get().album == expected.album + assert self.lib.items().get().title == expected.title + self.lib.items().get().remove() + + def test_imported(self): + given = Item( + album="retrodelica 2: back 2 the future", + artist="blue planet corporation", + title="generator", + ) + expected = Item( + album="Retrodelica 2: Back 2 the Future", + artist="Blue Planet Corporation", + title="Generator", + ) + p = patch("beets.importer.ImportTask.imported_items", lambda x: [given]) + p.start() + with self.configure_plugin({"fields": ["album", "artist", "title"]}): + import_session = ImportSession( + self.lib, loghandler=None, paths=None, query=None + ) + import_task = ImportTask(toppath=None, paths=None, items=[given]) + TitlecasePlugin().imported(import_session, import_task) + import_task.add(self.lib) + item = self.lib.items().get() + assert item.artist == expected.artist + assert item.album == expected.album + assert item.title == expected.title + p.stop() From 13f95dcf3a43ba9bffab4ecf210de2afa6d232da Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:12:36 -0500 Subject: [PATCH 097/274] Added documentation header --- test/plugins/{test_plugins.py => test_inline.py} | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) rename test/plugins/{test_plugins.py => test_inline.py} (53%) diff --git a/test/plugins/test_plugins.py b/test/plugins/test_inline.py similarity index 53% rename from test/plugins/test_plugins.py rename to test/plugins/test_inline.py index f4baf3663..fb6c038d0 100644 --- a/test/plugins/test_plugins.py +++ b/test/plugins/test_inline.py @@ -1,4 +1,16 @@ -# test/plugins/test_plugins.py +# This file is part of beets. +# Copyright 2025, Gabe Push. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. from beets import config, plugins from beets.test.helper import PluginTestCase From e827d43213d99a6938812ea90d19ca2ea337ebac Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:34:24 -0500 Subject: [PATCH 098/274] Fixed unit tests --- test/plugins/test_inline.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index fb6c038d0..5f4ded8f6 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -14,7 +14,7 @@ from beets import config, plugins from beets.test.helper import PluginTestCase - +from beetsplug.inline import InlinePlugin class TestInlineRecursion(PluginTestCase): def test_no_recursion_when_inline_shadows_fixed_field(self): @@ -41,3 +41,19 @@ class TestInlineRecursion(PluginTestCase): out = item.evaluate_template("$track_no") assert out == "01" + + def test_inline_function_body_item_field(self): + plugin = InlinePlugin() + func = plugin.compile_inline( + "return track + 1", album=False, field_name="next_track" + ) + + item = self.add_item_fixture(track=3) + assert func(item) == 4 + + def test_inline_album_expression_uses_items(self): + plugin = InlinePlugin() + func = plugin.compile_inline("len(items)", album=True, field_name="item_count") + + album = self.add_album_fixture() + assert func(album) == len(list(album.items())) From c59134bdb6f3ee0fa53da1308d4a23fd1900a1b3 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:38:09 -0500 Subject: [PATCH 099/274] Fixed unit tests import --- test/plugins/test_inline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index 5f4ded8f6..7a6b5c360 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -16,6 +16,7 @@ from beets import config, plugins from beets.test.helper import PluginTestCase from beetsplug.inline import InlinePlugin + class TestInlineRecursion(PluginTestCase): def test_no_recursion_when_inline_shadows_fixed_field(self): config["plugins"] = ["inline"] From 51164024c02a6cf423193b63404eb3910858bea3 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:41:31 -0500 Subject: [PATCH 100/274] Fixed unit tests import --- test/plugins/test_inline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index 7a6b5c360..79118bd06 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -54,7 +54,9 @@ class TestInlineRecursion(PluginTestCase): def test_inline_album_expression_uses_items(self): plugin = InlinePlugin() - func = plugin.compile_inline("len(items)", album=True, field_name="item_count") + func = plugin.compile_inline( + "len(items)", album=True, field_name="item_count" + ) album = self.add_album_fixture() assert func(album) == len(list(album.items())) From cd8e466a46abeabb5b0bc491b69ad397c9b58bd4 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 19:18:10 -0500 Subject: [PATCH 101/274] Updated changelog documentation --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d1a0e8c7f..19026eafe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,10 @@ New features: Bug fixes: +- :doc:`plugins/inline`: Fix recursion error when an inline field definition + shadows a built-in item field (e.g., redefining ``track_no``). Inline + expressions now skip self-references during evaluation to avoid infinite + recursion. :bug:`6115` - When hardlinking from a symlink (e.g. importing a symlink with hardlinking enabled), dereference the symlink then hardlink, rather than creating a new (potentially broken) symlink :bug:`5676` From 67d6e7dd62d3a7b3ad17bf3e5863915d01d904ed Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 26 Nov 2025 13:27:44 -0500 Subject: [PATCH 102/274] feat(types): Add type hints to smartplaylist.py --- beetsplug/smartplaylist.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 5fbc4785b..40b6d9f80 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -15,6 +15,7 @@ """Generates smart playlists based on beets queries.""" import os +from typing import Any, List, Optional, Set, Tuple, Union from urllib.parse import quote from urllib.request import pathname2url @@ -35,7 +36,7 @@ from beets.util import ( class SmartPlaylistPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( { @@ -60,7 +61,7 @@ class SmartPlaylistPlugin(BeetsPlugin): if self.config["auto"]: self.register_listener("database_change", self.db_change) - def commands(self): + def commands(self) -> List[ui.Subcommand]: spl_update = ui.Subcommand( "splupdate", help="update the smart playlists. Playlist names may be " @@ -123,7 +124,7 @@ class SmartPlaylistPlugin(BeetsPlugin): spl_update.func = self.update_cmd return [spl_update] - def update_cmd(self, lib, opts, args): + def update_cmd(self, lib: Any, opts: Any, args: List[str]) -> None: self.build_queries() if args: args = set(args) @@ -150,12 +151,12 @@ class SmartPlaylistPlugin(BeetsPlugin): self.__apply_opts_to_config(opts) self.update_playlists(lib, opts.pretend) - def __apply_opts_to_config(self, opts): + def __apply_opts_to_config(self, opts: Any) -> None: for k, v in opts.__dict__.items(): if v is not None and k in self.config: self.config[k] = v - def build_queries(self): + def build_queries(self) -> None: """ Instantiate queries for the playlists. @@ -206,7 +207,7 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists.add(playlist_data) - def matches(self, model, query, album_query): + def matches(self, model: Union[Item, Album], query: Any, album_query: Any) -> bool: # Handle single query object for Album if ( album_query @@ -233,7 +234,7 @@ class SmartPlaylistPlugin(BeetsPlugin): return False - def db_change(self, lib, model): + def db_change(self, lib: Any, model: Union[Item, Album]) -> None: if self._unmatched_playlists is None: self.build_queries() @@ -246,7 +247,7 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists -= self._matched_playlists - def update_playlists(self, lib, pretend=False): + def update_playlists(self, lib: Any, pretend: bool = False) -> None: if pretend: self._log.info( "Showing query results for {} smart playlists...", @@ -266,7 +267,7 @@ class SmartPlaylistPlugin(BeetsPlugin): relative_to = normpath(relative_to) # Maps playlist filenames to lists of track filenames. - m3us = {} + m3us: "dict[str, list[PlaylistItem]]" = {} for playlist in self._matched_playlists: name, (query, q_sort), (album_query, a_q_sort) = playlist @@ -373,6 +374,6 @@ class SmartPlaylistPlugin(BeetsPlugin): class PlaylistItem: - def __init__(self, item, uri): + def __init__(self, item: Item, uri: bytes) -> None: self.item = item self.uri = uri From 028401ac286a82754115adf19a844208abec2d41 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 26 Nov 2025 13:33:07 -0500 Subject: [PATCH 103/274] lint --- beetsplug/smartplaylist.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 40b6d9f80..856e575e8 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -207,7 +207,9 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists.add(playlist_data) - def matches(self, model: Union[Item, Album], query: Any, album_query: Any) -> bool: + def matches( + self, model: Union[Item, Album], query: Any, album_query: Any + ) -> bool: # Handle single query object for Album if ( album_query From b9de8f9aabac0fd7713555b915def704f1752985 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 26 Nov 2025 13:39:02 -0500 Subject: [PATCH 104/274] Remove duplication in matches method --- beetsplug/smartplaylist.py | 49 +++++++++++++++----------------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 856e575e8..3a9f630ed 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -14,8 +14,10 @@ """Generates smart playlists based on beets queries.""" +from __future__ import annotations + import os -from typing import Any, List, Optional, Set, Tuple, Union +from typing import Any from urllib.parse import quote from urllib.request import pathname2url @@ -61,7 +63,7 @@ class SmartPlaylistPlugin(BeetsPlugin): if self.config["auto"]: self.register_listener("database_change", self.db_change) - def commands(self) -> List[ui.Subcommand]: + def commands(self) -> list[ui.Subcommand]: spl_update = ui.Subcommand( "splupdate", help="update the smart playlists. Playlist names may be " @@ -124,7 +126,7 @@ class SmartPlaylistPlugin(BeetsPlugin): spl_update.func = self.update_cmd return [spl_update] - def update_cmd(self, lib: Any, opts: Any, args: List[str]) -> None: + def update_cmd(self, lib: Any, opts: Any, args: list[str]) -> None: self.build_queries() if args: args = set(args) @@ -207,36 +209,23 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists.add(playlist_data) - def matches( - self, model: Union[Item, Album], query: Any, album_query: Any - ) -> bool: - # Handle single query object for Album - if ( - album_query - and not isinstance(album_query, (list, tuple)) - and isinstance(model, Album) - ): - return album_query.match(model) - # Handle tuple/list of queries for Album - elif isinstance(album_query, (list, tuple)) and isinstance( - model, Album - ): - return any(q.match(model) for q, _ in album_query) - - # Handle single query object for Item - if ( - query - and not isinstance(query, (list, tuple)) - and isinstance(model, Item) - ): - return query.match(model) - # Handle tuple/list of queries for Item - elif isinstance(query, (list, tuple)) and isinstance(model, Item): + def _matches_query(self, model: Item | Album, query: Any) -> bool: + if not query: + return False + if isinstance(query, (list, tuple)): return any(q.match(model) for q, _ in query) + return query.match(model) + def matches( + self, model: Item | Album, query: Any, album_query: Any + ) -> bool: + if isinstance(model, Album): + return self._matches_query(model, album_query) + if isinstance(model, Item): + return self._matches_query(model, query) return False - def db_change(self, lib: Any, model: Union[Item, Album]) -> None: + def db_change(self, lib: Any, model: Item | Album) -> None: if self._unmatched_playlists is None: self.build_queries() @@ -269,7 +258,7 @@ class SmartPlaylistPlugin(BeetsPlugin): relative_to = normpath(relative_to) # Maps playlist filenames to lists of track filenames. - m3us: "dict[str, list[PlaylistItem]]" = {} + m3us: dict[str, list[PlaylistItem]] = {} for playlist in self._matched_playlists: name, (query, q_sort), (album_query, a_q_sort) = playlist From 002a051d06686c8e3abd2304aa9d1d354625c174 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 26 Nov 2025 13:44:29 -0500 Subject: [PATCH 105/274] fix(smartplaylist): Resolve mypy type errors and update tests --- beetsplug/smartplaylist.py | 15 ++++++++------- test/plugins/test_smartplaylist.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 3a9f630ed..82739a995 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -57,8 +57,8 @@ class SmartPlaylistPlugin(BeetsPlugin): ) self.config["prefix"].redact = True # May contain username/password. - self._matched_playlists = None - self._unmatched_playlists = None + self._matched_playlists: set[tuple[Any, Any, Any]] = set() + self._unmatched_playlists: set[tuple[Any, Any, Any]] = set() if self.config["auto"]: self.register_listener("database_change", self.db_change) @@ -129,15 +129,15 @@ class SmartPlaylistPlugin(BeetsPlugin): def update_cmd(self, lib: Any, opts: Any, args: list[str]) -> None: self.build_queries() if args: - args = set(args) - for a in list(args): + args_set = set(args) + for a in list(args_set): if not a.endswith(".m3u"): - args.add(f"{a}.m3u") + args_set.add(f"{a}.m3u") playlists = { (name, q, a_q) for name, q, a_q in self._unmatched_playlists - if name in args + if name in args_set } if not playlists: unmatched = [name for name, _, _ in self._unmatched_playlists] @@ -185,6 +185,7 @@ class SmartPlaylistPlugin(BeetsPlugin): try: for key, model_cls in (("query", Item), ("album_query", Album)): qs = playlist.get(key) + query_and_sort: tuple[Any, Any] if qs is None: query_and_sort = None, None elif isinstance(qs, str): @@ -353,7 +354,7 @@ class SmartPlaylistPlugin(BeetsPlugin): ) f.write(comment.encode("utf-8") + entry.uri + b"\n") # Send an event when playlists were updated. - send_event("smartplaylist_update") + send_event("smartplaylist_update") # type: ignore if pretend: self._log.info( diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index ad9f056cc..8ec2c74ce 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -33,8 +33,8 @@ from beetsplug.smartplaylist import SmartPlaylistPlugin class SmartPlaylistTest(BeetsTestCase): def test_build_queries(self): spl = SmartPlaylistPlugin() - assert spl._matched_playlists is None - assert spl._unmatched_playlists is None + assert spl._matched_playlists == set() + assert spl._unmatched_playlists == set() config["smartplaylist"]["playlists"].set([]) spl.build_queries() @@ -182,7 +182,7 @@ class SmartPlaylistTest(BeetsTestCase): q = Mock() a_q = Mock() pl = b"$title-my.m3u", (q, None), (a_q, None) - spl._matched_playlists = [pl] + spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["relative_to"] = False @@ -224,7 +224,7 @@ class SmartPlaylistTest(BeetsTestCase): q = Mock() a_q = Mock() pl = b"$title-my.m3u", (q, None), (a_q, None) - spl._matched_playlists = [pl] + spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["output"] = "extm3u" @@ -274,7 +274,7 @@ class SmartPlaylistTest(BeetsTestCase): q = Mock() a_q = Mock() pl = b"$title-my.m3u", (q, None), (a_q, None) - spl._matched_playlists = [pl] + spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["output"] = "extm3u" @@ -319,7 +319,7 @@ class SmartPlaylistTest(BeetsTestCase): q = Mock() a_q = Mock() pl = b"$title-my.m3u", (q, None), (a_q, None) - spl._matched_playlists = [pl] + spl._matched_playlists = {pl} dir = mkdtemp() tpl = "http://beets:8337/item/$id/file" @@ -380,7 +380,7 @@ class SmartPlaylistTest(BeetsTestCase): # Create playlist with multiple queries (stored as tuple) queries_and_sorts = ((q1, None), (q2, None), (q3, None)) pl = "ordered.m3u", (queries_and_sorts, None), (None, None) - spl._matched_playlists = [pl] + spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["relative_to"] = False @@ -435,7 +435,7 @@ class SmartPlaylistTest(BeetsTestCase): # Create playlist with multiple queries (stored as tuple) queries_and_sorts = ((q1, None), (q2, None)) pl = "dedup.m3u", (queries_and_sorts, None), (None, None) - spl._matched_playlists = [pl] + spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["relative_to"] = False From 6bfe7cfbc9daf8f30850f4434f32af9f894886f9 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 26 Nov 2025 13:47:18 -0500 Subject: [PATCH 106/274] refactor(smartplaylist): Improve type safety in query building --- beetsplug/smartplaylist.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 82739a995..8c61ee1fc 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -158,6 +158,20 @@ class SmartPlaylistPlugin(BeetsPlugin): if v is not None and k in self.config: self.config[k] = v + def _parse_one_query( + self, playlist: dict[str, Any], key: str, model_cls: type + ) -> tuple[Any, Any]: + qs = playlist.get(key) + if qs is None: + return None, None + if isinstance(qs, str): + return parse_query_string(qs, model_cls) + if len(qs) == 1: + return parse_query_string(qs[0], model_cls) + + queries_and_sorts = tuple(parse_query_string(q, model_cls) for q in qs) + return queries_and_sorts, None + def build_queries(self) -> None: """ Instantiate queries for the playlists. @@ -181,34 +195,16 @@ class SmartPlaylistPlugin(BeetsPlugin): self._log.warning("playlist configuration is missing name") continue - playlist_data = (playlist["name"],) try: - for key, model_cls in (("query", Item), ("album_query", Album)): - qs = playlist.get(key) - query_and_sort: tuple[Any, Any] - if qs is None: - query_and_sort = None, None - elif isinstance(qs, str): - query_and_sort = parse_query_string(qs, model_cls) - elif len(qs) == 1: - query_and_sort = parse_query_string(qs[0], model_cls) - else: - # multiple queries and sorts - preserve order - # Store tuple of (query, sort) tuples for hashability - queries_and_sorts = tuple( - parse_query_string(q, model_cls) for q in qs - ) - query_and_sort = queries_and_sorts, None - - playlist_data += (query_and_sort,) - + q_match = self._parse_one_query(playlist, "query", Item) + a_match = self._parse_one_query(playlist, "album_query", Album) except ParsingError as exc: self._log.warning( "invalid query in playlist {}: {}", playlist["name"], exc ) continue - self._unmatched_playlists.add(playlist_data) + self._unmatched_playlists.add((playlist["name"], q_match, a_match)) def _matches_query(self, model: Item | Album, query: Any) -> bool: if not query: From 5cc7dcfce7eb0998c0b354c3e356148046d7e4f9 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Thu, 27 Nov 2025 21:46:30 +0100 Subject: [PATCH 107/274] Sometimes it is time to let go of old things: This removes old references and docs for the old gmusic plugin. --- beetsplug/gmusic.py | 27 --------------------------- docs/changelog.rst | 10 ++++++---- docs/plugins/gmusic.rst | 5 ----- docs/plugins/index.rst | 1 - 4 files changed, 6 insertions(+), 37 deletions(-) delete mode 100644 beetsplug/gmusic.py delete mode 100644 docs/plugins/gmusic.rst diff --git a/beetsplug/gmusic.py b/beetsplug/gmusic.py deleted file mode 100644 index 5dda3a2e5..000000000 --- a/beetsplug/gmusic.py +++ /dev/null @@ -1,27 +0,0 @@ -# This file is part of beets. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Deprecation warning for the removed gmusic plugin.""" - -from beets.plugins import BeetsPlugin - - -class Gmusic(BeetsPlugin): - def __init__(self): - super().__init__() - - self._log.warning( - "The 'gmusic' plugin has been removed following the" - " shutdown of Google Play Music. Remove the plugin" - " from your configuration to silence this warning." - ) diff --git a/docs/changelog.rst b/docs/changelog.rst index 19026eafe..b3dde83a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -75,6 +75,8 @@ Other changes: maintainability. - :doc:`plugins/bpd`: Raise ImportError instead of ValueError when GStreamer is unavailable, enabling ``importorskip`` usage in pytest setup. +- Finally removed gmusic plugin and all related code/docs as the Google Play + Music service was shut down in 2020. 2.5.1 (October 14, 2025) ------------------------ @@ -1359,9 +1361,9 @@ There are some fixes in this release: - Fix a regression in the last release that made the image resizer fail to detect older versions of ImageMagick. :bug:`3269` -- :doc:`/plugins/gmusic`: The ``oauth_file`` config option now supports more +- ``/plugins/gmusic``: The ``oauth_file`` config option now supports more flexible path values, including ``~`` for the home directory. :bug:`3270` -- :doc:`/plugins/gmusic`: Fix a crash when using version 12.0.0 or later of the +- ``/plugins/gmusic``: Fix a crash when using version 12.0.0 or later of the ``gmusicapi`` module. :bug:`3270` - Fix an incompatibility with Python 3.8's AST changes. :bug:`3278` @@ -1412,7 +1414,7 @@ And many improvements to existing plugins: singletons. :bug:`3220` :bug:`3219` - :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some issues with foobar2000 and Winamp. Thanks to :user:`mz2212`. :bug:`2944` -- :doc:`/plugins/gmusic`: +- ``/plugins/gmusic``: - Add a new option to automatically upload to Google Play Music library on track import. Thanks to :user:`shuaiscott`. @@ -1851,7 +1853,7 @@ Here are the new features: - :ref:`Date queries ` can also be *relative*. You can say ``added:-1w..`` to match music added in the last week, for example. Thanks to :user:`euri10`. :bug:`2598` -- A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music +- A new ``/plugins/gmusic`` lets you interact with your Google Play Music library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586` - :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from classic ReplayGain data for formats that need it (namely, Ogg Opus). A new diff --git a/docs/plugins/gmusic.rst b/docs/plugins/gmusic.rst deleted file mode 100644 index 76697ea31..000000000 --- a/docs/plugins/gmusic.rst +++ /dev/null @@ -1,5 +0,0 @@ -Gmusic Plugin -============= - -The ``gmusic`` plugin interfaced beets to Google Play Music. It has been removed -after the shutdown of this service. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 4a2fce473..a1114976e 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -84,7 +84,6 @@ databases. They share the following configuration options: fromfilename ftintitle fuzzy - gmusic hook ihate importadded From c79cad4ed1ba811e904ed38f9e1d49020a8709ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 24 Oct 2025 23:45:36 +0100 Subject: [PATCH 108/274] Move deprecate_imports to beets.util.deprecation --- beets/__init__.py | 2 +- beets/autotag/__init__.py | 2 +- beets/library/__init__.py | 2 +- beets/util/__init__.py | 24 ------------------------ beets/util/deprecation.py | 26 ++++++++++++++++++++++++++ 5 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 beets/util/deprecation.py diff --git a/beets/__init__.py b/beets/__init__.py index d448d8c49..4891010a5 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -17,7 +17,7 @@ from sys import stderr import confuse -from .util import deprecate_imports +from .util.deprecation import deprecate_imports __version__ = "2.5.1" __author__ = "Adrian Sampson " diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 8fa5a6864..f79b193fd 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -24,8 +24,8 @@ from beets import config, logging # Parts of external interface. from beets.util import unique_list +from beets.util.deprecation import deprecate_imports -from ..util import deprecate_imports from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch from .match import Proposal, Recommendation, tag_album, tag_item diff --git a/beets/library/__init__.py b/beets/library/__init__.py index b38381438..afde96e0c 100644 --- a/beets/library/__init__.py +++ b/beets/library/__init__.py @@ -1,4 +1,4 @@ -from beets.util import deprecate_imports +from beets.util.deprecation import deprecate_imports from .exceptions import FileOperationError, ReadError, WriteError from .library import Library diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 2592f612a..2d4bb8a65 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -27,7 +27,6 @@ import subprocess import sys import tempfile import traceback -import warnings from collections import Counter from collections.abc import Callable, Sequence from contextlib import suppress @@ -1195,26 +1194,3 @@ def get_temp_filename( def unique_list(elements: Iterable[T]) -> list[T]: """Return a list with unique elements in the original order.""" return list(dict.fromkeys(elements)) - - -def deprecate_imports( - old_module: str, new_module_by_name: dict[str, str], name: str, version: str -) -> Any: - """Handle deprecated module imports by redirecting to new locations. - - Facilitates gradual migration of module structure by intercepting import - attempts for relocated functionality. Issues deprecation warnings while - transparently providing access to the moved implementation, allowing - existing code to continue working during transition periods. - """ - if new_module := new_module_by_name.get(name): - warnings.warn( - ( - f"'{old_module}.{name}' is deprecated and will be removed" - f" in {version}. Use '{new_module}.{name}' instead." - ), - DeprecationWarning, - stacklevel=2, - ) - return getattr(import_module(new_module), name) - raise AttributeError(f"module '{old_module}' has no attribute '{name}'") diff --git a/beets/util/deprecation.py b/beets/util/deprecation.py new file mode 100644 index 000000000..4bc939cb4 --- /dev/null +++ b/beets/util/deprecation.py @@ -0,0 +1,26 @@ +import warnings +from importlib import import_module +from typing import Any + + +def deprecate_imports( + old_module: str, new_module_by_name: dict[str, str], name: str, version: str +) -> Any: + """Handle deprecated module imports by redirecting to new locations. + + Facilitates gradual migration of module structure by intercepting import + attempts for relocated functionality. Issues deprecation warnings while + transparently providing access to the moved implementation, allowing + existing code to continue working during transition periods. + """ + if new_module := new_module_by_name.get(name): + warnings.warn( + ( + f"'{old_module}.{name}' is deprecated and will be removed" + f" in {version}. Use '{new_module}.{name}' instead." + ), + DeprecationWarning, + stacklevel=2, + ) + return getattr(import_module(new_module), name) + raise AttributeError(f"module '{old_module}' has no attribute '{name}'") From 39288637b926f65cf96036a979d6c1111b4552ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 25 Oct 2025 11:48:09 +0100 Subject: [PATCH 109/274] Centralise warnings for maintainers into deprecate_for_maintainers --- beets/__init__.py | 10 +++------ beets/autotag/__init__.py | 14 ++++-------- beets/library/__init__.py | 2 +- beets/mediafile.py | 14 ++++-------- beets/plugins.py | 18 +++++++++------ beets/ui/__init__.py | 8 ++----- beets/ui/commands/__init__.py | 10 ++++----- beets/util/deprecation.py | 41 ++++++++++++++++++++++++++++------- 8 files changed, 62 insertions(+), 55 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index 4891010a5..2c6069b29 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -26,13 +26,9 @@ __author__ = "Adrian Sampson " def __getattr__(name: str): """Handle deprecated imports.""" return deprecate_imports( - old_module=__name__, - new_module_by_name={ - "art": "beetsplug._utils", - "vfs": "beetsplug._utils", - }, - name=name, - version="3.0.0", + __name__, + {"art": "beetsplug._utils", "vfs": "beetsplug._utils"}, + name, ) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index f79b193fd..beaf4341c 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -16,7 +16,6 @@ from __future__ import annotations -import warnings from importlib import import_module from typing import TYPE_CHECKING @@ -24,7 +23,7 @@ from beets import config, logging # Parts of external interface. from beets.util import unique_list -from beets.util.deprecation import deprecate_imports +from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch from .match import Proposal, Recommendation, tag_album, tag_item @@ -37,18 +36,13 @@ if TYPE_CHECKING: def __getattr__(name: str): if name == "current_metadata": - warnings.warn( - ( - f"'beets.autotag.{name}' is deprecated and will be removed in" - " 3.0.0. Use 'beets.util.get_most_common_tags' instead." - ), - DeprecationWarning, - stacklevel=2, + deprecate_for_maintainers( + f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'" ) return import_module("beets.util").get_most_common_tags return deprecate_imports( - __name__, {"Distance": "beets.autotag.distance"}, name, "3.0.0" + __name__, {"Distance": "beets.autotag.distance"}, name ) diff --git a/beets/library/__init__.py b/beets/library/__init__.py index afde96e0c..22416ecb5 100644 --- a/beets/library/__init__.py +++ b/beets/library/__init__.py @@ -13,7 +13,7 @@ NEW_MODULE_BY_NAME = dict.fromkeys( def __getattr__(name: str): - return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name, "3.0.0") + return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name) __all__ = [ diff --git a/beets/mediafile.py b/beets/mediafile.py index 8bde9274c..df735afff 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -13,17 +13,11 @@ # included in all copies or substantial portions of the Software. -import warnings - import mediafile -warnings.warn( - "beets.mediafile is deprecated; use mediafile instead", - # Show the location of the `import mediafile` statement as the warning's - # source, rather than this file, such that the offending module can be - # identified easily. - stacklevel=2, -) +from .util.deprecation import deprecate_for_maintainers + +deprecate_for_maintainers("'beets.mediafile'", "'mediafile'", stacklevel=2) # Import everything from the mediafile module into this module. for key, value in mediafile.__dict__.items(): @@ -31,4 +25,4 @@ for key, value in mediafile.__dict__.items(): globals()[key] = value # Cleanup namespace. -del key, value, warnings, mediafile +del key, value, mediafile diff --git a/beets/plugins.py b/beets/plugins.py index 0c7bae234..458c2351c 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -20,7 +20,6 @@ import abc import inspect import re import sys -import warnings from collections import defaultdict from functools import cached_property, wraps from importlib import import_module @@ -33,6 +32,7 @@ from typing_extensions import ParamSpec import beets from beets import logging from beets.util import unique_list +from beets.util.deprecation import deprecate_for_maintainers if TYPE_CHECKING: from collections.abc import Callable, Iterable, Sequence @@ -184,11 +184,12 @@ class BeetsPlugin(metaclass=abc.ABCMeta): ): return - warnings.warn( - f"{cls.__name__} is used as a legacy metadata source. " - "It should extend MetadataSourcePlugin instead of BeetsPlugin. " - "Support for this will be removed in the v3.0.0 release!", - DeprecationWarning, + deprecate_for_maintainers( + ( + f"'{cls.__name__}' is used as a legacy metadata source since it" + " inherits 'beets.plugins.BeetsPlugin'. Support for this" + ), + "'beets.metadata_plugins.MetadataSourcePlugin'", stacklevel=3, ) @@ -265,7 +266,10 @@ class BeetsPlugin(metaclass=abc.ABCMeta): if source.filename: # user config self._log.warning(message) else: # 3rd-party plugin config - warnings.warn(message, DeprecationWarning, stacklevel=0) + deprecate_for_maintainers( + "'source_weight' configuration option", + "'data_source_mismatch_penalty'", + ) def commands(self) -> Sequence[Subcommand]: """Should return a list of beets.ui.Subcommand objects for diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 12eb6d005..cfd8b6bd7 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -28,7 +28,6 @@ import sqlite3 import sys import textwrap import traceback -import warnings from difflib import SequenceMatcher from functools import cache from itertools import chain @@ -40,6 +39,7 @@ from beets import config, library, logging, plugins, util from beets.dbcore import db from beets.dbcore import query as db_query from beets.util import as_string +from beets.util.deprecation import deprecate_for_maintainers from beets.util.functemplate import template if TYPE_CHECKING: @@ -114,11 +114,7 @@ def decargs(arglist): .. deprecated:: 2.4.0 This function will be removed in 3.0.0. """ - warnings.warn( - "decargs() is deprecated and will be removed in version 3.0.0.", - DeprecationWarning, - stacklevel=2, - ) + deprecate_for_maintainers("'beets.ui.decargs'") return arglist diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index 214bcfbd0..d88d397ec 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -16,7 +16,7 @@ interface. """ -from beets.util import deprecate_imports +from beets.util.deprecation import deprecate_imports from .completion import completion_cmd from .config import config_cmd @@ -36,14 +36,12 @@ from .write import write_cmd def __getattr__(name: str): """Handle deprecated imports.""" return deprecate_imports( - old_module=__name__, - new_module_by_name={ + __name__, + { "TerminalImportSession": "beets.ui.commands.import_.session", "PromptChoice": "beets.ui.commands.import_.session", - # TODO: We might want to add more deprecated imports here }, - name=name, - version="3.0.0", + name, ) diff --git a/beets/util/deprecation.py b/beets/util/deprecation.py index 4bc939cb4..832408060 100644 --- a/beets/util/deprecation.py +++ b/beets/util/deprecation.py @@ -1,10 +1,39 @@ +from __future__ import annotations + import warnings from importlib import import_module from typing import Any +from packaging.version import Version + +import beets + + +def _format_message(old: str, new: str | None = None) -> str: + next_major = f"{Version(beets.__version__).major + 1}.0.0" + msg = f"{old} is deprecated and will be removed in version {next_major}." + if new: + msg += f" Use {new} instead." + + return msg + + +def deprecate_for_maintainers( + old: str, new: str | None = None, stacklevel: int = 1 +) -> None: + """Issue a deprecation warning visible to maintainers during development. + + Emits a DeprecationWarning that alerts developers about deprecated code + patterns. Unlike user-facing warnings, these are primarily for internal + code maintenance and appear during test runs or with warnings enabled. + """ + warnings.warn( + _format_message(old, new), DeprecationWarning, stacklevel=stacklevel + 1 + ) + def deprecate_imports( - old_module: str, new_module_by_name: dict[str, str], name: str, version: str + old_module: str, new_module_by_name: dict[str, str], name: str ) -> Any: """Handle deprecated module imports by redirecting to new locations. @@ -14,13 +43,9 @@ def deprecate_imports( existing code to continue working during transition periods. """ if new_module := new_module_by_name.get(name): - warnings.warn( - ( - f"'{old_module}.{name}' is deprecated and will be removed" - f" in {version}. Use '{new_module}.{name}' instead." - ), - DeprecationWarning, - stacklevel=2, + deprecate_for_maintainers( + f"'{old_module}.{name}'", f"'{new_module}.{name}'", stacklevel=2 ) + return getattr(import_module(new_module), name) raise AttributeError(f"module '{old_module}' has no attribute '{name}'") From 5a3ecf684284bc4d60c89a6f8424de378b219997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 25 Oct 2025 12:37:50 +0100 Subject: [PATCH 110/274] Add deprecate_for_user function --- beets/plugins.py | 12 ++++++------ beets/util/deprecation.py | 9 ++++++++- beetsplug/musicbrainz.py | 8 +++++--- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 458c2351c..c9d503c72 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -32,7 +32,7 @@ from typing_extensions import ParamSpec import beets from beets import logging from beets.util import unique_list -from beets.util.deprecation import deprecate_for_maintainers +from beets.util.deprecation import deprecate_for_maintainers, deprecate_for_user if TYPE_CHECKING: from collections.abc import Callable, Iterable, Sequence @@ -257,14 +257,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta): ): return - message = ( - "'source_weight' configuration option is deprecated and will be" - " removed in v3.0.0. Use 'data_source_mismatch_penalty' instead" - ) for source in self.config.root().sources: if "source_weight" in (source.get(self.name) or {}): if source.filename: # user config - self._log.warning(message) + deprecate_for_user( + self._log, + f"'{self.name}.source_weight' configuration option", + f"'{self.name}.data_source_mismatch_penalty'", + ) else: # 3rd-party plugin config deprecate_for_maintainers( "'source_weight' configuration option", diff --git a/beets/util/deprecation.py b/beets/util/deprecation.py index 832408060..31f4f5eb2 100644 --- a/beets/util/deprecation.py +++ b/beets/util/deprecation.py @@ -2,12 +2,15 @@ from __future__ import annotations import warnings from importlib import import_module -from typing import Any +from typing import TYPE_CHECKING, Any from packaging.version import Version import beets +if TYPE_CHECKING: + from logging import Logger + def _format_message(old: str, new: str | None = None) -> str: next_major = f"{Version(beets.__version__).major + 1}.0.0" @@ -18,6 +21,10 @@ def _format_message(old: str, new: str | None = None) -> str: return msg +def deprecate_for_user(logger: Logger, old: str, new: str) -> None: + logger.warning(_format_message(old, new)) + + def deprecate_for_maintainers( old: str, new: str | None = None, stacklevel: int = 1 ) -> None: diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 2b9d5e9c2..231a045b7 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -31,6 +31,7 @@ import beets import beets.autotag.hooks from beets import config, plugins, util from beets.metadata_plugins import MetadataSourcePlugin +from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id if TYPE_CHECKING: @@ -403,9 +404,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self.config["search_limit"] = self.config["match"][ "searchlimit" ].get() - self._log.warning( - "'musicbrainz.searchlimit' option is deprecated and will be " - "removed in 3.0.0. Use 'musicbrainz.search_limit' instead." + deprecate_for_user( + self._log, + "'musicbrainz.searchlimit' configuration option", + "'musicbrainz.search_limit'", ) hostname = self.config["host"].as_str() https = self.config["https"].get(bool) From 9f7cb8dbe4d71d102aa64b19a277a52460999f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 25 Oct 2025 13:12:15 +0100 Subject: [PATCH 111/274] Load musicbrainz implicitly and supply a deprecation warning --- beets/config_default.yaml | 2 +- beets/plugins.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index c0bab8056..53763328f 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -6,7 +6,7 @@ statefile: state.pickle # --------------- Plugins --------------- -plugins: [musicbrainz] +plugins: [] pluginpath: [] diff --git a/beets/plugins.py b/beets/plugins.py index c9d503c72..8ae9c40a7 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -417,9 +417,13 @@ def get_plugin_names() -> list[str]: # TODO: Remove in v3.0.0 if ( "musicbrainz" not in plugins - and "musicbrainz" in beets.config - and beets.config["musicbrainz"].get().get("enabled") + and beets.config["musicbrainz"].flatten().get("enabled") is not False ): + deprecate_for_user( + log, + "Automatic loading of 'musicbrainz' plugin", + "'plugins' configuration to explicitly add 'musicbrainz'", + ) plugins.append("musicbrainz") beets.config.add({"disabled_plugins": []}) From 3bb068a67594dcbc8b52acd1ccbc838262fd7cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 25 Oct 2025 13:14:45 +0100 Subject: [PATCH 112/274] Warn users of deprecated musicbrainz.enabled option --- beets/plugins.py | 13 ++++++++----- beets/util/deprecation.py | 4 +++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 8ae9c40a7..b75581796 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -415,16 +415,19 @@ def get_plugin_names() -> list[str]: sys.path += paths plugins = unique_list(beets.config["plugins"].as_str_seq()) # TODO: Remove in v3.0.0 - if ( - "musicbrainz" not in plugins - and beets.config["musicbrainz"].flatten().get("enabled") is not False - ): + if "musicbrainz" not in plugins: deprecate_for_user( log, "Automatic loading of 'musicbrainz' plugin", "'plugins' configuration to explicitly add 'musicbrainz'", ) - plugins.append("musicbrainz") + enabled = beets.config["musicbrainz"].flatten().get("enabled") + if enabled is not None: + deprecate_for_user( + log, "'musicbrainz.enabled' configuration option" + ) + if enabled is not False: + plugins.append("musicbrainz") beets.config.add({"disabled_plugins": []}) disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq()) diff --git a/beets/util/deprecation.py b/beets/util/deprecation.py index 31f4f5eb2..b9ffeae82 100644 --- a/beets/util/deprecation.py +++ b/beets/util/deprecation.py @@ -21,7 +21,9 @@ def _format_message(old: str, new: str | None = None) -> str: return msg -def deprecate_for_user(logger: Logger, old: str, new: str) -> None: +def deprecate_for_user( + logger: Logger, old: str, new: str | None = None +) -> None: logger.warning(_format_message(old, new)) From b643fc4ce509ab1e23e04ebc613d2c2ba2c937bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 25 Oct 2025 14:21:06 +0100 Subject: [PATCH 113/274] Do not show a warning to users that have musicbrainz disabled --- beets/config_default.yaml | 1 + beets/plugins.py | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 53763328f..376859602 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -7,6 +7,7 @@ statefile: state.pickle # --------------- Plugins --------------- plugins: [] +disabled_plugins: [] pluginpath: [] diff --git a/beets/plugins.py b/beets/plugins.py index b75581796..5f695712b 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -414,23 +414,24 @@ def get_plugin_names() -> list[str]: # *contain* a `beetsplug` package. sys.path += paths plugins = unique_list(beets.config["plugins"].as_str_seq()) + beets.config.add({"disabled_plugins": []}) + disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq()) # TODO: Remove in v3.0.0 - if "musicbrainz" not in plugins: + if "musicbrainz" not in plugins and "musicbrainz" not in disabled_plugins: deprecate_for_user( log, "Automatic loading of 'musicbrainz' plugin", "'plugins' configuration to explicitly add 'musicbrainz'", ) - enabled = beets.config["musicbrainz"].flatten().get("enabled") - if enabled is not None: - deprecate_for_user( - log, "'musicbrainz.enabled' configuration option" - ) - if enabled is not False: - plugins.append("musicbrainz") - beets.config.add({"disabled_plugins": []}) - disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq()) + enabled = beets.config["musicbrainz"].flatten().get("enabled") + if enabled is not None: + deprecate_for_user(log, "'musicbrainz.enabled' configuration option") + if enabled is False: + disabled_plugins.add("musicbrainz") + else: + plugins.append("musicbrainz") + return [p for p in plugins if p not in disabled_plugins] From dd72704d3de3704c81bcec3f766433e3d174f34c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 26 Oct 2025 01:23:12 +0000 Subject: [PATCH 114/274] Do not force load musicbrainz, add a test to show the behaviour --- beets/config_default.yaml | 3 +-- beets/plugins.py | 14 ++++++-------- test/test_plugins.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 376859602..c0bab8056 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -6,8 +6,7 @@ statefile: state.pickle # --------------- Plugins --------------- -plugins: [] -disabled_plugins: [] +plugins: [musicbrainz] pluginpath: [] diff --git a/beets/plugins.py b/beets/plugins.py index 5f695712b..0dc2754b9 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -417,20 +417,18 @@ def get_plugin_names() -> list[str]: beets.config.add({"disabled_plugins": []}) disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq()) # TODO: Remove in v3.0.0 - if "musicbrainz" not in plugins and "musicbrainz" not in disabled_plugins: + mb_enabled = beets.config["musicbrainz"].flatten().get("enabled") + if mb_enabled: deprecate_for_user( log, - "Automatic loading of 'musicbrainz' plugin", + "'musicbrainz.enabled' configuration option", "'plugins' configuration to explicitly add 'musicbrainz'", ) - - enabled = beets.config["musicbrainz"].flatten().get("enabled") - if enabled is not None: + if "musicbrainz" not in plugins: + plugins.append("musicbrainz") + elif mb_enabled is False: deprecate_for_user(log, "'musicbrainz.enabled' configuration option") - if enabled is False: disabled_plugins.add("musicbrainz") - else: - plugins.append("musicbrainz") return [p for p in plugins if p not in disabled_plugins] diff --git a/test/test_plugins.py b/test/test_plugins.py index 07bbf0966..4543b5ecc 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -543,3 +543,39 @@ class TestDeprecationCopy: assert hasattr(LegacyMetadataPlugin, "data_source_mismatch_penalty") assert hasattr(LegacyMetadataPlugin, "_extract_id") assert hasattr(LegacyMetadataPlugin, "get_artist") + + +class TestMusicBrainzPluginLoading: + @pytest.fixture(autouse=True) + def config(self): + _config = config + _config.sources = [] + _config.read(user=False, defaults=True) + return _config + + def test_default(self): + assert "musicbrainz" in plugins.get_plugin_names() + + def test_other_plugin_enabled(self, config): + config["plugins"] = ["anything"] + + assert "musicbrainz" not in plugins.get_plugin_names() + + def test_deprecated_enabled(self, config, caplog): + config["plugins"] = ["anything"] + config["musicbrainz"]["enabled"] = True + + assert "musicbrainz" in plugins.get_plugin_names() + assert ( + "musicbrainz.enabled' configuration option is deprecated" + in caplog.text + ) + + def test_deprecated_disabled(self, config, caplog): + config["musicbrainz"]["enabled"] = False + + assert "musicbrainz" not in plugins.get_plugin_names() + assert ( + "musicbrainz.enabled' configuration option is deprecated" + in caplog.text + ) From 05430f312ca9719a53fba9577c8cfd542d06a27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 30 Nov 2025 08:11:22 +0000 Subject: [PATCH 115/274] Move PromptChoice to beets.util module And update imports that have been raising the deprecation warning. --- beets/ui/commands/__init__.py | 2 +- beets/ui/commands/import_/session.py | 9 +-------- beets/util/__init__.py | 6 ++++++ beetsplug/edit.py | 2 +- beetsplug/mbsubmit.py | 3 +-- beetsplug/play.py | 3 +-- docs/dev/plugins/other/prompts.rst | 2 +- test/test_plugins.py | 22 +++++++++++----------- 8 files changed, 23 insertions(+), 26 deletions(-) diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index d88d397ec..e1d0389a3 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -39,7 +39,7 @@ def __getattr__(name: str): __name__, { "TerminalImportSession": "beets.ui.commands.import_.session", - "PromptChoice": "beets.ui.commands.import_.session", + "PromptChoice": "beets.util", }, name, ) diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 27562664e..dcc80b793 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -1,10 +1,9 @@ from collections import Counter from itertools import chain -from typing import Any, NamedTuple from beets import autotag, config, importer, logging, plugins, ui from beets.autotag import Recommendation -from beets.util import displayable_path +from beets.util import PromptChoice, displayable_path from beets.util.units import human_bytes, human_seconds_short from .display import ( @@ -368,12 +367,6 @@ def _summary_judgment(rec): return action -class PromptChoice(NamedTuple): - short: str - long: str - callback: Any - - def choose_candidate( candidates, singleton, diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 2d4bb8a65..517e076de 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -167,6 +167,12 @@ class MoveOperation(Enum): REFLINK_AUTO = 5 +class PromptChoice(NamedTuple): + short: str + long: str + callback: Any + + def normpath(path: PathLike) -> bytes: """Provide the canonical form of the path suitable for storing in the database. diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 188afed1f..7ed465cfe 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -25,8 +25,8 @@ import yaml from beets import plugins, ui, util from beets.dbcore import types from beets.importer import Action -from beets.ui.commands.import_.session import PromptChoice from beets.ui.commands.utils import do_query +from beets.util import PromptChoice # These "safe" types can avoid the format/parse cycle that most fields go # through: they are safe to edit with native YAML types. diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index 93e88dc9e..f6d197256 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -26,8 +26,7 @@ import subprocess from beets import ui from beets.autotag import Recommendation from beets.plugins import BeetsPlugin -from beets.ui.commands import PromptChoice -from beets.util import displayable_path +from beets.util import PromptChoice, displayable_path from beetsplug.info import print_data diff --git a/beetsplug/play.py b/beetsplug/play.py index 8fb146213..0d96ee97f 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -21,8 +21,7 @@ from os.path import relpath from beets import config, ui, util from beets.plugins import BeetsPlugin from beets.ui import Subcommand -from beets.ui.commands import PromptChoice -from beets.util import get_temp_filename +from beets.util import PromptChoice, get_temp_filename # Indicate where arguments should be inserted into the command string. # If this is missing, they're placed at the end. diff --git a/docs/dev/plugins/other/prompts.rst b/docs/dev/plugins/other/prompts.rst index f734f0de3..29720b922 100644 --- a/docs/dev/plugins/other/prompts.rst +++ b/docs/dev/plugins/other/prompts.rst @@ -13,7 +13,7 @@ shall expose to the user: .. code-block:: python from beets.plugins import BeetsPlugin - from beets.ui.commands import PromptChoice + from beets.util import PromptChoice class ExamplePlugin(BeetsPlugin): diff --git a/test/test_plugins.py b/test/test_plugins.py index 4543b5ecc..6f7026718 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -41,7 +41,7 @@ from beets.test.helper import ( PluginTestCase, TerminalImportMixin, ) -from beets.util import displayable_path, syspath +from beets.util import PromptChoice, displayable_path, syspath class TestPluginRegistration(PluginTestCase): @@ -292,8 +292,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): def return_choices(self, session, task): return [ - ui.commands.PromptChoice("f", "Foo", None), - ui.commands.PromptChoice("r", "baR", None), + PromptChoice("f", "Foo", None), + PromptChoice("r", "baR", None), ] self.register_plugin(DummyPlugin) @@ -328,8 +328,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): def return_choices(self, session, task): return [ - ui.commands.PromptChoice("f", "Foo", None), - ui.commands.PromptChoice("r", "baR", None), + PromptChoice("f", "Foo", None), + PromptChoice("r", "baR", None), ] self.register_plugin(DummyPlugin) @@ -363,10 +363,10 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): def return_choices(self, session, task): return [ - ui.commands.PromptChoice("a", "A foo", None), # dupe - ui.commands.PromptChoice("z", "baZ", None), # ok - ui.commands.PromptChoice("z", "Zupe", None), # dupe - ui.commands.PromptChoice("z", "Zoo", None), + PromptChoice("a", "A foo", None), # dupe + PromptChoice("z", "baZ", None), # ok + PromptChoice("z", "Zupe", None), # dupe + PromptChoice("z", "Zoo", None), ] # dupe self.register_plugin(DummyPlugin) @@ -399,7 +399,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): ) def return_choices(self, session, task): - return [ui.commands.PromptChoice("f", "Foo", self.foo)] + return [PromptChoice("f", "Foo", self.foo)] def foo(self, session, task): pass @@ -441,7 +441,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): ) def return_choices(self, session, task): - return [ui.commands.PromptChoice("f", "Foo", self.foo)] + return [PromptChoice("f", "Foo", self.foo)] def foo(self, session, task): return Action.SKIP From 67e668d81ff03d7ce14671e68676a7ad9d0ed94a Mon Sep 17 00:00:00 2001 From: Anton Bobov Date: Mon, 24 Nov 2025 23:43:33 +0500 Subject: [PATCH 116/274] fix: Sanitize log messages by removing control characters Added regex pattern to strip C0/C1 control characters (excluding useful whitespace) from log messages before terminal output. This prevents disruptive/malicious control sequences from affecting terminal rendering. --- beets/logging.py | 14 ++++++++++++ docs/changelog.rst | 2 ++ test/test_logging.py | 52 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/beets/logging.py b/beets/logging.py index 8dab1cea6..5a837cd80 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -22,6 +22,7 @@ calls (`debug`, `info`, etc). from __future__ import annotations +import re import threading from copy import copy from logging import ( @@ -68,6 +69,15 @@ if TYPE_CHECKING: _ArgsType = Union[tuple[object, ...], Mapping[str, object]] +# Regular expression to match: +# - C0 control characters (0x00-0x1F) except useful whitespace (\t, \n, \r) +# - DEL control character (0x7f) +# - C1 control characters (0x80-0x9F) +# Used to sanitize log messages that could disrupt terminal output +_CONTROL_CHAR_REGEX = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]") +_UNICODE_REPLACEMENT_CHARACTER = "\ufffd" + + def _logsafe(val: T) -> str | T: """Coerce `bytes` to `str` to avoid crashes solely due to logging. @@ -82,6 +92,10 @@ def _logsafe(val: T) -> str | T: # type, and (b) warn the developer if they do this for other # bytestrings. return val.decode("utf-8", "replace") + if isinstance(val, str): + # Sanitize log messages by replacing control characters that can disrupt + # terminals. + return _CONTROL_CHAR_REGEX.sub(_UNICODE_REPLACEMENT_CHARACTER, val) # Other objects are used as-is so field access, etc., still works in # the format string. Relies on a working __str__ implementation. diff --git a/docs/changelog.rst b/docs/changelog.rst index b3dde83a9..76951a541 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -54,6 +54,8 @@ Bug fixes: endpoints. Previously, due to single-quotes (ie. string literal) in the SQL query, the query eg. `GET /item/values/albumartist` would return the literal "albumartist" instead of a list of unique album artists. +- Sanitize log messages by removing control characters preventing terminal + rendering issues. For plugin developers: diff --git a/test/test_logging.py b/test/test_logging.py index 48f9cbfd8..5990fd4e1 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -67,6 +67,58 @@ class TestStrFormatLogger: assert str(caplog.records[0].msg) == expected +class TestLogSanitization: + """Log messages should have control characters removed from: + - String arguments + - Keyword argument values + - Bytes arguments (which get decoded first) + """ + + @pytest.mark.parametrize( + "msg, args, kwargs, expected", + [ + # Valid UTF-8 bytes are decoded and preserved + ( + "foo {} bar {bar}", + (b"oof \xc3\xa9",), + {"bar": b"baz \xc3\xa9"}, + "foo oof é bar baz é", + ), + # Invalid UTF-8 bytes are decoded with replacement characters + ( + "foo {} bar {bar}", + (b"oof \xff",), + {"bar": b"baz \xff"}, + "foo oof � bar baz �", + ), + # Control characters should be removed + ( + "foo {} bar {bar}", + ("oof \x9e",), + {"bar": "baz \x9e"}, + "foo oof � bar baz �", + ), + # Whitespace control characters should be preserved + ( + "foo {} bar {bar}", + ("foo\t\n",), + {"bar": "bar\r"}, + "foo foo\t\n bar bar\r", + ), + ], + ) + def test_sanitization(self, msg, args, kwargs, expected, caplog): + level = log.INFO + logger = blog.getLogger("test_logger") + logger.setLevel(level) + + with caplog.at_level(level, logger="test_logger"): + logger.log(level, msg, *args, **kwargs) + + assert caplog.records, "No log records were captured" + assert str(caplog.records[0].msg) == expected + + class DummyModule(ModuleType): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): From fdaebc653a976932e09b392240da8539c3f780c5 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Mon, 1 Dec 2025 11:43:49 +0000 Subject: [PATCH 117/274] docs: Fix link to plugin development docs --- README.rst | 2 +- README_kr.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 3d5a84712..9e42eec30 100644 --- a/README.rst +++ b/README.rst @@ -85,7 +85,7 @@ simple if you know a little Python. .. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html -.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html +.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html Install ------- diff --git a/README_kr.rst b/README_kr.rst index 2233c379d..803229425 100644 --- a/README_kr.rst +++ b/README_kr.rst @@ -79,7 +79,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들 .. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html -.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html +.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html 설치 ------- From 1531c8f2277eadb5d25f397c54f769a2f63a0cea Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 2 Dec 2025 13:32:37 +0100 Subject: [PATCH 118/274] Added sql db indices as ORM model class. --- beets/dbcore/__init__.py | 3 +- beets/dbcore/db.py | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/beets/dbcore/__init__.py b/beets/dbcore/__init__.py index fa20eb00d..0b5e700cb 100644 --- a/beets/dbcore/__init__.py +++ b/beets/dbcore/__init__.py @@ -16,7 +16,7 @@ Library. """ -from .db import Database, Model, Results +from .db import Database, Index, Model, Results from .query import ( AndQuery, FieldQuery, @@ -43,6 +43,7 @@ __all__ = [ "Query", "Results", "Type", + "Index", "parse_sorted_query", "query_from_strings", "sort_from_strings", diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index cc172d0d8..7ad78d357 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -306,6 +306,11 @@ class Model(ABC, Generic[D]): terms. """ + _indices: Sequence[Index] = () + """A sequence of `Index` objects that describe the indices to be + created for this table. + """ + @cached_classproperty def _types(cls) -> dict[str, types.Type]: """Optional types for non-fixed (flexible and computed) fields.""" @@ -1066,6 +1071,7 @@ class Database: for model_cls in self._models: self._make_table(model_cls._table, model_cls._fields) self._make_attribute_table(model_cls._flex_table) + self._migrate_indices(model_cls._table, model_cls._indices) # Primitive access control: connections and transactions. @@ -1243,6 +1249,25 @@ class Database: ON {flex_table} (entity_id); """) + def _migrate_indices( + self, + table: str, + indices: Sequence[Index], + ): + """Create or replace indices for the given table. + + If the indices already exists and are up to date (i.e., the + index name and columns match), nothing is done. Otherwise, the + indices are created or replaced. + """ + with self.transaction() as tx: + current = { + Index.from_db(tx, r[1]) + for r in tx.query(f"PRAGMA index_list({table})") + } + for index in set(indices) - current: + index.recreate(tx, table) + # Querying. def _fetch( @@ -1312,3 +1337,38 @@ class Database: exist. """ return self._fetch(model_cls, MatchQuery("id", id)).get() + + +class Index(NamedTuple): + """A helper class to represent the index + information in the database schema. + """ + + name: str + columns: tuple[str, ...] + + def __hash__(self) -> int: + """Unique hash for the index based on its name and columns.""" + return hash((self.name, *self.columns)) + + def recreate(self, tx: Transaction, table: str) -> None: + """Recreate the index in the database. + + This is useful when the index has been changed and needs to be + updated. + """ + tx.script(f""" + DROP INDEX IF EXISTS {self.name}; + CREATE INDEX {self.name} ON {table} ({", ".join(self.columns)}) + """) + + @classmethod + def from_db(cls, tx: Transaction, name: str) -> Index: + """Create an Index object from the database if it exists. + + The name has to exists in the database! Otherwise, an + Error will be raised. + """ + rows = tx.query(f"PRAGMA index_info({name})") + columns = tuple(row[2] for row in rows) + return cls(name, columns) From 91c8fccacb29291e77bad46f605c2b8242ecdeef Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 2 Dec 2025 13:32:54 +0100 Subject: [PATCH 119/274] Added tests for index class. --- test/test_dbcore.py | 72 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 653adf298..3f53dd888 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -23,7 +23,7 @@ from tempfile import mkstemp import pytest from beets import dbcore -from beets.dbcore.db import DBCustomFunctionError +from beets.dbcore.db import DBCustomFunctionError, Index from beets.library import LibModel from beets.test import _common from beets.util import cached_classproperty @@ -66,6 +66,7 @@ class ModelFixture1(LibModel): _sorts = { "some_sort": SortFixture, } + _indices = (Index("field_one_index", ("field_one",)),) @cached_classproperty def _types(cls): @@ -137,6 +138,7 @@ class AnotherModelFixture(ModelFixture1): "id": dbcore.types.PRIMARY_ID, "foo": dbcore.types.INTEGER, } + _indices = (Index("another_foo_index", ("foo",)),) class ModelFixture5(ModelFixture1): @@ -808,3 +810,71 @@ class TestException: with pytest.raises(DBCustomFunctionError): with db.transaction() as tx: tx.query("select * from test where plz_raise()") + + +class TestIndex: + @pytest.fixture(autouse=True) + def db(self): + """Set up an in-memory SQLite database.""" + db = DatabaseFixture1(":memory:") + yield db + db._connection().close() + + @pytest.fixture + def sample_index(self): + """Fixture for a sample Index object.""" + return Index(name="sample_index", columns=("field_one",)) + + def test_from_db(self, db, sample_index: Index): + """Test retrieving an index from the database.""" + with db.transaction() as tx: + sample_index.recreate(tx, "test") + retrieved = Index.from_db(tx, sample_index.name) + assert retrieved == sample_index + + @pytest.mark.parametrize( + "index1, index2, equality", + [ + ( + # Same + Index(name="sample_index", columns=("field_one",)), + Index(name="sample_index", columns=("field_one",)), + True, + ), + ( + # Multiple columns + Index(name="sample_index", columns=("f1", "f2")), + Index(name="sample_index", columns=("f1", "f2")), + True, + ), + ( + # Difference in name + Index(name="sample_indey", columns=("field_one",)), + Index(name="sample_index", columns=("field_one",)), + False, + ), + ( + # Difference in columns + Index(name="sample_indey", columns=("field_one",)), + Index(name="sample_index", columns=("field_two",)), + False, + ), + ( + # Difference in num columns + Index(name="sample_index", columns=("f1",)), + Index(name="sample_index", columns=("f1", "f2")), + False, + ), + ], + ) + def test_index_equality(self, index1: Index, index2: Index, equality: bool): + """Test the hashing and set behavior of the Index class.""" + + # Simple equality + assert (index1 == index2) == equality + + # Should be unique or not + index_set = {index1, index2} + assert len(index_set) == (1 if equality else 2) + + From 9ca95bf721faf72ffb0d2b4f9dad71f679079ed3 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 2 Dec 2025 13:33:32 +0100 Subject: [PATCH 120/274] Added album_id index to speed up queries against album items. --- beets/library/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beets/library/models.py b/beets/library/models.py index cbee2a411..f57b4201a 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -716,6 +716,7 @@ class Item(LibModel): "mtime": types.DATE, "added": types.DATE, } + _indices = (dbcore.Index("idx_item_album_id", ("album_id",)),) _search_fields = ( "artist", From f7ddcdeb592df96796fa9a668e5fdd0587680fad Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 2 Dec 2025 13:37:54 +0100 Subject: [PATCH 121/274] Ruff format after rebase. --- beets/dbcore/db.py | 2 +- test/test_dbcore.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 7ad78d357..843bfeaff 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -35,7 +35,7 @@ from collections.abc import ( Sequence, ) from sqlite3 import Connection, sqlite_version_info -from typing import TYPE_CHECKING, Any, AnyStr, Generic +from typing import TYPE_CHECKING, Any, AnyStr, Generic, NamedTuple from typing_extensions import TypeVar # default value support from unidecode import unidecode diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 3f53dd888..06aceaec0 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -811,7 +811,7 @@ class TestException: with db.transaction() as tx: tx.query("select * from test where plz_raise()") - + class TestIndex: @pytest.fixture(autouse=True) def db(self): @@ -876,5 +876,3 @@ class TestIndex: # Should be unique or not index_set = {index1, index2} assert len(index_set) == (1 if equality else 2) - - From b7541bedbd9df4b29ea1a3e63cf170d2d2d7998d Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 2 Dec 2025 09:10:06 -0500 Subject: [PATCH 122/274] Annotated handlers to accept a Library instead of Any and added typed playlist helpers --- beetsplug/smartplaylist.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 8c61ee1fc..40eb591b1 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -17,13 +17,13 @@ from __future__ import annotations import os -from typing import Any +from typing import Any, TypeAlias from urllib.parse import quote from urllib.request import pathname2url from beets import ui -from beets.dbcore.query import ParsingError -from beets.library import Album, Item, parse_query_string +from beets.dbcore.query import ParsingError, Query, Sort +from beets.library import Album, Item, Library, parse_query_string from beets.plugins import BeetsPlugin from beets.plugins import send as send_event from beets.util import ( @@ -36,6 +36,14 @@ from beets.util import ( syspath, ) +QueryAndSort = tuple[Query, Sort] +PlaylistQuery = Query | tuple[QueryAndSort, ...] | None +PlaylistMatch: TypeAlias = tuple[ + str, + tuple[PlaylistQuery, Sort | None], + tuple[PlaylistQuery, Sort | None], +] + class SmartPlaylistPlugin(BeetsPlugin): def __init__(self) -> None: @@ -57,8 +65,8 @@ class SmartPlaylistPlugin(BeetsPlugin): ) self.config["prefix"].redact = True # May contain username/password. - self._matched_playlists: set[tuple[Any, Any, Any]] = set() - self._unmatched_playlists: set[tuple[Any, Any, Any]] = set() + self._matched_playlists: set[PlaylistMatch] = set() + self._unmatched_playlists: set[PlaylistMatch] = set() if self.config["auto"]: self.register_listener("database_change", self.db_change) @@ -126,7 +134,7 @@ class SmartPlaylistPlugin(BeetsPlugin): spl_update.func = self.update_cmd return [spl_update] - def update_cmd(self, lib: Any, opts: Any, args: list[str]) -> None: + def update_cmd(self, lib: Library, opts: Any, args: list[str]) -> None: self.build_queries() if args: args_set = set(args) @@ -160,7 +168,7 @@ class SmartPlaylistPlugin(BeetsPlugin): def _parse_one_query( self, playlist: dict[str, Any], key: str, model_cls: type - ) -> tuple[Any, Any]: + ) -> tuple[PlaylistQuery, Sort | None]: qs = playlist.get(key) if qs is None: return None, None @@ -169,7 +177,9 @@ class SmartPlaylistPlugin(BeetsPlugin): if len(qs) == 1: return parse_query_string(qs[0], model_cls) - queries_and_sorts = tuple(parse_query_string(q, model_cls) for q in qs) + queries_and_sorts: tuple[QueryAndSort, ...] = tuple( + parse_query_string(q, model_cls) for q in qs + ) return queries_and_sorts, None def build_queries(self) -> None: @@ -206,7 +216,7 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists.add((playlist["name"], q_match, a_match)) - def _matches_query(self, model: Item | Album, query: Any) -> bool: + def _matches_query(self, model: Item | Album, query: PlaylistQuery) -> bool: if not query: return False if isinstance(query, (list, tuple)): @@ -214,7 +224,10 @@ class SmartPlaylistPlugin(BeetsPlugin): return query.match(model) def matches( - self, model: Item | Album, query: Any, album_query: Any + self, + model: Item | Album, + query: PlaylistQuery, + album_query: PlaylistQuery, ) -> bool: if isinstance(model, Album): return self._matches_query(model, album_query) @@ -222,7 +235,7 @@ class SmartPlaylistPlugin(BeetsPlugin): return self._matches_query(model, query) return False - def db_change(self, lib: Any, model: Item | Album) -> None: + def db_change(self, lib: Library, model: Item | Album) -> None: if self._unmatched_playlists is None: self.build_queries() @@ -235,7 +248,7 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists -= self._matched_playlists - def update_playlists(self, lib: Any, pretend: bool = False) -> None: + def update_playlists(self, lib: Library, pretend: bool = False) -> None: if pretend: self._log.info( "Showing query results for {} smart playlists...", From 03283aaf27a717237406bb181dc6805a1a01d305 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 2 Dec 2025 09:19:09 -0500 Subject: [PATCH 123/274] update lint From 20d9b6a13670049a5a76c888ee614c95bcc8cb5b Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 2 Dec 2025 09:25:42 -0500 Subject: [PATCH 124/274] Fix URL-encoding path conversion --- beetsplug/smartplaylist.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 40eb591b1..ed417f2b9 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -319,7 +319,9 @@ class SmartPlaylistPlugin(BeetsPlugin): if self.config["forward_slash"].get(): item_uri = path_as_posix(item_uri) if self.config["urlencode"]: - item_uri = bytestring_path(pathname2url(item_uri)) + item_uri = bytestring_path( + pathname2url(os.fsdecode(item_uri)) + ) item_uri = prefix + item_uri if item_uri not in m3us[m3u_name]: From 2bd77b9895fa9779818ff1c0430b9f9738d1616b Mon Sep 17 00:00:00 2001 From: Guy Bloom <65376566+GuyBloom@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:48:41 -0500 Subject: [PATCH 125/274] Fix convert --format with never_convert_lossy_files (#6171) ## Description Fixes #5625 When `convert.never_convert_lossy_files` is enabled, `beet convert` was ignoring the explicit `--format` option and just copying the lossy files without transcoding them. For example: - `beet convert format:mp3 --format opus` would still produce MP3 files instead of OPUS. Change: - Allows to override options `never_convert_lossy_files`, `max_bitrate` or `no_convert` for `beet convert` as well as trying to convert to the same format as existing already with a new option `--force`. That way, for example lossy files selected by the query are transcoded to the requested format anyway. - Keeps existing behavior for automatic conversion on import (no CLI override there). - Adds tests to cover checking whether `--force` correctly overrides settings or CLI options. - Documents the behavior in the convert plugin docs Co-authored-by: J0J0 Todos --- beetsplug/convert.py | 44 ++++++++++++++++++++++++++++++------ docs/changelog.rst | 2 ++ docs/plugins/convert.rst | 15 ++++++++++-- test/plugins/test_convert.py | 38 +++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index e72f8c75a..74ced8ae3 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -95,12 +95,18 @@ def in_no_convert(item: Item) -> bool: return False -def should_transcode(item, fmt): +def should_transcode(item, fmt, force: bool = False): """Determine whether the item should be transcoded as part of conversion (i.e., its bitrate is high or it has the wrong format). + + If ``force`` is True, safety checks like ``no_convert`` and + ``never_convert_lossy_files`` are ignored and the item is always + transcoded. """ + if force: + return True if in_no_convert(item) or ( - config["convert"]["never_convert_lossy_files"] + config["convert"]["never_convert_lossy_files"].get(bool) and item.format.lower() not in LOSSLESS_FORMATS ): return False @@ -236,6 +242,16 @@ class ConvertPlugin(BeetsPlugin): drive, relative paths pointing to media files will be used.""", ) + cmd.parser.add_option( + "-F", + "--force", + action="store_true", + dest="force", + help=( + "force transcoding. Ignores no_convert, " + "never_convert_lossy_files, and max_bitrate" + ), + ) cmd.parser.add_album_option() cmd.func = self.convert_func return [cmd] @@ -259,6 +275,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, link, playlist, + force, ) = self._get_opts_and_config(empty_opts) items = task.imported_items() @@ -272,6 +289,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, threads, items, + force, ) # Utilities converted from functions to methods on logging overhaul @@ -347,6 +365,7 @@ class ConvertPlugin(BeetsPlugin): pretend=False, link=False, hardlink=False, + force=False, ): """A pipeline thread that converts `Item` objects from a library. @@ -372,11 +391,11 @@ class ConvertPlugin(BeetsPlugin): if keep_new: original = dest converted = item.path - if should_transcode(item, fmt): + if should_transcode(item, fmt, force): converted = replace_ext(converted, ext) else: original = item.path - if should_transcode(item, fmt): + if should_transcode(item, fmt, force): dest = replace_ext(dest, ext) converted = dest @@ -406,7 +425,7 @@ class ConvertPlugin(BeetsPlugin): ) util.move(item.path, original) - if should_transcode(item, fmt): + if should_transcode(item, fmt, force): linked = False try: self.encode(command, original, converted, pretend) @@ -577,6 +596,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, link, playlist, + force, ) = self._get_opts_and_config(opts) if opts.album: @@ -613,6 +633,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, threads, items, + force, ) if playlist: @@ -735,7 +756,7 @@ class ConvertPlugin(BeetsPlugin): else: hardlink = self.config["hardlink"].get(bool) link = self.config["link"].get(bool) - + force = getattr(opts, "force", False) return ( dest, threads, @@ -745,6 +766,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, link, playlist, + force, ) def _parallel_convert( @@ -758,13 +780,21 @@ class ConvertPlugin(BeetsPlugin): hardlink, threads, items, + force, ): """Run the convert_item function for every items on as many thread as defined in threads """ convert = [ self.convert_item( - dest, keep_new, path_formats, fmt, pretend, link, hardlink + dest, + keep_new, + path_formats, + fmt, + pretend, + link, + hardlink, + force, ) for _ in range(threads) ] diff --git a/docs/changelog.rst b/docs/changelog.rst index 76951a541..b9a5c1f3f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,8 @@ New features: - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. +- :doc:`/plugins/convert`: ``force`` can be passed to override checks like + no_convert, never_convert_lossy_files, same format, and max_bitrate - :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to resolve differences in metadata source styles. diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index ecf60a85b..14b545b28 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -51,6 +51,11 @@ instead, passing ``-H`` (``--hardlink``) creates hard links. Note that album art embedding is disabled for files that are linked. Refer to the ``link`` and ``hardlink`` options below. +The ``-F`` (or ``--force``) option forces transcoding even when safety options +such as ``no_convert``, ``never_convert_lossy_files``, or ``max_bitrate`` would +normally cause a file to be copied or skipped instead. This can be combined with +``--format`` to explicitly transcode lossy inputs to a chosen target format. + The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8 playlist file in the destination folder given by the ``-d`` (``--dest``) option or the ``dest`` configuration. The path to the playlist file can either be @@ -104,15 +109,21 @@ The available options are: with high bitrates, even if they are already in the same format as the output. Note that this does not guarantee that all converted files will have a lower bitrate---that depends on the encoder and its configuration. Default: none. + This option will be overridden by the ``--force`` flag - **no_convert**: Does not transcode items matching the query string provided (see :doc:`/reference/query`). For example, to not convert AAC or WMA formats, you can use ``format:AAC, format:WMA`` or ``path::\.(m4a|wma)$``. If you only want to transcode WMA format, you can use a negative query, e.g., - ``^path::\.(wma)$``, to not convert any other format except WMA. + ``^path::\.(wma)$``, to not convert any other format except WMA. This option + will be overridden by the ``--force`` flag - **never_convert_lossy_files**: Cross-conversions between lossy codecs---such as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality even further. If set to ``yes``, lossy files are always copied. Default: - ``no``. + ``no``. When ``never_convert_lossy_files`` is enabled, lossy source files (for + example MP3 or Ogg Vorbis) are normally not transcoded and are instead copied + or linked as-is. To explicitly transcode lossy files in spite of this, use the + ``--force`` option with the ``convert`` command (optionally together with + ``--format`` to choose a target format) - **paths**: The directory structure and naming scheme for the converted files. Uses the same format as the top-level ``paths`` section (see :ref:`path-format-config`). Default: Reuse your top-level path format diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 1452686a7..9ae0ebf6d 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -236,6 +236,16 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): self.convert_dest / "converted.ogg", "ogg" ) + def test_force_overrides_max_bitrate_and_same_formats(self): + self.config["convert"]["max_bitrate"] = 5000 + self.config["convert"]["format"] = "ogg" + + with control_stdin("y"): + self.run_convert("--force") + + converted = self.convert_dest / "converted.ogg" + assert self.file_endswith(converted, "ogg") + def test_transcode_when_maxbr_set_low_and_same_formats(self): self.config["convert"]["max_bitrate"] = 5 self.config["convert"]["format"] = "ogg" @@ -260,6 +270,21 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): self.run_convert("--playlist", "playlist.m3u8", "--pretend") assert not (self.convert_dest / "playlist.m3u8").exists() + def test_force_overrides_no_convert(self): + self.config["convert"]["formats"]["opus"] = { + "command": self.tagged_copy_cmd("opus"), + "extension": "ops", + } + self.config["convert"]["no_convert"] = "format:ogg" + + [item] = self.add_item_fixtures(ext="ogg") + + with control_stdin("y"): + self.run_convert_path(item, "--format", "opus", "--force") + + converted = self.convert_dest / "converted.ops" + assert self.file_endswith(converted, "opus") + @_common.slow_test() class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): @@ -301,6 +326,19 @@ class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): converted = self.convert_dest / "converted.ogg" assert not self.file_endswith(converted, "mp3") + def test_force_overrides_never_convert_lossy_files(self): + self.config["convert"]["formats"]["opus"] = { + "command": self.tagged_copy_cmd("opus"), + "extension": "ops", + } + [item] = self.add_item_fixtures(ext="ogg") + + with control_stdin("y"): + self.run_convert_path(item, "--format", "opus", "--force") + + converted = self.convert_dest / "converted.ops" + assert self.file_endswith(converted, "opus") + class TestNoConvert: """Test the effect of the `no_convert` option.""" From 03f84eb877c8a15086674d446bb1e839d3c5370e Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 2 Dec 2025 20:02:17 -0500 Subject: [PATCH 126/274] Fix edit plugin cancel flow restoring in-memory tags --- beetsplug/edit.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 7ed465cfe..168f72da1 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -282,16 +282,18 @@ class EditPlugin(plugins.BeetsPlugin): if choice == "a": # Apply. return True elif choice == "c": # Cancel. + # Revert all temporary changes made in this edit session + # so that objects return to their original in-memory + # state (including tags provided by other plugins such as + # `fromfilename`). + self.apply_data(objs, new_data, old_data) return False elif choice == "e": # Keep editing. - # Reset the temporary changes to the objects. I we have a - # copy from above, use that, else reload from the database. - objs = [ - (old_obj or obj) for old_obj, obj in zip(objs_old, objs) - ] - for obj in objs: - if not obj.id < 0: - obj.load() + # Revert changes on the objects, but keep the edited YAML + # file so the user can continue editing from their last + # version. On the next iteration, differences will again + # be computed against the original state (`old_data`). + self.apply_data(objs, new_data, old_data) continue # Remove the temporary file before returning. @@ -380,9 +382,11 @@ class EditPlugin(plugins.BeetsPlugin): # to the files if needed without re-applying metadata. return Action.RETAG else: - # Edit cancelled / no edits made. Revert changes. - for obj in task.items: - obj.read() + # Edit cancelled / no edits made. `edit_objects` has already + # restored each object to its original in-memory state, so there + # is nothing more to do here. Returning None lets the importer + # resume the candidate prompt. + return None def importer_edit_candidate(self, session, task): """Callback for invoking the functionality during an interactive From 556f3932ce6f82d73a69ecf2a564abcd5e47cd7c Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 2 Dec 2025 20:06:09 -0500 Subject: [PATCH 127/274] Added documentation --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b9a5c1f3f..327ebae8a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,6 +34,9 @@ New features: Bug fixes: +- When using :doc:`plugins/fromfilename` together with + :doc:`plugins/edit`, temporary tags extracted from filenames are no longer + lost when discarding or cancelling an edit session during import. :bug:`6104` - :doc:`plugins/inline`: Fix recursion error when an inline field definition shadows a built-in item field (e.g., redefining ``track_no``). Inline expressions now skip self-references during evaluation to avoid infinite From 8a089b5d77950af31e788abd61d3f4abcb98533e Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 2 Dec 2025 20:21:18 -0500 Subject: [PATCH 128/274] Fixed doc --- docs/changelog.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 327ebae8a..9d794e1a2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,9 +34,9 @@ New features: Bug fixes: -- When using :doc:`plugins/fromfilename` together with - :doc:`plugins/edit`, temporary tags extracted from filenames are no longer - lost when discarding or cancelling an edit session during import. :bug:`6104` +- When using :doc:`plugins/fromfilename` together with :doc:`plugins/edit`, + temporary tags extracted from filenames are no longer lost when discarding or + cancelling an edit session during import. :bug:`6104` - :doc:`plugins/inline`: Fix recursion error when an inline field definition shadows a built-in item field (e.g., redefining ``track_no``). Inline expressions now skip self-references during evaluation to avoid infinite From b242e3d052c1f8ce834e847a823aa03dfaa606c3 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 2 Dec 2025 21:00:18 -0500 Subject: [PATCH 129/274] Added test for new case --- test/plugins/test_edit.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/plugins/test_edit.py b/test/plugins/test_edit.py index 4e6c97ab2..8be4c29b8 100644 --- a/test/plugins/test_edit.py +++ b/test/plugins/test_edit.py @@ -176,6 +176,25 @@ class EditCommandTest(EditMixin, BeetsTestCase): ) assert list(self.album.items())[-1].title == "modified t\u00eftle 9" + def test_title_edit_keep_editing_then_apply(self, mock_write): + """Edit titles, keep editing once, then apply changes.""" + # First, choose "keep editing" so changes are reverted in memory but + # kept in the YAML file; then choose "apply" to commit them. + self.run_mocked_command( + {"replacements": {"t\u00eftle": "modified t\u00eftle"}}, + # keep Editing, then Apply + ["e", "a"], + ) + + # Writes should only happen once per track, when we finally apply. + assert mock_write.call_count == self.TRACK_COUNT + # All item titles (and mtimes) should now reflect the modified values. + self.assertItemFieldsModified( + self.album.items(), + self.items_orig, + ["title", "mtime"], + ) + def test_noedit(self, mock_write): """Do not edit anything.""" # Do not edit anything. From cefb4bfe225459c4d36c72f82af6e26b5371097b Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 9 Dec 2025 12:12:53 -0500 Subject: [PATCH 130/274] Fix verbose comments and add e,c test --- beetsplug/edit.py | 15 ++------------- test/plugins/test_edit.py | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 168f72da1..46e756122 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -275,24 +275,17 @@ class EditPlugin(plugins.BeetsPlugin): ui.print_("No changes to apply.") return False - # Confirm the changes. + # For cancel/keep-editing, restore objects to their original + # in-memory state so temp edits don't leak into the session choice = ui.input_options( ("continue Editing", "apply", "cancel") ) if choice == "a": # Apply. return True elif choice == "c": # Cancel. - # Revert all temporary changes made in this edit session - # so that objects return to their original in-memory - # state (including tags provided by other plugins such as - # `fromfilename`). self.apply_data(objs, new_data, old_data) return False elif choice == "e": # Keep editing. - # Revert changes on the objects, but keep the edited YAML - # file so the user can continue editing from their last - # version. On the next iteration, differences will again - # be computed against the original state (`old_data`). self.apply_data(objs, new_data, old_data) continue @@ -382,10 +375,6 @@ class EditPlugin(plugins.BeetsPlugin): # to the files if needed without re-applying metadata. return Action.RETAG else: - # Edit cancelled / no edits made. `edit_objects` has already - # restored each object to its original in-memory state, so there - # is nothing more to do here. Returning None lets the importer - # resume the candidate prompt. return None def importer_edit_candidate(self, session, task): diff --git a/test/plugins/test_edit.py b/test/plugins/test_edit.py index 8be4c29b8..d0e03d0e5 100644 --- a/test/plugins/test_edit.py +++ b/test/plugins/test_edit.py @@ -178,23 +178,34 @@ class EditCommandTest(EditMixin, BeetsTestCase): def test_title_edit_keep_editing_then_apply(self, mock_write): """Edit titles, keep editing once, then apply changes.""" - # First, choose "keep editing" so changes are reverted in memory but - # kept in the YAML file; then choose "apply" to commit them. self.run_mocked_command( {"replacements": {"t\u00eftle": "modified t\u00eftle"}}, # keep Editing, then Apply ["e", "a"], ) - # Writes should only happen once per track, when we finally apply. assert mock_write.call_count == self.TRACK_COUNT - # All item titles (and mtimes) should now reflect the modified values. self.assertItemFieldsModified( self.album.items(), self.items_orig, ["title", "mtime"], ) + def test_title_edit_keep_editing_then_cancel(self, mock_write): + """Edit titles, keep editing once, then cancel.""" + self.run_mocked_command( + {"replacements": {"t\u00eftle": "modified t\u00eftle"}}, + # keep Editing, then Cancel + ["e", "c"], + ) + + assert mock_write.call_count == 0 + self.assertItemFieldsModified( + self.album.items(), + self.items_orig, + [], + ) + def test_noedit(self, mock_write): """Do not edit anything.""" # Do not edit anything. From 9ba3e12e8f70f870fa964a2f924008ca882ff081 Mon Sep 17 00:00:00 2001 From: Matthew Kay Date: Wed, 10 Dec 2025 20:52:37 +0000 Subject: [PATCH 131/274] Fix ftintitle plugin to prioritize explicit featuring tokens - Prioritize explicit featuring tokens (feat, ft, featuring) over generic separators (&, and) when splitting artist names - Prevents incorrect splits like 'Alice & Bob feat Charlie' from splitting on '&' instead of 'feat' - Add test cases to verify the fix --- beetsplug/ftintitle.py | 20 ++++++++++++++++---- docs/changelog.rst | 3 +++ test/plugins/test_ftintitle.py | 4 ++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index ab841a12c..825bac033 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -36,11 +36,23 @@ def split_on_feat( artist, which is always a string, and the featuring artist, which may be a string or None if none is present. """ - # split on the first "feat". - regex = re.compile( - plugins.feat_tokens(for_artist, custom_words), re.IGNORECASE + # Try explicit featuring tokens first (ft, feat, featuring, etc.) + # to avoid splitting on generic separators like "&" when both are present + regex_explicit = re.compile( + plugins.feat_tokens(for_artist=False, custom_words=custom_words), + re.IGNORECASE, ) - parts = tuple(s.strip() for s in regex.split(artist, 1)) + parts = tuple(s.strip() for s in regex_explicit.split(artist, 1)) + if len(parts) == 2: + return parts + + # Fall back to all tokens including generic separators if no explicit match + if for_artist: + regex = re.compile( + plugins.feat_tokens(for_artist, custom_words), re.IGNORECASE + ) + parts = tuple(s.strip() for s in regex.split(artist, 1)) + if len(parts) == 1: return parts[0], None else: diff --git a/docs/changelog.rst b/docs/changelog.rst index 475e56634..8080fab03 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -62,6 +62,9 @@ Bug fixes: "albumartist" instead of a list of unique album artists. - Sanitize log messages by removing control characters preventing terminal rendering issues. +- :doc:`/plugins/ftintitle`: Fixed artist name splitting to prioritize explicit + featuring tokens (feat, ft, featuring) over generic separators (&, and), + preventing incorrect splits when both are present. For plugin developers: diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 6f01601e0..fb9f4eb39 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -303,6 +303,10 @@ def test_find_feat_part( ("Alice and Bob", ("Alice", "Bob")), ("Alice With Bob", ("Alice", "Bob")), ("Alice defeat Bob", ("Alice defeat Bob", None)), + ("Alice & Bob feat Charlie", ("Alice & Bob", "Charlie")), + ("Alice & Bob ft. Charlie", ("Alice & Bob", "Charlie")), + ("Alice & Bob featuring Charlie", ("Alice & Bob", "Charlie")), + ("Alice and Bob feat Charlie", ("Alice and Bob", "Charlie")), ], ) def test_split_on_feat( From 84d37b820a9b80259185da9be172a0d6aa85fe8f Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 14 Dec 2025 18:15:08 -0600 Subject: [PATCH 132/274] fix: inline default bracket_keywords instead of defining/cloning constant --- beetsplug/ftintitle.py | 48 +++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 06c5e69be..a81c58574 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -60,31 +60,6 @@ def contains_feat(title: str, custom_words: list[str] | None = None) -> bool: ) -# Default keywords that indicate remix/edit/version content -DEFAULT_BRACKET_KEYWORDS = [ - "abridged", - "acapella", - "club", - "demo", - "edit", - "edition", - "extended", - "instrumental", - "live", - "mix", - "radio", - "release", - "remaster", - "remastered", - "remix", - "rmx", - "unabridged", - "unreleased", - "version", - "vip", -] - - def find_feat_part( artist: str, albumartist: str | None, @@ -140,7 +115,28 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": DEFAULT_BRACKET_KEYWORDS.copy(), + "bracket_keywords": [ + "abridged", + "acapella", + "club", + "demo", + "edit", + "edition", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", + ], } ) From ef40d1ac536bd979392acd4236f08203242c8616 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sun, 14 Dec 2025 18:19:38 -0600 Subject: [PATCH 133/274] fix: revert needless whitespace change --- beetsplug/ftintitle.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index a81c58574..e6c8c897a 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -277,11 +277,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have a featuring artist, move it to the title. self.update_metadata( - item, - feat_part, - drop_feat, - keep_in_artist_field, - custom_words, + item, feat_part, drop_feat, keep_in_artist_field, custom_words ) return True From d7b9ccab3b223a245a083ff11b359a745b679a6a Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 16 Dec 2025 18:56:39 -0800 Subject: [PATCH 134/274] Titlecase Plugin Improvements: Add preserving all lowercase and all upper case strings; Fix spelling of 'separator' in config, docs and code; Move most of the logging for the plugin to debug to keep log cleaner. --- beetsplug/titlecase.py | 41 ++++++++++++++++++++++++---------- docs/plugins/titlecase.rst | 18 +++++++++++++-- test/plugins/test_titlecase.py | 32 +++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py index 2482e1c34..e7003fd28 100644 --- a/beetsplug/titlecase.py +++ b/beetsplug/titlecase.py @@ -47,10 +47,12 @@ class TitlecasePlugin(BeetsPlugin): "preserve": [], "fields": [], "replace": [], - "seperators": [], + "separators": [], "force_lowercase": False, "small_first_last": True, "the_artist": True, + "all_caps": False, + "all_lowercase": False, "after_choice": False, } ) @@ -60,14 +62,16 @@ class TitlecasePlugin(BeetsPlugin): preserve - Provide a list of strings with specific case requirements. fields - Fields to apply titlecase to. replace - List of pairs, first is the target, second is the replacement - seperators - Other characters to treat like periods. - force_lowercase - Lowercases the string before titlecasing. + separators - Other characters to treat like periods. + force_lowercase - Lowercase the string before titlecase. small_first_last - If small characters should be cased at the start of strings. the_artist - If the plugin infers the field to be an artist field (e.g. the field contains "artist") It will capitalize a lowercase The, helpful for the artist names that start with 'The', like 'The Who' or 'The Talking Heads' when - they are not at the start of a string. Superceded by preserved phrases. + they are not at the start of a string. Superseded by preserved phrases. + all_caps - If the alphabet in the string is all uppercase, do not modify. + all_lowercase - If the alphabet in the string is all lowercase, do not modify. """ # Register template function self.template_funcs["titlecase"] = self.titlecase @@ -121,17 +125,25 @@ class TitlecasePlugin(BeetsPlugin): return preserved @cached_property - def seperators(self) -> re.Pattern[str] | None: - if seperators := "".join( - dict.fromkeys(self.config["seperators"].as_str_seq()) + def separators(self) -> re.Pattern[str] | None: + if separators := "".join( + dict.fromkeys(self.config["separators"].as_str_seq()) ): - return re.compile(rf"(.*?[{re.escape(seperators)}]+)(\s*)(?=.)") + return re.compile(rf"(.*?[{re.escape(separators)}]+)(\s*)(?=.)") return None @cached_property def small_first_last(self) -> bool: return self.config["small_first_last"].get(bool) + @cached_property + def all_caps(self) -> bool: + return self.config["all_caps"].get(bool) + + @cached_property + def all_lowercase(self) -> bool: + return self.config["all_lowercase"].get(bool) + @cached_property def the_artist_regexp(self) -> re.Pattern[str]: return re.compile(r"\bthe\b") @@ -180,7 +192,7 @@ class TitlecasePlugin(BeetsPlugin): ] if cased_list != init_field: setattr(item, field, cased_list) - self._log.info( + self._log.debug( f"{field}: {', '.join(init_field)} ->", f"{', '.join(cased_list)}", ) @@ -188,7 +200,7 @@ class TitlecasePlugin(BeetsPlugin): cased: str = self.titlecase(init_field, field) if cased != init_field: setattr(item, field, cased) - self._log.info(f"{field}: {init_field} -> {cased}") + self._log.debug(f"{field}: {init_field} -> {cased}") else: self._log.debug(f"{field}: no string present") else: @@ -197,8 +209,8 @@ class TitlecasePlugin(BeetsPlugin): def titlecase(self, text: str, field: str = "") -> str: """Titlecase the given text.""" # Check we should split this into two substrings. - if self.seperators: - if len(splits := self.seperators.findall(text)): + if self.separators: + if len(splits := self.separators.findall(text)): split_cased = "".join( [self.titlecase(s[0], field) + s[1] for s in splits] ) @@ -206,6 +218,11 @@ class TitlecasePlugin(BeetsPlugin): return split_cased + self.titlecase( text[len(split_cased) :], field ) + # Check if A-Z is all uppercase or all lowercase + if self.all_lowercase and text.islower(): + return text + elif self.all_caps and text.isupper(): + return text # Any necessary replacements go first, mainly punctuation. titlecased = text.lower() if self.force_lowercase else text for pair in self.replace: diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst index c35bc10a4..541c52613 100644 --- a/docs/plugins/titlecase.rst +++ b/docs/plugins/titlecase.rst @@ -57,10 +57,12 @@ Default fields: [] preserve: [] replace: [] - seperators: [] + separators: [] force_lowercase: no small_first_last: yes the_artist: yes + all_lowercase: no + all_caps: no after_choice: no .. conf:: auto @@ -120,7 +122,7 @@ Default - "“": '"' - "”": '"' -.. conf:: seperators +.. conf:: separators :default: [] A list of characters to treat as markers of new sentences. Helpful for split titles @@ -146,6 +148,18 @@ Default capitalized. Useful for bands with `The` as part of the proper name, like ``Amyl and The Sniffers``. +.. conf:: all_caps + :default: no + + If the alphabet content of a string is all caps, do not modify. Useful + if you encounter a lot of acronyms, etc. + +.. conf:: all_lowercase + + If the alphabet content of a string is all lowercase, do not modify. Useful + if you encounter a lot of stylized lowercase spellings, but otherwise + want titlecase applied. + .. conf:: after_choice :default: no diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py index 44058780c..0d5437d5d 100644 --- a/test/plugins/test_titlecase.py +++ b/test/plugins/test_titlecase.py @@ -112,7 +112,7 @@ class TestTitlecasePlugin(PluginTestCase): assert TitlecasePlugin().titlecase(word.upper()) == word assert TitlecasePlugin().titlecase(word.lower()) == word - def test_seperators(self): + def test_separators(self): testcases = [ ([], "it / a / in / of / to / the", "It / a / in / of / to / The"), (["/"], "it / the test", "It / The Test"), @@ -129,8 +129,34 @@ class TestTitlecasePlugin(PluginTestCase): ), ] for testcase in testcases: - seperators, given, expected = testcase - with self.configure_plugin({"seperators": seperators}): + separators, given, expected = testcase + with self.configure_plugin({"separators": separators}): + assert TitlecasePlugin().titlecase(given) == expected + + def test_all_caps(self): + testcases = [ + (True, "Unaffected", "Unaffected"), + (True, "RBMK1000", "RBMK1000"), + (False, "RBMK1000", "Rbmk1000"), + (True, "P A R I S!", "P A R I S!"), + (True, "pillow dub...", "Pillow Dub..."), + (False, "P A R I S!", "P a R I S!") + ] + for testcase in testcases: + all_caps, given, expected = testcase + with self.configure_plugin({"all_caps": all_caps}): + assert TitlecasePlugin().titlecase(given) == expected + + def test_all_lowercase(self): + testcases = [ + (True, "Unaffected", "Unaffected"), + (True, "RBMK1000", "Rbmk1000"), + (True, "pillow dub...", "pillow dub..."), + (False, "pillow dub...", "Pillow Dub..."), + ] + for testcase in testcases: + all_lowercase, given, expected = testcase + with self.configure_plugin({"all_lowercase": all_lowercase}): assert TitlecasePlugin().titlecase(given) == expected def test_received_info_handler(self): From e039df4eb4c993c6b132e59a0368582b82d9db70 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 16 Dec 2025 19:06:37 -0800 Subject: [PATCH 135/274] Cleanup, fix format --- docs/plugins/titlecase.rst | 9 +++++---- test/plugins/test_titlecase.py | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst index 541c52613..e2861f0ac 100644 --- a/docs/plugins/titlecase.rst +++ b/docs/plugins/titlecase.rst @@ -151,13 +151,14 @@ Default .. conf:: all_caps :default: no - If the alphabet content of a string is all caps, do not modify. Useful - if you encounter a lot of acronyms, etc. + If the letters a-Z in a string are all caps, do not modify the string. Useful + if you encounter a lot of acronyms. .. conf:: all_lowercase + :default: no - If the alphabet content of a string is all lowercase, do not modify. Useful - if you encounter a lot of stylized lowercase spellings, but otherwise + If the letters a-Z in a string are all lowercase, do not modify the string. + Useful if you encounter a lot of stylized lowercase spellings, but otherwise want titlecase applied. .. conf:: after_choice diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py index 0d5437d5d..c25661bbf 100644 --- a/test/plugins/test_titlecase.py +++ b/test/plugins/test_titlecase.py @@ -140,8 +140,8 @@ class TestTitlecasePlugin(PluginTestCase): (False, "RBMK1000", "Rbmk1000"), (True, "P A R I S!", "P A R I S!"), (True, "pillow dub...", "Pillow Dub..."), - (False, "P A R I S!", "P a R I S!") - ] + (False, "P A R I S!", "P a R I S!"), + ] for testcase in testcases: all_caps, given, expected = testcase with self.configure_plugin({"all_caps": all_caps}): @@ -153,7 +153,7 @@ class TestTitlecasePlugin(PluginTestCase): (True, "RBMK1000", "Rbmk1000"), (True, "pillow dub...", "pillow dub..."), (False, "pillow dub...", "Pillow Dub..."), - ] + ] for testcase in testcases: all_lowercase, given, expected = testcase with self.configure_plugin({"all_lowercase": all_lowercase}): From 62256adf4e08ee011cd9595cc79daa8c0511b364 Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Wed, 17 Dec 2025 10:52:50 -0800 Subject: [PATCH 136/274] support multiple artists for spotify and improve multiartist support for lastgenre --- beetsplug/lastgenre/__init__.py | 63 ++++++++++++++++++++++++++++++--- beetsplug/spotify.py | 33 +++++++++++++---- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index ea0ab951a..0f04b49c3 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -302,23 +302,76 @@ class LastGenrePlugin(plugins.BeetsPlugin): def fetch_album_genre(self, obj): """Return raw album genres from Last.fm for this Item or Album.""" - return self._last_lookup( + genre = self._last_lookup( "album", LASTFM.get_album, obj.albumartist, obj.album ) + if genre: + return genre + + # If no genres found for the joint 'albumartist', try the individual + # album artists if available in 'albumartists'. + if obj.albumartists and len(obj.albumartists) > 1: + for albumartist in obj.albumartists: + genre = self._last_lookup( + "album", LASTFM.get_album, albumartist, obj.album + ) + + if genre: + return genre + + return genre def fetch_album_artist_genre(self, obj): """Return raw album artist genres from Last.fm for this Item or Album.""" - return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) + genres = self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) + if genres: + return genres - def fetch_artist_genre(self, item): + # If not genres found for the joint 'albumartist', try the individual + # album artists if available in 'albumartists'. + if obj.ablumartists and len(obj.albumartists) > 1: + for albumartist in obj.albumartists: + genre = self._last_lookup( + "artist", LASTFM.get_artist, albumartist + ) + + if genre: + return genre + return genres + + def fetch_artist_genre(self, obj): """Returns raw track artist genres from Last.fm for this Item.""" - return self._last_lookup("artist", LASTFM.get_artist, item.artist) + genres = self._last_lookup("artist", LASTFM.get_artist, obj.artist) + if genres: + return genres + + # If not genres found for the joint 'artist', try the individual + # album artists if available in 'artists'. + if obj.artists and len(obj.artists) > 1: + for artist in obj.artists: + genre = self._last_lookup("artist", LASTFM.get_artist, artist) + if genre: + return genre + return genres def fetch_track_genre(self, obj): """Returns raw track genres from Last.fm for this Item.""" - return self._last_lookup( + genres = self._last_lookup( "track", LASTFM.get_track, obj.artist, obj.title ) + if genres: + return genres + + # If not genres found for the joint 'artist', try the individual + # album artists if available in 'artists'. + if obj.artists and len(obj.artists) > 1: + for artist in obj.artists: + genre = self._last_lookup( + "track", LASTFM.get_track, artist, obj.title + ) + if genre: + return genre + return genres # Main processing: _get_genre() and helpers. diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index b3c653682..ee5069e38 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -302,6 +302,21 @@ class SpotifyPlugin( self._log.error("Request failed. Error: {}", e) raise APIError("Request failed.") + def _multi_artist_credit( + self, artists: list[dict[str | int, str]] + ) -> tuple[list[str], list[str]]: + """Given a list representing an ``artist``, accumulate data into a pair + of lists: the first being the artist names, and the second being the + artist IDs. + """ + artist_names = [] + artist_ids = [] + for artist in artists: + name, id = self.get_artist([artist]) + artist_names.append(name) + artist_ids.append(id) + return artist_names, artist_ids + def album_for_id(self, album_id: str) -> AlbumInfo | None: """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. @@ -321,7 +336,8 @@ class SpotifyPlugin( if album_data["name"] == "": self._log.debug("Album removed from Spotify: {}", album_id) return None - artist, artist_id = self.get_artist(album_data["artists"]) + artists_names, artists_ids = self._multi_artist_credit(album_data["artists"]) + artist = ", ".join(artists_names) date_parts = [ int(part) for part in album_data["release_date"].split("-") @@ -364,8 +380,10 @@ class SpotifyPlugin( album_id=spotify_id, spotify_album_id=spotify_id, artist=artist, - artist_id=artist_id, - spotify_artist_id=artist_id, + artist_id=artists_ids[0], + spotify_artist_id=artists_ids[0], + artists=artists_names, + artists_ids=artists_ids, tracks=tracks, albumtype=album_data["album_type"], va=len(album_data["artists"]) == 1 @@ -388,7 +406,8 @@ class SpotifyPlugin( :returns: TrackInfo object for track """ - artist, artist_id = self.get_artist(track_data["artists"]) + artists_names, artists_ids = self._multi_artist_credit(track_data["artists"]) + artist = ", ".join(artists_names) # Get album information for spotify tracks try: @@ -401,8 +420,10 @@ class SpotifyPlugin( spotify_track_id=track_data["id"], artist=artist, album=album, - artist_id=artist_id, - spotify_artist_id=artist_id, + artist_id=artists_ids[0], + spotify_artist_id=artists_ids[0], + artists=artists_names, + artists_ids=artists_ids, length=track_data["duration_ms"] / 1000, index=track_data["track_number"], medium=track_data["disc_number"], From 963a9692ccd5d3fed56b31deb67bebc32e71d180 Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Wed, 17 Dec 2025 11:54:12 -0800 Subject: [PATCH 137/274] added tests for multi-artist spotify and lastgenre changes --- beetsplug/spotify.py | 8 +- docs/changelog.rst | 5 + test/plugins/test_lastgenre.py | 50 ++++++- test/plugins/test_spotify.py | 24 +++ test/rsrc/spotify/multi_artist_request.json | 154 ++++++++++++++++++++ 5 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 test/rsrc/spotify/multi_artist_request.json diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index ee5069e38..5cc4a836c 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -336,7 +336,9 @@ class SpotifyPlugin( if album_data["name"] == "": self._log.debug("Album removed from Spotify: {}", album_id) return None - artists_names, artists_ids = self._multi_artist_credit(album_data["artists"]) + artists_names, artists_ids = self._multi_artist_credit( + album_data["artists"] + ) artist = ", ".join(artists_names) date_parts = [ @@ -406,7 +408,9 @@ class SpotifyPlugin( :returns: TrackInfo object for track """ - artists_names, artists_ids = self._multi_artist_credit(track_data["artists"]) + artists_names, artists_ids = self._multi_artist_credit( + track_data["artists"] + ) artist = ", ".join(artists_names) # Get album information for spotify tracks diff --git a/docs/changelog.rst b/docs/changelog.rst index 475e56634..f42dc838a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,6 +31,11 @@ New features: no_convert, never_convert_lossy_files, same format, and max_bitrate - :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to resolve differences in metadata source styles. +- :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, + saving all contributing artists to the respective fields. +- :doc:`plugins/lastgenre`: If looking up a multi-artist album or track, + fallback to searching the individual artists for genres when no results + are found for the combined artist string. Bug fixes: diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 12ff30f8e..c47a54e03 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -546,24 +546,25 @@ class LastGenrePluginTest(PluginTestCase): def test_get_genre(config_values, item_genre, mock_genres, expected_result): """Test _get_genre with various configurations.""" - def mock_fetch_track_genre(self, obj=None): + def mock_fetch_track_genre(obj=None): return mock_genres["track"] - def mock_fetch_album_genre(self, obj): + def mock_fetch_album_genre(obj): return mock_genres["album"] - def mock_fetch_artist_genre(self, obj): + def mock_fetch_artist_genre(obj): return mock_genres["artist"] + # Initialize plugin instance and item + plugin = lastgenre.LastGenrePlugin() + # Mock the last.fm fetchers. When whitelist enabled, we can assume only # whitelisted genres get returned, the plugin's _resolve_genre method # ensures it. - lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre - lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre - lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre + plugin.fetch_track_genre = mock_fetch_track_genre + plugin.fetch_album_genre = mock_fetch_album_genre + plugin.fetch_artist_genre = mock_fetch_artist_genre - # Initialize plugin instance and item - plugin = lastgenre.LastGenrePlugin() # Configure plugin.config.set(config_values) plugin.setup() # Loads default whitelist and canonicalization tree @@ -573,3 +574,36 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result): # Run res = plugin._get_genre(item) assert res == expected_result + + +def test_multiartist_fallback(): + def mock_lookup(entity, method, *args): + # Only response for the first artist, e.g. no results for the joint + # artist + if entity == "album" and args[0] == "Project Skylate": + return ["Electronic"] + return [] + + plugin = lastgenre.LastGenrePlugin() + plugin._last_lookup = mock_lookup + plugin.config.set( + { + "force": True, + "keep_existing": False, + "source": "album", + "whitelist": True, + "canonical": False, + "count": 5, + } + ) + plugin.setup() + + res = plugin._get_genre( + _common.item( + albumartist="Project Skylate & Sugar Shrill", + albumartists=["Project Skylate", "Sugar Shrill"], + artist="Project Skylate & Sugar Shrill", + artists=["Project Skylate", "Sugar Shrill"], + ) + ) + assert res == ("Electronic", "album, whitelist") diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index bc55485c6..6f90887c0 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -249,3 +249,27 @@ class SpotifyPluginTest(PluginTestCase): query = params["q"][0] assert query.isascii() + + @responses.activate + def test_multi_artist_album(self): + """Tests if plugin is able to map multiple artists in an album""" + + # Mock the Spotify 'Get Album' call + json_file = os.path.join( + _common.RSRC, b"spotify", b"multi_artist_request.json" + ) + with open(json_file, "rb") as f: + response_body = f.read() + + responses.add( + responses.GET, + f"{spotify.SpotifyPlugin.album_url}0yhKyyjyKXWUieJ4w1IAEa", + body=response_body, + status=200, + content_type="application/json", + ) + + album_info = self.spotify.album_for_id("0yhKyyjyKXWUieJ4w1IAEa") + assert album_info is not None + assert album_info.artist == "Project Skylate, Sugar Shrill" + assert album_info.artists == ["Project Skylate", "Sugar Shrill"] diff --git a/test/rsrc/spotify/multi_artist_request.json b/test/rsrc/spotify/multi_artist_request.json new file mode 100644 index 000000000..8efbc5eef --- /dev/null +++ b/test/rsrc/spotify/multi_artist_request.json @@ -0,0 +1,154 @@ +{ + "album_type": "single", + "total_tracks": 1, + "available_markets": [ + "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", + "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", + "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", + "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", + "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", + "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", + "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", + "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", + "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", + "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", + "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", + "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", + "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", + "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", + "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", + "LY", "TJ", "VE", "ET", "XK" + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/0yhKyyjyKXWUieJ4w1IAEa" + }, + "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa", + "id": "0yhKyyjyKXWUieJ4w1IAEa", + "images": [ + { + "url": "https://i.scdn.co/image/ab67616d0000b2739a26f5e04909c87cead97c77", + "height": 640, + "width": 640 + }, + { + "url": "https://i.scdn.co/image/ab67616d00001e029a26f5e04909c87cead97c77", + "height": 300, + "width": 300 + }, + { + "url": "https://i.scdn.co/image/ab67616d000048519a26f5e04909c87cead97c77", + "height": 64, + "width": 64 + } + ], + "name": "Akiba Night", + "release_date": "2017-12-22", + "release_date_precision": "day", + "type": "album", + "uri": "spotify:album:0yhKyyjyKXWUieJ4w1IAEa", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" + }, + "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", + "id": "6m8MRXIVKb6wQaPlBIDMr1", + "name": "Project Skylate", + "type": "artist", + "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" + }, + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" + }, + "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", + "id": "4kkAIoQmNT5xEoNH5BuQLe", + "name": "Sugar Shrill", + "type": "artist", + "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" + } + ], + "tracks": { + "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa/tracks?offset=0&limit=50", + "limit": 50, + "next": null, + "offset": 0, + "previous": null, + "total": 1, + "items": [ + { + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" + }, + "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", + "id": "6m8MRXIVKb6wQaPlBIDMr1", + "name": "Project Skylate", + "type": "artist", + "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" + }, + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" + }, + "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", + "id": "4kkAIoQmNT5xEoNH5BuQLe", + "name": "Sugar Shrill", + "type": "artist", + "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" + } + ], + "available_markets": [ + "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", + "CY", "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", + "GT", "HN", "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", + "MT", "MX", "NL", "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", + "PT", "SG", "SK", "ES", "SE", "CH", "TW", "TR", "UY", "US", "GB", + "AD", "LI", "MC", "ID", "JP", "TH", "VN", "RO", "IL", "ZA", "SA", + "AE", "BH", "QA", "OM", "KW", "EG", "MA", "DZ", "TN", "LB", "JO", + "PS", "IN", "BY", "KZ", "MD", "UA", "AL", "BA", "HR", "ME", "MK", + "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", "NG", "TZ", "UG", + "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", "CW", "DM", + "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", "LR", + "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", + "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", + "TL", "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", + "KM", "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", + "RW", "TG", "UZ", "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", + "ZM", "CD", "CG", "IQ", "LY", "TJ", "VE", "ET", "XK" + ], + "disc_number": 1, + "duration_ms": 225268, + "explicit": false, + "external_urls": { + "spotify": "https://open.spotify.com/track/6sjZfVJworBX6TqyjkxIJ1" + }, + "href": "https://api.spotify.com/v1/tracks/6sjZfVJworBX6TqyjkxIJ1", + "id": "6sjZfVJworBX6TqyjkxIJ1", + "name": "Akiba Nights", + "preview_url": "https://p.scdn.co/mp3-preview/a1c6c0c71f42caff0b19d988849602fefbf7754a?cid=4e414367a1d14c75a5c5129a627fcab8", + "track_number": 1, + "type": "track", + "uri": "spotify:track:6sjZfVJworBX6TqyjkxIJ1", + "is_local": false + } + ] + }, + "copyrights": [ + { + "text": "2017 Sugar Shrill", + "type": "C" + }, + { + "text": "2017 Project Skylate", + "type": "P" + } + ], + "external_ids": { + "upc": "5057728789361" + }, + "genres": [], + "label": "Project Skylate", + "popularity": 21 +} From 01e0aeb662f43d3a0c450661d3e4b44230d64560 Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Wed, 17 Dec 2025 12:20:05 -0800 Subject: [PATCH 138/274] address linter and ai comments from pr --- beetsplug/lastgenre/__init__.py | 8 ++++---- beetsplug/spotify.py | 14 ++++++++------ docs/changelog.rst | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 0f04b49c3..3873f5f93 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -327,9 +327,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): if genres: return genres - # If not genres found for the joint 'albumartist', try the individual + # If no genres found for the joint 'albumartist', try the individual # album artists if available in 'albumartists'. - if obj.ablumartists and len(obj.albumartists) > 1: + if obj.albumartists and len(obj.albumartists) > 1: for albumartist in obj.albumartists: genre = self._last_lookup( "artist", LASTFM.get_artist, albumartist @@ -345,7 +345,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if genres: return genres - # If not genres found for the joint 'artist', try the individual + # If no genres found for the joint 'artist', try the individual # album artists if available in 'artists'. if obj.artists and len(obj.artists) > 1: for artist in obj.artists: @@ -362,7 +362,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if genres: return genres - # If not genres found for the joint 'artist', try the individual + # If no genres found for the joint 'artist', try the individual # album artists if available in 'artists'. if obj.artists and len(obj.artists) > 1: for artist in obj.artists: diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 5cc4a836c..08cf86fd9 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -304,14 +304,16 @@ class SpotifyPlugin( def _multi_artist_credit( self, artists: list[dict[str | int, str]] - ) -> tuple[list[str], list[str]]: - """Given a list representing an ``artist``, accumulate data into a pair + ) -> tuple[list[str], list[str | None]]: + """Given a list of artist dictionaries, accumulate data into a pair of lists: the first being the artist names, and the second being the artist IDs. """ artist_names = [] artist_ids = [] for artist in artists: + # Still use the get_artist helper to handle the artical + # normalization for each individual artist. name, id = self.get_artist([artist]) artist_names.append(name) artist_ids.append(id) @@ -382,8 +384,8 @@ class SpotifyPlugin( album_id=spotify_id, spotify_album_id=spotify_id, artist=artist, - artist_id=artists_ids[0], - spotify_artist_id=artists_ids[0], + artist_id=artists_ids[0] if len(artists_ids) > 0 else None, + spotify_artist_id=artists_ids[0] if len(artists_ids) > 0 else None, artists=artists_names, artists_ids=artists_ids, tracks=tracks, @@ -424,8 +426,8 @@ class SpotifyPlugin( spotify_track_id=track_data["id"], artist=artist, album=album, - artist_id=artists_ids[0], - spotify_artist_id=artists_ids[0], + artist_id=artists_ids[0] if len(artists_ids) > 0 else None, + spotify_artist_id=artists_ids[0] if len(artists_ids) > 0 else None, artists=artists_names, artists_ids=artists_ids, length=track_data["duration_ms"] / 1000, diff --git a/docs/changelog.rst b/docs/changelog.rst index f42dc838a..f1de4220d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,7 +34,7 @@ New features: - :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, saving all contributing artists to the respective fields. - :doc:`plugins/lastgenre`: If looking up a multi-artist album or track, - fallback to searching the individual artists for genres when no results + fall back to searching the individual artists for genres when no results are found for the combined artist string. Bug fixes: From 9cbbad19f80dc69fd4626be3052ebd0233b9a40a Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Wed, 17 Dec 2025 15:57:23 -0800 Subject: [PATCH 139/274] remove changes for lastgenre as there was an existing PR for that work --- beetsplug/lastgenre/__init__.py | 63 +++------------------------------ docs/changelog.rst | 3 -- test/plugins/test_lastgenre.py | 50 +++++--------------------- 3 files changed, 13 insertions(+), 103 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 3873f5f93..ea0ab951a 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -302,76 +302,23 @@ class LastGenrePlugin(plugins.BeetsPlugin): def fetch_album_genre(self, obj): """Return raw album genres from Last.fm for this Item or Album.""" - genre = self._last_lookup( + return self._last_lookup( "album", LASTFM.get_album, obj.albumartist, obj.album ) - if genre: - return genre - - # If no genres found for the joint 'albumartist', try the individual - # album artists if available in 'albumartists'. - if obj.albumartists and len(obj.albumartists) > 1: - for albumartist in obj.albumartists: - genre = self._last_lookup( - "album", LASTFM.get_album, albumartist, obj.album - ) - - if genre: - return genre - - return genre def fetch_album_artist_genre(self, obj): """Return raw album artist genres from Last.fm for this Item or Album.""" - genres = self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) - if genres: - return genres + return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) - # If no genres found for the joint 'albumartist', try the individual - # album artists if available in 'albumartists'. - if obj.albumartists and len(obj.albumartists) > 1: - for albumartist in obj.albumartists: - genre = self._last_lookup( - "artist", LASTFM.get_artist, albumartist - ) - - if genre: - return genre - return genres - - def fetch_artist_genre(self, obj): + def fetch_artist_genre(self, item): """Returns raw track artist genres from Last.fm for this Item.""" - genres = self._last_lookup("artist", LASTFM.get_artist, obj.artist) - if genres: - return genres - - # If no genres found for the joint 'artist', try the individual - # album artists if available in 'artists'. - if obj.artists and len(obj.artists) > 1: - for artist in obj.artists: - genre = self._last_lookup("artist", LASTFM.get_artist, artist) - if genre: - return genre - return genres + return self._last_lookup("artist", LASTFM.get_artist, item.artist) def fetch_track_genre(self, obj): """Returns raw track genres from Last.fm for this Item.""" - genres = self._last_lookup( + return self._last_lookup( "track", LASTFM.get_track, obj.artist, obj.title ) - if genres: - return genres - - # If no genres found for the joint 'artist', try the individual - # album artists if available in 'artists'. - if obj.artists and len(obj.artists) > 1: - for artist in obj.artists: - genre = self._last_lookup( - "track", LASTFM.get_track, artist, obj.title - ) - if genre: - return genre - return genres # Main processing: _get_genre() and helpers. diff --git a/docs/changelog.rst b/docs/changelog.rst index f1de4220d..6d37a64a4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -33,9 +33,6 @@ New features: resolve differences in metadata source styles. - :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, saving all contributing artists to the respective fields. -- :doc:`plugins/lastgenre`: If looking up a multi-artist album or track, - fall back to searching the individual artists for genres when no results - are found for the combined artist string. Bug fixes: diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index c47a54e03..12ff30f8e 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -546,25 +546,24 @@ class LastGenrePluginTest(PluginTestCase): def test_get_genre(config_values, item_genre, mock_genres, expected_result): """Test _get_genre with various configurations.""" - def mock_fetch_track_genre(obj=None): + def mock_fetch_track_genre(self, obj=None): return mock_genres["track"] - def mock_fetch_album_genre(obj): + def mock_fetch_album_genre(self, obj): return mock_genres["album"] - def mock_fetch_artist_genre(obj): + def mock_fetch_artist_genre(self, obj): return mock_genres["artist"] - # Initialize plugin instance and item - plugin = lastgenre.LastGenrePlugin() - # Mock the last.fm fetchers. When whitelist enabled, we can assume only # whitelisted genres get returned, the plugin's _resolve_genre method # ensures it. - plugin.fetch_track_genre = mock_fetch_track_genre - plugin.fetch_album_genre = mock_fetch_album_genre - plugin.fetch_artist_genre = mock_fetch_artist_genre + lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre + lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre + lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre + # Initialize plugin instance and item + plugin = lastgenre.LastGenrePlugin() # Configure plugin.config.set(config_values) plugin.setup() # Loads default whitelist and canonicalization tree @@ -574,36 +573,3 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result): # Run res = plugin._get_genre(item) assert res == expected_result - - -def test_multiartist_fallback(): - def mock_lookup(entity, method, *args): - # Only response for the first artist, e.g. no results for the joint - # artist - if entity == "album" and args[0] == "Project Skylate": - return ["Electronic"] - return [] - - plugin = lastgenre.LastGenrePlugin() - plugin._last_lookup = mock_lookup - plugin.config.set( - { - "force": True, - "keep_existing": False, - "source": "album", - "whitelist": True, - "canonical": False, - "count": 5, - } - ) - plugin.setup() - - res = plugin._get_genre( - _common.item( - albumartist="Project Skylate & Sugar Shrill", - albumartists=["Project Skylate", "Sugar Shrill"], - artist="Project Skylate & Sugar Shrill", - artists=["Project Skylate", "Sugar Shrill"], - ) - ) - assert res == ("Electronic", "album, whitelist") From a7170fae45bd15eaeccf87ef4035990180a5b1d3 Mon Sep 17 00:00:00 2001 From: Arden Rasmussen Date: Thu, 18 Dec 2025 16:23:58 -0800 Subject: [PATCH 140/274] expand tests to include check for track artists --- beetsplug/spotify.py | 9 +- test/plugins/test_spotify.py | 44 +++++- ...st_request.json => multiartist_album.json} | 8 +- test/rsrc/spotify/multiartist_track.json | 131 ++++++++++++++++++ 4 files changed, 177 insertions(+), 15 deletions(-) rename test/rsrc/spotify/{multi_artist_request.json => multiartist_album.json} (97%) create mode 100644 test/rsrc/spotify/multiartist_track.json diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 08cf86fd9..6f85b1397 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -304,7 +304,7 @@ class SpotifyPlugin( def _multi_artist_credit( self, artists: list[dict[str | int, str]] - ) -> tuple[list[str], list[str | None]]: + ) -> tuple[list[str], list[str]]: """Given a list of artist dictionaries, accumulate data into a pair of lists: the first being the artist names, and the second being the artist IDs. @@ -312,11 +312,8 @@ class SpotifyPlugin( artist_names = [] artist_ids = [] for artist in artists: - # Still use the get_artist helper to handle the artical - # normalization for each individual artist. - name, id = self.get_artist([artist]) - artist_names.append(name) - artist_ids.append(id) + artist_names.append(artist["name"]) + artist_ids.append(artist["id"]) return artist_names, artist_ids def album_for_id(self, album_id: str) -> AlbumInfo | None: diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 6f90887c0..6e322ca0b 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -251,20 +251,36 @@ class SpotifyPluginTest(PluginTestCase): assert query.isascii() @responses.activate - def test_multi_artist_album(self): - """Tests if plugin is able to map multiple artists in an album""" + def test_multiartist_album_and_track(self): + """Tests if plugin is able to map multiple artists in an album and + track info correctly""" # Mock the Spotify 'Get Album' call json_file = os.path.join( - _common.RSRC, b"spotify", b"multi_artist_request.json" + _common.RSRC, b"spotify", b"multiartist_album.json" ) with open(json_file, "rb") as f: - response_body = f.read() + album_response_body = f.read() responses.add( responses.GET, f"{spotify.SpotifyPlugin.album_url}0yhKyyjyKXWUieJ4w1IAEa", - body=response_body, + body=album_response_body, + status=200, + content_type="application/json", + ) + + # Mock the Spotify 'Get Track' call + json_file = os.path.join( + _common.RSRC, b"spotify", b"multiartist_track.json" + ) + with open(json_file, "rb") as f: + track_response_body = f.read() + + responses.add( + responses.GET, + f"{spotify.SpotifyPlugin.track_url}6sjZfVJworBX6TqyjkxIJ1", + body=track_response_body, status=200, content_type="application/json", ) @@ -273,3 +289,21 @@ class SpotifyPluginTest(PluginTestCase): assert album_info is not None assert album_info.artist == "Project Skylate, Sugar Shrill" assert album_info.artists == ["Project Skylate", "Sugar Shrill"] + assert album_info.artist_id == "6m8MRXIVKb6wQaPlBIDMr1" + assert album_info.artists_ids == [ + "6m8MRXIVKb6wQaPlBIDMr1", + "4kkAIoQmNT5xEoNH5BuQLe", + ] + + assert len(album_info.tracks) == 1 + assert album_info.tracks[0].artist == "Foo, Bar" + assert album_info.tracks[0].artists == ["Foo", "Bar"] + assert album_info.tracks[0].artist_id == "12345" + assert album_info.tracks[0].artists_ids == ["12345", "67890"] + + track_info = self.spotify.track_for_id("6sjZfVJworBX6TqyjkxIJ1") + assert track_info is not None + assert track_info.artist == "Foo, Bar" + assert track_info.artists == ["Foo", "Bar"] + assert track_info.artist_id == "12345" + assert track_info.artists_ids == ["12345", "67890"] diff --git a/test/rsrc/spotify/multi_artist_request.json b/test/rsrc/spotify/multiartist_album.json similarity index 97% rename from test/rsrc/spotify/multi_artist_request.json rename to test/rsrc/spotify/multiartist_album.json index 8efbc5eef..9aef25f10 100644 --- a/test/rsrc/spotify/multi_artist_request.json +++ b/test/rsrc/spotify/multiartist_album.json @@ -83,8 +83,8 @@ "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" }, "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", - "id": "6m8MRXIVKb6wQaPlBIDMr1", - "name": "Project Skylate", + "id": "12345", + "name": "Foo", "type": "artist", "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" }, @@ -93,8 +93,8 @@ "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" }, "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", - "id": "4kkAIoQmNT5xEoNH5BuQLe", - "name": "Sugar Shrill", + "id": "67890", + "name": "Bar", "type": "artist", "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" } diff --git a/test/rsrc/spotify/multiartist_track.json b/test/rsrc/spotify/multiartist_track.json new file mode 100644 index 000000000..e77acee9e --- /dev/null +++ b/test/rsrc/spotify/multiartist_track.json @@ -0,0 +1,131 @@ +{ + "album": { + "album_type": "single", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" + }, + "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", + "id": "6m8MRXIVKb6wQaPlBIDMr1", + "name": "Project Skylate", + "type": "artist", + "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" + }, + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" + }, + "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", + "id": "4kkAIoQmNT5xEoNH5BuQLe", + "name": "Sugar Shrill", + "type": "artist", + "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" + } + ], + "available_markets": [ + "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", + "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", + "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", + "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", + "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", + "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", + "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", + "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", + "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", + "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", + "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", + "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", + "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", + "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", + "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", + "LY", "TJ", "VE", "ET", "XK" + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/0yhKyyjyKXWUieJ4w1IAEa" + }, + "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa", + "id": "0yhKyyjyKXWUieJ4w1IAEa", + "images": [ + { + "url": "https://i.scdn.co/image/ab67616d0000b2739a26f5e04909c87cead97c77", + "width": 640, + "height": 640 + }, + { + "url": "https://i.scdn.co/image/ab67616d00001e029a26f5e04909c87cead97c77", + "width": 300, + "height": 300 + }, + { + "url": "https://i.scdn.co/image/ab67616d000048519a26f5e04909c87cead97c77", + "width": 64, + "height": 64 + } + ], + "name": "Akiba Night", + "release_date": "2017-12-22", + "release_date_precision": "day", + "total_tracks": 1, + "type": "album", + "uri": "spotify:album:0yhKyyjyKXWUieJ4w1IAEa" + }, + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" + }, + "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", + "id": "12345", + "name": "Foo", + "type": "artist", + "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" + }, + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" + }, + "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", + "id": "67890", + "name": "Bar", + "type": "artist", + "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" + } + ], + "available_markets": [ + "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", + "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", + "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", + "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", + "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", + "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", + "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", + "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", + "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", + "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", + "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", + "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", + "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", + "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", + "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", + "LY", "TJ", "VE", "ET", "XK" + ], + "disc_number": 1, + "duration_ms": 225268, + "explicit": false, + "external_ids": { + "isrc": "GB-SMU-45-66095" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/6sjZfVJworBX6TqyjkxIJ1" + }, + "href": "https://api.spotify.com/v1/tracks/6sjZfVJworBX6TqyjkxIJ1", + "id": "6sjZfVJworBX6TqyjkxIJ1", + "is_local": false, + "name": "Akiba Nights", + "popularity": 29, + "preview_url": "https://p.scdn.co/mp3-preview/a1c6c0c71f42caff0b19d988849602fefbf7754a?cid=4e414367a1d14c75a5c5129a627fcab8", + "track_number": 1, + "type": "track", + "uri": "spotify:track:6sjZfVJworBX6TqyjkxIJ1" +} From fda3bbaea542d27438a9e4227df41414aa2fcc3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 13:16:25 +0100 Subject: [PATCH 141/274] Move TimeoutSession under beetsplug._utils --- beetsplug/_utils/requests.py | 38 ++++++++++++++++++++++++++++++++++++ beetsplug/lyrics.py | 35 ++------------------------------- 2 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 beetsplug/_utils/requests.py diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py new file mode 100644 index 000000000..9b82d4538 --- /dev/null +++ b/beetsplug/_utils/requests.py @@ -0,0 +1,38 @@ +import atexit +from http import HTTPStatus + +import requests + +from beets import __version__ + + +class NotFoundError(requests.exceptions.HTTPError): + pass + + +class CaptchaError(requests.exceptions.HTTPError): + pass + + +class TimeoutSession(requests.Session): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/" + + @atexit.register + def close_session(): + """Close the requests session on shut down.""" + self.close() + + def request(self, *args, **kwargs): + """Wrap the request method to raise an exception on HTTP errors.""" + kwargs.setdefault("timeout", 10) + r = super().request(*args, **kwargs) + if r.status_code == HTTPStatus.NOT_FOUND: + raise NotFoundError("HTTP Error: Not Found", response=r) + if 300 <= r.status_code < 400: + raise CaptchaError("Captcha is required", response=r) + + r.raise_for_status() + + return r diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 677467776..145438f19 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -16,7 +16,6 @@ from __future__ import annotations -import atexit import itertools import math import re @@ -25,7 +24,6 @@ from contextlib import contextmanager, suppress from dataclasses import dataclass from functools import cached_property, partial, total_ordering from html import unescape -from http import HTTPStatus from itertools import groupby from pathlib import Path from typing import TYPE_CHECKING, NamedTuple @@ -41,6 +39,8 @@ from beets import plugins, ui from beets.autotag.distance import string_dist from beets.util.config import sanitize_choices +from ._utils.requests import TimeoutSession + if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -56,41 +56,10 @@ if TYPE_CHECKING: TranslatorAPI, ) -USER_AGENT = f"beets/{beets.__version__}" INSTRUMENTAL_LYRICS = "[Instrumental]" -class NotFoundError(requests.exceptions.HTTPError): - pass - - -class CaptchaError(requests.exceptions.HTTPError): - pass - - -class TimeoutSession(requests.Session): - def request(self, *args, **kwargs): - """Wrap the request method to raise an exception on HTTP errors.""" - kwargs.setdefault("timeout", 10) - r = super().request(*args, **kwargs) - if r.status_code == HTTPStatus.NOT_FOUND: - raise NotFoundError("HTTP Error: Not Found", response=r) - if 300 <= r.status_code < 400: - raise CaptchaError("Captcha is required", response=r) - - r.raise_for_status() - - return r - - r_session = TimeoutSession() -r_session.headers.update({"User-Agent": USER_AGENT}) - - -@atexit.register -def close_session(): - """Close the requests session on shut down.""" - r_session.close() # Utilities. From a866347345b9f8f4b84926ed830014eaadfaf863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 13:17:43 +0100 Subject: [PATCH 142/274] Define MusicBrainzAPI class with rate limiting --- beetsplug/musicbrainz.py | 22 +++++++++++++++++++++- poetry.lock | 35 ++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 231a045b7..374189e2e 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -26,6 +26,7 @@ from urllib.parse import urljoin import musicbrainzngs from confuse.exceptions import NotFoundError +from requests_ratelimiter import LimiterMixin import beets import beets.autotag.hooks @@ -34,6 +35,8 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id +from ._utils.requests import TimeoutSession + if TYPE_CHECKING: from collections.abc import Iterable, Sequence from typing import Literal @@ -57,6 +60,11 @@ FIELDS_TO_MB_KEYS = { "year": "date", } + +class LimiterTimeoutSession(LimiterMixin, TimeoutSession): + pass + + musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/") @@ -121,12 +129,24 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 +class MusicBrainzAPI: + api_url = "https://musicbrainz.org/ws/2/" + + @cached_property + def session(self) -> LimiterTimeoutSession: + return LimiterTimeoutSession(per_second=1) + + def _get(self, entity: str, **kwargs) -> JSONDict: + return self.session.get( + f"{self.api_url}/{entity}", params={**kwargs, "fmt": "json"} + ).json() + + def _preferred_alias( aliases: list[JSONDict], languages: list[str] | None = None ) -> JSONDict | None: """Given a list of alias structures for an artist credit, select and return the user's preferred alias or None if no matching - alias is found. """ if not aliases: return None diff --git a/poetry.lock b/poetry.lock index ba16420c2..46bf443ca 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2683,6 +2683,21 @@ docs = ["sphinx", "sphinx_rtd_theme"] fuzzer = ["atheris", "hypothesis"] test = ["coverage[toml] (>=5.2)", "hypothesis", "pytest (>=6.0)", "pytest-benchmark", "pytest-cov", "pytest-timeout"] +[[package]] +name = "pyrate-limiter" +version = "2.10.0" +description = "Python Rate-Limiter using Leaky-Bucket Algorithm" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pyrate_limiter-2.10.0-py3-none-any.whl", hash = "sha256:a99e52159f5ed5eb58118bed8c645e30818e7c0e0d127a0585c8277c776b0f7f"}, + {file = "pyrate_limiter-2.10.0.tar.gz", hash = "sha256:98cc52cdbe058458e945ae87d4fd5a73186497ffa545ee6e98372f8599a5bd34"}, +] + +[package.extras] +all = ["filelock (>=3.0)", "redis (>=3.3,<4.0)", "redis-py-cluster (>=2.1.3,<3.0.0)"] +docs = ["furo (>=2022.3.4,<2023.0.0)", "myst-parser (>=0.17)", "sphinx (>=4.3.0,<5.0.0)", "sphinx-autodoc-typehints (>=1.17,<2.0)", "sphinx-copybutton (>=0.5)", "sphinxcontrib-apidoc (>=0.3,<0.4)"] + [[package]] name = "pytest" version = "8.4.2" @@ -3236,6 +3251,24 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "requests-ratelimiter" +version = "0.7.0" +description = "Rate-limiting for the requests library" +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "requests_ratelimiter-0.7.0-py3-none-any.whl", hash = "sha256:1a7ef2faaa790272722db8539728690046237766fcc479f85b9591e5356a8185"}, + {file = "requests_ratelimiter-0.7.0.tar.gz", hash = "sha256:a070c8a359a6f3a001b0ccb08f17228b7ae0a6e21d8df5b6f6bd58389cddde45"}, +] + +[package.dependencies] +pyrate-limiter = "<3.0" +requests = ">=2.20" + +[package.extras] +docs = ["furo (>=2023.3,<2024.0)", "myst-parser (>=1.0)", "sphinx (>=5.2,<6.0)", "sphinx-autodoc-typehints (>=1.22,<2.0)", "sphinx-copybutton (>=0.5)"] + [[package]] name = "resampy" version = "0.4.3" @@ -4189,4 +4222,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "9e154214b2f404415ef17df83f926a326ffb62a83b3901a404946110354d4067" +content-hash = "1b69db4cdc3908316b2e18a5620916aa55235ded58b275c4433819ffa4ed660b" diff --git a/pyproject.toml b/pyproject.toml index 8b33e9fcb..7fd1cadba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ numpy = [ ] platformdirs = ">=3.5.0" pyyaml = "*" +requests-ratelimiter = ">=0.7.0" typing_extensions = "*" unidecode = ">=1.3.6" From 69e3a8233dd7635413b493cbbf834269ff1cad79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 13:18:06 +0100 Subject: [PATCH 143/274] Add missing blame ignore revs from musicbrainz plugin --- .git-blame-ignore-revs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 11842573f..c8cb065f5 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -49,6 +49,10 @@ f36bc497c8c8f89004f3f6879908d3f0b25123e1 c490ac5810b70f3cf5fd8649669838e8fdb19f4d # Importer restructure 9147577b2b19f43ca827e9650261a86fb0450cef +# Move functionality under MusicBrainz plugin +529aaac7dced71266c6d69866748a7d044ec20ff +# musicbrainz: reorder methods +5dc6f45110b99f0cc8dbb94251f9b1f6d69583fa # Copy paste query, types from library to dbcore 1a045c91668c771686f4c871c84f1680af2e944b # Library restructure (split library.py into multiple modules) From 7fdb45852459a78a760aa4bf6714315e3fdf0610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 13:22:58 +0100 Subject: [PATCH 144/274] Move pseudo release lookup under the plugin --- beetsplug/musicbrainz.py | 37 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 374189e2e..9a41b2a04 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -327,30 +327,6 @@ def _set_date_str( setattr(info, key, date_num) -def _is_translation(r): - _trans_key = "transl-tracklisting" - return r["type"] == _trans_key and r["direction"] == "backward" - - -def _find_actual_release_from_pseudo_release( - pseudo_rel: JSONDict, -) -> JSONDict | None: - try: - relations = pseudo_rel["release"]["release-relation-list"] - except KeyError: - return None - - # currently we only support trans(liter)ation's - translations = [r for r in relations if _is_translation(r)] - - if not translations: - return None - - actual_id = translations[0]["target"] - - return musicbrainzngs.get_release_by_id(actual_id, RELEASE_INCLUDES) - - def _merge_pseudo_and_actual_album( pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo ) -> beets.autotag.hooks.AlbumInfo: @@ -891,8 +867,17 @@ class MusicBrainzPlugin(MetadataSourcePlugin): # resolve linked release relations actual_res = None - if res["release"].get("status") == "Pseudo-Release": - actual_res = _find_actual_release_from_pseudo_release(res) + if res.get("status") == "Pseudo-Release" and ( + relations := res["release"].get("release-relation-list") + ): + for rel in relations: + if ( + rel["type"] == "transl-tracklisting" + and rel["direction"] == "backward" + ): + actual_res = musicbrainzngs.get_release_by_id( + rel["target"], RELEASE_INCLUDES + ) except musicbrainzngs.ResponseError: self._log.debug("Album ID match failed.") From 2a63e13617e0c4e78567b85c14c5ade27726f881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 23:19:34 +0100 Subject: [PATCH 145/274] musicbrainz: lookup release directly --- beetsplug/_utils/requests.py | 4 +- beetsplug/lyrics.py | 4 +- beetsplug/mbpseudo.py | 20 +- beetsplug/musicbrainz.py | 222 +- test/plugins/test_mbpseudo.py | 124 +- test/plugins/test_musicbrainz.py | 539 +++-- test/rsrc/mbpseudo/official_release.json | 2663 +++++++++++++++------- test/rsrc/mbpseudo/pseudo_release.json | 819 ++++--- 8 files changed, 2856 insertions(+), 1539 deletions(-) diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 9b82d4538..a9a1af372 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -6,7 +6,7 @@ import requests from beets import __version__ -class NotFoundError(requests.exceptions.HTTPError): +class HTTPNotFoundError(requests.exceptions.HTTPError): pass @@ -29,7 +29,7 @@ class TimeoutSession(requests.Session): kwargs.setdefault("timeout", 10) r = super().request(*args, **kwargs) if r.status_code == HTTPStatus.NOT_FOUND: - raise NotFoundError("HTTP Error: Not Found", response=r) + raise HTTPNotFoundError("HTTP Error: Not Found", response=r) if 300 <= r.status_code < 400: raise CaptchaError("Captcha is required", response=r) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 145438f19..8b28a6179 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -39,7 +39,7 @@ from beets import plugins, ui from beets.autotag.distance import string_dist from beets.util.config import sanitize_choices -from ._utils.requests import TimeoutSession +from ._utils.requests import CaptchaError, HTTPNotFoundError, TimeoutSession if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -327,7 +327,7 @@ class LRCLib(Backend): yield self.fetch_json(self.SEARCH_URL, params=base_params) - with suppress(NotFoundError): + with suppress(HTTPNotFoundError): yield [self.fetch_json(self.GET_URL, params=get_params)] @classmethod diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 9cfa99969..0e131a712 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -22,7 +22,6 @@ from copy import deepcopy from typing import TYPE_CHECKING, Any import mediafile -import musicbrainzngs from typing_extensions import override from beets import config @@ -32,7 +31,6 @@ from beets.autotag.match import assign_items from beets.plugins import find_plugins from beets.util.id_extractors import extract_release_id from beetsplug.musicbrainz import ( - RELEASE_INCLUDES, MusicBrainzAPIError, MusicBrainzPlugin, _merge_pseudo_and_actual_album, @@ -53,8 +51,6 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): def __init__(self) -> None: super().__init__() - self._release_getter = musicbrainzngs.get_release_by_id - self.config.add( { "scripts": [], @@ -143,12 +139,12 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): if release.get("status") == _STATUS_PSEUDO: return official_release - elif pseudo_release_ids := self._intercept_mb_release(release): - album_id = self._extract_id(pseudo_release_ids[0]) + + if (ids := self._intercept_mb_release(release)) and ( + album_id := self._extract_id(ids[0]) + ): try: - raw_pseudo_release = self._release_getter( - album_id, RELEASE_INCLUDES - )["release"] + raw_pseudo_release = self.api.get_release(album_id) pseudo_release = super().album_info(raw_pseudo_release) if self.config["custom_tags_only"].get(bool): @@ -181,7 +177,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): return [ pr_id - for rel in data.get("release-relation-list", []) + for rel in data.get("release-relations", []) if (pr_id := self._wanted_pseudo_release_id(album_id, rel)) is not None ] @@ -234,7 +230,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): "artist-credit", [] ) aliases = [ - artist_credit.get("artist", {}).get("alias-list", []) + artist_credit.get("artist", {}).get("aliases", []) for artist_credit in artist_credits ] @@ -247,7 +243,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): aliases_flattened, ) if alias_dict := _preferred_alias(aliases_flattened, [locale]): - if alias := alias_dict.get("alias"): + if alias := alias_dict.get("name"): self._log.debug("Got alias '{0}'", alias) pseudo_release.artist = alias for track in pseudo_release.tracks: diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 9a41b2a04..ac82aeec9 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -16,11 +16,12 @@ from __future__ import annotations +import operator import traceback from collections import Counter from contextlib import suppress -from functools import cached_property -from itertools import product +from functools import cached_property, singledispatchmethod +from itertools import groupby, product from typing import TYPE_CHECKING, Any from urllib.parse import urljoin @@ -35,7 +36,7 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id -from ._utils.requests import TimeoutSession +from ._utils.requests import HTTPNotFoundError, TimeoutSession if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -83,27 +84,24 @@ class MusicBrainzAPIError(util.HumanReadableError): return f"{self._reasonstr()} in {self.verb} with query {self.query!r}" -RELEASE_INCLUDES = list( - { - "artists", - "media", - "recordings", - "release-groups", - "labels", - "artist-credits", - "aliases", - "recording-level-rels", - "work-rels", - "work-level-rels", - "artist-rels", - "isrcs", - "url-rels", - "release-rels", - "genres", - "tags", - } - & set(musicbrainzngs.VALID_INCLUDES["release"]) -) +RELEASE_INCLUDES = [ + "artists", + "media", + "recordings", + "release-groups", + "labels", + "artist-credits", + "aliases", + "recording-level-rels", + "work-rels", + "work-level-rels", + "artist-rels", + "isrcs", + "url-rels", + "release-rels", + "genres", + "tags", +] TRACK_INCLUDES = list( { @@ -130,7 +128,7 @@ BROWSE_MAXTRACKS = 500 class MusicBrainzAPI: - api_url = "https://musicbrainz.org/ws/2/" + api_url = "https://musicbrainz.org/ws/2" @cached_property def session(self) -> LimiterTimeoutSession: @@ -141,6 +139,54 @@ class MusicBrainzAPI: f"{self.api_url}/{entity}", params={**kwargs, "fmt": "json"} ).json() + def get_release(self, id_: str) -> JSONDict: + return self._group_relations( + self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) + ) + + @singledispatchmethod + @classmethod + def _group_relations(cls, data: Any) -> Any: + """Normalize MusicBrainz 'relations' into type-keyed fields recursively. + + This helper rewrites payloads that use a generic 'relations' list into + a structure that is easier to consume downstream. When a mapping + contains 'relations', those entries are regrouped by their 'target-type' + and stored under keys like '-relations'. The original + 'relations' key is removed to avoid ambiguous access patterns. + + The transformation is applied recursively so that nested objects and + sequences are normalized consistently, while non-container values are + left unchanged. + """ + return data + + @_group_relations.register(list) + @classmethod + def _(cls, data: list[Any]) -> list[Any]: + return [cls._group_relations(i) for i in data] + + @_group_relations.register(dict) + @classmethod + def _(cls, data: JSONDict) -> JSONDict: + for k, v in list(data.items()): + if k == "relations": + get_target_type = operator.methodcaller("get", "target-type") + for target_type, group in groupby( + sorted(v, key=get_target_type), get_target_type + ): + relations = [ + {k: v for k, v in item.items() if k != "target-type"} + for item in group + ] + data[f"{target_type}-relations"] = cls._group_relations( + relations + ) + data.pop("relations") + else: + data[k] = cls._group_relations(v) + return data + def _preferred_alias( aliases: list[JSONDict], languages: list[str] | None = None @@ -169,7 +215,7 @@ def _preferred_alias( for alias in valid_aliases: if ( alias["locale"] == locale - and "primary" in alias + and alias.get("primary") and alias.get("type", "").lower() not in ignored_alias_types ): matches.append(alias) @@ -194,36 +240,33 @@ def _multi_artist_credit( artist_sort_parts = [] artist_credit_parts = [] for el in credit: - if isinstance(el, str): - # Join phrase. - if include_join_phrase: - artist_parts.append(el) - artist_credit_parts.append(el) - artist_sort_parts.append(el) + alias = _preferred_alias(el["artist"].get("aliases", ())) + # An artist. + if alias: + cur_artist_name = alias["name"] else: - alias = _preferred_alias(el["artist"].get("alias-list", ())) + cur_artist_name = el["artist"]["name"] + artist_parts.append(cur_artist_name) - # An artist. - if alias: - cur_artist_name = alias["alias"] - else: - cur_artist_name = el["artist"]["name"] - artist_parts.append(cur_artist_name) + # Artist sort name. + if alias: + artist_sort_parts.append(alias["sort-name"]) + elif "sort-name" in el["artist"]: + artist_sort_parts.append(el["artist"]["sort-name"]) + else: + artist_sort_parts.append(cur_artist_name) - # Artist sort name. - if alias: - artist_sort_parts.append(alias["sort-name"]) - elif "sort-name" in el["artist"]: - artist_sort_parts.append(el["artist"]["sort-name"]) - else: - artist_sort_parts.append(cur_artist_name) + # Artist credit. + if "name" in el: + artist_credit_parts.append(el["name"]) + else: + artist_credit_parts.append(cur_artist_name) - # Artist credit. - if "name" in el: - artist_credit_parts.append(el["name"]) - else: - artist_credit_parts.append(cur_artist_name) + if include_join_phrase and (joinphrase := el.get("joinphrase")): + artist_parts.append(joinphrase) + artist_sort_parts.append(joinphrase) + artist_credit_parts.append(joinphrase) return ( artist_parts, @@ -293,9 +336,9 @@ def _preferred_release_event( ].as_str_seq() for country in preferred_countries: - for event in release.get("release-event-list", {}): + for event in release.get("release-events", {}): try: - if country in event["area"]["iso-3166-1-code-list"]: + if country in event["area"]["iso-3166-1-codes"]: return country, event["date"] except KeyError: pass @@ -370,7 +413,11 @@ def _merge_pseudo_and_actual_album( class MusicBrainzPlugin(MetadataSourcePlugin): @cached_property def genres_field(self) -> str: - return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}-list" + return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}s" + + @cached_property + def api(self) -> MusicBrainzAPI: + return MusicBrainzAPI() def __init__(self): """Set up the python-musicbrainz-ngs module according to settings @@ -461,9 +508,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): info.artists_ids = _artist_ids(recording["artist-credit"]) info.artist_id = info.artists_ids[0] - if recording.get("artist-relation-list"): + if recording.get("artist-relations"): info.remixer = _get_related_artist_names( - recording["artist-relation-list"], relation_type="remixer" + recording["artist-relations"], relation_type="remixer" ) if recording.get("length"): @@ -477,7 +524,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): lyricist = [] composer = [] composer_sort = [] - for work_relation in recording.get("work-relation-list", ()): + for work_relation in recording.get("work-relations", ()): if work_relation["type"] != "performance": continue info.work = work_relation["work"]["title"] @@ -486,7 +533,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): info.work_disambig = work_relation["work"]["disambiguation"] for artist_relation in work_relation["work"].get( - "artist-relation-list", () + "artist-relations", () ): if "type" in artist_relation: type = artist_relation["type"] @@ -504,7 +551,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): info.composer_sort = ", ".join(composer_sort) arranger = [] - for artist_relation in recording.get("artist-relation-list", ()): + for artist_relation in recording.get("artist-relations", ()): if "type" in artist_relation: type = artist_relation["type"] if type == "arranger": @@ -536,9 +583,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): release["artist-credit"], include_join_phrase=False ) - ntracks = sum(len(m["track-list"]) for m in release["medium-list"]) + ntracks = sum(len(m["tracks"]) for m in release["media"]) - # The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list' + # The MusicBrainz API omits 'relations' # when the release has more than 500 tracks. So we use browse_recordings # on chunks of tracks to recover the same information in this case. if ntracks > BROWSE_MAXTRACKS: @@ -555,27 +602,27 @@ class MusicBrainzPlugin(MetadataSourcePlugin): )["recording-list"] ) track_map = {r["id"]: r for r in recording_list} - for medium in release["medium-list"]: - for recording in medium["track-list"]: + for medium in release["media"]: + for recording in medium["tracks"]: recording_info = track_map[recording["recording"]["id"]] recording["recording"] = recording_info # Basic info. track_infos = [] index = 0 - for medium in release["medium-list"]: + for medium in release["media"]: disctitle = medium.get("title") format = medium.get("format") if format in config["match"]["ignored_media"].as_str_seq(): continue - all_tracks = medium["track-list"] + all_tracks = medium["tracks"] if ( - "data-track-list" in medium + "data-tracks" in medium and not config["match"]["ignore_data_tracks"] ): - all_tracks += medium["data-track-list"] + all_tracks += medium["data-tracks"] track_count = len(all_tracks) if "pregap" in medium: @@ -590,7 +637,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): if ( "video" in track["recording"] - and track["recording"]["video"] == "true" + and track["recording"]["video"] and config["match"]["ignore_video_tracks"] ): continue @@ -644,7 +691,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): artists=artists_names, artists_ids=album_artist_ids, tracks=track_infos, - mediums=len(release["medium-list"]), + mediums=len(release["media"]), artist_sort=artist_sort_name, artists_sort=artists_sort_names, artist_credit=artist_credit_name, @@ -684,9 +731,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): rel_primarytype = release["release-group"]["primary-type"] if rel_primarytype: albumtypes.append(rel_primarytype.lower()) - if "secondary-type-list" in release["release-group"]: - if release["release-group"]["secondary-type-list"]: - for sec_type in release["release-group"]["secondary-type-list"]: + if "secondary-types" in release["release-group"]: + if release["release-group"]["secondary-types"]: + for sec_type in release["release-group"]["secondary-types"]: albumtypes.append(sec_type.lower()) info.albumtypes = albumtypes @@ -702,8 +749,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin): _set_date_str(info, release_group_date, True) # Label name. - if release.get("label-info-list"): - label_info = release["label-info-list"][0] + if release.get("label-info"): + label_info = release["label-info"][0] if label_info.get("label"): label = label_info["label"]["name"] if label != "[no label]": @@ -717,10 +764,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin): info.language = rep.get("language") # Media (format). - if release["medium-list"]: + if release["media"]: # If all media are the same, use that medium name - if len({m.get("format") for m in release["medium-list"]}) == 1: - info.media = release["medium-list"][0].get("format") + if len({m.get("format") for m in release["media"]}) == 1: + info.media = release["media"][0].get("format") # Otherwise, let's just call it "Media" else: info.media = "Media" @@ -744,11 +791,11 @@ class MusicBrainzPlugin(MetadataSourcePlugin): wanted_sources = { site for site, wanted in external_ids.items() if wanted } - if wanted_sources and (url_rels := release.get("url-relation-list")): + if wanted_sources and (url_rels := release.get("url-relations")): urls = {} for source, url in product(wanted_sources, url_rels): - if f"{source}.com" in (target := url["target"]): + if f"{source}.com" in (target := url["url"]["resource"]): urls[source] = target self._log.debug( "Found link to {} release via MusicBrainz", @@ -838,7 +885,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin): criteria = self.get_album_criteria(items, artist, album, va_likely) release_ids = (r["id"] for r in self._search_api("release", criteria)) - yield from filter(None, map(self.album_for_id, release_ids)) + for id_ in release_ids: + with suppress(HTTPNotFoundError): + if album_info := self.album_for_id(id_): + yield album_info def item_candidates( self, item: Item, artist: str, title: str @@ -862,22 +912,20 @@ class MusicBrainzPlugin(MetadataSourcePlugin): return None try: - res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES) + res = self.api.get_release(albumid) # resolve linked release relations actual_res = None if res.get("status") == "Pseudo-Release" and ( - relations := res["release"].get("release-relation-list") + relations := res.get("release-relations") ): for rel in relations: if ( rel["type"] == "transl-tracklisting" and rel["direction"] == "backward" ): - actual_res = musicbrainzngs.get_release_by_id( - rel["target"], RELEASE_INCLUDES - ) + actual_res = self.api.get_release(rel["target"]) except musicbrainzngs.ResponseError: self._log.debug("Album ID match failed.") @@ -888,11 +936,11 @@ class MusicBrainzPlugin(MetadataSourcePlugin): ) # release is potentially a pseudo release - release = self.album_info(res["release"]) + release = self.album_info(res) # should be None unless we're dealing with a pseudo release if actual_res is not None: - actual_release = self.album_info(actual_res["release"]) + actual_release = self.album_info(actual_res) return _merge_pseudo_and_actual_album(release, actual_release) else: return release diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index 621e08950..b333800a3 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -1,14 +1,14 @@ import json import pathlib +from copy import deepcopy import pytest -from beets import config from beets.autotag import AlbumMatch from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item -from beets.test.helper import PluginMixin +from beets.test.helper import ConfigMixin, PluginMixin from beetsplug._typing import JSONDict from beetsplug.mbpseudo import ( _STATUS_PSEUDO, @@ -18,6 +18,23 @@ from beetsplug.mbpseudo import ( @pytest.fixture(scope="module") +def rsrc_dir(pytestconfig: pytest.Config): + return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo" + + +@pytest.fixture +def official_release(rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "official_release.json").read_text(encoding="utf-8") + return json.loads(info_json) + + +@pytest.fixture +def pseudo_release(rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "pseudo_release.json").read_text(encoding="utf-8") + return json.loads(info_json) + + +@pytest.fixture def official_release_info() -> AlbumInfo: return AlbumInfo( tracks=[TrackInfo(title="百花繚乱")], @@ -26,7 +43,7 @@ def official_release_info() -> AlbumInfo: ) -@pytest.fixture(scope="module") +@pytest.fixture def pseudo_release_info() -> AlbumInfo: return AlbumInfo( tracks=[TrackInfo(title="In Bloom")], @@ -35,6 +52,14 @@ def pseudo_release_info() -> AlbumInfo: ) +@pytest.fixture(scope="module", autouse=True) +def config(): + config = ConfigMixin().config + with pytest.MonkeyPatch.context() as m: + m.setattr("beetsplug.mbpseudo.config", config) + yield config + + class TestPseudoAlbumInfo: def test_album_id_always_from_pseudo( self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo @@ -62,8 +87,7 @@ class TestPseudoAlbumInfo: info = PseudoAlbumInfo( pseudo_release_info, official_release_info, data_source="test" ) - item = Item() - item["title"] = "百花繚乱" + item = Item(title="百花繚乱") assert info.determine_best_ref([item]) == "official" @@ -71,37 +95,29 @@ class TestPseudoAlbumInfo: assert info.data_source == "test" -@pytest.fixture(scope="module") -def rsrc_dir(pytestconfig: pytest.Config): - return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo" - - -class TestMBPseudoPlugin(PluginMixin): +class TestMBPseudoMixin(PluginMixin): plugin = "mbpseudo" + @pytest.fixture(autouse=True) + def patch_get_release(self, monkeypatch, pseudo_release: JSONDict): + monkeypatch.setattr( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release", + lambda _, album_id: deepcopy( + {pseudo_release["id"]: pseudo_release}[album_id] + ), + ) + @pytest.fixture(scope="class") def plugin_config(self): return {"scripts": ["Latn", "Dummy"]} - @pytest.fixture(scope="class") + @pytest.fixture def mbpseudo_plugin(self, plugin_config) -> MusicBrainzPseudoReleasePlugin: self.config[self.plugin].set(plugin_config) return MusicBrainzPseudoReleasePlugin() - @pytest.fixture - def official_release(self, rsrc_dir: pathlib.Path) -> JSONDict: - info_json = (rsrc_dir / "official_release.json").read_text( - encoding="utf-8" - ) - return json.loads(info_json) - - @pytest.fixture - def pseudo_release(self, rsrc_dir: pathlib.Path) -> JSONDict: - info_json = (rsrc_dir / "pseudo_release.json").read_text( - encoding="utf-8" - ) - return json.loads(info_json) +class TestMBPseudoPlugin(TestMBPseudoMixin): def test_scripts_init( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin ): @@ -129,7 +145,7 @@ class TestMBPseudoPlugin(PluginMixin): mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, pseudo_release: JSONDict, ): - album_info = mbpseudo_plugin.album_info(pseudo_release["release"]) + album_info = mbpseudo_plugin.album_info(pseudo_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" assert album_info.albumstatus == _STATUS_PSEUDO @@ -148,9 +164,9 @@ class TestMBPseudoPlugin(PluginMixin): official_release: JSONDict, json_key: str, ): - del official_release["release"]["release-relation-list"][0][json_key] + del official_release["release-relations"][0][json_key] - album_info = mbpseudo_plugin.album_info(official_release["release"]) + album_info = mbpseudo_plugin.album_info(official_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" @@ -159,11 +175,11 @@ class TestMBPseudoPlugin(PluginMixin): mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, ): - official_release["release"]["release-relation-list"][0]["release"][ + official_release["release-relations"][0]["release"][ "text-representation" ]["script"] = "Null" - album_info = mbpseudo_plugin.album_info(official_release["release"]) + album_info = mbpseudo_plugin.album_info(official_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" @@ -171,12 +187,8 @@ class TestMBPseudoPlugin(PluginMixin): self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, - pseudo_release: JSONDict, ): - mbpseudo_plugin._release_getter = ( - lambda album_id, includes: pseudo_release - ) - album_info = mbpseudo_plugin.album_info(official_release["release"]) + album_info = mbpseudo_plugin.album_info(official_release) assert isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" @@ -226,40 +238,19 @@ class TestMBPseudoPlugin(PluginMixin): assert match.info.album == "In Bloom" -class TestMBPseudoPluginCustomTagsOnly(PluginMixin): - plugin = "mbpseudo" - +class TestMBPseudoPluginCustomTagsOnly(TestMBPseudoMixin): @pytest.fixture(scope="class") - def mbpseudo_plugin(self) -> MusicBrainzPseudoReleasePlugin: - self.config[self.plugin]["scripts"] = ["Latn"] - self.config[self.plugin]["custom_tags_only"] = True - return MusicBrainzPseudoReleasePlugin() - - @pytest.fixture(scope="class") - def official_release(self, rsrc_dir: pathlib.Path) -> JSONDict: - info_json = (rsrc_dir / "official_release.json").read_text( - encoding="utf-8" - ) - return json.loads(info_json) - - @pytest.fixture(scope="class") - def pseudo_release(self, rsrc_dir: pathlib.Path) -> JSONDict: - info_json = (rsrc_dir / "pseudo_release.json").read_text( - encoding="utf-8" - ) - return json.loads(info_json) + def plugin_config(self): + return {"scripts": ["Latn", "Dummy"], "custom_tags_only": True} def test_custom_tags( self, + config, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, - pseudo_release: JSONDict, ): - config["import"]["languages"] = [] - mbpseudo_plugin._release_getter = ( - lambda album_id, includes: pseudo_release - ) - album_info = mbpseudo_plugin.album_info(official_release["release"]) + config["import"]["languages"] = ["en", "jp"] + album_info = mbpseudo_plugin.album_info(official_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" assert album_info["album_transl"] == "In Bloom" @@ -269,15 +260,12 @@ class TestMBPseudoPluginCustomTagsOnly(PluginMixin): def test_custom_tags_with_import_languages( self, + config, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, - pseudo_release: JSONDict, ): - config["import"]["languages"] = ["en", "jp"] - mbpseudo_plugin._release_getter = ( - lambda album_id, includes: pseudo_release - ) - album_info = mbpseudo_plugin.album_info(official_release["release"]) + config["import"]["languages"] = [] + album_info = mbpseudo_plugin.album_info(official_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" assert album_info["album_transl"] == "In Bloom" diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 9e271a481..749e2805c 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -64,10 +64,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ], "date": "3001", - "medium-list": [], - "genre-list": [{"count": 1, "name": "GENRE"}], - "tag-list": [{"count": 1, "name": "TAG"}], - "label-info-list": [ + "media": [], + "genres": [{"count": 1, "name": "GENRE"}], + "tags": [{"count": 1, "name": "TAG"}], + "label-info": [ { "catalog-number": "CATALOG NUMBER", "label": {"name": "LABEL NAME"}, @@ -83,7 +83,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } if multi_artist_credit: - release["artist-credit"].append(" & ") # add join phase + release["artist-credit"][0]["joinphrase"] = " & " release["artist-credit"].append( { "artist": { @@ -124,7 +124,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] if multi_artist_credit: - track["artist-credit"].append(" & ") # add join phase + track["artist-credit"][0]["joinphrase"] = " & " track["artist-credit"].append( { "artist": { @@ -148,11 +148,11 @@ class MBAlbumInfoTest(MusicBrainzTestCase): "number": "A1", } data_track_list.append(data_track) - release["medium-list"].append( + release["media"].append( { "position": "1", - "track-list": track_list, - "data-track-list": data_track_list, + "tracks": track_list, + "data-tracks": data_track_list, "format": medium_format, "title": "MEDIUM TITLE", } @@ -188,7 +188,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ] if multi_artist_credit: - track["artist-credit"].append(" & ") # add join phase + track["artist-credit"][0]["joinphrase"] = " & " track["artist-credit"].append( { "artist": { @@ -200,7 +200,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ) if remixer: - track["artist-relation-list"] = [ + track["artist-relations"] = [ { "type": "remixer", "type-id": "RELATION TYPE ID", @@ -215,7 +215,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ] if video: - track["video"] = "true" + track["video"] = True if disambiguation: track["disambiguation"] = disambiguation return track @@ -301,10 +301,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): "number": "A1", } ] - release["medium-list"].append( + release["media"].append( { "position": "2", - "track-list": second_track_list, + "tracks": second_track_list, } ) @@ -700,15 +700,15 @@ class ArtistFlatteningTest(unittest.TestCase): def _add_alias(self, credit_dict, suffix="", locale="", primary=False): alias = { - "alias": f"ALIAS{suffix}", + "name": f"ALIAS{suffix}", "locale": locale, "sort-name": f"ALIASSORT{suffix}", } if primary: alias["primary"] = "primary" - if "alias-list" not in credit_dict["artist"]: - credit_dict["artist"]["alias-list"] = [] - credit_dict["artist"]["alias-list"].append(alias) + if "aliases" not in credit_dict["artist"]: + credit_dict["artist"]["aliases"] = [] + credit_dict["artist"]["aliases"].append(alias) def test_single_artist(self): credit = [self._credit_dict()] @@ -725,7 +725,10 @@ class ArtistFlatteningTest(unittest.TestCase): assert c == ["CREDIT"] def test_two_artists(self): - credit = [self._credit_dict("a"), " AND ", self._credit_dict("b")] + credit = [ + {**self._credit_dict("a"), "joinphrase": " AND "}, + self._credit_dict("b"), + ] a, s, c = musicbrainz._flatten_artist_credit(credit) assert a == "NAMEa AND NAMEb" assert s == "SORTa AND SORTb" @@ -783,86 +786,84 @@ class MBLibraryTest(MusicBrainzTestCase): def test_follow_pseudo_releases(self): side_effect = [ { - "release": { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "release-relation-list": [ - { - "type": "transl-tracklisting", - "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", - "direction": "backward", - } - ], - } + "title": "pseudo", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", + "status": "Pseudo-Release", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "translated title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + "release-relations": [ + { + "type": "transl-tracklisting", + "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", + "direction": "backward", + } + ], }, { - "release": { - "title": "actual", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01", - "status": "Official", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "original title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "country": "COUNTRY", - } + "title": "actual", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01", + "status": "Official", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "original title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + "country": "COUNTRY", }, ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country == "COUNTRY" @@ -870,44 +871,43 @@ class MBLibraryTest(MusicBrainzTestCase): def test_pseudo_releases_with_empty_links(self): side_effect = [ { - "release": { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "release-relation-list": [], - } - }, + "title": "pseudo", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", + "status": "Pseudo-Release", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "translated title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + } ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None @@ -915,43 +915,43 @@ class MBLibraryTest(MusicBrainzTestCase): def test_pseudo_releases_without_links(self): side_effect = [ { - "release": { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - } - }, + "title": "pseudo", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", + "status": "Pseudo-Release", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "translated title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + } ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None @@ -959,50 +959,50 @@ class MBLibraryTest(MusicBrainzTestCase): def test_pseudo_releases_with_unsupported_links(self): side_effect = [ { - "release": { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "release-relation-list": [ - { - "type": "remaster", - "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", - "direction": "backward", - } - ], - } - }, + "title": "pseudo", + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", + "status": "Pseudo-Release", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": { + "title": "translated title", + "id": "bar", + "length": 42, + }, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + { + "artist": { + "name": "some-artist", + "id": "some-id", + }, + } + ], + "release-group": { + "id": "another-id", + }, + "release-relations": [ + { + "type": "remaster", + "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", + "direction": "backward", + } + ], + } ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None @@ -1069,30 +1069,28 @@ class TestMusicBrainzPlugin(PluginMixin): lambda *_, **__: {"release-list": [{"id": self.mbid}]}, ) monkeypatch.setattr( - "musicbrainzngs.get_release_by_id", + "beetsplug.musicbrainz.MusicBrainzAPI.get_release", lambda *_, **__: { - "release": { - "title": "hi", - "id": self.mbid, - "status": "status", - "medium-list": [ - { - "track-list": [ - { - "id": "baz", - "recording": self.RECORDING, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - {"artist": {"name": "some-artist", "id": "some-id"}} - ], - "release-group": {"id": "another-id"}, - } + "title": "hi", + "id": self.mbid, + "status": "status", + "media": [ + { + "tracks": [ + { + "id": "baz", + "recording": self.RECORDING, + "position": 9, + "number": "A1", + } + ], + "position": 5, + } + ], + "artist-credit": [ + {"artist": {"name": "some-artist", "id": "some-id"}} + ], + "release-group": {"id": "another-id"}, }, ) candidates = list(mb.candidates([], "hello", "there", False)) @@ -1100,3 +1098,84 @@ class TestMusicBrainzPlugin(PluginMixin): assert len(candidates) == 1 assert candidates[0].tracks[0].track_id == self.RECORDING["id"] assert candidates[0].album == "hi" + + +def test_group_relations(): + raw_release = { + "id": "r1", + "relations": [ + {"target-type": "artist", "type": "vocal", "name": "A"}, + {"target-type": "url", "type": "streaming", "url": "http://s"}, + {"target-type": "url", "type": "purchase", "url": "http://p"}, + { + "target-type": "work", + "type": "performance", + "work": { + "relations": [ + { + "artist": {"name": "幾田りら"}, + "target-type": "artist", + "type": "composer", + }, + { + "target-type": "url", + "type": "lyrics", + "url": { + "resource": "https://utaten.com/lyric/tt24121002/" + }, + }, + { + "artist": {"name": "幾田りら"}, + "target-type": "artist", + "type": "lyricist", + }, + { + "target-type": "url", + "type": "lyrics", + "url": { + "resource": "https://www.uta-net.com/song/366579/" + }, + }, + ], + "title": "百花繚乱", + "type": "Song", + }, + }, + ], + } + + assert musicbrainz.MusicBrainzAPI._group_relations(raw_release) == { + "id": "r1", + "artist-relations": [{"type": "vocal", "name": "A"}], + "url-relations": [ + {"type": "streaming", "url": "http://s"}, + {"type": "purchase", "url": "http://p"}, + ], + "work-relations": [ + { + "type": "performance", + "work": { + "artist-relations": [ + {"type": "composer", "artist": {"name": "幾田りら"}}, + {"type": "lyricist", "artist": {"name": "幾田りら"}}, + ], + "url-relations": [ + { + "type": "lyrics", + "url": { + "resource": "https://utaten.com/lyric/tt24121002/" + }, + }, + { + "type": "lyrics", + "url": { + "resource": "https://www.uta-net.com/song/366579/" + }, + }, + ], + "title": "百花繚乱", + "type": "Song", + }, + }, + ], + } diff --git a/test/rsrc/mbpseudo/official_release.json b/test/rsrc/mbpseudo/official_release.json index 63f1d60dd..cd6bb3ba9 100644 --- a/test/rsrc/mbpseudo/official_release.json +++ b/test/rsrc/mbpseudo/official_release.json @@ -1,841 +1,1878 @@ { - "release": { - "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", - "title": "百花繚乱", - "status": "Official", - "quality": "normal", - "packaging": "None", - "text-representation": { - "language": "jpn", - "script": "Jpan" - }, - "artist-credit": [ - { - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP", - "alias-list": [ - { - "locale": "en", - "sort-name": "Ikuta, Lilas", - "type": "Artist name", - "primary": "primary", - "alias": "Lilas Ikuta" - } - ], - "alias-count": 1, - "tag-list": [ - { - "count": "1", - "name": "j-pop" - }, - { - "count": "1", - "name": "singer-songwriter" - } - ] - } - } - ], - "release-group": { - "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", - "type": "Single", - "title": "百花繚乱", - "first-release-date": "2025-01-10", - "primary-type": "Single", - "artist-credit": [ - { - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP", - "alias-list": [ - { - "locale": "en", - "sort-name": "Ikuta, Lilas", - "type": "Artist name", - "primary": "primary", - "alias": "Lilas Ikuta" - } - ], - "alias-count": 1, - "tag-list": [ - { - "count": "1", - "name": "j-pop" - }, - { - "count": "1", - "name": "singer-songwriter" - } - ] - } - } - ], - "artist-credit-phrase": "幾田りら" - }, - "date": "2025-01-10", - "country": "XW", - "release-event-list": [ - { - "date": "2025-01-10", - "area": { - "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", - "name": "[Worldwide]", - "sort-name": "[Worldwide]", - "iso-3166-1-code-list": [ - "XW" - ] - } - } - ], - "release-event-count": 1, - "barcode": "199066336168", - "asin": "B0DR8Y2YDC", - "cover-art-archive": { - "artwork": "true", - "count": "1", - "front": "true", - "back": "false" - }, - "label-info-list": [ - { - "catalog-number": "Lilas-020", - "label": { - "id": "157afde4-4bf5-4039-8ad2-5a15acc85176", - "type": "Production", - "name": "[no label]", - "sort-name": "[no label]", - "disambiguation": "Special purpose label – white labels, self-published releases and other “no label” releases", - "alias-list": [ - { - "sort-name": "2636621 Records DK", - "alias": "2636621 Records DK" - }, - { - "sort-name": "Auto production", - "type": "Search hint", - "alias": "Auto production" - }, - { - "sort-name": "Auto-Edición", - "type": "Search hint", - "alias": "Auto-Edición" - }, - { - "sort-name": "Auto-Product", - "type": "Search hint", - "alias": "Auto-Product" - }, - { - "sort-name": "Autoedición", - "type": "Search hint", - "alias": "Autoedición" - }, - { - "sort-name": "Autoeditado", - "type": "Search hint", - "alias": "Autoeditado" - }, - { - "sort-name": "Autoproduit", - "type": "Search hint", - "alias": "Autoproduit" - }, - { - "sort-name": "D.I.Y.", - "type": "Search hint", - "alias": "D.I.Y." - }, - { - "sort-name": "Demo", - "type": "Search hint", - "alias": "Demo" - }, - { - "sort-name": "DistroKid", - "type": "Search hint", - "alias": "DistroKid" - }, - { - "sort-name": "Eigenverlag", - "type": "Search hint", - "alias": "Eigenverlag" - }, - { - "sort-name": "Eigenvertrieb", - "type": "Search hint", - "alias": "Eigenvertrieb" - }, - { - "sort-name": "GRIND MODE", - "alias": "GRIND MODE" - }, - { - "sort-name": "INDIPENDANT", - "type": "Search hint", - "alias": "INDIPENDANT" - }, - { - "sort-name": "Indepandant", - "type": "Search hint", - "alias": "Indepandant" - }, - { - "sort-name": "Independant release", - "type": "Search hint", - "alias": "Independant release" - }, - { - "sort-name": "Independent", - "type": "Search hint", - "alias": "Independent" - }, - { - "sort-name": "Independente", - "type": "Search hint", - "alias": "Independente" - }, - { - "sort-name": "Independiente", - "type": "Search hint", - "alias": "Independiente" - }, - { - "sort-name": "Indie", - "type": "Search hint", - "alias": "Indie" - }, - { - "sort-name": "Joost Klein", - "alias": "Joost Klein" - }, - { - "sort-name": "MoroseSound", - "alias": "MoroseSound" - }, - { - "sort-name": "N/A", - "type": "Search hint", - "alias": "N/A" - }, - { - "sort-name": "No Label", - "type": "Search hint", - "alias": "No Label" - }, - { - "sort-name": "None", - "type": "Search hint", - "alias": "None" - }, - { - "sort-name": "Not On A Lebel", - "type": "Search hint", - "alias": "Not On A Lebel" - }, - { - "sort-name": "Not On Label", - "type": "Search hint", - "alias": "Not On Label" - }, - { - "sort-name": "P2019", - "alias": "P2019" - }, - { - "sort-name": "P2020", - "alias": "P2020" - }, - { - "sort-name": "P2021", - "alias": "P2021" - }, - { - "sort-name": "P2022", - "alias": "P2022" - }, - { - "sort-name": "P2023", - "alias": "P2023" - }, - { - "sort-name": "P2024", - "alias": "P2024" - }, - { - "sort-name": "P2025", - "alias": "P2025" - }, - { - "sort-name": "Records DK", - "type": "Search hint", - "alias": "Records DK" - }, - { - "sort-name": "Self Digital", - "type": "Search hint", - "alias": "Self Digital" - }, - { - "sort-name": "Self Release", - "type": "Search hint", - "alias": "Self Release" - }, - { - "sort-name": "Self Released", - "type": "Search hint", - "alias": "Self Released" - }, - { - "sort-name": "Self-release", - "type": "Search hint", - "alias": "Self-release" - }, - { - "sort-name": "Self-released", - "type": "Search hint", - "alias": "Self-released" - }, - { - "sort-name": "Self-released/independent", - "type": "Search hint", - "alias": "Self-released/independent" - }, - { - "sort-name": "Sevdaliza", - "alias": "Sevdaliza" - }, - { - "sort-name": "TOMMY CASH", - "alias": "TOMMY CASH" - }, - { - "sort-name": "Talwiinder", - "alias": "Talwiinder" - }, - { - "sort-name": "Unsigned", - "type": "Search hint", - "alias": "Unsigned" - }, - { - "locale": "fi", - "sort-name": "ei levymerkkiä", - "type": "Label name", - "primary": "primary", - "alias": "[ei levymerkkiä]" - }, - { - "locale": "nl", - "sort-name": "[geen platenmaatschappij]", - "type": "Label name", - "primary": "primary", - "alias": "[geen platenmaatschappij]" - }, - { - "locale": "et", - "sort-name": "[ilma plaadifirmata]", - "type": "Label name", - "alias": "[ilma plaadifirmata]" - }, - { - "locale": "es", - "sort-name": "[nada]", - "type": "Label name", - "primary": "primary", - "alias": "[nada]" - }, - { - "locale": "en", - "sort-name": "[no label]", - "type": "Label name", - "primary": "primary", - "alias": "[no label]" - }, - { - "sort-name": "[nolabel]", - "type": "Search hint", - "alias": "[nolabel]" - }, - { - "sort-name": "[none]", - "type": "Search hint", - "alias": "[none]" - }, - { - "locale": "lt", - "sort-name": "[nėra leidybinės kompanijos]", - "type": "Label name", - "alias": "[nėra leidybinės kompanijos]" - }, - { - "locale": "lt", - "sort-name": "[nėra leidyklos]", - "type": "Label name", - "alias": "[nėra leidyklos]" - }, - { - "locale": "lt", - "sort-name": "[nėra įrašų kompanijos]", - "type": "Label name", - "primary": "primary", - "alias": "[nėra įrašų kompanijos]" - }, - { - "locale": "et", - "sort-name": "[puudub]", - "type": "Label name", - "alias": "[puudub]" - }, - { - "locale": "ru", - "sort-name": "samizdat", - "type": "Label name", - "alias": "[самиздат]" - }, - { - "locale": "ja", - "sort-name": "[レーベルなし]", - "type": "Label name", - "primary": "primary", - "alias": "[レーベルなし]" - }, - { - "sort-name": "auto-release", - "type": "Search hint", - "alias": "auto-release" - }, - { - "sort-name": "autoprod.", - "type": "Search hint", - "alias": "autoprod." - }, - { - "sort-name": "blank", - "type": "Search hint", - "alias": "blank" - }, - { - "sort-name": "d.silvestre", - "alias": "d.silvestre" - }, - { - "sort-name": "independent release", - "type": "Search hint", - "alias": "independent release" - }, - { - "sort-name": "nyamura", - "alias": "nyamura" - }, - { - "sort-name": "pls dnt stp", - "alias": "pls dnt stp" - }, - { - "sort-name": "self", - "type": "Search hint", - "alias": "self" - }, - { - "sort-name": "self issued", - "type": "Search hint", - "alias": "self issued" - }, - { - "sort-name": "self-issued", - "type": "Search hint", - "alias": "self-issued" - }, - { - "sort-name": "white label", - "type": "Search hint", - "alias": "white label" - }, - { - "sort-name": "но лабел", - "type": "Search hint", - "alias": "но лабел" - }, - { - "sort-name": "独立发行", - "type": "Search hint", - "alias": "独立发行" - } - ], - "alias-count": 71, - "tag-list": [ - { - "count": "12", - "name": "special purpose" - }, - { - "count": "18", - "name": "special purpose label" - } - ] - } - } - ], - "label-info-count": 1, - "medium-list": [ - { - "position": "1", - "format": "Digital Media", - "track-list": [ + "aliases": [ + { + "begin": null, + "end": null, + "ended": false, + "locale": "en", + "name": "In Bloom", + "primary": true, + "sort-name": "In Bloom", + "type": "Release name", + "type-id": "df187855-059b-3514-9d5e-d240de0b4228" + } + ], + "artist-credit": [ + { + "artist": { + "aliases": [ { - "id": "0bd01e8b-18e1-4708-b0a3-c9603b89ab97", - "position": "1", - "number": "1", - "length": "179239", - "recording": { - "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", - "title": "百花繚乱", - "length": "179546", - "artist-credit": [ - { - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", + "begin": null, + "end": null, + "ended": false, + "locale": "en", + "name": "Lilas Ikuta", + "primary": true, + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" + } + ], + "country": "JP", + "disambiguation": "", + "genres": [ + { + "count": 1, + "disambiguation": "", + "id": "eba7715e-ee26-4989-8d49-9db382955419", + "name": "j-pop" + }, + { + "count": 1, + "disambiguation": "", + "id": "455f264b-db00-4716-991d-fbd32dc24523", + "name": "singer-songwriter" + } + ], + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "tags": [ + { + "count": 1, + "name": "j-pop" + }, + { + "count": 1, + "name": "singer-songwriter" + } + ], + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "幾田りら" + } + ], + "artist-relations": [ + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": "2025", + "direction": "backward", + "end": "2025", + "ended": true, + "source-credit": "", + "target-credit": "Lilas Ikuta", + "type": "copyright", + "type-id": "730b5251-7432-4896-8fc6-e1cba943bfe1" + }, + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": "2025", + "direction": "backward", + "end": "2025", + "ended": true, + "source-credit": "", + "target-credit": "Lilas Ikuta", + "type": "phonographic copyright", + "type-id": "01d3488d-8d2a-4cff-9226-5250404db4dc" + } + ], + "asin": "B0DR8Y2YDC", + "barcode": "199066336168", + "country": "XW", + "cover-art-archive": { + "artwork": true, + "back": false, + "count": 1, + "darkened": false, + "front": true + }, + "date": "2025-01-10", + "disambiguation": "", + "genres": [], + "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "label-info": [ + { + "catalog-number": "Lilas-020", + "label": { + "aliases": [ + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "2636621 Records DK", + "primary": null, + "sort-name": "2636621 Records DK", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Antipole", + "primary": null, + "sort-name": "Antipole", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Auto production", + "primary": null, + "sort-name": "Auto production", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Auto-Edición", + "primary": null, + "sort-name": "Auto-Edición", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Auto-Product", + "primary": null, + "sort-name": "Auto-Product", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Autoedición", + "primary": null, + "sort-name": "Autoedición", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Autoeditado", + "primary": null, + "sort-name": "Autoeditado", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Autoproduit", + "primary": null, + "sort-name": "Autoproduit", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Banana Skin Records", + "primary": null, + "sort-name": "Banana Skin Records", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Cannelle", + "primary": null, + "sort-name": "Cannelle", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Cece Natalie", + "primary": null, + "sort-name": "Cece Natalie", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Cherry X", + "primary": null, + "sort-name": "Cherry X", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Chung", + "primary": null, + "sort-name": "Chung", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Cody Johnson", + "primary": null, + "sort-name": "Cody Johnson", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Cowgirl Clue", + "primary": null, + "sort-name": "Cowgirl Clue", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "D.I.Y.", + "primary": null, + "sort-name": "D.I.Y.", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Damjan Mravunac Self-released)", + "primary": null, + "sort-name": "Damjan Mravunac Self-released)", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Demo", + "primary": null, + "sort-name": "Demo", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "DistroKid", + "primary": null, + "sort-name": "DistroKid", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Egzod", + "primary": null, + "sort-name": "Egzod", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Eigenverlag", + "primary": null, + "sort-name": "Eigenverlag", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Eigenvertrieb", + "primary": null, + "sort-name": "Eigenvertrieb", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "GRIND MODE", + "primary": null, + "sort-name": "GRIND MODE", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "INDIPENDANT", + "primary": null, + "sort-name": "INDIPENDANT", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Indepandant", + "primary": null, + "sort-name": "Indepandant", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Independant release", + "primary": null, + "sort-name": "Independant release", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Independent", + "primary": null, + "sort-name": "Independent", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Independente", + "primary": null, + "sort-name": "Independente", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Independiente", + "primary": null, + "sort-name": "Independiente", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Indie", + "primary": null, + "sort-name": "Indie", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Joost Klein", + "primary": null, + "sort-name": "Joost Klein", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Millington Records", + "primary": null, + "sort-name": "Millington Records", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "MoroseSound", + "primary": null, + "sort-name": "MoroseSound", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "N/A", + "primary": null, + "sort-name": "N/A", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "No Label", + "primary": null, + "sort-name": "No Label", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "None", + "primary": null, + "sort-name": "None", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "None Like Joshua", + "primary": null, + "sort-name": "None Like Joshua", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Not On A Lebel", + "primary": null, + "sort-name": "Not On A Lebel", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Not On Label", + "primary": null, + "sort-name": "Not On Label", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Offensively Average Productions", + "primary": null, + "sort-name": "Offensively Average Productions", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Ours", + "primary": null, + "sort-name": "Ours", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "P2019", + "primary": null, + "sort-name": "P2019", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "P2020", + "primary": null, + "sort-name": "P2020", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "P2021", + "primary": null, + "sort-name": "P2021", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "P2022", + "primary": null, + "sort-name": "P2022", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "P2023", + "primary": null, + "sort-name": "P2023", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "P2024", + "primary": null, + "sort-name": "P2024", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "P2025", + "primary": null, + "sort-name": "P2025", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Patriarchy", + "primary": null, + "sort-name": "Patriarchy", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Plini", + "primary": null, + "sort-name": "Plini", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Records DK", + "primary": null, + "sort-name": "Records DK", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Self Digital", + "primary": null, + "sort-name": "Self Digital", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Self Release", + "primary": null, + "sort-name": "Self Release", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Self Released", + "primary": null, + "sort-name": "Self Released", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Self-release", + "primary": null, + "sort-name": "Self-release", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Self-released", + "primary": null, + "sort-name": "Self-released", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Self-released/independent", + "primary": null, + "sort-name": "Self-released/independent", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Sevdaliza", + "primary": null, + "sort-name": "Sevdaliza", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "TOMMY CASH", + "primary": null, + "sort-name": "TOMMY CASH", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Take Van", + "primary": null, + "sort-name": "Take Van", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Talwiinder", + "primary": null, + "sort-name": "Talwiinder", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Unsigned", + "primary": null, + "sort-name": "Unsigned", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "VGR", + "primary": null, + "sort-name": "VGR", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "Woo Da Savage", + "primary": null, + "sort-name": "Woo Da Savage", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "YANAA", + "primary": null, + "sort-name": "YANAA", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "fi", + "name": "[ei levymerkkiä]", + "primary": true, + "sort-name": "ei levymerkkiä", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "nl", + "name": "[geen platenmaatschappij]", + "primary": true, + "sort-name": "[geen platenmaatschappij]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "et", + "name": "[ilma plaadifirmata]", + "primary": false, + "sort-name": "[ilma plaadifirmata]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "es", + "name": "[nada]", + "primary": true, + "sort-name": "[nada]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "en", + "name": "[no label]", + "primary": true, + "sort-name": "[no label]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "[nolabel]", + "primary": null, + "sort-name": "[nolabel]", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "[none]", + "primary": null, + "sort-name": "[none]", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "lt", + "name": "[nėra leidybinės kompanijos]", + "primary": false, + "sort-name": "[nėra leidybinės kompanijos]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "lt", + "name": "[nėra leidyklos]", + "primary": false, + "sort-name": "[nėra leidyklos]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "lt", + "name": "[nėra įrašų kompanijos]", + "primary": true, + "sort-name": "[nėra įrašų kompanijos]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "et", + "name": "[puudub]", + "primary": false, + "sort-name": "[puudub]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "ru", + "name": "[самиздат]", + "primary": false, + "sort-name": "samizdat", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": "ja", + "name": "[レーベルなし]", + "primary": true, + "sort-name": "[レーベルなし]", + "type": "Label name", + "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "annapantsu music", + "primary": null, + "sort-name": "annapantsu music", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "auto-release", + "primary": null, + "sort-name": "auto-release", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "autoprod.", + "primary": null, + "sort-name": "autoprod.", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "ayesha erotica", + "primary": null, + "sort-name": "ayesha erotica", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "blank", + "primary": null, + "sort-name": "blank", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "cupcakKe", + "primary": null, + "sort-name": "cupcakKe", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "d.silvestre", + "primary": null, + "sort-name": "d.silvestre", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "dj-Jo", + "primary": null, + "sort-name": "dj-Jo", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "independent release", + "primary": null, + "sort-name": "independent release", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "lor2mg", + "primary": null, + "sort-name": "lor2mg", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "nyamura", + "primary": null, + "sort-name": "nyamura", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "pls dnt stp", + "primary": null, + "sort-name": "pls dnt stp", + "type": null, + "type-id": null + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "self", + "primary": null, + "sort-name": "self", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "self issued", + "primary": null, + "sort-name": "self issued", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "self-issued", + "primary": null, + "sort-name": "self-issued", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "white label", + "primary": null, + "sort-name": "white label", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "но лабел", + "primary": null, + "sort-name": "но лабел", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + }, + { + "begin": null, + "end": null, + "ended": false, + "locale": null, + "name": "独立发行", + "primary": null, + "sort-name": "独立发行", + "type": "Search hint", + "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + } + ], + "disambiguation": "Special purpose label – white labels, self-published releases and other “no label” releases", + "genres": [], + "id": "157afde4-4bf5-4039-8ad2-5a15acc85176", + "label-code": null, + "name": "[no label]", + "sort-name": "[no label]", + "tags": [ + { + "count": 12, + "name": "special purpose" + }, + { + "count": 18, + "name": "special purpose label" + } + ], + "type": "Production", + "type-id": "a2426aab-2dd4-339c-b47d-b4923a241678" + } + } + ], + "media": [ + { + "format": "Digital Media", + "format-id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794", + "id": "43f08d54-a896-3561-be75-b881cbc832d5", + "position": 1, + "title": "", + "track-count": 1, + "track-offset": 0, + "tracks": [ + { + "artist-credit": [ + { + "artist": { + "aliases": [ + { + "begin": null, + "end": null, + "ended": false, + "locale": "en", + "name": "Lilas Ikuta", + "primary": true, "sort-name": "Ikuta, Lilas", - "country": "JP", - "alias-list": [ - { - "locale": "en", - "sort-name": "Ikuta, Lilas", - "type": "Artist name", - "primary": "primary", - "alias": "Lilas Ikuta" - } - ], - "alias-count": 1, - "tag-list": [ - { - "count": "1", - "name": "j-pop" - }, - { - "count": "1", - "name": "singer-songwriter" - } - ] + "type": "Artist name", + "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" } - } - ], - "isrc-list": [ - "JPP302400868" - ], - "isrc-count": 1, - "artist-relation-list": [ - { - "type": "arranger", - "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", - "target": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", - "direction": "backward", - "artist": { - "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", - "type": "Person", - "name": "KOHD", - "sort-name": "KOHD", - "country": "JP", - "disambiguation": "Japanese composer/arranger/guitarist, agehasprings" - } - }, - { - "type": "phonographic copyright", - "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "begin": "2025", - "end": "2025", - "ended": "true", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - }, - "target-credit": "Lilas Ikuta" - }, - { - "type": "producer", - "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", - "target": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", - "direction": "backward", - "artist": { - "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", - "type": "Person", - "name": "山本秀哉", - "sort-name": "Yamamoto, Shuya", - "country": "JP" - } - }, - { - "type": "vocal", - "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - } - } - ], - "work-relation-list": [ - { - "type": "performance", - "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", - "target": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", - "direction": "forward", - "work": { - "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", - "type": "Song", - "title": "百花繚乱", - "language": "jpn", - "artist-relation-list": [ - { - "type": "composer", - "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - } - }, - { - "type": "lyricist", - "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - } - } - ], - "url-relation-list": [ - { - "type": "lyrics", - "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", - "target": "https://utaten.com/lyric/tt24121002/", - "direction": "backward" - }, - { - "type": "lyrics", - "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", - "target": "https://www.uta-net.com/song/366579/", - "direction": "backward" - } - ] - } - } - ], - "artist-credit-phrase": "幾田りら" - }, + ], + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "幾田りら" + } + ], + "id": "0bd01e8b-18e1-4708-b0a3-c9603b89ab97", + "length": 179239, + "number": "1", + "position": 1, + "recording": { + "aliases": [], "artist-credit": [ { "artist": { + "country": "JP", + "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", "name": "幾田りら", "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "幾田りら" + } + ], + "artist-relations": [ + { + "artist": { "country": "JP", - "alias-list": [ + "disambiguation": "Japanese composer/arranger/guitarist, agehasprings", + "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "name": "KOHD", + "sort-name": "KOHD", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "arranger", + "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d" + }, + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": "2025", + "direction": "backward", + "end": "2025", + "ended": true, + "source-credit": "", + "target-credit": "Lilas Ikuta", + "type": "phonographic copyright", + "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30" + }, + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "name": "山本秀哉", + "sort-name": "Yamamoto, Shuya", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "producer", + "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0" + }, + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "vocal", + "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa" + } + ], + "disambiguation": "", + "first-release-date": "2025-01-10", + "genres": [], + "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", + "isrcs": [ + "JPP302400868" + ], + "length": 179546, + "tags": [], + "title": "百花繚乱", + "url-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "free streaming", + "type-id": "7e41ef12-a124-4324-afdb-fdbae687a89c", + "url": { + "id": "d076eaf9-5fde-4f6e-a946-cde16b67aa3b", + "resource": "https://open.spotify.com/track/782PTXsbAWB70ySDZ5NHmP" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "92777657-504c-4acb-bd33-51a201bd57e1", + "url": { + "id": "64879627-6eca-4755-98b5-b2234a8dbc61", + "resource": "https://music.apple.com/jp/song/1857886416" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "streaming", + "type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122", + "url": { + "id": "64879627-6eca-4755-98b5-b2234a8dbc61", + "resource": "https://music.apple.com/jp/song/1857886416" + } + } + ], + "video": false, + "work-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "performance", + "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", + "work": { + "artist-relations": [ { - "locale": "en", - "sort-name": "Ikuta, Lilas", - "type": "Artist name", - "primary": "primary", - "alias": "Lilas Ikuta" - } - ], - "alias-count": 1, - "tag-list": [ - { - "count": "1", - "name": "j-pop" + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "composer", + "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f" }, { - "count": "1", - "name": "singer-songwriter" + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "lyricist", + "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c" + } + ], + "attributes": [], + "disambiguation": "", + "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "iswcs": [], + "language": "jpn", + "languages": [ + "jpn" + ], + "title": "百花繚乱", + "type": "Song", + "type-id": "f061270a-2fd6-32f1-a641-f0f8676d14e6", + "url-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "url": { + "id": "dfac3640-6b23-4991-a59c-7cb80e8eb950", + "resource": "https://utaten.com/lyric/tt24121002/" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "url": { + "id": "b1b5d5df-e79d-4cda-bb2a-8014e5505415", + "resource": "https://www.uta-net.com/song/366579/" + } } ] } } - ], - "artist-credit-phrase": "幾田りら", - "track_or_recording_length": "179239" - } - ], - "track-count": 1 - } - ], - "medium-count": 1, - "artist-relation-list": [ - { - "type": "copyright", - "type-id": "730b5251-7432-4896-8fc6-e1cba943bfe1", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "begin": "2025", - "end": "2025", - "ended": "true", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - }, - "target-credit": "Lilas Ikuta" - }, - { - "type": "phonographic copyright", - "type-id": "01d3488d-8d2a-4cff-9226-5250404db4dc", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "begin": "2025", - "end": "2025", - "ended": "true", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - }, - "target-credit": "Lilas Ikuta" - } - ], - "release-relation-list": [ - { - "type": "transl-tracklisting", - "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644", - "target": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", - "direction": "forward", - "release": { - "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", - "title": "In Bloom", - "quality": "normal", - "text-representation": { - "language": "eng", - "script": "Latn" + ] }, - "artist-credit": [ + "title": "百花繚乱" + } + ] + } + ], + "packaging": "None", + "packaging-id": "119eba76-b343-3e02-a292-f0f00644bb9b", + "quality": "normal", + "release-events": [ + { + "area": { + "disambiguation": "", + "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", + "iso-3166-1-codes": [ + "XW" + ], + "name": "[Worldwide]", + "sort-name": "[Worldwide]", + "type": null, + "type-id": null + }, + "date": "2025-01-10" + } + ], + "release-group": { + "aliases": [], + "artist-credit": [ + { + "artist": { + "aliases": [ { + "begin": null, + "end": null, + "ended": false, + "locale": "en", "name": "Lilas Ikuta", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - } + "primary": true, + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" } ], - "medium-list": [], - "medium-count": 0, - "artist-credit-phrase": "Lilas Ikuta" - } + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "幾田りら" } ], - "url-relation-list": [ - { - "type": "amazon asin", - "type-id": "4f2e710d-166c-480c-a293-2e2c8d658d87", - "target": "https://www.amazon.co.jp/gp/product/B0DR8Y2YDC", - "direction": "forward" + "disambiguation": "", + "first-release-date": "2025-01-10", + "genres": [], + "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", + "primary-type": "Single", + "primary-type-id": "d6038452-8ee0-3f68-affc-2de9a1ede0b9", + "secondary-type-ids": [], + "secondary-types": [], + "tags": [], + "title": "百花繚乱" + }, + "release-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "release": { + "artist-credit": [ + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": null, + "type-id": null + }, + "joinphrase": "", + "name": "Lilas Ikuta" + } + ], + "barcode": null, + "disambiguation": "", + "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "media": [], + "packaging": null, + "packaging-id": null, + "quality": "normal", + "release-group": null, + "status": null, + "status-id": null, + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "title": "In Bloom" }, - { - "type": "free streaming", - "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", - "target": "https://open.spotify.com/album/3LDV2xGL9HiqCsQujEPQLb", - "direction": "forward" - }, - { - "type": "free streaming", - "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", - "target": "https://www.deezer.com/album/687686261", - "direction": "forward" - }, - { - "type": "purchase for download", - "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", - "target": "https://mora.jp/package/43000011/199066336168/", - "direction": "forward" - }, - { - "type": "purchase for download", - "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", - "target": "https://mora.jp/package/43000011/199066336168_HD/", - "direction": "forward" - }, - { - "type": "purchase for download", - "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", - "target": "https://mora.jp/package/43000011/199066336168_LL/", - "direction": "forward" - }, - { - "type": "purchase for download", - "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", - "target": "https://music.apple.com/jp/album/1786972161", - "direction": "forward" - }, - { - "type": "purchase for download", - "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", - "target": "https://ototoy.jp/_/default/p/2501951", - "direction": "forward" - }, - { - "type": "purchase for download", - "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", - "target": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/fl9tx2j78reza", - "direction": "forward" - }, - { - "type": "purchase for download", - "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", - "target": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/l1dnc4xoi6l7a", - "direction": "forward" - }, - { - "type": "streaming", - "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", - "target": "https://music.amazon.co.jp/albums/B0DR8Y2YDC", - "direction": "forward" - }, - { - "type": "streaming", - "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", - "target": "https://music.apple.com/jp/album/1786972161", - "direction": "forward" - }, - { - "type": "vgmdb", - "type-id": "6af0134a-df6a-425a-96e2-895f9cd342ba", - "target": "https://vgmdb.net/album/145936", - "direction": "forward" + "source-credit": "", + "target-credit": "", + "type": "transl-tracklisting", + "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644" + } + ], + "status": "Official", + "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", + "tags": [], + "text-representation": { + "language": "jpn", + "script": "Jpan" + }, + "title": "百花繚乱", + "url-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "amazon asin", + "type-id": "4f2e710d-166c-480c-a293-2e2c8d658d87", + "url": { + "id": "b50c7fb8-2327-4a05-b989-f2211a41afee", + "resource": "https://www.amazon.co.jp/gp/product/B0DR8Y2YDC" } - ], - "artist-credit-phrase": "幾田りら" - } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "free streaming", + "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "url": { + "id": "5106a7b0-1443-4803-91a2-28cac2cfb5e0", + "resource": "https://open.spotify.com/album/3LDV2xGL9HiqCsQujEPQLb" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "free streaming", + "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "url": { + "id": "d481d94b-a7bf-4e82-8da0-1757fedcda62", + "resource": "https://www.deezer.com/album/687686261" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "url": { + "id": "6156d2e4-d107-43f9-8f44-52f04d39c78e", + "resource": "https://mora.jp/package/43000011/199066336168/" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "url": { + "id": "a4eabb88-1746-4aa2-ab09-c28cfbe65efb", + "resource": "https://mora.jp/package/43000011/199066336168_HD/" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "url": { + "id": "ab8440f0-3b13-4436-b3ad-f4695c9d8875", + "resource": "https://mora.jp/package/43000011/199066336168_LL/" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "url": { + "id": "9a8ee8d1-f946-44a1-be16-8f7a77c951e9", + "resource": "https://music.apple.com/jp/album/1786972161" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "url": { + "id": "c6faaa80-38fb-46a4-aa2b-78cddc5cbe70", + "resource": "https://ototoy.jp/_/default/p/2501951" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "url": { + "id": "0e7e8bc5-0779-492d-a9db-9ab58f96d23b", + "resource": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/fl9tx2j78reza" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "url": { + "id": "c0cf8fe0-3413-4544-a026-37d346a59a77", + "resource": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/l1dnc4xoi6l7a" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "streaming", + "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", + "url": { + "id": "e4ce55a9-a5e1-4842-b42d-11be6a31fdab", + "resource": "https://music.amazon.co.jp/albums/B0DR8Y2YDC" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "streaming", + "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", + "url": { + "id": "9a8ee8d1-f946-44a1-be16-8f7a77c951e9", + "resource": "https://music.apple.com/jp/album/1786972161" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "vgmdb", + "type-id": "6af0134a-df6a-425a-96e2-895f9cd342ba", + "url": { + "id": "1885772a-4004-4d45-9512-d0c8822506c9", + "resource": "https://vgmdb.net/album/145936" + } + } + ] } diff --git a/test/rsrc/mbpseudo/pseudo_release.json b/test/rsrc/mbpseudo/pseudo_release.json index 99fa0b417..ae4bf7b6b 100644 --- a/test/rsrc/mbpseudo/pseudo_release.json +++ b/test/rsrc/mbpseudo/pseudo_release.json @@ -1,346 +1,515 @@ { - "release": { - "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", - "title": "In Bloom", - "status": "Pseudo-Release", - "quality": "normal", - "text-representation": { - "language": "eng", - "script": "Latn" - }, - "artist-credit": [ - { - "name": "Lilas Ikuta", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP", - "alias-list": [ + "aliases": [], + "artist-credit": [ + { + "artist": { + "aliases": [ + { + "begin": null, + "end": null, + "ended": false, + "locale": "en", + "name": "Lilas Ikuta", + "primary": true, + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" + } + ], + "country": "JP", + "disambiguation": "", + "genres": [ + { + "count": 1, + "disambiguation": "", + "id": "eba7715e-ee26-4989-8d49-9db382955419", + "name": "j-pop" + }, + { + "count": 1, + "disambiguation": "", + "id": "455f264b-db00-4716-991d-fbd32dc24523", + "name": "singer-songwriter" + } + ], + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "tags": [ + { + "count": 1, + "name": "j-pop" + }, + { + "count": 1, + "name": "singer-songwriter" + } + ], + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "Lilas Ikuta" + } + ], + "asin": null, + "barcode": null, + "cover-art-archive": { + "artwork": false, + "back": false, + "count": 0, + "darkened": false, + "front": false + }, + "disambiguation": "", + "genres": [], + "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "label-info": [], + "media": [ + { + "format": "Digital Media", + "format-id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794", + "id": "606faab7-60fa-3a8b-a40f-2c66150cce81", + "position": 1, + "title": "", + "track-count": 1, + "track-offset": 0, + "tracks": [ + { + "artist-credit": [ { - "locale": "en", - "sort-name": "Ikuta, Lilas", - "type": "Artist name", - "primary": "primary", - "alias": "Lilas Ikuta" + "artist": { + "aliases": [ + { + "begin": null, + "end": null, + "ended": false, + "locale": "en", + "name": "Lilas Ikuta", + "primary": true, + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" + } + ], + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "Lilas Ikuta" } ], - "alias-count": 1, - "tag-list": [ - { - "count": "1", - "name": "j-pop" - }, - { - "count": "1", - "name": "singer-songwriter" - } - ] - } - } - ], - "release-group": { - "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", - "type": "Single", - "title": "百花繚乱", - "first-release-date": "2025-01-10", - "primary-type": "Single", - "artist-credit": [ - { - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP", - "alias-list": [ - { - "locale": "en", - "sort-name": "Ikuta, Lilas", - "type": "Artist name", - "primary": "primary", - "alias": "Lilas Ikuta" - } - ], - "alias-count": 1, - "tag-list": [ - { - "count": "1", - "name": "j-pop" - }, - { - "count": "1", - "name": "singer-songwriter" - } - ] - } - } - ], - "artist-credit-phrase": "幾田りら" - }, - "cover-art-archive": { - "artwork": "false", - "count": "0", - "front": "false", - "back": "false" - }, - "label-info-list": [], - "label-info-count": 0, - "medium-list": [ - { - "position": "1", - "format": "Digital Media", - "track-list": [ - { - "id": "2018b012-a184-49a2-a464-fb4628a89588", - "position": "1", - "number": "1", - "title": "In Bloom", - "length": "179239", + "id": "2018b012-a184-49a2-a464-fb4628a89588", + "length": 179239, + "number": "1", + "position": 1, + "recording": { + "aliases": [], "artist-credit": [ { - "name": "Lilas Ikuta", "artist": { + "country": "JP", + "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", "name": "幾田りら", "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "幾田りら" + } + ], + "artist-relations": [ + { + "artist": { "country": "JP", - "alias-list": [ + "disambiguation": "Japanese composer/arranger/guitarist, agehasprings", + "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "name": "KOHD", + "sort-name": "KOHD", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "arranger", + "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d" + }, + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": "2025", + "direction": "backward", + "end": "2025", + "ended": true, + "source-credit": "", + "target-credit": "Lilas Ikuta", + "type": "phonographic copyright", + "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30" + }, + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "name": "山本秀哉", + "sort-name": "Yamamoto, Shuya", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "producer", + "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0" + }, + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "vocal", + "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa" + } + ], + "disambiguation": "", + "first-release-date": "2025-01-10", + "genres": [], + "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", + "isrcs": [ + "JPP302400868" + ], + "length": 179546, + "tags": [], + "title": "百花繚乱", + "url-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "free streaming", + "type-id": "7e41ef12-a124-4324-afdb-fdbae687a89c", + "url": { + "id": "d076eaf9-5fde-4f6e-a946-cde16b67aa3b", + "resource": "https://open.spotify.com/track/782PTXsbAWB70ySDZ5NHmP" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "purchase for download", + "type-id": "92777657-504c-4acb-bd33-51a201bd57e1", + "url": { + "id": "64879627-6eca-4755-98b5-b2234a8dbc61", + "resource": "https://music.apple.com/jp/song/1857886416" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "streaming", + "type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122", + "url": { + "id": "64879627-6eca-4755-98b5-b2234a8dbc61", + "resource": "https://music.apple.com/jp/song/1857886416" + } + } + ], + "video": false, + "work-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "performance", + "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", + "work": { + "artist-relations": [ { - "locale": "en", - "sort-name": "Ikuta, Lilas", - "type": "Artist name", - "primary": "primary", - "alias": "Lilas Ikuta" - } - ], - "alias-count": 1, - "tag-list": [ - { - "count": "1", - "name": "j-pop" + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "composer", + "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f" }, { - "count": "1", - "name": "singer-songwriter" + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "lyricist", + "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c" + } + ], + "attributes": [], + "disambiguation": "", + "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "iswcs": [], + "language": "jpn", + "languages": [ + "jpn" + ], + "title": "百花繚乱", + "type": "Song", + "type-id": "f061270a-2fd6-32f1-a641-f0f8676d14e6", + "url-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "url": { + "id": "dfac3640-6b23-4991-a59c-7cb80e8eb950", + "resource": "https://utaten.com/lyric/tt24121002/" + } + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "source-credit": "", + "target-credit": "", + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "url": { + "id": "b1b5d5df-e79d-4cda-bb2a-8014e5505415", + "resource": "https://www.uta-net.com/song/366579/" + } } ] } } - ], - "recording": { - "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", - "title": "百花繚乱", - "length": "179546", - "artist-credit": [ - { - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP", - "alias-list": [ - { - "locale": "en", - "sort-name": "Ikuta, Lilas", - "type": "Artist name", - "primary": "primary", - "alias": "Lilas Ikuta" - } - ], - "alias-count": 1, - "tag-list": [ - { - "count": "1", - "name": "j-pop" - }, - { - "count": "1", - "name": "singer-songwriter" - } - ] - } - } - ], - "isrc-list": [ - "JPP302400868" - ], - "isrc-count": 1, - "artist-relation-list": [ - { - "type": "arranger", - "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", - "target": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", - "direction": "backward", - "artist": { - "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", - "type": "Person", - "name": "KOHD", - "sort-name": "KOHD", - "country": "JP", - "disambiguation": "Japanese composer/arranger/guitarist, agehasprings" - } - }, - { - "type": "phonographic copyright", - "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "begin": "2025", - "end": "2025", - "ended": "true", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - }, - "target-credit": "Lilas Ikuta" - }, - { - "type": "producer", - "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", - "target": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", - "direction": "backward", - "artist": { - "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", - "type": "Person", - "name": "山本秀哉", - "sort-name": "Yamamoto, Shuya", - "country": "JP" - } - }, - { - "type": "vocal", - "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - } - } - ], - "work-relation-list": [ - { - "type": "performance", - "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", - "target": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", - "direction": "forward", - "work": { - "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", - "type": "Song", - "title": "百花繚乱", - "language": "jpn", - "artist-relation-list": [ - { - "type": "composer", - "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - } - }, - { - "type": "lyricist", - "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", - "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "direction": "backward", - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "type": "Person", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - } - } - ], - "url-relation-list": [ - { - "type": "lyrics", - "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", - "target": "https://utaten.com/lyric/tt24121002/", - "direction": "backward" - }, - { - "type": "lyrics", - "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", - "target": "https://www.uta-net.com/song/366579/", - "direction": "backward" - } - ] - } - } - ], - "artist-credit-phrase": "幾田りら" + ] + }, + "title": "In Bloom" + } + ] + } + ], + "packaging": null, + "packaging-id": null, + "quality": "normal", + "release-group": { + "aliases": [], + "artist-credit": [ + { + "artist": { + "aliases": [ + { + "begin": null, + "end": null, + "ended": false, + "locale": "en", + "name": "Lilas Ikuta", + "primary": true, + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" + } + ], + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "幾田りら" + } + ], + "disambiguation": "", + "first-release-date": "2025-01-10", + "genres": [], + "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", + "primary-type": "Single", + "primary-type-id": "d6038452-8ee0-3f68-affc-2de9a1ede0b9", + "secondary-type-ids": [], + "secondary-types": [], + "tags": [], + "title": "百花繚乱" + }, + "release-relations": [ + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "backward", + "end": null, + "ended": false, + "release": { + "artist-credit": [ + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": null, + "type-id": null }, - "artist-credit-phrase": "Lilas Ikuta", - "track_or_recording_length": "179239" + "joinphrase": "", + "name": "幾田りら" } ], - "track-count": 1 - } - ], - "medium-count": 1, - "release-relation-list": [ - { - "type": "transl-tracklisting", - "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644", - "target": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", - "direction": "backward", - "release": { - "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", - "title": "百花繚乱", - "quality": "normal", - "text-representation": { - "language": "jpn", - "script": "Jpan" - }, - "artist-credit": [ - { - "artist": { - "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", - "name": "幾田りら", - "sort-name": "Ikuta, Lilas", - "country": "JP" - } - } - ], - "date": "2025-01-10", - "country": "XW", - "release-event-list": [ - { - "date": "2025-01-10", - "area": { - "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", - "name": "[Worldwide]", - "sort-name": "[Worldwide]", - "iso-3166-1-code-list": [ - "XW" - ] - } - } - ], - "release-event-count": 1, - "barcode": "199066336168", - "medium-list": [], - "medium-count": 0, - "artist-credit-phrase": "幾田りら" - } - } - ], - "artist-credit-phrase": "Lilas Ikuta" - } -} \ No newline at end of file + "barcode": "199066336168", + "country": "XW", + "date": "2025-01-10", + "disambiguation": "", + "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "media": [], + "packaging": null, + "packaging-id": null, + "quality": "normal", + "release-events": [ + { + "area": { + "disambiguation": "", + "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", + "iso-3166-1-codes": [ + "XW" + ], + "name": "[Worldwide]", + "sort-name": "[Worldwide]", + "type": null, + "type-id": null + }, + "date": "2025-01-10" + } + ], + "release-group": null, + "status": null, + "status-id": null, + "text-representation": { + "language": "jpn", + "script": "Jpan" + }, + "title": "百花繚乱" + }, + "source-credit": "", + "target-credit": "", + "type": "transl-tracklisting", + "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644" + } + ], + "status": "Pseudo-Release", + "status-id": "41121bb9-3413-3818-8a9a-9742318349aa", + "tags": [], + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "title": "In Bloom" +} From d70e5917389e13409316ebe75489285668f75075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 23:24:10 +0100 Subject: [PATCH 146/274] musicbrainz: lookup recordings directly --- beetsplug/musicbrainz.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index ac82aeec9..a4d1db983 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -103,16 +103,13 @@ RELEASE_INCLUDES = [ "tags", ] -TRACK_INCLUDES = list( - { - "artists", - "aliases", - "isrcs", - "work-level-rels", - "artist-rels", - } - & set(musicbrainzngs.VALID_INCLUDES["recording"]) -) +TRACK_INCLUDES = [ + "artists", + "aliases", + "isrcs", + "work-level-rels", + "artist-rels", +] BROWSE_INCLUDES = [ "artist-credits", @@ -144,6 +141,9 @@ class MusicBrainzAPI: self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) ) + def get_recording(self, id_: str) -> JSONDict: + return self._get(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) + @singledispatchmethod @classmethod def _group_relations(cls, data: Any) -> Any: @@ -518,8 +518,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin): info.trackdisambig = recording.get("disambiguation") - if recording.get("isrc-list"): - info.isrc = ";".join(recording["isrc-list"]) + if recording.get("isrcs"): + info.isrc = ";".join(recording["isrcs"]) lyricist = [] composer = [] @@ -956,12 +956,12 @@ class MusicBrainzPlugin(MetadataSourcePlugin): return None try: - res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES) - except musicbrainzngs.ResponseError: + res = self.api.get_recording(trackid) + except (HTTPNotFoundError, musicbrainzngs.ResponseError): self._log.debug("Track ID match failed.") return None except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( exc, "get recording by ID", trackid, traceback.format_exc() ) - return self.track_info(res["recording"]) + return self.track_info(res) From abad03c1cb5c8eda811dd51327584188673638ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 23:27:57 +0100 Subject: [PATCH 147/274] musicbrainz: search directly --- beetsplug/musicbrainz.py | 38 ++++++++++++++++++-------------- test/plugins/test_musicbrainz.py | 10 ++++----- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index a4d1db983..2d3c95f43 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -833,17 +833,20 @@ class MusicBrainzPlugin(MetadataSourcePlugin): def get_album_criteria( self, items: Sequence[Item], artist: str, album: str, va_likely: bool ) -> dict[str, str]: - criteria = { - "release": album, - "alias": album, - "tracks": str(len(items)), - } | ({"arid": VARIOUS_ARTISTS_ID} if va_likely else {"artist": artist}) + criteria = {"release": album} | ( + {"arid": VARIOUS_ARTISTS_ID} if va_likely else {"artist": artist} + ) for tag, mb_field in self.extra_mb_field_by_tag.items(): - most_common, _ = util.plurality(i.get(tag) for i in items) - value = str(most_common) - if tag == "catalognum": - value = value.replace(" ", "") + if tag == "tracks": + value = str(len(items)) + elif tag == "alias": + value = album + else: + most_common, _ = util.plurality(i.get(tag) for i in items) + value = str(most_common) + if tag == "catalognum": + value = value.replace(" ", "") criteria[mb_field] = value @@ -860,20 +863,23 @@ class MusicBrainzPlugin(MetadataSourcePlugin): using the provided criteria. Handles API errors by converting them into MusicBrainzAPIError exceptions with contextual information. """ - filters = { - k: _v for k, v in filters.items() if (_v := v.lower().strip()) - } + query = " AND ".join( + f'{k}:"{_v}"' + for k, v in filters.items() + if (_v := v.lower().strip()) + ) self._log.debug( - "Searching for MusicBrainz {}s with: {!r}", query_type, filters + "Searching for MusicBrainz {}s with: {!r}", query_type, query ) try: - method = getattr(musicbrainzngs, f"search_{query_type}s") - res = method(limit=self.config["search_limit"].get(), **filters) + res = self.api._get( + query_type, query=query, limit=self.config["search_limit"].get() + ) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( exc, f"{query_type} search", filters, traceback.format_exc() ) - return res[f"{query_type}-list"] + return res[f"{query_type}s"] def candidates( self, diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 749e2805c..a81c85c4d 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -1047,15 +1047,13 @@ class TestMusicBrainzPlugin(PluginMixin): assert mb.get_album_criteria(items, "Artist ", " Album", va_likely) == { "release": " Album", - "alias": " Album", - "tracks": str(len(items)), **expected_additional_criteria, } def test_item_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "musicbrainzngs.search_recordings", - lambda *_, **__: {"recording-list": [self.RECORDING]}, + "beetsplug.musicbrainz.MusicBrainzAPI._get", + lambda *_, **__: {"recordings": [self.RECORDING]}, ) candidates = list(mb.item_candidates(Item(), "hello", "there")) @@ -1065,8 +1063,8 @@ class TestMusicBrainzPlugin(PluginMixin): def test_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "musicbrainzngs.search_releases", - lambda *_, **__: {"release-list": [{"id": self.mbid}]}, + "beetsplug.musicbrainz.MusicBrainzAPI._get", + lambda *_, **__: {"releases": [{"id": self.mbid}]}, ) monkeypatch.setattr( "beetsplug.musicbrainz.MusicBrainzAPI.get_release", From 6b034da14772e7d224e1fd8d2646deb7524c5035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Sep 2025 23:55:04 +0100 Subject: [PATCH 148/274] musicbrainz: browse directly --- beetsplug/musicbrainz.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 2d3c95f43..018f2387c 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -118,8 +118,6 @@ BROWSE_INCLUDES = [ "recording-rels", "release-rels", ] -if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES["recording"]: - BROWSE_INCLUDES.append("work-level-rels") BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 @@ -144,6 +142,11 @@ class MusicBrainzAPI: def get_recording(self, id_: str) -> JSONDict: return self._get(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) + def browse_recordings(self, **kwargs) -> list[JSONDict]: + kwargs.setdefault("limit", BROWSE_CHUNKSIZE) + kwargs.setdefault("inc", BROWSE_INCLUDES) + return self._get("recording", **kwargs)["recordings"] + @singledispatchmethod @classmethod def _group_relations(cls, data: Any) -> Any: @@ -594,12 +597,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): for i in range(0, ntracks, BROWSE_CHUNKSIZE): self._log.debug("Retrieving tracks starting at {}", i) recording_list.extend( - musicbrainzngs.browse_recordings( - release=release["id"], - limit=BROWSE_CHUNKSIZE, - includes=BROWSE_INCLUDES, - offset=i, - )["recording-list"] + self.api.browse_recordings(release=release["id"], offset=i) ) track_map = {r["id"]: r for r in recording_list} for medium in release["media"]: From ca0b3171cc4e72fa479b5bfc42f0e7cfdc3a2b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 29 Sep 2025 00:20:47 +0100 Subject: [PATCH 149/274] musicbrainz: access the custom server directly, if configured --- beetsplug/musicbrainz.py | 31 +++++++++++++++++-------------- docs/plugins/musicbrainz.rst | 9 ++++----- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 018f2387c..b111ee638 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -20,6 +20,7 @@ import operator import traceback from collections import Counter from contextlib import suppress +from dataclasses import dataclass from functools import cached_property, singledispatchmethod from itertools import groupby, product from typing import TYPE_CHECKING, Any @@ -122,16 +123,18 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 +@dataclass class MusicBrainzAPI: - api_url = "https://musicbrainz.org/ws/2" + api_host: str + rate_limit: float @cached_property def session(self) -> LimiterTimeoutSession: - return LimiterTimeoutSession(per_second=1) + return LimiterTimeoutSession(per_second=self.rate_limit) def _get(self, entity: str, **kwargs) -> JSONDict: return self.session.get( - f"{self.api_url}/{entity}", params={**kwargs, "fmt": "json"} + f"{self.api_host}/ws/2/{entity}", params={**kwargs, "fmt": "json"} ).json() def get_release(self, id_: str) -> JSONDict: @@ -420,7 +423,17 @@ class MusicBrainzPlugin(MetadataSourcePlugin): @cached_property def api(self) -> MusicBrainzAPI: - return MusicBrainzAPI() + hostname = self.config["host"].as_str() + if hostname == "musicbrainz.org": + hostname, rate_limit = "https://musicbrainz.org", 1.0 + else: + https = self.config["https"].get(bool) + hostname = f"http{'s' if https else ''}://{hostname}" + rate_limit = ( + self.config["ratelimit"].get(int) + / self.config["ratelimit_interval"].as_number() + ) + return MusicBrainzAPI(hostname, rate_limit) def __init__(self): """Set up the python-musicbrainz-ngs module according to settings @@ -455,16 +468,6 @@ class MusicBrainzPlugin(MetadataSourcePlugin): "'musicbrainz.searchlimit' configuration option", "'musicbrainz.search_limit'", ) - hostname = self.config["host"].as_str() - https = self.config["https"].get(bool) - # Only call set_hostname when a custom server is configured. Since - # musicbrainz-ngs connects to musicbrainz.org with HTTPS by default - if hostname != "musicbrainz.org": - musicbrainzngs.set_hostname(hostname, https) - musicbrainzngs.set_rate_limit( - self.config["ratelimit_interval"].as_number(), - self.config["ratelimit"].get(int), - ) def track_info( self, diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 7fe436c2c..60c3bc4a2 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -69,15 +69,14 @@ Default .. conf:: ratelimit :default: 1 - Controls the number of Web service requests per second. - - **Do not change the rate limit setting** if you're using the main MusicBrainz - server---on this public server, you're limited_ to one request per second. + Controls the number of Web service requests per second. This setting applies only + to custom servers. The official MusicBrainz server enforces a rate limit of 1 + request per second. .. conf:: ratelimit_interval :default: 1.0 - The time interval (in seconds) for the rate limit. + The time interval (in seconds) for the rate limit. Only applies to custom servers. .. conf:: enabled :default: yes From 10ebd98ca5281d625b17fe1dc759691311c4d329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 29 Sep 2025 00:30:36 +0100 Subject: [PATCH 150/274] musicbrainz: remove error handling --- beetsplug/mbpseudo.py | 38 +++++++------------ beetsplug/musicbrainz.py | 79 ++++++++++------------------------------ 2 files changed, 33 insertions(+), 84 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 0e131a712..94b6f09a0 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -17,7 +17,6 @@ from __future__ import annotations import itertools -import traceback from copy import deepcopy from typing import TYPE_CHECKING, Any @@ -31,7 +30,6 @@ from beets.autotag.match import assign_items from beets.plugins import find_plugins from beets.util.id_extractors import extract_release_id from beetsplug.musicbrainz import ( - MusicBrainzAPIError, MusicBrainzPlugin, _merge_pseudo_and_actual_album, _preferred_alias, @@ -143,29 +141,21 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): if (ids := self._intercept_mb_release(release)) and ( album_id := self._extract_id(ids[0]) ): - try: - raw_pseudo_release = self.api.get_release(album_id) - pseudo_release = super().album_info(raw_pseudo_release) + raw_pseudo_release = self.api.get_release(album_id) + pseudo_release = super().album_info(raw_pseudo_release) - if self.config["custom_tags_only"].get(bool): - self._replace_artist_with_alias( - raw_pseudo_release, pseudo_release - ) - self._add_custom_tags(official_release, pseudo_release) - return official_release - else: - return PseudoAlbumInfo( - pseudo_release=_merge_pseudo_and_actual_album( - pseudo_release, official_release - ), - official_release=official_release, - ) - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, - "get pseudo-release by ID", - album_id, - traceback.format_exc(), + if self.config["custom_tags_only"].get(bool): + self._replace_artist_with_alias( + raw_pseudo_release, pseudo_release + ) + self._add_custom_tags(official_release, pseudo_release) + return official_release + else: + return PseudoAlbumInfo( + pseudo_release=_merge_pseudo_and_actual_album( + pseudo_release, official_release + ), + official_release=official_release, ) else: return official_release diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index b111ee638..e777a5d18 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -17,7 +17,6 @@ from __future__ import annotations import operator -import traceback from collections import Counter from contextlib import suppress from dataclasses import dataclass @@ -26,7 +25,6 @@ from itertools import groupby, product from typing import TYPE_CHECKING, Any from urllib.parse import urljoin -import musicbrainzngs from confuse.exceptions import NotFoundError from requests_ratelimiter import LimiterMixin @@ -67,24 +65,6 @@ class LimiterTimeoutSession(LimiterMixin, TimeoutSession): pass -musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/") - - -class MusicBrainzAPIError(util.HumanReadableError): - """An error while talking to MusicBrainz. The `query` field is the - parameter to the action and may have any type. - """ - - def __init__(self, reason, verb, query, tb=None): - self.query = query - if isinstance(reason, musicbrainzngs.WebServiceError): - reason = "MusicBrainz not reachable" - super().__init__(reason, verb, tb) - - def get_message(self): - return f"{self._reasonstr()} in {self.verb} with query {self.query!r}" - - RELEASE_INCLUDES = [ "artists", "media", @@ -872,15 +852,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug( "Searching for MusicBrainz {}s with: {!r}", query_type, query ) - try: - res = self.api._get( - query_type, query=query, limit=self.config["search_limit"].get() - ) - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, f"{query_type} search", filters, traceback.format_exc() - ) - return res[f"{query_type}s"] + return self.api._get( + query_type, query=query, limit=self.config["search_limit"].get() + )[f"{query_type}s"] def candidates( self, @@ -918,29 +892,20 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug("Invalid MBID ({}).", album_id) return None - try: - res = self.api.get_release(albumid) + res = self.api.get_release(albumid) - # resolve linked release relations - actual_res = None + # resolve linked release relations + actual_res = None - if res.get("status") == "Pseudo-Release" and ( - relations := res.get("release-relations") - ): - for rel in relations: - if ( - rel["type"] == "transl-tracklisting" - and rel["direction"] == "backward" - ): - actual_res = self.api.get_release(rel["target"]) - - except musicbrainzngs.ResponseError: - self._log.debug("Album ID match failed.") - return None - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, "get release by ID", albumid, traceback.format_exc() - ) + if res.get("status") == "Pseudo-Release" and ( + relations := res.get("release-relations") + ): + for rel in relations: + if ( + rel["type"] == "transl-tracklisting" + and rel["direction"] == "backward" + ): + actual_res = self.api.get_release(rel["target"]) # release is potentially a pseudo release release = self.album_info(res) @@ -962,13 +927,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug("Invalid MBID ({}).", track_id) return None - try: - res = self.api.get_recording(trackid) - except (HTTPNotFoundError, musicbrainzngs.ResponseError): - self._log.debug("Track ID match failed.") - return None - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, "get recording by ID", trackid, traceback.format_exc() - ) - return self.track_info(res) + with suppress(HTTPNotFoundError): + return self.track_info(self.api.get_recording(trackid)) + + return None From 041d4b803689fde0ccc96d1d8c4c43ee9fc48afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 29 Sep 2025 11:38:13 +0100 Subject: [PATCH 151/274] Make musicbrainzngs dependency optional and requests required --- .github/workflows/ci.yaml | 4 ++-- docs/plugins/listenbrainz.rst | 9 ++++----- docs/plugins/mbcollection.rst | 15 ++++++++++++--- docs/plugins/missing.rst | 15 ++++++++++++--- docs/plugins/parentwork.rst | 11 +++++++++-- poetry.lock | 8 ++++++-- pyproject.toml | 10 +++++++--- 7 files changed, 52 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bfd05c718..520a368ef 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,7 +66,7 @@ jobs: - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage run: | - poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate + poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate --extras=parentwork poe test - if: ${{ env.IS_MAIN_PYTHON == 'true' }} @@ -74,7 +74,7 @@ jobs: env: LYRICS_UPDATED: ${{ steps.lyrics-update.outputs.any_changed }} run: | - poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate + poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate --extras=parentwork poe docs poe test-with-coverage diff --git a/docs/plugins/listenbrainz.rst b/docs/plugins/listenbrainz.rst index 21629ab54..17926e878 100644 --- a/docs/plugins/listenbrainz.rst +++ b/docs/plugins/listenbrainz.rst @@ -9,13 +9,12 @@ service. Installation ------------ -To enable the ListenBrainz plugin, add the following to your beets configuration -file (config.yaml_): +To use the ``listenbrainz`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``listenbrainz`` extra -.. code-block:: yaml +.. code-block:: bash - plugins: - - listenbrainz + pip install "beets[listenbrainz]" You can then configure the plugin by providing your Listenbrainz token (see intructions here_) and username: diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst index 87efcd6d5..ffa86f330 100644 --- a/docs/plugins/mbcollection.rst +++ b/docs/plugins/mbcollection.rst @@ -6,9 +6,18 @@ maintain your `music collection`_ list there. .. _music collection: https://musicbrainz.org/doc/Collections -To begin, just enable the ``mbcollection`` plugin in your configuration (see -:ref:`using-plugins`). Then, add your MusicBrainz username and password to your -:doc:`configuration file ` under a ``musicbrainz`` section: +Installation +------------ + +To use the ``mbcollection`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``mbcollection`` extra + +.. code-block:: bash + + pip install "beets[mbcollection]" + +Then, add your MusicBrainz username and password to your :doc:`configuration +file ` under a ``musicbrainz`` section: :: diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index 10842933c..f6962f337 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -5,12 +5,21 @@ This plugin adds a new command, ``missing`` or ``miss``, which finds and lists missing tracks for albums in your collection. Each album requires one network call to album data source. +Installation +------------ + +To use the ``missing`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``missing`` extra + +.. code-block:: bash + + pip install "beets[missing]" + Usage ----- -Add the ``missing`` plugin to your configuration (see :ref:`using-plugins`). The -``beet missing`` command fetches album information from the origin data source -and lists names of the **tracks** that are missing from your library. +The ``beet missing`` command fetches album information from the origin data +source and lists names of the **tracks** that are missing from your library. It can also list the names of missing **albums** for each artist, although this is limited to albums from the MusicBrainz data source only. diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst index 50c2c1ff0..e015bed68 100644 --- a/docs/plugins/parentwork.rst +++ b/docs/plugins/parentwork.rst @@ -38,8 +38,15 @@ This plugin adds seven tags: to keep track of recordings whose works have changed. - **parentwork_date**: The composition date of the parent work. -To use the ``parentwork`` plugin, enable it in your configuration (see -:ref:`using-plugins`). +Installation +------------ + +To use the ``parentwork`` plugin, first enable it in your configuration (see +:ref:`using-plugins`). Then, install ``beets`` with ``parentwork`` extra + +.. code-block:: bash + + pip install "beets[parentwork]" Configuration ------------- diff --git a/poetry.lock b/poetry.lock index 46bf443ca..8e489b4ed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1838,7 +1838,7 @@ type = ["mypy", "mypy-extensions"] name = "musicbrainzngs" version = "0.7.1" description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"}, @@ -4207,9 +4207,13 @@ import = ["py7zr", "rarfile"] kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] +listenbrainz = ["musicbrainzngs"] lyrics = ["beautifulsoup4", "langdetect", "requests"] +mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] +missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] +parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = ["PyGObject"] @@ -4222,4 +4226,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "1b69db4cdc3908316b2e18a5620916aa55235ded58b275c4433819ffa4ed660b" +content-hash = "8cf2ad0e6a842511e1215720a63bfdf9d5f49345410644cbb0b5fd8fb74f50d2" diff --git a/pyproject.toml b/pyproject.toml index 7fd1cadba..24cf21b33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,13 +48,13 @@ confuse = ">=2.1.0" jellyfish = "*" lap = ">=0.5.12" mediafile = ">=0.12.0" -musicbrainzngs = ">=0.4" numpy = [ { python = "<3.13", version = ">=2.0.2" }, { python = ">=3.13", version = ">=2.3.4" }, ] platformdirs = ">=3.5.0" pyyaml = "*" +requests = ">=2.32.5" requests-ratelimiter = ">=0.7.0" typing_extensions = "*" unidecode = ">=1.3.6" @@ -69,6 +69,7 @@ scipy = [ # for librosa { python = "<3.13", version = ">=1.13.1", optional = true }, { python = ">=3.13", version = ">=1.16.1", optional = true }, ] +musicbrainzngs = { version = ">=0.4", optional = true } numba = [ # for librosa { python = "<3.13", version = ">=0.60", optional = true }, { python = ">=3.13", version = ">=0.62.1", optional = true }, @@ -84,7 +85,6 @@ python3-discogs-client = { version = ">=2.3.15", optional = true } pyxdg = { version = "*", optional = true } rarfile = { version = "*", optional = true } reflink = { version = "*", optional = true } -requests = { version = "*", optional = true } resampy = { version = ">=0.4.3", optional = true } requests-oauthlib = { version = ">=0.6.1", optional = true } soco = { version = "*", optional = true } @@ -94,7 +94,7 @@ pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } sphinx-design = { version = ">=0.6.1", optional = true } sphinx-copybutton = { version = ">=0.5.2", optional = true } -titlecase = {version = "^2.4.1", optional = true} +titlecase = { version = "^2.4.1", optional = true } [tool.poetry.group.test.dependencies] beautifulsoup4 = "*" @@ -165,9 +165,13 @@ import = ["py7zr", "rarfile"] kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] +listenbrainz = ["musicbrainzngs"] lyrics = ["beautifulsoup4", "langdetect", "requests"] +mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] +missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] +parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = [ From 00792922b58b6a68d4c5e5fc32416ea926a98290 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 20 Dec 2025 02:19:54 -0600 Subject: [PATCH 152/274] fix: address remaining review comments --- beetsplug/ftintitle.py | 140 +++++++++++++++--------- test/plugins/test_ftintitle.py | 194 ++++++++------------------------- 2 files changed, 130 insertions(+), 204 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index e6c8c897a..b8bd3e261 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -17,7 +17,7 @@ from __future__ import annotations import re -from functools import cached_property +from functools import cached_property, lru_cache from typing import TYPE_CHECKING from beets import plugins, ui @@ -99,11 +99,78 @@ def find_feat_part( return feat_part +DEFAULT_BRACKET_KEYWORDS: tuple[str, ...] = ( + "abridged", + "acapella", + "club", + "demo", + "edit", + "edition", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", +) + + class FtInTitlePlugin(plugins.BeetsPlugin): @cached_property - def bracket_keywords(self) -> list[str] | None: + def bracket_keywords(self) -> list[str]: return self.config["bracket_keywords"].as_str_seq() + @staticmethod + @lru_cache(maxsize=256) + def _bracket_position_pattern(keywords: tuple[str, ...]) -> re.Pattern[str]: + """ + Build a compiled regex to find the first bracketed segment that contains + any of the provided keywords. + + Cached by keyword tuple to avoid recompiling on every track/title. + """ + kw_inner = "|".join(map(re.escape, keywords)) + + # If we have keywords, require one of them to appear in the bracket text. + # If kw == "", the lookahead becomes trivially true and we match any bracket content. + kw = rf"\b(?:{kw_inner})\b" if kw_inner else "" + + return re.compile( + rf""" + (?: # Match ONE bracketed segment of any supported type + \( # "(" + (?=[^)]*{kw}) # Lookahead: keyword must appear before closing ")" + # - if kw == "", this is always true + [^)]* # Consume bracket content (no nested ")" handling) + \) # ")" + + | \[ # "[" + (?=[^\]]*{kw}) # Lookahead + [^\]]* # Consume content up to first "]" + \] # "]" + + | < # "<" + (?=[^>]*{kw}) # Lookahead + [^>]* # Consume content up to first ">" + > # ">" + + | \x7B # Literal open brace + (?=[^\x7D]*{kw}) # Lookahead + [^\x7D]* # Consume content up to first close brace + \x7D # Literal close brace + ) # End bracketed segment alternation + """, + re.IGNORECASE | re.VERBOSE, + ) + def __init__(self) -> None: super().__init__() @@ -115,28 +182,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], - "bracket_keywords": [ - "abridged", - "acapella", - "club", - "demo", - "edit", - "edition", - "extended", - "instrumental", - "live", - "mix", - "radio", - "release", - "remaster", - "remastered", - "remix", - "rmx", - "unabridged", - "unreleased", - "version", - "vip", - ], + "bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS), } ) @@ -235,7 +281,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() formatted = feat_format.format(feat_part) - new_title = self.insert_ft_into_title(item.title, formatted) + new_title = FtInTitlePlugin.insert_ft_into_title( + item.title, formatted, self.bracket_keywords + ) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -281,43 +329,29 @@ class FtInTitlePlugin(plugins.BeetsPlugin): ) return True + @staticmethod def find_bracket_position( - self, - title: str, + title: str, keywords: list[str] | None = None ) -> int | None: - """Find the position of the first opening bracket that contains - remix/edit-related keywords and has a matching closing bracket. - """ - keywords = self.bracket_keywords - - # If keywords is empty, match any bracket content - if not keywords: - keyword_ptn = ".*?" - else: - # Build regex supporting keywords/multi-word phrases. - keyword_ptn = rf"\b{'|'.join(map(re.escape, keywords))}\b" - - pattern = re.compile( - rf""" - \(.*?({keyword_ptn}).*?\) | - \[.*?({keyword_ptn}).*?\] | - <.*?({keyword_ptn}).*?> | - \{{.*?({keyword_ptn}).*?}} - """, - re.IGNORECASE | re.VERBOSE, + normalized = ( + DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) ) + pattern = FtInTitlePlugin._bracket_position_pattern(normalized) + m: re.Match[str] | None = pattern.search(title) + return m.start() if m else None - return m.start() if (m := pattern.search(title)) else None - + @staticmethod def insert_ft_into_title( - self, - title: str, - feat_part: str, + title: str, feat_part: str, keywords: list[str] | None = None ) -> str: """Insert featured artist before the first bracket containing remix/edit keywords if present. """ - if (bracket_pos := self.find_bracket_position(title)) is not None: + if ( + bracket_pos := FtInTitlePlugin.find_bracket_position( + title, keywords + ) + ) is not None: title_before = title[:bracket_pos].rstrip() title_after = title[bracket_pos:] return f"{title_before} {feat_part} {title_after}" diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 73853e6c3..9d6b54a93 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,11 +15,10 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator -from typing import TypeAlias, cast +from typing import TypeAlias import pytest -from beets.importer import ImportSession, ImportTask from beets.library.models import Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle @@ -51,21 +50,11 @@ def set_config( "auto": True, "keep_in_artist": False, "custom_words": [], - "bracket_keywords": ftintitle.DEFAULT_BRACKET_KEYWORDS.copy(), } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) -def build_plugin( - env: FtInTitlePluginFunctional, - cfg: dict[str, ConfigValue] | None = None, -) -> ftintitle.FtInTitlePlugin: - """Instantiate plugin with provided config applied first.""" - set_config(env, cfg) - return ftintitle.FtInTitlePlugin() - - def add_item( env: FtInTitlePluginFunctional, path: str, @@ -82,16 +71,6 @@ def add_item( ) -class DummyImportTask: - """Minimal stand-in for ImportTask used to exercise import hooks.""" - - def __init__(self, items: list[Item]) -> None: - self._items = items - - def imported_items(self) -> list[Item]: - return self._items - - @pytest.mark.parametrize( "cfg, cmd_args, given, expected", [ @@ -272,61 +251,18 @@ class DummyImportTask: ), # ---- titles with brackets/parentheses ---- pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), - ("Alice", "Song 1 ft. Bob (Carol Remix)"), - id="title-with-brackets-insert-before", - ), - pytest.param( - {"format": "ft. {}", "keep_in_artist": True}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"), - ("Alice ft. Bob", "Song 1 ft. Bob (Carol Remix)"), - id="title-with-brackets-keep-in-artist", - ), - pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 (Remix) (Live)", "Alice"), - ("Alice", "Song 1 ft. Bob (Remix) (Live)"), - id="title-with-multiple-brackets-uses-first-with-keyword", - ), - pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 (Arbitrary)", "Alice"), - ("Alice", "Song 1 (Arbitrary) ft. Bob"), - id="title-with-brackets-no-keyword-appends", - ), - pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 [Edit]", "Alice"), - ("Alice", "Song 1 ft. Bob [Edit]"), - id="title-with-square-brackets-keyword", - ), - pytest.param( - {"format": "ft. {}"}, - ("ftintitle",), - ("Alice ft. Bob", "Song 1 ", "Alice"), - ("Alice", "Song 1 ft. Bob "), - id="title-with-angle-brackets-keyword", - ), - # multi-word keyword - pytest.param( - {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + {"format": "ft. {}", "bracket_keywords": ["mix"]}, ("ftintitle",), ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), ("Alice", "Song 1 ft. Bob (Club Mix)"), - id="multi-word-keyword-positive-match", + id="ft-inserted-before-matching-bracket-keyword", ), pytest.param( - {"format": "ft. {}", "bracket_keywords": ["club mix"]}, + {"format": "ft. {}", "bracket_keywords": ["nomatch"]}, ("ftintitle",), ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), ("Alice", "Song 1 (Club Remix) ft. Bob"), - id="multi-word-keyword-negative-no-match", + id="ft-inserted-at-end-no-bracket-keyword-match", ), ], ) @@ -351,31 +287,6 @@ def test_ftintitle_functional( assert item["title"] == expected_title -def test_imported_stage_moves_featured_artist( - env: FtInTitlePluginFunctional, -) -> None: - """The import-stage hook should fetch config settings and process items.""" - set_config(env, None) - plugin = ftintitle.FtInTitlePlugin() - item = add_item( - env, - "/imported-hook", - "Alice feat. Bob", - "Song 1 (Carol Remix)", - "Various Artists", - ) - task = DummyImportTask([item]) - - plugin.imported( - cast(ImportSession, None), - cast(ImportTask, task), - ) - item.load() - - assert item["artist"] == "Alice" - assert item["title"] == "Song 1 feat. Bob (Carol Remix)" - - @pytest.mark.parametrize( "artist,albumartist,expected", [ @@ -419,64 +330,46 @@ def test_split_on_feat( assert ftintitle.split_on_feat(given) == expected -@pytest.mark.parametrize( - "given,expected", - [ - # different braces and keywords - ("Song (Remix)", 5), - ("Song [Version]", 5), - ("Song {Extended Mix}", 5), - ("Song ", 5), - # two keyword clauses - ("Song (Remix) (Live)", 5), - # brace insensitivity - ("Song (Live) [Remix]", 5), - ("Song [Edit] (Remastered)", 5), - # negative cases - ("Song", None), # no clause - ("Song (Arbitrary)", None), # no keyword - ("Song (", None), # no matching brace or keyword - ("Song (Live", None), # no matching brace with keyword - # one keyword clause, one non-keyword clause - ("Song (Live) (Arbitrary)", 5), - ("Song (Arbitrary) (Remix)", 5), - # nested brackets - same type - ("Song (Remix (Extended))", 5), - ("Song [Arbitrary [Description]]", None), - # nested brackets - different types - ("Song (Remix [Extended])", 5), - # nested - returns outer start position despite inner keyword - ("Song [Arbitrary {Extended}]", 5), - ("Song {Live }", 5), - ("Song ", 5), - ("Song [Live]", 5), - ("Song (Version) ", 5), - ("Song (Arbitrary [Description])", None), - ("Song [Description (Arbitrary)]", None), - ], -) -def test_find_bracket_position( - env: FtInTitlePluginFunctional, - given: str, - expected: int | None, -) -> None: - plugin = build_plugin(env) - assert plugin.find_bracket_position(given) == expected - - @pytest.mark.parametrize( "given,keywords,expected", [ + ## default keywords + # different braces and keywords + ("Song (Remix)", None, 5), + ("Song [Version]", None, 5), + ("Song {Extended Mix}", None, 5), + ("Song ", None, 5), + # two keyword clauses + ("Song (Remix) (Live)", None, 5), + # brace insensitivity + ("Song (Live) [Remix]", None, 5), + ("Song [Edit] (Remastered)", None, 5), + # negative cases + ("Song", None, None), # no clause + ("Song (Arbitrary)", None, None), # no keyword + ("Song (", None, None), # no matching brace or keyword + ("Song (Live", None, None), # no matching brace with keyword + # one keyword clause, one non-keyword clause + ("Song (Live) (Arbitrary)", None, 5), + ("Song (Arbitrary) (Remix)", None, 17), + # nested brackets - same type + ("Song (Remix (Extended))", None, 5), + ("Song [Arbitrary [Description]]", None, None), + # nested brackets - different types + ("Song (Remix [Extended])", None, 5), + # nested - returns outer start position despite inner keyword + ("Song [Arbitrary {Extended}]", None, 5), + ("Song {Live }", None, 5), + ("Song ", None, 5), + ("Song [Live]", None, 5), + ("Song (Version) ", None, 5), + ("Song (Arbitrary [Description])", None, None), + ("Song [Description (Arbitrary)]", None, None), + ## custom keywords ("Song (Live)", ["live"], 5), - ("Song (Live)", None, 5), - ("Song (Arbitrary)", None, None), ("Song (Concert)", ["concert"], 5), - ("Song (Concert)", None, None), ("Song (Remix)", ["custom"], None), ("Song (Custom)", ["custom"], 5), - ("Song (Live)", [], 5), - ("Song (Anything)", [], 5), - ("Song (Remix)", [], 5), ("Song", [], None), ("Song (", [], None), # Multi-word keyword tests @@ -484,16 +377,15 @@ def test_find_bracket_position( ("Song (Club Remix)", ["club mix"], None), # Negative: no match ], ) -def test_find_bracket_position_custom_keywords( - env: FtInTitlePluginFunctional, +def test_find_bracket_position( given: str, keywords: list[str] | None, expected: int | None, ) -> None: - cfg: dict[str, ConfigValue] | None - cfg = None if keywords is None else {"bracket_keywords": keywords} - plugin = build_plugin(env, cfg) - assert plugin.find_bracket_position(given) == expected + assert ( + ftintitle.FtInTitlePlugin.find_bracket_position(given, keywords) + == expected + ) @pytest.mark.parametrize( From c0c7a9df8f487b7ca029a9f44800090c4a0e850f Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Sat, 20 Dec 2025 02:34:15 -0600 Subject: [PATCH 153/274] fix: line length --- beetsplug/ftintitle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index eafc9e191..44f17bc4e 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -145,7 +145,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): kw_inner = "|".join(map(re.escape, keywords)) # If we have keywords, require one of them to appear in the bracket text. - # If kw == "", the lookahead becomes trivially true and we match any bracket content. + # If kw == "", the lookahead becomes true and we match any bracket content. kw = rf"\b(?:{kw_inner})\b" if kw_inner else "" return re.compile( From 72f7d6ebe3cc006c89fb82d6151d9a0aa0a9cc9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 20 Oct 2025 21:54:26 +0100 Subject: [PATCH 154/274] Refactor HTTP request handling with RequestHandler base class Introduce a new RequestHandler base class to introduce a shared session, centralize HTTP request management and error handling across plugins. Key changes: - Add RequestHandler base class with a shared/cached session - Convert TimeoutSession to use SingletonMeta for proper resource management - Create LyricsRequestHandler subclass with lyrics-specific error handling - Update MusicBrainzAPI to inherit from RequestHandler --- beetsplug/_utils/requests.py | 145 +++++++++++++++++++++++++++---- beetsplug/lyrics.py | 58 ++++++++----- beetsplug/musicbrainz.py | 39 +++++---- test/plugins/test_musicbrainz.py | 4 +- 4 files changed, 187 insertions(+), 59 deletions(-) diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index a9a1af372..b45efd780 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -1,38 +1,149 @@ +from __future__ import annotations + import atexit +import threading +from contextlib import contextmanager +from functools import cached_property from http import HTTPStatus +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar import requests from beets import __version__ - -class HTTPNotFoundError(requests.exceptions.HTTPError): - pass +if TYPE_CHECKING: + from collections.abc import Iterator -class CaptchaError(requests.exceptions.HTTPError): - pass +class BeetsHTTPError(requests.exceptions.HTTPError): + STATUS: ClassVar[HTTPStatus] + + def __init__(self, *args, **kwargs) -> None: + super().__init__( + f"HTTP Error: {self.STATUS.value} {self.STATUS.phrase}", + *args, + **kwargs, + ) -class TimeoutSession(requests.Session): +class HTTPNotFoundError(BeetsHTTPError): + STATUS = HTTPStatus.NOT_FOUND + + +class Closeable(Protocol): + """Protocol for objects that have a close method.""" + + def close(self) -> None: ... + + +C = TypeVar("C", bound=Closeable) + + +class SingletonMeta(type, Generic[C]): + """Metaclass ensuring a single shared instance per class. + + Creates one instance per class type on first instantiation, reusing it + for all subsequent calls. Automatically registers cleanup on program exit + for proper resource management. + """ + + _instances: ClassVar[dict[type[Any], Any]] = {} + _lock: ClassVar[threading.Lock] = threading.Lock() + + def __call__(cls, *args: Any, **kwargs: Any) -> C: + if cls not in cls._instances: + with cls._lock: + if cls not in SingletonMeta._instances: + instance = super().__call__(*args, **kwargs) + SingletonMeta._instances[cls] = instance + atexit.register(instance.close) + return SingletonMeta._instances[cls] + + +class TimeoutSession(requests.Session, metaclass=SingletonMeta): + """HTTP session with automatic timeout and status checking. + + Extends requests.Session to provide sensible defaults for beets HTTP + requests: automatic timeout enforcement, status code validation, and + proper user agent identification. + """ + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/" - @atexit.register - def close_session(): - """Close the requests session on shut down.""" - self.close() - def request(self, *args, **kwargs): - """Wrap the request method to raise an exception on HTTP errors.""" + """Execute HTTP request with automatic timeout and status validation. + + Ensures all requests have a timeout (defaults to 10 seconds) and raises + an exception for HTTP error status codes. + """ kwargs.setdefault("timeout", 10) r = super().request(*args, **kwargs) - if r.status_code == HTTPStatus.NOT_FOUND: - raise HTTPNotFoundError("HTTP Error: Not Found", response=r) - if 300 <= r.status_code < 400: - raise CaptchaError("Captcha is required", response=r) - r.raise_for_status() return r + + +class RequestHandler: + """Manages HTTP requests with custom error handling and session management. + + Provides a reusable interface for making HTTP requests with automatic + conversion of standard HTTP errors to beets-specific exceptions. Supports + custom session types and error mappings that can be overridden by + subclasses. + """ + + session_type: ClassVar[type[TimeoutSession]] = TimeoutSession + explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [ + HTTPNotFoundError + ] + + @cached_property + def session(self) -> Any: + """Lazily initialize and cache the HTTP session.""" + return self.session_type() + + def status_to_error( + self, code: int + ) -> type[requests.exceptions.HTTPError] | None: + """Map HTTP status codes to beets-specific exception types. + + Searches the configured explicit HTTP errors for a matching status code. + Returns None if no specific error type is registered for the given code. + """ + return next( + (e for e in self.explicit_http_errors if e.STATUS == code), None + ) + + @contextmanager + def handle_http_error(self) -> Iterator[None]: + """Convert standard HTTP errors to beets-specific exceptions. + + Wraps operations that may raise HTTPError, automatically translating + recognized status codes into their corresponding beets exception types. + Unrecognized errors are re-raised unchanged. + """ + try: + yield + except requests.exceptions.HTTPError as e: + if beets_error := self.status_to_error(e.response.status_code): + raise beets_error(response=e.response) from e + raise + + def request(self, *args, **kwargs) -> requests.Response: + """Perform HTTP request using the session with automatic error handling. + + Delegates to the underlying session method while converting recognized + HTTP errors to beets-specific exceptions through the error handler. + """ + with self.handle_http_error(): + return self.session.request(*args, **kwargs) + + def get(self, *args, **kwargs) -> requests.Response: + """Perform HTTP GET request with automatic error handling.""" + return self.request("get", *args, **kwargs) + + def get_json(self, *args, **kwargs): + """Fetch and parse JSON data from an HTTP endpoint.""" + return self.get(*args, **kwargs).json() diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 8b28a6179..d6e14c175 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -34,16 +34,17 @@ import requests from bs4 import BeautifulSoup from unidecode import unidecode -import beets from beets import plugins, ui from beets.autotag.distance import string_dist from beets.util.config import sanitize_choices -from ._utils.requests import CaptchaError, HTTPNotFoundError, TimeoutSession +from ._utils.requests import HTTPNotFoundError, RequestHandler if TYPE_CHECKING: from collections.abc import Iterable, Iterator + import confuse + from beets.importer import ImportTask from beets.library import Item, Library from beets.logging import BeetsLogger as Logger @@ -59,7 +60,9 @@ if TYPE_CHECKING: INSTRUMENTAL_LYRICS = "[Instrumental]" -r_session = TimeoutSession() +class CaptchaError(requests.exceptions.HTTPError): + def __init__(self, *args, **kwargs) -> None: + super().__init__("Captcha is required", *args, **kwargs) # Utilities. @@ -155,9 +158,18 @@ def slug(text: str) -> str: return re.sub(r"\W+", "-", unidecode(text).lower().strip()).strip("-") -class RequestHandler: +class LyricsRequestHandler(RequestHandler): _log: Logger + def status_to_error(self, code: int) -> type[requests.HTTPError] | None: + if err := super().status_to_error(code): + return err + + if 300 <= code < 400: + return CaptchaError + + return None + def debug(self, message: str, *args) -> None: """Log a debug message with the class name.""" self._log.debug(f"{self.__class__.__name__}: {message}", *args) @@ -177,7 +189,7 @@ class RequestHandler: return f"{url}?{urlencode(params)}" - def fetch_text( + def get_text( self, url: str, params: JSONDict | None = None, **kwargs ) -> str: """Return text / HTML data from the given URL. @@ -187,21 +199,21 @@ class RequestHandler: """ url = self.format_url(url, params) self.debug("Fetching HTML from {}", url) - r = r_session.get(url, **kwargs) + r = self.get(url, **kwargs) r.encoding = None return r.text - def fetch_json(self, url: str, params: JSONDict | None = None, **kwargs): + def get_json(self, url: str, params: JSONDict | None = None, **kwargs): """Return JSON data from the given URL.""" url = self.format_url(url, params) self.debug("Fetching JSON from {}", url) - return r_session.get(url, **kwargs).json() + return super().get_json(url, **kwargs) def post_json(self, url: str, params: JSONDict | None = None, **kwargs): """Send POST request and return JSON response.""" url = self.format_url(url, params) self.debug("Posting JSON to {}", url) - return r_session.post(url, **kwargs).json() + return self.request("post", url, **kwargs).json() @contextmanager def handle_request(self) -> Iterator[None]: @@ -220,8 +232,10 @@ class BackendClass(type): return cls.__name__.lower() -class Backend(RequestHandler, metaclass=BackendClass): - def __init__(self, config, log): +class Backend(LyricsRequestHandler, metaclass=BackendClass): + config: confuse.Subview + + def __init__(self, config: confuse.Subview, log: Logger) -> None: self._log = log self.config = config @@ -325,10 +339,10 @@ class LRCLib(Backend): if album: get_params["album_name"] = album - yield self.fetch_json(self.SEARCH_URL, params=base_params) + yield self.get_json(self.SEARCH_URL, params=base_params) with suppress(HTTPNotFoundError): - yield [self.fetch_json(self.GET_URL, params=get_params)] + yield [self.get_json(self.GET_URL, params=get_params)] @classmethod def pick_best_match(cls, lyrics: Iterable[LRCLyrics]) -> LRCLyrics | None: @@ -376,7 +390,7 @@ class MusiXmatch(Backend): def fetch(self, artist: str, title: str, *_) -> tuple[str, str] | None: url = self.build_url(artist, title) - html = self.fetch_text(url) + html = self.get_text(url) if "We detected that your IP is blocked" in html: self.warn("Failed: Blocked IP address") return None @@ -501,7 +515,7 @@ class SearchBackend(SoupMixin, Backend): def fetch(self, artist: str, title: str, *_) -> tuple[str, str] | None: """Fetch lyrics for the given artist and title.""" for result in self.get_results(artist, title): - if (html := self.fetch_text(result.url)) and ( + if (html := self.get_text(result.url)) and ( lyrics := self.scrape(html) ): return lyrics, result.url @@ -531,7 +545,7 @@ class Genius(SearchBackend): return {"Authorization": f"Bearer {self.config['genius_api_key']}"} def search(self, artist: str, title: str) -> Iterable[SearchResult]: - search_data: GeniusAPI.Search = self.fetch_json( + search_data: GeniusAPI.Search = self.get_json( self.SEARCH_URL, params={"q": f"{artist} {title}"}, headers=self.headers, @@ -560,7 +574,7 @@ class Tekstowo(SearchBackend): return self.SEARCH_URL.format(quote_plus(unidecode(artistitle))) def search(self, artist: str, title: str) -> Iterable[SearchResult]: - if html := self.fetch_text(self.build_url(title, artist)): + if html := self.get_text(self.build_url(title, artist)): soup = self.get_soup(html) for tag in soup.select("div[class=flex-group] > a[title*=' - ']"): artist, title = str(tag["title"]).split(" - ", 1) @@ -626,12 +640,12 @@ class Google(SearchBackend): html = Html.remove_ads(super().pre_process_html(html)) return Html.remove_formatting(Html.merge_paragraphs(html)) - def fetch_text(self, *args, **kwargs) -> str: + def get_text(self, *args, **kwargs) -> str: """Handle an error so that we can continue with the next URL.""" kwargs.setdefault("allow_redirects", False) with self.handle_request(): try: - return super().fetch_text(*args, **kwargs) + return super().get_text(*args, **kwargs) except CaptchaError: self.ignored_domains.add(urlparse(args[0]).netloc) raise @@ -687,7 +701,7 @@ class Google(SearchBackend): "excludeTerms": ", ".join(self.EXCLUDE_PAGES), } - data: GoogleCustomSearchAPI.Response = self.fetch_json( + data: GoogleCustomSearchAPI.Response = self.get_json( self.SEARCH_URL, params=params ) for item in data.get("items", []): @@ -712,7 +726,7 @@ class Google(SearchBackend): @dataclass -class Translator(RequestHandler): +class Translator(LyricsRequestHandler): TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate" LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$") SEPARATOR = " | " @@ -922,7 +936,7 @@ class RestFiles: ui.print_(textwrap.dedent(text)) -class LyricsPlugin(RequestHandler, plugins.BeetsPlugin): +class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin): BACKEND_BY_NAME = { b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch] } diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index e777a5d18..91d829dcc 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -35,7 +35,7 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id -from ._utils.requests import HTTPNotFoundError, TimeoutSession +from ._utils.requests import HTTPNotFoundError, RequestHandler, TimeoutSession if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -61,10 +61,6 @@ FIELDS_TO_MB_KEYS = { } -class LimiterTimeoutSession(LimiterMixin, TimeoutSession): - pass - - RELEASE_INCLUDES = [ "artists", "media", @@ -103,32 +99,39 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 +class LimiterTimeoutSession(LimiterMixin, TimeoutSession): + pass + + @dataclass -class MusicBrainzAPI: +class MusicBrainzAPI(RequestHandler): + session_type = LimiterTimeoutSession + api_host: str rate_limit: float @cached_property def session(self) -> LimiterTimeoutSession: - return LimiterTimeoutSession(per_second=self.rate_limit) + return self.session_type(per_second=self.rate_limit) - def _get(self, entity: str, **kwargs) -> JSONDict: - return self.session.get( - f"{self.api_host}/ws/2/{entity}", params={**kwargs, "fmt": "json"} - ).json() - - def get_release(self, id_: str) -> JSONDict: + def get_entity(self, entity: str, **kwargs) -> JSONDict: return self._group_relations( - self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) + self.get_json( + f"{self.api_host}/ws/2/{entity}", + params={**kwargs, "fmt": "json"}, + ) ) + def get_release(self, id_: str) -> JSONDict: + return self.get_entity(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) + def get_recording(self, id_: str) -> JSONDict: - return self._get(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) + return self.get_entity(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) def browse_recordings(self, **kwargs) -> list[JSONDict]: kwargs.setdefault("limit", BROWSE_CHUNKSIZE) kwargs.setdefault("inc", BROWSE_INCLUDES) - return self._get("recording", **kwargs)["recordings"] + return self.get_entity("recording", **kwargs)["recordings"] @singledispatchmethod @classmethod @@ -202,7 +205,7 @@ def _preferred_alias( if ( alias["locale"] == locale and alias.get("primary") - and alias.get("type", "").lower() not in ignored_alias_types + and (alias.get("type") or "").lower() not in ignored_alias_types ): matches.append(alias) @@ -852,7 +855,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug( "Searching for MusicBrainz {}s with: {!r}", query_type, query ) - return self.api._get( + return self.api.get_entity( query_type, query=query, limit=self.config["search_limit"].get() )[f"{query_type}s"] diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index a81c85c4d..0a3155430 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -1052,7 +1052,7 @@ class TestMusicBrainzPlugin(PluginMixin): def test_item_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI._get", + "beetsplug.musicbrainz.MusicBrainzAPI.get_json", lambda *_, **__: {"recordings": [self.RECORDING]}, ) @@ -1063,7 +1063,7 @@ class TestMusicBrainzPlugin(PluginMixin): def test_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI._get", + "beetsplug.musicbrainz.MusicBrainzAPI.get_json", lambda *_, **__: {"releases": [{"id": self.mbid}]}, ) monkeypatch.setattr( From 9dad0409771ef0ac4606eea98f65a93c2cd8c7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 19 Dec 2025 13:43:06 +0000 Subject: [PATCH 155/274] Add Usage block to RequestHandler --- beetsplug/_utils/requests.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index b45efd780..0321b5ad6 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -92,6 +92,18 @@ class RequestHandler: conversion of standard HTTP errors to beets-specific exceptions. Supports custom session types and error mappings that can be overridden by subclasses. + + Usage: + Subclass and override :class:`RequestHandler.session_type`, + :class:`RequestHandler.explicit_http_errors` or + :class:`RequestHandler.status_to_error()` to customize behavior. + + Use + * :class:`RequestHandler.get_json()` to get JSON response data + * :class:`RequestHandler.get()` to get HTTP response object + * :class:`RequestHandler.request()` to invoke arbitrary HTTP methods + + Feel free to define common methods that are used in multiple plugins. """ session_type: ClassVar[type[TimeoutSession]] = TimeoutSession From d1aa45a008fccde978cac1b79d7d0cb5bff66cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 19 Dec 2025 20:36:19 +0000 Subject: [PATCH 156/274] Add retries for connection errors --- beetsplug/_utils/requests.py | 30 ++++++++--- beetsplug/musicbrainz.py | 15 +++--- test/plugins/utils/test_request_handler.py | 58 ++++++++++++++++++++++ 3 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 test/plugins/utils/test_request_handler.py diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 0321b5ad6..1cb4f6c2b 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -8,6 +8,8 @@ from http import HTTPStatus from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry from beets import __version__ @@ -60,18 +62,24 @@ class SingletonMeta(type, Generic[C]): return SingletonMeta._instances[cls] -class TimeoutSession(requests.Session, metaclass=SingletonMeta): - """HTTP session with automatic timeout and status checking. +class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta): + """HTTP session with sensible defaults. - Extends requests.Session to provide sensible defaults for beets HTTP - requests: automatic timeout enforcement, status code validation, and - proper user agent identification. + * default beets User-Agent header + * default request timeout + * automatic retries on transient connection errors + * raises exceptions for HTTP error status codes """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/" + retry = Retry(connect=2, total=2, backoff_factor=1) + adapter = HTTPAdapter(max_retries=retry) + self.mount("https://", adapter) + self.mount("http://", adapter) + def request(self, *args, **kwargs): """Execute HTTP request with automatic timeout and status validation. @@ -106,15 +114,21 @@ class RequestHandler: Feel free to define common methods that are used in multiple plugins. """ - session_type: ClassVar[type[TimeoutSession]] = TimeoutSession explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [ HTTPNotFoundError ] + def create_session(self) -> TimeoutAndRetrySession: + """Create a new HTTP session instance. + + Can be overridden by subclasses to provide custom session types. + """ + return TimeoutAndRetrySession() + @cached_property - def session(self) -> Any: + def session(self) -> TimeoutAndRetrySession: """Lazily initialize and cache the HTTP session.""" - return self.session_type() + return self.create_session() def status_to_error( self, code: int diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 91d829dcc..f36d5a3a7 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -35,7 +35,11 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id -from ._utils.requests import HTTPNotFoundError, RequestHandler, TimeoutSession +from ._utils.requests import ( + HTTPNotFoundError, + RequestHandler, + TimeoutAndRetrySession, +) if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -99,20 +103,17 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 -class LimiterTimeoutSession(LimiterMixin, TimeoutSession): +class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): pass @dataclass class MusicBrainzAPI(RequestHandler): - session_type = LimiterTimeoutSession - api_host: str rate_limit: float - @cached_property - def session(self) -> LimiterTimeoutSession: - return self.session_type(per_second=self.rate_limit) + def create_session(self) -> LimiterTimeoutSession: + return LimiterTimeoutSession(per_second=self.rate_limit) def get_entity(self, entity: str, **kwargs) -> JSONDict: return self._group_relations( diff --git a/test/plugins/utils/test_request_handler.py b/test/plugins/utils/test_request_handler.py new file mode 100644 index 000000000..c17a9387b --- /dev/null +++ b/test/plugins/utils/test_request_handler.py @@ -0,0 +1,58 @@ +import io +from http import HTTPStatus +from unittest.mock import Mock +from urllib.error import URLError + +import pytest +import requests +from urllib3 import HTTPResponse +from urllib3.exceptions import NewConnectionError + +from beetsplug._utils.requests import RequestHandler + + +class TestRequestHandlerRetry: + @pytest.fixture(autouse=True) + def patch_connection(self, monkeypatch, last_response): + monkeypatch.setattr( + "urllib3.connectionpool.HTTPConnectionPool._make_request", + Mock( + side_effect=[ + NewConnectionError(None, "Connection failed"), + URLError("bad"), + last_response, + ] + ), + ) + + @pytest.fixture + def request_handler(self): + return RequestHandler() + + @pytest.mark.parametrize( + "last_response", + [ + HTTPResponse( + body=io.BytesIO(b"success"), + status=HTTPStatus.OK, + preload_content=False, + ), + ], + ids=["success"], + ) + def test_retry_on_connection_error(self, request_handler): + """Verify that the handler retries on connection errors.""" + response = request_handler.get("http://example.com/api") + + assert response.text == "success" + assert response.status_code == HTTPStatus.OK + + @pytest.mark.parametrize( + "last_response", [ConnectionResetError], ids=["conn_error"] + ) + def test_retry_exhaustion(self, request_handler): + """Verify that the handler raises an error after exhausting retries.""" + with pytest.raises( + requests.exceptions.ConnectionError, match="Max retries exceeded" + ): + request_handler.get("http://example.com/api") From 5785ce3a84b5db598bf62be203ca663d46a0d0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 20 Dec 2025 00:41:24 +0000 Subject: [PATCH 157/274] Ensure that inc are joined with a plus See this line in https://musicbrainz.org/doc/MusicBrainz_API#Lookups > To include more than one subquery in a single request, separate the arguments to inc= with a + (plus sign), like inc=recordings+labels. --- beetsplug/musicbrainz.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index f36d5a3a7..221afea71 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -115,7 +115,12 @@ class MusicBrainzAPI(RequestHandler): def create_session(self) -> LimiterTimeoutSession: return LimiterTimeoutSession(per_second=self.rate_limit) - def get_entity(self, entity: str, **kwargs) -> JSONDict: + def get_entity( + self, entity: str, inc_list: list[str] | None = None, **kwargs + ) -> JSONDict: + if inc_list: + kwargs["inc"] = "+".join(inc_list) + return self._group_relations( self.get_json( f"{self.api_host}/ws/2/{entity}", @@ -124,14 +129,14 @@ class MusicBrainzAPI(RequestHandler): ) def get_release(self, id_: str) -> JSONDict: - return self.get_entity(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES)) + return self.get_entity(f"release/{id_}", inc_list=RELEASE_INCLUDES) def get_recording(self, id_: str) -> JSONDict: - return self.get_entity(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES)) + return self.get_entity(f"recording/{id_}", inc_list=TRACK_INCLUDES) def browse_recordings(self, **kwargs) -> list[JSONDict]: kwargs.setdefault("limit", BROWSE_CHUNKSIZE) - kwargs.setdefault("inc", BROWSE_INCLUDES) + kwargs.setdefault("inc_list", BROWSE_INCLUDES) return self.get_entity("recording", **kwargs)["recordings"] @singledispatchmethod From 0230352da152f6444fc891921ed46dea0f0b9e17 Mon Sep 17 00:00:00 2001 From: Doron Behar Date: Wed, 10 Dec 2025 12:08:03 +0200 Subject: [PATCH 158/274] importsource: fix potential prevent_suggest_removal crash --- beetsplug/importsource.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beetsplug/importsource.py b/beetsplug/importsource.py index 19b2530ba..e42be3f1f 100644 --- a/beetsplug/importsource.py +++ b/beetsplug/importsource.py @@ -39,6 +39,8 @@ class ImportSourcePlugin(BeetsPlugin): ) def prevent_suggest_removal(self, session, task): + if task.skip: + return for item in task.imported_items(): if "mb_albumid" in item: self.stop_suggestions_for_albums.add(item.mb_albumid) From be3485b066f4f230ce4d7920daac61c2edb38151 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 21 Dec 2025 12:14:21 +0100 Subject: [PATCH 159/274] Fix initial importsource plugin #4748 changelog - Fix position (wrong release) - Elaborate wording --- docs/changelog.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6d37a64a4..d37c0c802 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,6 +24,9 @@ New features: to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. +- :doc:`plugins/importsource`: Added new plugin that tracks original import + paths and optionally suggests removing source files when items are removed + from the library. - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. @@ -491,7 +494,6 @@ New features: ``beet list -a title:something`` or ``beet list artpath:cover``. Consequently album queries involving ``path`` field have been sped up, like ``beet list -a path:/path/``. -- :doc:`plugins/importsource`: Added plugin - :doc:`plugins/ftintitle`: New ``keep_in_artist`` option for the plugin, which allows keeping the "feat." part in the artist metadata while still changing the title. From 9ffae4bef1ede558bde04545378f897463f2f089 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 21 Dec 2025 12:45:31 +0100 Subject: [PATCH 160/274] importsource: Test skip, Test reimport-skip --- test/plugins/test_importsource.py | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_importsource.py b/test/plugins/test_importsource.py index e05a8f177..a4f498181 100644 --- a/test/plugins/test_importsource.py +++ b/test/plugins/test_importsource.py @@ -18,7 +18,7 @@ import os import time -from beets import importer +from beets import importer, plugins from beets.test.helper import AutotagImportTestCase, PluginMixin, control_stdin from beets.util import syspath from beetsplug.importsource import ImportSourcePlugin @@ -113,3 +113,34 @@ class ImportSourceTest(PluginMixin, AutotagImportTestCase): assert current_mtime == original_mtime, ( f"Source file timestamp changed: {path}" ) + + def test_prevent_suggest_removal_on_reimport(self): + """Test that removal suggestions are prevented during reimport.""" + album = self.lib.albums().get() + mb_albumid = album.mb_albumid + + # Reimport from library + reimporter = self.setup_importer(import_dir=self.libdir) + reimporter.add_choice(importer.Action.APPLY) + reimporter.run() + + plugin = plugins._instances[0] + assert mb_albumid in plugin.stop_suggestions_for_albums + + # Calling suggest_removal should exit early without prompting + item = self.lib.items().get() + plugin.suggest_removal(item) + assert os.path.exists(item.source_path) + + def test_prevent_suggest_removal_handles_skipped_task(self): + """Test that skipped tasks don't crash prevent_suggest_removal.""" + + class MockTask: + skip = True + + def imported_items(self): + return "whatever" + + plugin = plugins._instances[0] + mock_task = MockTask() + plugin.prevent_suggest_removal(None, mock_task) From ea157832fe3704efe105f004ee39c6423db65d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 26 Oct 2025 01:57:15 +0000 Subject: [PATCH 161/274] hooks: make AlbumMatch.mapping a tuple --- beets/autotag/__init__.py | 12 ++++++------ beets/autotag/distance.py | 8 ++++---- beets/autotag/hooks.py | 8 ++++++++ beets/autotag/match.py | 12 +++++++----- beets/importer/tasks.py | 8 ++++---- beets/ui/commands/import_/display.py | 5 +++-- beetsplug/bpsync.py | 8 ++++---- beetsplug/mbpseudo.py | 7 ++----- beetsplug/mbsync.py | 12 +++++++----- test/autotag/test_distance.py | 2 +- test/test_autotag.py | 14 +++++++------- test/ui/commands/test_import.py | 8 +++++--- 12 files changed, 58 insertions(+), 46 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index beaf4341c..feeefbf28 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -29,7 +29,7 @@ from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch from .match import Proposal, Recommendation, tag_album, tag_item if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence from beets.library import Album, Item, LibModel @@ -204,11 +204,11 @@ def apply_album_metadata(album_info: AlbumInfo, album: Album): correct_list_fields(album) -def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]): - """Set the items' metadata to match an AlbumInfo object using a - mapping from Items to TrackInfo objects. - """ - for item, track_info in mapping.items(): +def apply_metadata( + album_info: AlbumInfo, item_info_pairs: list[tuple[Item, TrackInfo]] +): + """Set items metadata to match corresponding tagged info.""" + for item, track_info in item_info_pairs: # Artist or artist credit. if config["artist_credit"]: item.artist = ( diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 37c6f84f4..5e3f630e3 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -422,7 +422,7 @@ def track_distance( def distance( items: Sequence[Item], album_info: AlbumInfo, - mapping: dict[Item, TrackInfo], + item_info_pairs: list[tuple[Item, TrackInfo]], ) -> Distance: """Determines how "significant" an album metadata change would be. Returns a Distance object. `album_info` is an AlbumInfo object @@ -518,16 +518,16 @@ def distance( # Tracks. dist.tracks = {} - for item, track in mapping.items(): + for item, track in item_info_pairs: dist.tracks[track] = track_distance(item, track, album_info.va) dist.add("tracks", dist.tracks[track].distance) # Missing tracks. - for _ in range(len(album_info.tracks) - len(mapping)): + for _ in range(len(album_info.tracks) - len(item_info_pairs)): dist.add("missing_tracks", 1.0) # Unmatched tracks. - for _ in range(len(items) - len(mapping)): + for _ in range(len(items) - len(item_info_pairs)): dist.add("unmatched_tracks", 1.0) dist.add_data_source(likelies["data_source"], album_info.data_source) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index b809609ea..b5d3e866c 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -223,6 +223,14 @@ class AlbumMatch(NamedTuple): extra_items: list[Item] extra_tracks: list[TrackInfo] + @property + def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]: + return list(self.mapping.items()) + + @property + def items(self) -> list[Item]: + return [i for i, _ in self.item_info_pairs] + class TrackMatch(NamedTuple): distance: Distance diff --git a/beets/autotag/match.py b/beets/autotag/match.py index d0f3fd134..acbcca5ac 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -69,7 +69,7 @@ class Proposal(NamedTuple): def assign_items( items: Sequence[Item], tracks: Sequence[TrackInfo], -) -> tuple[dict[Item, TrackInfo], list[Item], list[TrackInfo]]: +) -> tuple[list[tuple[Item, TrackInfo]], list[Item], list[TrackInfo]]: """Given a list of Items and a list of TrackInfo objects, find the best mapping between them. Returns a mapping from Items to TrackInfo objects, a set of extra Items, and a set of extra TrackInfo @@ -95,7 +95,7 @@ def assign_items( extra_items.sort(key=lambda i: (i.disc, i.track, i.title)) extra_tracks = list(set(tracks) - set(mapping.values())) extra_tracks.sort(key=lambda t: (t.index, t.title)) - return mapping, extra_items, extra_tracks + return list(mapping.items()), extra_items, extra_tracks def match_by_id(items: Iterable[Item]) -> AlbumInfo | None: @@ -217,10 +217,12 @@ def _add_candidate( return # Find mapping between the items and the track info. - mapping, extra_items, extra_tracks = assign_items(items, info.tracks) + item_info_pairs, extra_items, extra_tracks = assign_items( + items, info.tracks + ) # Get the change distance. - dist = distance(items, info, mapping) + dist = distance(items, info, item_info_pairs) # Skip matches with ignored penalties. penalties = [key for key, _ in dist] @@ -232,7 +234,7 @@ def _add_candidate( log.debug("Success. Distance: {}", dist) results[info.album_id] = hooks.AlbumMatch( - dist, info, mapping, extra_items, extra_tracks + dist, info, dict(item_info_pairs), extra_items, extra_tracks ) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 9f60d7619..3a9c044b2 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -245,21 +245,21 @@ class ImportTask(BaseImportTask): matched items. """ if self.choice_flag in (Action.ASIS, Action.RETAG): - return list(self.items) + return self.items elif self.choice_flag == Action.APPLY and isinstance( self.match, autotag.AlbumMatch ): - return list(self.match.mapping.keys()) + return self.match.items else: assert False def apply_metadata(self): """Copy metadata from match info to the items.""" if config["import"]["from_scratch"]: - for item in self.match.mapping: + for item in self.match.items: item.clear() - autotag.apply_metadata(self.match.info, self.match.mapping) + autotag.apply_metadata(self.match.info, self.match.item_info_pairs) def duplicate_items(self, lib: library.Library): duplicate_items = [] diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index a12f1f8d3..fd6758b54 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -373,8 +373,9 @@ class AlbumChange(ChangeRepresentation): # Tracks. # match is an AlbumMatch NamedTuple, mapping is a dict # Sort the pairs by the track_info index (at index 1 of the NamedTuple) - pairs = list(self.match.mapping.items()) - pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index) + pairs = sorted( + self.match.item_info_pairs, key=lambda pair: pair[1].index + ) # Build up LHS and RHS for track difference display. The `lines` list # contains `(left, right)` tuples. lines = [] diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py index 9ae6d47d5..fbdf8cc70 100644 --- a/beetsplug/bpsync.py +++ b/beetsplug/bpsync.py @@ -149,14 +149,14 @@ class BPSyncPlugin(BeetsPlugin): library_trackid_to_item = { int(item.mb_trackid): item for item in items } - item_to_trackinfo = { - item: beatport_trackid_to_trackinfo[track_id] + item_info_pairs = [ + (item, beatport_trackid_to_trackinfo[track_id]) for track_id, item in library_trackid_to_item.items() - } + ] self._log.info("applying changes to {}", album) with lib.transaction(): - autotag.apply_metadata(albuminfo, item_to_trackinfo) + autotag.apply_metadata(albuminfo, item_info_pairs) changed = False # Find any changed item to apply Beatport changes to album. any_changed_item = items[0] diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 94b6f09a0..b61af2cc7 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -264,11 +264,8 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): album_info.album_id, ) album_info.use_pseudo_as_ref() - mapping = match.mapping - new_mappings, _, _ = assign_items( - list(mapping.keys()), album_info.tracks - ) - mapping.update(new_mappings) + new_pairs, *_ = assign_items(match.items, album_info.tracks) + album_info.mapping = dict(new_pairs) if album_info.data_source == self.data_source: album_info.data_source = "MusicBrainz" diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 3f7daec6c..5b74b67c9 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -121,18 +121,20 @@ class MBSyncPlugin(BeetsPlugin): # Construct a track mapping according to MBIDs (release track MBIDs # first, if available, and recording MBIDs otherwise). This should # work for albums that have missing or extra tracks. - mapping = {} + item_info_pairs = [] items = list(album.items()) for item in items: if ( item.mb_releasetrackid and item.mb_releasetrackid in releasetrack_index ): - mapping[item] = releasetrack_index[item.mb_releasetrackid] + item_info_pairs.append( + (item, releasetrack_index[item.mb_releasetrackid]) + ) else: candidates = track_index[item.mb_trackid] if len(candidates) == 1: - mapping[item] = candidates[0] + item_info_pairs.append((item, candidates[0])) else: # If there are multiple copies of a recording, they are # disambiguated using their disc and track number. @@ -141,13 +143,13 @@ class MBSyncPlugin(BeetsPlugin): c.medium_index == item.track and c.medium == item.disc ): - mapping[item] = c + item_info_pairs.append((item, c)) break # Apply. self._log.debug("applying changes to {}", album) with lib.transaction(): - autotag.apply_metadata(album_info, mapping) + autotag.apply_metadata(album_info, item_info_pairs) changed = False # Find any changed item to apply changes to album. any_changed_item = items[0] diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 213d32956..9a658f5e1 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -182,7 +182,7 @@ class TestAlbumDistance: @pytest.fixture def get_dist(self, items): def inner(info: AlbumInfo): - return distance(items, info, dict(zip(items, info.tracks))) + return distance(items, info, list(zip(items, info.tracks))) return inner diff --git a/test/test_autotag.py b/test/test_autotag.py index 8d467e5ed..48ae09ccb 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -55,10 +55,12 @@ class TestAssignment(ConfigMixin): items = [Item(title=title) for title in item_titles] tracks = [TrackInfo(title=title) for title in track_titles] - mapping, extra_items, extra_tracks = match.assign_items(items, tracks) + item_info_pairs, extra_items, extra_tracks = match.assign_items( + items, tracks + ) assert ( - {i.title: t.title for i, t in mapping.items()}, + {i.title: t.title for i, t in item_info_pairs}, [i.title for i in extra_items], [t.title for t in extra_tracks], ) == (expected_mapping, expected_extra_items, expected_extra_tracks) @@ -105,7 +107,7 @@ class TestAssignment(ConfigMixin): trackinfo.append(info(11, "Beloved One", 243.733)) trackinfo.append(info(12, "In the Lord's Arms", 186.13300000000001)) - expected = dict(zip(items, trackinfo)), [], [] + expected = list(zip(items, trackinfo)), [], [] assert match.assign_items(items, trackinfo) == expected @@ -113,12 +115,10 @@ class TestAssignment(ConfigMixin): class ApplyTestUtil: def _apply(self, info=None, per_disc_numbering=False, artist_credit=False): info = info or self.info - mapping = {} - for i, t in zip(self.items, info.tracks): - mapping[i] = t + item_info_pairs = list(zip(self.items, info.tracks)) config["per_disc_numbering"] = per_disc_numbering config["artist_credit"] = artist_credit - autotag.apply_metadata(info, mapping) + autotag.apply_metadata(info, item_info_pairs) class ApplyTest(BeetsTestCase, ApplyTestUtil): diff --git a/test/ui/commands/test_import.py b/test/ui/commands/test_import.py index d74d2d816..6e96c3bf3 100644 --- a/test/ui/commands/test_import.py +++ b/test/ui/commands/test_import.py @@ -87,15 +87,17 @@ class ShowChangeTest(IOMixin, unittest.TestCase): """Return an unicode string representing the changes""" items = items or self.items info = info or self.info - mapping = dict(zip(items, info.tracks)) + item_info_pairs = list(zip(items, info.tracks)) config["ui"]["color"] = color config["import"]["detail"] = True - change_dist = distance(items, info, mapping) + change_dist = distance(items, info, item_info_pairs) change_dist._penalties = {"album": [dist], "artist": [dist]} show_change( cur_artist, cur_album, - autotag.AlbumMatch(change_dist, info, mapping, set(), set()), + autotag.AlbumMatch( + change_dist, info, dict(item_info_pairs), set(), set() + ), ) return self.io.getoutput().lower() From acc7c2aeacc37a87493b8a690ad6f597c334ad01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 26 Oct 2025 02:28:51 +0000 Subject: [PATCH 162/274] matching: replace search_title, search_album with search_name --- beets/autotag/match.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index acbcca5ac..8adbaeda1 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -241,7 +241,7 @@ def _add_candidate( def tag_album( items, search_artist: str | None = None, - search_album: str | None = None, + search_name: str | None = None, search_ids: list[str] = [], ) -> tuple[str, str, Proposal]: """Return a tuple of the current artist name, the current album @@ -302,10 +302,10 @@ def tag_album( ) # Search terms. - if not (search_artist and search_album): + if not (search_artist and search_name): # No explicit search terms -- use current metadata. - search_artist, search_album = cur_artist, cur_album - log.debug("Search terms: {} - {}", search_artist, search_album) + search_artist, search_name = cur_artist, cur_album + log.debug("Search terms: {} - {}", search_artist, search_name) # Is this album likely to be a "various artist" release? va_likely = ( @@ -317,7 +317,7 @@ def tag_album( # Get the results from the data sources. for matched_candidate in metadata_plugins.candidates( - items, search_artist, search_album, va_likely + items, search_artist, search_name, va_likely ): _add_candidate(items, candidates, matched_candidate) if opt_candidate := candidates.get(matched_candidate.album_id): @@ -333,7 +333,7 @@ def tag_album( def tag_item( item, search_artist: str | None = None, - search_title: str | None = None, + search_name: str | None = None, search_ids: list[str] | None = None, ) -> Proposal: """Find metadata for a single track. Return a `Proposal` consisting @@ -375,12 +375,12 @@ def tag_item( # Search terms. search_artist = search_artist or item.artist - search_title = search_title or item.title - log.debug("Item search terms: {} - {}", search_artist, search_title) + search_name = search_name or item.title + log.debug("Item search terms: {} - {}", search_artist, search_name) # Get and evaluate candidate metadata. for track_info in metadata_plugins.item_candidates( - item, search_artist, search_title + item, search_artist, search_name ): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) From 84f6ada739a94f96f75be9dfdcfc92a7a4d547b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 21 May 2025 01:34:32 +0100 Subject: [PATCH 163/274] hooks: Generalise AlbumMatch and TrackMatch into Match --- beets/autotag/hooks.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index b5d3e866c..aae4846ca 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -17,7 +17,8 @@ from __future__ import annotations from copy import deepcopy -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypeVar from typing_extensions import Self @@ -214,10 +215,14 @@ class TrackInfo(Info): # Structures that compose all the information for a candidate match. - - -class AlbumMatch(NamedTuple): +@dataclass +class Match: distance: Distance + info: Info + + +@dataclass +class AlbumMatch(Match): info: AlbumInfo mapping: dict[Item, TrackInfo] extra_items: list[Item] @@ -232,6 +237,6 @@ class AlbumMatch(NamedTuple): return [i for i, _ in self.item_info_pairs] -class TrackMatch(NamedTuple): - distance: Distance +@dataclass +class TrackMatch(Match): info: TrackInfo From 7873ae56f03df0db9494e2efb95f499c06cacea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 19 Dec 2025 12:09:42 +0000 Subject: [PATCH 164/274] hooks: introduce Info.name property --- beets/autotag/hooks.py | 19 +++ beets/ui/commands/import_/display.py | 202 +++++++++++++-------------- beets/ui/commands/import_/session.py | 5 +- 3 files changed, 117 insertions(+), 109 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index aae4846ca..82e685b7a 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -18,10 +18,13 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass +from functools import cached_property from typing import TYPE_CHECKING, Any, TypeVar from typing_extensions import Self +from beets.util import cached_classproperty + if TYPE_CHECKING: from beets.library import Item @@ -55,6 +58,10 @@ class AttrDict(dict[str, V]): class Info(AttrDict[Any]): """Container for metadata about a musical entity.""" + @cached_property + def name(self) -> str: + raise NotImplementedError + def __init__( self, album: str | None = None, @@ -96,6 +103,10 @@ class AlbumInfo(Info): user items, and later to drive tagging decisions once selected. """ + @cached_property + def name(self) -> str: + return self.album or "" + def __init__( self, tracks: list[TrackInfo], @@ -168,6 +179,10 @@ class TrackInfo(Info): stand alone for singleton matching. """ + @cached_property + def name(self) -> str: + return self.title or "" + def __init__( self, *, @@ -220,6 +235,10 @@ class Match: distance: Distance info: Info + @cached_classproperty + def type(cls) -> str: + return cls.__name__.removesuffix("Match") # type: ignore[attr-defined] + @dataclass class AlbumMatch(Match): diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index fd6758b54..057101ec5 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -1,15 +1,36 @@ +from __future__ import annotations + import os -from collections.abc import Sequence +from dataclasses import dataclass from functools import cached_property +from typing import TYPE_CHECKING, TypedDict + +from typing_extensions import NotRequired from beets import autotag, config, ui from beets.autotag import hooks from beets.util import displayable_path from beets.util.units import human_seconds_short +if TYPE_CHECKING: + from collections.abc import Sequence + + import confuse + + from beets.library.models import Item + from beets.ui import ColorName + VARIOUS_ARTISTS = "Various Artists" +class Side(TypedDict): + prefix: str + contents: str + suffix: str + width: NotRequired[int] + + +@dataclass class ChangeRepresentation: """Keeps track of all information needed to generate a (colored) text representation of the changes that will be made if an album or singleton's @@ -17,46 +38,46 @@ class ChangeRepresentation: TrackMatch object, accordingly. """ + cur_artist: str + cur_name: str + match: autotag.hooks.Match + @cached_property def changed_prefix(self) -> str: return ui.colorize("changed", "\u2260") - cur_artist = None - # cur_album set if album, cur_title set if singleton - cur_album = None - cur_title = None - match = None - indent_header = "" - indent_detail = "" + @cached_property + def _indentation_config(self) -> confuse.ConfigView: + return config["ui"]["import"]["indentation"] - def __init__(self): - # Read match header indentation width from config. - match_header_indent_width = config["ui"]["import"]["indentation"][ - "match_header" - ].as_number() - self.indent_header = ui.indent(match_header_indent_width) + @cached_property + def indent_header(self) -> str: + return ui.indent(self._indentation_config["match_header"].as_number()) - # Read match detail indentation width from config. - match_detail_indent_width = config["ui"]["import"]["indentation"][ - "match_details" - ].as_number() - self.indent_detail = ui.indent(match_detail_indent_width) + @cached_property + def indent_detail(self) -> str: + return ui.indent(self._indentation_config["match_details"].as_number()) - # Read match tracklist indentation width from config - match_tracklist_indent_width = config["ui"]["import"]["indentation"][ - "match_tracklist" - ].as_number() - self.indent_tracklist = ui.indent(match_tracklist_indent_width) - self.layout = config["ui"]["import"]["layout"].as_choice( - { - "column": 0, - "newline": 1, - } + @cached_property + def indent_tracklist(self) -> str: + return ui.indent( + self._indentation_config["match_tracklist"].as_number() + ) + + @cached_property + def layout(self) -> int: + return config["ui"]["import"]["layout"].as_choice( + {"column": 0, "newline": 1} ) def print_layout( - self, indent, left, right, separator=" -> ", max_width=None - ): + self, + indent: str, + left: Side, + right: Side, + separator: str = " -> ", + max_width: int | None = None, + ) -> None: if not max_width: # If no max_width provided, use terminal width max_width = ui.term_width() @@ -65,7 +86,7 @@ class ChangeRepresentation: else: ui.print_newline_layout(indent, left, right, separator, max_width) - def show_match_header(self): + def show_match_header(self) -> None: """Print out a 'header' identifying the suggested match (album name, artist name,...) and summarizing the changes that would be made should the user accept the match. @@ -78,19 +99,10 @@ class ChangeRepresentation: f"{self.indent_header}Match ({dist_string(self.match.distance)}):" ) - if isinstance(self.match.info, autotag.hooks.AlbumInfo): - # Matching an album - print that - artist_album_str = ( - f"{self.match.info.artist} - {self.match.info.album}" - ) - else: - # Matching a single track - artist_album_str = ( - f"{self.match.info.artist} - {self.match.info.title}" - ) + artist_name_str = f"{self.match.info.artist} - {self.match.info.name}" ui.print_( self.indent_header - + dist_colorize(artist_album_str, self.match.distance) + + dist_colorize(artist_name_str, self.match.distance) ) # Penalties. @@ -108,7 +120,7 @@ class ChangeRepresentation: url = ui.colorize("text_faint", f"{self.match.info.data_url}") ui.print_(f"{self.indent_header}{url}") - def show_match_details(self): + def show_match_details(self) -> None: """Print out the details of the match, including changes in album name and artist name. """ @@ -117,6 +129,8 @@ class ChangeRepresentation: if artist_r == VARIOUS_ARTISTS: # Hide artists for VA releases. artist_l, artist_r = "", "" + left: Side + right: Side if artist_l != artist_r: artist_l, artist_r = ui.colordiff(artist_l, artist_r) left = { @@ -130,39 +144,22 @@ class ChangeRepresentation: else: ui.print_(f"{self.indent_detail}*", "Artist:", artist_r) - if self.cur_album: - # Album - album_l, album_r = self.cur_album or "", self.match.info.album - if ( - self.cur_album != self.match.info.album - and self.match.info.album != VARIOUS_ARTISTS - ): - album_l, album_r = ui.colordiff(album_l, album_r) + if self.cur_name: + type_ = self.match.type + name_l, name_r = self.cur_name or "", self.match.info.name + if self.cur_name != self.match.info.name != VARIOUS_ARTISTS: + name_l, name_r = ui.colordiff(name_l, name_r) left = { - "prefix": f"{self.changed_prefix} Album: ", - "contents": album_l, + "prefix": f"{self.changed_prefix} {type_}: ", + "contents": name_l, "suffix": "", } - right = {"prefix": "", "contents": album_r, "suffix": ""} + right = {"prefix": "", "contents": name_r, "suffix": ""} self.print_layout(self.indent_detail, left, right) else: - ui.print_(f"{self.indent_detail}*", "Album:", album_r) - elif self.cur_title: - # Title - for singletons - title_l, title_r = self.cur_title or "", self.match.info.title - if self.cur_title != self.match.info.title: - title_l, title_r = ui.colordiff(title_l, title_r) - left = { - "prefix": f"{self.changed_prefix} Title: ", - "contents": title_l, - "suffix": "", - } - right = {"prefix": "", "contents": title_r, "suffix": ""} - self.print_layout(self.indent_detail, left, right) - else: - ui.print_(f"{self.indent_detail}*", "Title:", title_r) + ui.print_(f"{self.indent_detail}*", f"{type_}:", name_r) - def make_medium_info_line(self, track_info): + def make_medium_info_line(self, track_info: hooks.TrackInfo) -> str: """Construct a line with the current medium's info.""" track_media = track_info.get("media", "Media") # Build output string. @@ -177,7 +174,7 @@ class ChangeRepresentation: else: return "" - def format_index(self, track_info): + def format_index(self, track_info: hooks.TrackInfo | Item) -> str: """Return a string representing the track index of the given TrackInfo or Item object. """ @@ -198,12 +195,15 @@ class ChangeRepresentation: else: return str(index) - def make_track_numbers(self, item, track_info): + def make_track_numbers( + self, item, track_info: hooks.TrackInfo + ) -> tuple[str, str, bool]: """Format colored track indices.""" cur_track = self.format_index(item) new_track = self.format_index(track_info) changed = False # Choose color based on change. + highlight_color: ColorName if cur_track != new_track: changed = True if item.track in (track_info.index, track_info.medium_index): @@ -218,9 +218,11 @@ class ChangeRepresentation: return lhs_track, rhs_track, changed @staticmethod - def make_track_titles(item, track_info): + def make_track_titles( + item: Item, track_info: hooks.TrackInfo + ) -> tuple[str, str, bool]: """Format colored track titles.""" - new_title = track_info.title + new_title = track_info.name if not item.title.strip(): # If there's no title, we use the filename. Don't colordiff. cur_title = displayable_path(os.path.basename(item.path)) @@ -232,9 +234,12 @@ class ChangeRepresentation: return cur_col, new_col, cur_title != new_title @staticmethod - def make_track_lengths(item, track_info): + def make_track_lengths( + item: Item, track_info: hooks.TrackInfo + ) -> tuple[str, str, bool]: """Format colored track lengths.""" changed = False + highlight_color: ColorName if ( item.length and track_info.length @@ -258,7 +263,9 @@ class ChangeRepresentation: return lhs_length, rhs_length, changed - def make_line(self, item, track_info): + def make_line( + self, item: Item, track_info: hooks.TrackInfo + ) -> tuple[Side, Side]: """Extract changes from item -> new TrackInfo object, and colorize appropriately. Returns (lhs, rhs) for column printing. """ @@ -282,12 +289,12 @@ class ChangeRepresentation: # the case, thus the 'info' dictionary is unneeded. # penalties = penalty_string(self.match.distance.tracks[track_info]) - lhs = { + lhs: Side = { "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", "contents": lhs_title, "suffix": f" {lhs_length}", } - rhs = {"prefix": "", "contents": "", "suffix": ""} + rhs: Side = {"prefix": "", "contents": "", "suffix": ""} if not changed: # Only return the left side, as nothing changed. return (lhs, rhs) @@ -358,27 +365,18 @@ class ChangeRepresentation: class AlbumChange(ChangeRepresentation): - """Album change representation, setting cur_album""" + match: autotag.hooks.AlbumMatch - def __init__(self, cur_artist, cur_album, match): - super().__init__() - self.cur_artist = cur_artist - self.cur_album = cur_album - self.match = match - - def show_match_tracks(self): + def show_match_tracks(self) -> None: """Print out the tracks of the match, summarizing changes the match suggests for them. """ - # Tracks. - # match is an AlbumMatch NamedTuple, mapping is a dict - # Sort the pairs by the track_info index (at index 1 of the NamedTuple) pairs = sorted( - self.match.item_info_pairs, key=lambda pair: pair[1].index + self.match.item_info_pairs, key=lambda pair: pair[1].index or 0 ) # Build up LHS and RHS for track difference display. The `lines` list # contains `(left, right)` tuples. - lines = [] + lines: list[tuple[Side, Side]] = [] medium = disctitle = None for item, track_info in pairs: # If the track is the first on a new medium, show medium @@ -427,21 +425,17 @@ class AlbumChange(ChangeRepresentation): class TrackChange(ChangeRepresentation): """Track change representation, comparing item with match.""" - def __init__(self, cur_artist, cur_title, match): - super().__init__() - self.cur_artist = cur_artist - self.cur_title = cur_title - self.match = match + match: autotag.hooks.TrackMatch -def show_change(cur_artist, cur_album, match): +def show_change( + cur_artist: str, cur_album: str, match: hooks.AlbumMatch +) -> None: """Print out a representation of the changes that will be made if an album's tags are changed according to `match`, which must be an AlbumMatch object. """ - change = AlbumChange( - cur_artist=cur_artist, cur_album=cur_album, match=match - ) + change = AlbumChange(cur_artist, cur_album, match) # Print the match header. change.show_match_header() @@ -453,13 +447,11 @@ def show_change(cur_artist, cur_album, match): change.show_match_tracks() -def show_item_change(item, match): +def show_item_change(item: Item, match: hooks.TrackMatch) -> None: """Print out the change that would occur by tagging `item` with the metadata from `match`, a TrackMatch object. """ - change = TrackChange( - cur_artist=item.artist, cur_title=item.title, match=match - ) + change = TrackChange(item.artist, item.title, match) # Print the match header. change.show_match_header() # Print the match details. diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index dcc80b793..9c8c8dd62 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -444,10 +444,7 @@ def choose_candidate( index = dist_colorize(index0, match.distance) dist = f"({(1 - match.distance) * 100:.1f}%)" distance = dist_colorize(dist, match.distance) - metadata = ( - f"{match.info.artist} -" - f" {match.info.title if singleton else match.info.album}" - ) + metadata = f"{match.info.artist} - {match.info.name}" if i == 0: metadata = dist_colorize(metadata, match.distance) else: From 60b4a38c09a08ff2c8ce00742eac01f6610c0aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 19 Dec 2025 12:10:21 +0000 Subject: [PATCH 165/274] Add missing type defs in import_/display.py --- beets/ui/commands/import_/display.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index 057101ec5..113462d19 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: import confuse + from beets.autotag.distance import Distance from beets.library.models import Item from beets.ui import ColorName @@ -47,7 +48,7 @@ class ChangeRepresentation: return ui.colorize("changed", "\u2260") @cached_property - def _indentation_config(self) -> confuse.ConfigView: + def _indentation_config(self) -> confuse.Subview: return config["ui"]["import"]["indentation"] @cached_property @@ -196,7 +197,7 @@ class ChangeRepresentation: return str(index) def make_track_numbers( - self, item, track_info: hooks.TrackInfo + self, item: Item, track_info: hooks.TrackInfo ) -> tuple[str, str, bool]: """Format colored track indices.""" cur_track = self.format_index(item) @@ -307,7 +308,7 @@ class ChangeRepresentation: } return (lhs, rhs) - def print_tracklist(self, lines): + def print_tracklist(self, lines: list[tuple[Side, Side]]) -> None: """Calculates column widths for tracks stored as line tuples: (left, right). Then prints each line of tracklist. """ @@ -315,7 +316,7 @@ class ChangeRepresentation: # If no lines provided, e.g. details not required, do nothing. return - def get_width(side): + def get_width(side: Side) -> int: """Return the width of left or right in uncolorized characters.""" try: return len( @@ -458,7 +459,7 @@ def show_item_change(item: Item, match: hooks.TrackMatch) -> None: change.show_match_details() -def disambig_string(info): +def disambig_string(info: hooks.Info) -> str: """Generate a string for an AlbumInfo or TrackInfo object that provides context that helps disambiguate similar-looking albums and tracks. @@ -524,7 +525,7 @@ def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: return out -def dist_colorize(string, dist): +def dist_colorize(string: str, dist: Distance) -> str: """Formats a string as a colorized similarity string according to a distance. """ @@ -537,7 +538,7 @@ def dist_colorize(string, dist): return string -def dist_string(dist): +def dist_string(dist: Distance) -> str: """Formats a distance (a float) as a colorized similarity percentage string. """ @@ -545,7 +546,7 @@ def dist_string(dist): return dist_colorize(string, dist) -def penalty_string(distance, limit=None): +def penalty_string(distance: Distance, limit: int | None = None) -> str: """Returns a colorized string that indicates all the penalties applied to a distance object. """ @@ -561,3 +562,5 @@ def penalty_string(distance, limit=None): # Prefix penalty string with U+2260: Not Equal To penalty_string = f"\u2260 {', '.join(penalties)}" return ui.colorize("changed", penalty_string) + + return "" From 8ccb33e4bc394513e15a29e7fcf12a3f41500ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 1 Aug 2025 17:08:08 +0100 Subject: [PATCH 166/274] dbcore: add Model.db cached attribute --- beets/dbcore/db.py | 28 +++++++++++++++++----------- beets/library/models.py | 8 +++----- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index cc172d0d8..c682f0c0c 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -34,6 +34,7 @@ from collections.abc import ( Mapping, Sequence, ) +from functools import cached_property from sqlite3 import Connection, sqlite_version_info from typing import TYPE_CHECKING, Any, AnyStr, Generic @@ -360,6 +361,14 @@ class Model(ABC, Generic[D]): """Fields in the related table.""" return cls._relation._fields.keys() - cls.shared_db_fields + @cached_property + def db(self) -> D: + """Get the database associated with this object. + + This validates that the database is attached and the object has an id. + """ + return self._check_db() + @classmethod def _getters(cls: type[Model]): """Return a mapping from field names to getter functions.""" @@ -599,7 +608,6 @@ class Model(ABC, Generic[D]): """ if fields is None: fields = self._fields - db = self._check_db() # Build assignments for query. assignments = [] @@ -611,7 +619,7 @@ class Model(ABC, Generic[D]): value = self._type(key).to_sql(self[key]) subvars.append(value) - with db.transaction() as tx: + with self.db.transaction() as tx: # Main table update. if assignments: query = f"UPDATE {self._table} SET {','.join(assignments)} WHERE id=?" @@ -645,11 +653,10 @@ class Model(ABC, Generic[D]): If check_revision is true, the database is only queried loaded when a transaction has been committed since the item was last loaded. """ - db = self._check_db() - if not self._dirty and db.revision == self._revision: + if not self._dirty and self.db.revision == self._revision: # Exit early return - stored_obj = db._get(type(self), self.id) + stored_obj = self.db._get(type(self), self.id) assert stored_obj is not None, f"object {self.id} not in DB" self._values_fixed = LazyConvertDict(self) self._values_flex = LazyConvertDict(self) @@ -658,8 +665,7 @@ class Model(ABC, Generic[D]): def remove(self): """Remove the object's associated rows from the database.""" - db = self._check_db() - with db.transaction() as tx: + with self.db.transaction() as tx: tx.mutate(f"DELETE FROM {self._table} WHERE id=?", (self.id,)) tx.mutate( f"DELETE FROM {self._flex_table} WHERE entity_id=?", (self.id,) @@ -675,7 +681,7 @@ class Model(ABC, Generic[D]): """ if db: self._db = db - db = self._check_db(False) + db = self._check_db(need_id=False) with db.transaction() as tx: new_id = tx.mutate(f"INSERT INTO {self._table} DEFAULT VALUES") @@ -740,9 +746,9 @@ class Model(ABC, Generic[D]): Remove the database connection as sqlite connections are not picklable. """ - state = self.__dict__.copy() - state["_db"] = None - return state + return { + k: v for k, v in self.__dict__.items() if k not in {"_db", "db"} + } # Database controller and supporting interfaces. diff --git a/beets/library/models.py b/beets/library/models.py index cbee2a411..76618d929 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -1143,7 +1143,6 @@ class Item(LibModel): If `store` is `False` however, the item won't be stored and it will have to be manually stored after invoking this method. """ - self._check_db() dest = self.destination(basedir=basedir) # Create necessary ancestry for the move. @@ -1183,9 +1182,8 @@ class Item(LibModel): is true, returns just the fragment of the path underneath the library base directory. """ - db = self._check_db() - basedir = basedir or db.directory - path_formats = path_formats or db.path_formats + basedir = basedir or self.db.directory + path_formats = path_formats or self.db.path_formats # Use a path format based on a query, falling back on the # default. @@ -1224,7 +1222,7 @@ class Item(LibModel): ) lib_path_str, fallback = util.legalize_path( - subpath, db.replacements, self.filepath.suffix + subpath, self.db.replacements, self.filepath.suffix ) if fallback: # Print an error message if legalization fell back to From e1e0d945f84933cf17b96a7951258c698ccc350b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 26 Dec 2025 17:21:30 +0000 Subject: [PATCH 167/274] Add NotFoundError and Model.get_fresh_from_db; tidy DB getters Introduce NotFoundError and a Model.get_fresh_from_db helper that reloads an object from the database and raises when missing. Use it to simplify Model.load and UI change detection. --- beets/dbcore/db.py | 36 +++++++++++++++++++++--------------- beets/library/library.py | 18 +++++++----------- beets/library/models.py | 2 ++ beets/ui/__init__.py | 2 +- 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index c682f0c0c..83f0543d2 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -38,7 +38,10 @@ from functools import cached_property from sqlite3 import Connection, sqlite_version_info from typing import TYPE_CHECKING, Any, AnyStr, Generic -from typing_extensions import TypeVar # default value support +from typing_extensions import ( + Self, + TypeVar, # default value support +) from unidecode import unidecode import beets @@ -84,6 +87,10 @@ class DBCustomFunctionError(Exception): ) +class NotFoundError(LookupError): + pass + + class FormattedMapping(Mapping[str, str]): """A `dict`-like formatted view of a model. @@ -369,6 +376,14 @@ class Model(ABC, Generic[D]): """ return self._check_db() + def get_fresh_from_db(self) -> Self: + """Load this object from the database.""" + model_cls = self.__class__ + if obj := self.db._get(model_cls, self.id): + return obj + + raise NotFoundError(f"No matching {model_cls.__name__} found") from None + @classmethod def _getters(cls: type[Model]): """Return a mapping from field names to getter functions.""" @@ -656,11 +671,8 @@ class Model(ABC, Generic[D]): if not self._dirty and self.db.revision == self._revision: # Exit early return - stored_obj = self.db._get(type(self), self.id) - assert stored_obj is not None, f"object {self.id} not in DB" - self._values_fixed = LazyConvertDict(self) - self._values_flex = LazyConvertDict(self) - self.update(dict(stored_obj)) + + self.__dict__.update(self.get_fresh_from_db().__dict__) self.clear_dirty() def remove(self): @@ -1309,12 +1321,6 @@ class Database: sort if sort.is_slow() else None, # Slow sort component. ) - def _get( - self, - model_cls: type[AnyModel], - id, - ) -> AnyModel | None: - """Get a Model object by its id or None if the id does not - exist. - """ - return self._fetch(model_cls, MatchQuery("id", id)).get() + def _get(self, model_cls: type[AnyModel], id_: int) -> AnyModel | None: + """Get a Model object by its id or None if the id does not exist.""" + return self._fetch(model_cls, MatchQuery("id", id_)).get() diff --git a/beets/library/library.py b/beets/library/library.py index 7370f7ecd..39d559901 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -125,24 +125,20 @@ class Library(dbcore.Database): return self._fetch(Item, query, sort or self.get_default_item_sort()) # Convenience accessors. - - def get_item(self, id): + def get_item(self, id_: int) -> Item | None: """Fetch a :class:`Item` by its ID. Return `None` if no match is found. """ - return self._get(Item, id) + return self._get(Item, id_) - def get_album(self, item_or_id): + def get_album(self, item_or_id: Item | int) -> Album | None: """Given an album ID or an item associated with an album, return a :class:`Album` object for the album. If no such album exists, return `None`. """ - if isinstance(item_or_id, int): - album_id = item_or_id - else: - album_id = item_or_id.album_id - if album_id is None: - return None - return self._get(Album, album_id) + album_id = ( + item_or_id if isinstance(item_or_id, int) else item_or_id.album_id + ) + return self._get(Album, album_id) if album_id else None diff --git a/beets/library/models.py b/beets/library/models.py index 76618d929..9609989bc 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -620,6 +620,8 @@ class Album(LibModel): class Item(LibModel): """Represent a song or track.""" + album_id: int | None + _table = "items" _flex_table = "item_attributes" _fields = { diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index cfd8b6bd7..cbe0fb109 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1073,7 +1073,7 @@ def show_model_changes( restrict the detection to. `always` indicates whether the object is always identified, regardless of whether any changes are present. """ - old = old or new._db._get(type(new), new.id) + old = old or new.get_fresh_from_db() # Keep the formatted views around instead of re-creating them in each # iteration step From 75baec611acc66979cc28338bb857de5ad1b319b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 26 Dec 2025 16:40:29 +0000 Subject: [PATCH 168/274] Improve and simplify show_model_changes --- beets/dbcore/db.py | 4 ++- beets/ui/__init__.py | 64 ++++++++++++++++++-------------------- docs/changelog.rst | 2 ++ pyproject.toml | 1 + test/ui/test_field_diff.py | 64 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 34 deletions(-) create mode 100644 test/ui/test_field_diff.py diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 83f0543d2..110cd70d0 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -105,6 +105,8 @@ class FormattedMapping(Mapping[str, str]): are replaced. """ + model: Model + ALL_KEYS = "*" def __init__( @@ -714,7 +716,7 @@ class Model(ABC, Generic[D]): self, included_keys: str = _formatter.ALL_KEYS, for_path: bool = False, - ): + ) -> FormattedMapping: """Get a mapping containing all values on this object formatted as human-readable unicode strings. """ diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index cbe0fb109..5eeef815d 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -43,7 +43,10 @@ from beets.util.deprecation import deprecate_for_maintainers from beets.util.functemplate import template if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Iterable + + from beets.dbcore.db import FormattedMapping + # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == "win32": @@ -1028,42 +1031,47 @@ def print_newline_layout( FLOAT_EPSILON = 0.01 -def _field_diff(field, old, old_fmt, new, new_fmt): +def _field_diff( + field: str, old: FormattedMapping, new: FormattedMapping +) -> str | None: """Given two Model objects and their formatted views, format their values for `field` and highlight changes among them. Return a human-readable string. If the value has not changed, return None instead. """ - oldval = old.get(field) - newval = new.get(field) - # If no change, abort. - if ( + if (oldval := old.model.get(field)) == (newval := new.model.get(field)) or ( isinstance(oldval, float) and isinstance(newval, float) and abs(oldval - newval) < FLOAT_EPSILON ): return None - elif oldval == newval: - return None # Get formatted values for output. - oldstr = old_fmt.get(field, "") - newstr = new_fmt.get(field, "") + oldstr, newstr = old.get(field, ""), new.get(field, "") + if field not in new: + return colorize("text_diff_removed", f"{field}: {oldstr}") + + if field not in old: + return colorize("text_diff_added", f"{field}: {newstr}") # For strings, highlight changes. For others, colorize the whole # thing. if isinstance(oldval, str): - oldstr, newstr = colordiff(oldval, newstr) + oldstr, newstr = colordiff(oldstr, newstr) else: oldstr = colorize("text_diff_removed", oldstr) newstr = colorize("text_diff_added", newstr) - return f"{oldstr} -> {newstr}" + return f"{field}: {oldstr} -> {newstr}" def show_model_changes( - new, old=None, fields=None, always=False, print_obj: bool = True -): + new: library.LibModel, + old: library.LibModel | None = None, + fields: Iterable[str] | None = None, + always: bool = False, + print_obj: bool = True, +) -> bool: """Given a Model object, print a list of changes from its pristine version stored in the database. Return a boolean indicating whether any changes were found. @@ -1081,31 +1089,21 @@ def show_model_changes( new_fmt = new.formatted() # Build up lines showing changed fields. - changes = [] - for field in old: - # Subset of the fields. Never show mtime. - if field == "mtime" or (fields and field not in fields): - continue + diff_fields = (set(old) | set(new)) - {"mtime"} + if allowed_fields := set(fields or {}): + diff_fields &= allowed_fields - # Detect and show difference for this field. - line = _field_diff(field, old, old_fmt, new, new_fmt) - if line: - changes.append(f" {field}: {line}") - - # New fields. - for field in set(new) - set(old): - if fields and field not in fields: - continue - - changes.append( - f" {field}: {colorize('text_highlight', new_fmt[field])}" - ) + changes = [ + d + for f in sorted(diff_fields) + if (d := _field_diff(f, old_fmt, new_fmt)) + ] # Print changes. if print_obj and (changes or always): print_(format(old)) if changes: - print_("\n".join(changes)) + print_(textwrap.indent("\n".join(changes), " ")) return bool(changes) diff --git a/docs/changelog.rst b/docs/changelog.rst index a471b4c56..b292863ee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -70,6 +70,8 @@ Bug fixes: - When using :doc:`plugins/fromfilename` together with :doc:`plugins/edit`, temporary tags extracted from filenames are no longer lost when discarding or cancelling an edit session during import. :bug:`6104` +- :ref:`update-cmd` :doc:`plugins/edit` fix display formatting of field changes + to clearly show added and removed flexible fields. For plugin developers: diff --git a/pyproject.toml b/pyproject.toml index 24cf21b33..bc694de90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -322,6 +322,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "beets/**" = ["PT"] "test/test_util.py" = ["E501"] +"test/ui/test_field_diff.py" = ["E501"] [tool.ruff.lint.isort] split-on-trailing-comma = false diff --git a/test/ui/test_field_diff.py b/test/ui/test_field_diff.py new file mode 100644 index 000000000..dce7ba161 --- /dev/null +++ b/test/ui/test_field_diff.py @@ -0,0 +1,64 @@ +import pytest + +from beets.library import Item +from beets.test.helper import ConfigMixin +from beets.ui import _field_diff + +p = pytest.param + + +class TestFieldDiff: + @pytest.fixture(scope="class", autouse=True) + def config(self): + return ConfigMixin().config + + @pytest.fixture(autouse=True) + def configure_color(self, config, color): + config["ui"]["color"] = color + + @pytest.fixture(autouse=True) + def patch_colorize(self, monkeypatch): + """Patch to return a deterministic string format instead of ANSI codes.""" + monkeypatch.setattr( + "beets.ui.colorize", + lambda color_name, text: f"[{color_name}]{text}[/]", + ) + + @staticmethod + def diff_fmt(old, new): + return f"[text_diff_removed]{old}[/] -> [text_diff_added]{new}[/]" + + @pytest.mark.parametrize( + "old_data, new_data, field, expected_diff", + [ + p({"title": "foo"}, {"title": "foo"}, "title", None, id="no_change"), + p({"bpm": 120.0}, {"bpm": 120.005}, "bpm", None, id="float_close_enough"), + p({"bpm": 120.0}, {"bpm": 121.0}, "bpm", f"bpm: {diff_fmt('120', '121')}", id="float_changed"), + p({"title": "foo"}, {"title": "bar"}, "title", f"title: {diff_fmt('foo', 'bar')}", id="string_full_replace"), + p({"title": "prefix foo"}, {"title": "prefix bar"}, "title", "title: prefix [text_diff_removed]foo[/] -> prefix [text_diff_added]bar[/]", id="string_partial_change"), + p({"year": 2000}, {"year": 2001}, "year", f"year: {diff_fmt('2000', '2001')}", id="int_changed"), + p({}, {"genre": "Rock"}, "genre", "genre: -> [text_diff_added]Rock[/]", id="field_added"), + p({"genre": "Rock"}, {}, "genre", "genre: [text_diff_removed]Rock[/] -> ", id="field_removed"), + p({"track": 1}, {"track": 2}, "track", f"track: {diff_fmt('01', '02')}", id="formatted_value_changed"), + p({"mb_trackid": None}, {"mb_trackid": "1234"}, "mb_trackid", "mb_trackid: -> [text_diff_added]1234[/]", id="none_to_value"), + p({}, {"new_flex": "foo"}, "new_flex", "[text_diff_added]new_flex: foo[/]", id="flex_field_added"), + p({"old_flex": "foo"}, {}, "old_flex", "[text_diff_removed]old_flex: foo[/]", id="flex_field_removed"), + ], + ) # fmt: skip + @pytest.mark.parametrize("color", [True], ids=["color_enabled"]) + def test_field_diff_colors(self, old_data, new_data, field, expected_diff): + old_item = Item(**old_data) + new_item = Item(**new_data) + + diff = _field_diff(field, old_item.formatted(), new_item.formatted()) + + assert diff == expected_diff + + @pytest.mark.parametrize("color", [False], ids=["color_disabled"]) + def test_field_diff_no_color(self): + old_item = Item(title="foo") + new_item = Item(title="bar") + + diff = _field_diff("title", old_item.formatted(), new_item.formatted()) + + assert diff == "title: foo -> bar" From c807effeda334e1f7019dbb529e8292106a0e288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 26 Dec 2025 16:41:57 +0000 Subject: [PATCH 169/274] Define a shared fixture for config --- test/autotag/test_distance.py | 4 +--- test/conftest.py | 7 +++++++ test/plugins/test_mbpseudo.py | 11 ++--------- test/test_autotag.py | 10 +++++----- test/ui/test_field_diff.py | 5 ----- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 9a658f5e1..3686f82c9 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -12,15 +12,13 @@ from beets.autotag.distance import ( from beets.library import Item from beets.metadata_plugins import MetadataSourcePlugin, get_penalty from beets.plugins import BeetsPlugin -from beets.test.helper import ConfigMixin _p = pytest.param class TestDistance: @pytest.fixture(autouse=True, scope="class") - def setup_config(self): - config = ConfigMixin().config + def setup_config(self, config): config["match"]["distance_weights"]["data_source"] = 2.0 config["match"]["distance_weights"]["album"] = 4.0 config["match"]["distance_weights"]["medium"] = 2.0 diff --git a/test/conftest.py b/test/conftest.py index eb46b94b0..059526d2f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -5,6 +5,7 @@ import pytest from beets.autotag.distance import Distance from beets.dbcore.query import Query +from beets.test.helper import ConfigMixin from beets.util import cached_classproperty @@ -53,3 +54,9 @@ def pytest_assertrepr_compare(op, left, right): @pytest.fixture(autouse=True) def clear_cached_classproperty(): cached_classproperty.cache.clear() + + +@pytest.fixture(scope="module") +def config(): + """Provide a fresh beets configuration for a module, when requested.""" + return ConfigMixin().config diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index b333800a3..a98a59248 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -8,7 +8,7 @@ from beets.autotag import AlbumMatch from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item -from beets.test.helper import ConfigMixin, PluginMixin +from beets.test.helper import PluginMixin from beetsplug._typing import JSONDict from beetsplug.mbpseudo import ( _STATUS_PSEUDO, @@ -52,14 +52,7 @@ def pseudo_release_info() -> AlbumInfo: ) -@pytest.fixture(scope="module", autouse=True) -def config(): - config = ConfigMixin().config - with pytest.MonkeyPatch.context() as m: - m.setattr("beetsplug.mbpseudo.config", config) - yield config - - +@pytest.mark.usefixtures("config") class TestPseudoAlbumInfo: def test_album_id_always_from_pseudo( self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo diff --git a/test/test_autotag.py b/test/test_autotag.py index 48ae09ccb..119ca15e8 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -19,18 +19,18 @@ import pytest from beets import autotag, config from beets.autotag import AlbumInfo, TrackInfo, correct_list_fields, match from beets.library import Item -from beets.test.helper import BeetsTestCase, ConfigMixin +from beets.test.helper import BeetsTestCase -class TestAssignment(ConfigMixin): +class TestAssignment: A = "one" B = "two" C = "three" @pytest.fixture(autouse=True) - def _setup_config(self): - self.config["match"]["track_length_grace"] = 10 - self.config["match"]["track_length_max"] = 30 + def config(self, config): + config["match"]["track_length_grace"] = 10 + config["match"]["track_length_max"] = 30 @pytest.mark.parametrize( # 'expected' is a tuple of expected (mapping, extra_items, extra_tracks) diff --git a/test/ui/test_field_diff.py b/test/ui/test_field_diff.py index dce7ba161..35f3c6ca7 100644 --- a/test/ui/test_field_diff.py +++ b/test/ui/test_field_diff.py @@ -1,17 +1,12 @@ import pytest from beets.library import Item -from beets.test.helper import ConfigMixin from beets.ui import _field_diff p = pytest.param class TestFieldDiff: - @pytest.fixture(scope="class", autouse=True) - def config(self): - return ConfigMixin().config - @pytest.fixture(autouse=True) def configure_color(self, config, color): config["ui"]["color"] = color From f9c3aae4ed2212232e4ede683d5aa3fb8088eab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 29 Dec 2025 17:05:32 +0000 Subject: [PATCH 170/274] Musicbrainz: fix original release id access for a pseudo releae --- beetsplug/musicbrainz.py | 2 +- test/plugins/test_musicbrainz.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 221afea71..8cab1786b 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -914,7 +914,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): rel["type"] == "transl-tracklisting" and rel["direction"] == "backward" ): - actual_res = self.api.get_release(rel["target"]) + actual_res = self.api.get_release(rel["release"]["id"]) # release is potentially a pseudo release release = self.album_info(res) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 0a3155430..30b9f7d1a 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -204,7 +204,6 @@ class MBAlbumInfoTest(MusicBrainzTestCase): { "type": "remixer", "type-id": "RELATION TYPE ID", - "target": "RECORDING REMIXER ARTIST ID", "direction": "RECORDING RELATION DIRECTION", "artist": { "id": "RECORDING REMIXER ARTIST ID", @@ -820,8 +819,10 @@ class MBLibraryTest(MusicBrainzTestCase): "release-relations": [ { "type": "transl-tracklisting", - "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", "direction": "backward", + "release": { + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01" + }, } ], }, @@ -993,8 +994,10 @@ class MBLibraryTest(MusicBrainzTestCase): "release-relations": [ { "type": "remaster", - "target": "d2a6f856-b553-40a0-ac54-a321e8e2da01", "direction": "backward", + "release": { + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01" + }, } ], } From 9ddddf4c3948d8405b7a9e4da761005d456e3aa9 Mon Sep 17 00:00:00 2001 From: Danny Trunk Date: Tue, 30 Dec 2025 00:19:27 +0100 Subject: [PATCH 171/274] fetchart: Add support for configurable fallback cover art --- beetsplug/fetchart.py | 14 ++++++++++++++ docs/changelog.rst | 1 + docs/plugins/fetchart.rst | 2 ++ test/plugins/test_art.py | 11 ++++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index e6bd05119..9f5ed69fb 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -1101,6 +1101,16 @@ class FileSystem(LocalArtSource): else: remaining.append(fn) + # Fall back to a configured image. + if plugin.fallback: + self._log.debug( + "using fallback art file {}", + util.displayable_path(plugin.fallback), + ) + yield self._candidate( + path=plugin.fallback, match=MetadataMatch.FALLBACK + ) + # Fall back to any image in the folder. if remaining and not plugin.cautious: self._log.debug( @@ -1332,6 +1342,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): "enforce_ratio": False, "cautious": False, "cover_names": ["cover", "front", "art", "album", "folder"], + "fallback": None, "sources": [ "filesystem", "coverart", @@ -1380,6 +1391,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): cover_names = self.config["cover_names"].as_str_seq() self.cover_names = list(map(util.bytestring_path, cover_names)) self.cautious = self.config["cautious"].get(bool) + self.fallback = self.config["fallback"].get( + confuse.Optional(confuse.Filename()) + ) self.store_source = self.config["store_source"].get(bool) self.cover_format = self.config["cover_format"].get( diff --git a/docs/changelog.rst b/docs/changelog.rst index b292863ee..0cfc1af24 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,7 @@ been dropped. New features: +- :doc:`plugins/fetchart`: Added config setting for a fallback cover art image. - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. - :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``. - :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 1d64f4b2e..fd578212a 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -33,6 +33,8 @@ file. The available options are: contain one of the keywords in ``cover_names``. Default: ``no``. - **cover_names**: Prioritize images containing words in this list. Default: ``cover front art album folder``. +- **fallback**: Path to a fallback album art file if no album art was found + otherwise. Default: ``None`` (disabled). - **minwidth**: Only images with a width bigger or equal to ``minwidth`` are considered as valid album art candidates. Default: 0. - **maxwidth**: A maximum image width to downscale fetched images if they are diff --git a/test/plugins/test_art.py b/test/plugins/test_art.py index 285bb70e5..02d23d59b 100644 --- a/test/plugins/test_art.py +++ b/test/plugins/test_art.py @@ -261,7 +261,9 @@ class FSArtTest(UseThePlugin): os.mkdir(syspath(self.dpath)) self.source = fetchart.FileSystem(logger, self.plugin.config) - self.settings = Settings(cautious=False, cover_names=("art",)) + self.settings = Settings( + cautious=False, cover_names=("art",), fallback=None + ) def test_finds_jpg_in_directory(self): _common.touch(os.path.join(self.dpath, b"a.jpg")) @@ -285,6 +287,13 @@ class FSArtTest(UseThePlugin): with pytest.raises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) + def test_configured_fallback_is_used(self): + fallback = os.path.join(self.temp_dir, b"a.jpg") + _common.touch(fallback) + self.settings.fallback = fallback + candidate = next(self.source.get(None, self.settings, [self.dpath])) + assert candidate.path == fallback + def test_empty_dir(self): with pytest.raises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) From 40a212a2c4d660d7f527d30a41bd419b0160e826 Mon Sep 17 00:00:00 2001 From: j0j0 Date: Sun, 16 Nov 2025 08:29:51 +0100 Subject: [PATCH 172/274] lastgenre: Simplify genre fetchers Reduce fetcher methods to 3: last.fm can be asked for for a genre for these combinations of metadata: - albumartist/album - artist/track - artist Passing them in the callers instead of hiding it in the methods also helps readability in _get_genre(). --- beetsplug/lastgenre/__init__.py | 36 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index ea0ab951a..698365078 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -300,24 +300,20 @@ class LastGenrePlugin(plugins.BeetsPlugin): self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre) return genre - def fetch_album_genre(self, obj): - """Return raw album genres from Last.fm for this Item or Album.""" + def fetch_album_genre(self, albumartist, albumtitle): + """Return genres from Last.fm for the album by albumartist.""" return self._last_lookup( - "album", LASTFM.get_album, obj.albumartist, obj.album + "album", LASTFM.get_album, albumartist, albumtitle ) - def fetch_album_artist_genre(self, obj): - """Return raw album artist genres from Last.fm for this Item or Album.""" - return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) + def fetch_artist_genre(self, artist): + """Return genres from Last.fm for the artist.""" + return self._last_lookup("artist", LASTFM.get_artist, artist) - def fetch_artist_genre(self, item): - """Returns raw track artist genres from Last.fm for this Item.""" - return self._last_lookup("artist", LASTFM.get_artist, item.artist) - - def fetch_track_genre(self, obj): - """Returns raw track genres from Last.fm for this Item.""" + def fetch_track_genre(self, trackartist, tracktitle): + """Return genres from Last.fm for the track by artist.""" return self._last_lookup( - "track", LASTFM.get_track, obj.artist, obj.title + "track", LASTFM.get_track, trackartist, tracktitle ) # Main processing: _get_genre() and helpers. @@ -405,14 +401,14 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Run through stages: track, album, artist, # album artist, or most popular track genre. if isinstance(obj, library.Item) and "track" in self.sources: - if new_genres := self.fetch_track_genre(obj): + if new_genres := self.fetch_track_genre(obj.artist, obj.title): if result := _try_resolve_stage( "track", keep_genres, new_genres ): return result if "album" in self.sources: - if new_genres := self.fetch_album_genre(obj): + if new_genres := self.fetch_album_genre(obj.albumartist, obj.album): if result := _try_resolve_stage( "album", keep_genres, new_genres ): @@ -421,10 +417,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): if "artist" in self.sources: new_genres = [] if isinstance(obj, library.Item): - new_genres = self.fetch_artist_genre(obj) + new_genres = self.fetch_artist_genre(obj.artist) stage_label = "artist" elif obj.albumartist != config["va_name"].as_str(): - new_genres = self.fetch_album_artist_genre(obj) + new_genres = self.fetch_artist_genre(obj.albumartist) stage_label = "album artist" else: # For "Various Artists", pick the most popular track genre. @@ -432,9 +428,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): for item in obj.items(): item_genre = None if "track" in self.sources: - item_genre = self.fetch_track_genre(item) + item_genre = self.fetch_track_genre( + item.artist, item.title + ) if not item_genre: - item_genre = self.fetch_artist_genre(item) + item_genre = self.fetch_artist_genre(item.artist) if item_genre: item_genres += item_genre if item_genres: From 355c9cc1b608b1e5ed5956eac0b7e32a4b9bff62 Mon Sep 17 00:00:00 2001 From: j0j0 Date: Sun, 16 Nov 2025 08:40:15 +0100 Subject: [PATCH 173/274] lastgenre: Use multi-valued albumartists field In case the albumartist genre can't be found (often due to variations of artist-combination wording issues, eg "featuring", "+", "&" and so on) use the albumartists list field, fetch a genre for each artist separately and concatenate them. --- beetsplug/lastgenre/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 698365078..ba85c3871 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -422,6 +422,19 @@ class LastGenrePlugin(plugins.BeetsPlugin): elif obj.albumartist != config["va_name"].as_str(): new_genres = self.fetch_artist_genre(obj.albumartist) stage_label = "album artist" + if not new_genres: + self._tunelog( + 'No album artist genre found for "{}", ' + "trying multi-valued field...", + obj.albumartist, + ) + for albumartist in obj.albumartists: + self._tunelog( + 'Fetching artist genre for "{}"', albumartist + ) + new_genres += self.fetch_artist_genre(albumartist) + if new_genres: + stage_label = "multi-valued album artist" else: # For "Various Artists", pick the most popular track genre. item_genres = [] From a046f60c5173bd9c15c1402cab00383f01283cb0 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 19 Nov 2025 07:16:26 +0100 Subject: [PATCH 174/274] lastgenre: Hint mypy to Album.items() instead of obj.items() --- beetsplug/lastgenre/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index ba85c3871..40019f548 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -438,6 +438,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): else: # For "Various Artists", pick the most popular track genre. item_genres = [] + assert isinstance(obj, Album) # Type narrowing for mypy for item in obj.items(): item_genre = None if "track" in self.sources: From d72307a16ff3c08b2949fe99d433c257e7148641 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 20 Nov 2025 06:01:51 +0100 Subject: [PATCH 175/274] lastgenre: Adapt test_get_genre function signatures --- test/plugins/test_lastgenre.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 12ff30f8e..026001e38 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -546,13 +546,13 @@ class LastGenrePluginTest(PluginTestCase): def test_get_genre(config_values, item_genre, mock_genres, expected_result): """Test _get_genre with various configurations.""" - def mock_fetch_track_genre(self, obj=None): + def mock_fetch_track_genre(self, trackartist, tracktitle): return mock_genres["track"] - def mock_fetch_album_genre(self, obj): + def mock_fetch_album_genre(self, albumartist, albumtitle): return mock_genres["album"] - def mock_fetch_artist_genre(self, obj): + def mock_fetch_artist_genre(self, artist): return mock_genres["artist"] # Mock the last.fm fetchers. When whitelist enabled, we can assume only From f19d672016dbfae8b9296f4a07d2c98db7771ce3 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 25 Dec 2025 10:36:20 +0100 Subject: [PATCH 176/274] lastgenre: Type hints for genre fetch methods --- beetsplug/lastgenre/__init__.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 40019f548..3d4d5b6b0 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -28,7 +28,7 @@ import os import traceback from functools import singledispatchmethod from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable import pylast import yaml @@ -259,9 +259,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): valid_tags = [t for t in tags if self._is_valid(t)] return valid_tags[:count] - def fetch_genre(self, lastfm_obj): - """Return the genre for a pylast entity or None if no suitable genre - can be found. Ex. 'Electronic, House, Dance' + def fetch_genre( + self, lastfm_obj: pylast.Album | pylast.Artist | pylast.Track + ) -> list[str]: + """Return genres for a pylast entity. Returns an empty list if + no suitable genres are found. """ min_weight = self.config["min_weight"].get(int) return self._tags_for(lastfm_obj, min_weight) @@ -278,8 +280,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Cached last.fm entity lookups. - def _last_lookup(self, entity, method, *args): - """Get a genre based on the named entity using the callable `method` + def _last_lookup( + self, entity: str, method: Callable[..., Any], *args: str + ) -> list[str]: + """Get genres based on the named entity using the callable `method` whose arguments are given in the sequence `args`. The genre lookup is cached based on the entity name and the arguments. @@ -293,24 +297,24 @@ class LastGenrePlugin(plugins.BeetsPlugin): key = f"{entity}.{'-'.join(str(a) for a in args)}" if key not in self._genre_cache: - args = [a.replace("\u2010", "-") for a in args] - self._genre_cache[key] = self.fetch_genre(method(*args)) + args_replaced = [a.replace("\u2010", "-") for a in args] + self._genre_cache[key] = self.fetch_genre(method(*args_replaced)) genre = self._genre_cache[key] self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre) return genre - def fetch_album_genre(self, albumartist, albumtitle): + def fetch_album_genre(self, albumartist: str, albumtitle: str) -> list[str]: """Return genres from Last.fm for the album by albumartist.""" return self._last_lookup( "album", LASTFM.get_album, albumartist, albumtitle ) - def fetch_artist_genre(self, artist): + def fetch_artist_genre(self, artist: str) -> list[str]: """Return genres from Last.fm for the artist.""" return self._last_lookup("artist", LASTFM.get_artist, artist) - def fetch_track_genre(self, trackartist, tracktitle): + def fetch_track_genre(self, trackartist: str, tracktitle: str) -> list[str]: """Return genres from Last.fm for the track by artist.""" return self._last_lookup( "track", LASTFM.get_track, trackartist, tracktitle From 28dc78be95c3f862bc578a8e2a2dc689264f5bad Mon Sep 17 00:00:00 2001 From: j0j0 Date: Sun, 16 Nov 2025 08:54:12 +0100 Subject: [PATCH 177/274] lastgenre: Changelog for #5981 lastgenre --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0cfc1af24..49402bad7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -73,6 +73,11 @@ Bug fixes: cancelling an edit session during import. :bug:`6104` - :ref:`update-cmd` :doc:`plugins/edit` fix display formatting of field changes to clearly show added and removed flexible fields. +- :doc:`plugins/lastgenre`: Fix the issue where last.fm doesn't return any + result in the artist genre stage because "concatenation" words in the artist + name (like "feat.", "+", or "&") prevent it. Using the albumartists list field + and fetching a genre for each artist separately improves the chance of + receiving valid results in that stage. For plugin developers: From b8c7c87b41f995dddd0aa3206f8196d946e81747 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 25 Dec 2025 10:50:02 +0100 Subject: [PATCH 178/274] lastgenre: Add typehints to remaining methods, to finally reach full type hint coverage in the plugin! --- beetsplug/lastgenre/__init__.py | 51 ++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 3d4d5b6b0..e622096cf 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -38,6 +38,8 @@ from beets.library import Album, Item from beets.util import plurality, unique_list if TYPE_CHECKING: + import optparse + from beets.library import LibModel LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) @@ -52,7 +54,11 @@ PYLAST_EXCEPTIONS = ( # Canonicalization tree processing. -def flatten_tree(elem, path, branches): +def flatten_tree( + elem: dict[Any, Any] | list[Any] | str, + path: list[str], + branches: list[list[str]], +) -> None: """Flatten nested lists/dictionaries into lists of strings (branches). """ @@ -69,7 +75,7 @@ def flatten_tree(elem, path, branches): branches.append(path + [str(elem)]) -def find_parents(candidate, branches): +def find_parents(candidate: str, branches: list[list[str]]) -> list[str]: """Find parents genre of a given genre, ordered from the closest to the further parent. """ @@ -89,7 +95,7 @@ C14N_TREE = os.path.join(os.path.dirname(__file__), "genres-tree.yaml") class LastGenrePlugin(plugins.BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( @@ -111,12 +117,12 @@ class LastGenrePlugin(plugins.BeetsPlugin): ) self.setup() - def setup(self): + def setup(self) -> None: """Setup plugin from config options""" if self.config["auto"]: self.import_stages = [self.imported] - self._genre_cache = {} + self._genre_cache: dict[str, list[str]] = {} self.whitelist = self._load_whitelist() self.c14n_branches, self.canonicalize = self._load_c14n_tree() @@ -161,7 +167,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize - def _tunelog(self, msg, *args, **kwargs): + def _tunelog(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log tuning messages at DEBUG level when verbosity level is high enough.""" if config["verbose"].as_number() >= 3: self._log.debug(msg, *args, **kwargs) @@ -182,7 +188,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # More canonicalization and general helpers. - def _get_depth(self, tag): + def _get_depth(self, tag: str) -> int | None: """Find the depth of a tag in the genres tree.""" depth = None for key, value in enumerate(self.c14n_branches): @@ -191,7 +197,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): break return depth - def _sort_by_depth(self, tags): + def _sort_by_depth(self, tags: list[str]) -> list[str]: """Given a list of tags, sort the tags by their depths in the genre tree. """ @@ -372,7 +378,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): and the whitelist feature was disabled. """ - def _try_resolve_stage(stage_label: str, keep_genres, new_genres): + def _try_resolve_stage( + stage_label: str, keep_genres: list[str], new_genres: list[str] + ) -> tuple[str, str] | None: """Try to resolve genres for a given stage and log the result.""" resolved_genres = self._combine_resolve_and_log( keep_genres, new_genres @@ -516,7 +524,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): write=write, move=False, inherit="track" not in self.sources ) - def commands(self): + def commands(self) -> list[ui.Subcommand]: lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd.parser.add_option( "-p", @@ -575,7 +583,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): ) lastgenre_cmd.parser.set_defaults(album=True) - def lastgenre_func(lib, opts, args): + def lastgenre_func( + lib: library.Library, opts: optparse.Values, args: list[str] + ) -> None: self.config.set_args(opts) method = lib.albums if opts.album else lib.items @@ -585,10 +595,16 @@ class LastGenrePlugin(plugins.BeetsPlugin): lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] - def imported(self, session, task): + def imported( + self, session: library.Session, task: library.ImportTask + ) -> None: self._process(task.album if task.is_album else task.item, write=False) - def _tags_for(self, obj, min_weight=None): + def _tags_for( + self, + obj: pylast.Album | pylast.Artist | pylast.Track, + min_weight: int | None = None, + ) -> list[str]: """Core genre identification routine. Given a pylast entity (album or track), return a list of @@ -600,11 +616,12 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Work around an inconsistency in pylast where # Album.get_top_tags() does not return TopItem instances. # https://github.com/pylast/pylast/issues/86 + obj_to_query: Any = obj if isinstance(obj, pylast.Album): - obj = super(pylast.Album, obj) + obj_to_query = super(pylast.Album, obj) try: - res = obj.get_top_tags() + res: Any = obj_to_query.get_top_tags() except PYLAST_EXCEPTIONS as exc: self._log.debug("last.fm error: {}", exc) return [] @@ -619,6 +636,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): res = [el for el in res if (int(el.weight or 0)) >= min_weight] # Get strings from tags. - res = [el.item.get_name().lower() for el in res] + tags: list[str] = [el.item.get_name().lower() for el in res] - return res + return tags From c1e36e52a865940ad9319d7716c35c07260bf496 Mon Sep 17 00:00:00 2001 From: Alexandre Detiste Date: Thu, 1 Jan 2026 01:49:17 +0100 Subject: [PATCH 179/274] drop extraneous dependency on old external "mock" --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bc694de90..e7eebd3a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,6 @@ beautifulsoup4 = "*" codecov = ">=2.1.13" flask = "*" langdetect = "*" -mock = "*" pylast = "*" pytest = "*" pytest-cov = "*" @@ -125,7 +124,6 @@ sphinx-lint = ">=1.0.0" mypy = "*" types-beautifulsoup4 = "*" types-docutils = ">=0.22.2.20251006" -types-mock = "*" types-Flask-Cors = "*" types-Pillow = "*" types-PyYAML = "*" From d6da6cda7ebcfb8b999bf716c92bf0b5f41ad80c Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Thu, 1 Jan 2026 15:46:06 +0000 Subject: [PATCH 180/274] Update poetry.lock after removing mock --- poetry.lock | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8e489b4ed..dbd3ecf3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1731,22 +1731,6 @@ mutagen = ">=1.46" [package.extras] test = ["tox"] -[[package]] -name = "mock" -version = "5.2.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, - {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - [[package]] name = "msgpack" version = "1.1.2" @@ -4063,17 +4047,6 @@ files = [ {file = "types_html5lib-1.1.11.20251014.tar.gz", hash = "sha256:cc628d626e0111a2426a64f5f061ecfd113958b69ff6b3dc0eaaed2347ba9455"}, ] -[[package]] -name = "types-mock" -version = "5.2.0.20250924" -description = "Typing stubs for mock" -optional = false -python-versions = ">=3.9" -files = [ - {file = "types_mock-5.2.0.20250924-py3-none-any.whl", hash = "sha256:23617ffb4cf948c085db69ec90bd474afbce634ef74995045ae0a5748afbe57d"}, - {file = "types_mock-5.2.0.20250924.tar.gz", hash = "sha256:953197543b4183f00363e8e626f6c7abea1a3f7a4dd69d199addb70b01b6bb35"}, -] - [[package]] name = "types-pillow" version = "10.2.0.20240822" @@ -4226,4 +4199,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "8cf2ad0e6a842511e1215720a63bfdf9d5f49345410644cbb0b5fd8fb74f50d2" +content-hash = "45c7dc4ec30f4460a09554d0ec0ebcafebff097386e005e29e12830d16d223dd" From afc26fa58f15a18f1d672eb1b2432027d7b6ad35 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Thu, 1 Jan 2026 15:50:37 +0000 Subject: [PATCH 181/274] Add packaging note about mock dependency removal --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 49402bad7..d3d9f3f6a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -88,6 +88,7 @@ For plugin developers: For packagers: - The minimum supported Python version is now 3.10. +- An unused dependency on ``mock`` has been removed. Other changes: From b14755df881b46a5fcce1ada4d5d895c5e54a331 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Thu, 1 Jan 2026 15:39:17 -0600 Subject: [PATCH 182/274] fix(ftintitle): remaining opportunities for improvement --- beetsplug/ftintitle.py | 56 ++++++++++---------------- pyproject.toml | 1 + test/plugins/test_ftintitle.py | 72 +++++++++++++++++----------------- 3 files changed, 58 insertions(+), 71 deletions(-) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 44f17bc4e..cf30e83f4 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -146,32 +146,19 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have keywords, require one of them to appear in the bracket text. # If kw == "", the lookahead becomes true and we match any bracket content. - kw = rf"\b(?:{kw_inner})\b" if kw_inner else "" - + kw = rf"\b(?={kw_inner})\b" if kw_inner else "" return re.compile( rf""" - (?: # Match ONE bracketed segment of any supported type - \( # "(" - (?=[^)]*{kw}) # Lookahead: keyword must appear before closing ")" - # - if kw == "", this is always true - [^)]* # Consume bracket content (no nested ")" handling) - \) # ")" - - | \[ # "[" - (?=[^\]]*{kw}) # Lookahead - [^\]]* # Consume content up to first "]" - \] # "]" - - | < # "<" - (?=[^>]*{kw}) # Lookahead - [^>]* # Consume content up to first ">" - > # ">" - - | \x7B # Literal open brace - (?=[^\x7D]*{kw}) # Lookahead - [^\x7D]* # Consume content up to first close brace - \x7D # Literal close brace - ) # End bracketed segment alternation + (?: # non-capturing group for the split + \s*? # optional whitespace before brackets + (?= # any bracket containing a keyword + \([^)]*{kw}.*?\) + | \[[^]]*{kw}.*?\] + | <[^>]*{kw}.*? > + | \{{[^}}]*{kw}.*?\}} + | $ # or the end of the string + ) + ) """, re.IGNORECASE | re.VERBOSE, ) @@ -290,7 +277,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() formatted = feat_format.format(feat_part) - new_title = FtInTitlePlugin.insert_ft_into_title( + new_title = self.insert_ft_into_title( item.title, formatted, self.bracket_keywords ) self._log.info("title: {.title} -> {}", item, new_title) @@ -349,19 +336,16 @@ class FtInTitlePlugin(plugins.BeetsPlugin): m: re.Match[str] | None = pattern.search(title) return m.start() if m else None - @staticmethod + @classmethod def insert_ft_into_title( - title: str, feat_part: str, keywords: list[str] | None = None + cls, title: str, feat_part: str, keywords: list[str] | None = None ) -> str: """Insert featured artist before the first bracket containing remix/edit keywords if present. """ - if ( - bracket_pos := FtInTitlePlugin.find_bracket_position( - title, keywords - ) - ) is not None: - title_before = title[:bracket_pos].rstrip() - title_after = title[bracket_pos:] - return f"{title_before} {feat_part} {title_after}" - return f"{title} {feat_part}" + normalized = ( + DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) + ) + pattern = cls._bracket_position_pattern(normalized) + parts = pattern.split(title, maxsplit=1) + return f" {feat_part} ".join(parts).strip() diff --git a/pyproject.toml b/pyproject.toml index 24cf21b33..06552f124 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -322,6 +322,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "beets/**" = ["PT"] "test/test_util.py" = ["E501"] +"test/plugins/test_ftintitle.py" = ["E501"] [tool.ruff.lint.isort] split-on-trailing-comma = false diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index abba22d11..b21ac1c7f 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -335,55 +335,57 @@ def test_split_on_feat( [ ## default keywords # different braces and keywords - ("Song (Remix)", None, 5), - ("Song [Version]", None, 5), - ("Song {Extended Mix}", None, 5), - ("Song ", None, 5), + ("Song (Remix)", None, "Song ft. Bob (Remix)"), + ("Song [Version]", None, "Song ft. Bob [Version]"), + ("Song {Extended Mix}", None, "Song ft. Bob {Extended Mix}"), + ("Song ", None, "Song ft. Bob "), # two keyword clauses - ("Song (Remix) (Live)", None, 5), + ("Song (Remix) (Live)", None, "Song ft. Bob (Remix) (Live)"), # brace insensitivity - ("Song (Live) [Remix]", None, 5), - ("Song [Edit] (Remastered)", None, 5), + ("Song (Live) [Remix]", None, "Song ft. Bob (Live) [Remix]"), + ("Song [Edit] (Remastered)", None, "Song ft. Bob [Edit] (Remastered)"), # negative cases - ("Song", None, None), # no clause - ("Song (Arbitrary)", None, None), # no keyword - ("Song (", None, None), # no matching brace or keyword - ("Song (Live", None, None), # no matching brace with keyword + ("Song", None, "Song ft. Bob"), # no clause + ("Song (Arbitrary)", None, "Song (Arbitrary) ft. Bob"), # no keyword + ("Song (", None, "Song ( ft. Bob"), # no matching brace or keyword + ("Song (Live", None, "Song (Live ft. Bob"), # no matching brace with keyword # one keyword clause, one non-keyword clause - ("Song (Live) (Arbitrary)", None, 5), - ("Song (Arbitrary) (Remix)", None, 17), + ("Song (Live) (Arbitrary)", None, "Song ft. Bob (Live) (Arbitrary)"), + ("Song (Arbitrary) (Remix)", None, "Song (Arbitrary) ft. Bob (Remix)"), # nested brackets - same type - ("Song (Remix (Extended))", None, 5), - ("Song [Arbitrary [Description]]", None, None), + ("Song (Remix (Extended))", None, "Song ft. Bob (Remix (Extended))"), + ("Song [Arbitrary [Description]]", None, "Song [Arbitrary [Description]] ft. Bob"), # nested brackets - different types - ("Song (Remix [Extended])", None, 5), + ("Song (Remix [Extended])", None, "Song ft. Bob (Remix [Extended])"), # nested - returns outer start position despite inner keyword - ("Song [Arbitrary {Extended}]", None, 5), - ("Song {Live }", None, 5), - ("Song ", None, 5), - ("Song [Live]", None, 5), - ("Song (Version) ", None, 5), - ("Song (Arbitrary [Description])", None, None), - ("Song [Description (Arbitrary)]", None, None), + ("Song [Arbitrary {Extended}]", None, "Song ft. Bob [Arbitrary {Extended}]"), + ("Song {Live }", None, "Song ft. Bob {Live }"), + ("Song ", None, "Song ft. Bob "), + ("Song [Live]", None, "Song ft. Bob [Live]"), + ("Song (Version) ", None, "Song ft. Bob (Version) "), + ("Song (Arbitrary [Description])", None, "Song (Arbitrary [Description]) ft. Bob"), + ("Song [Description (Arbitrary)]", None, "Song [Description (Arbitrary)] ft. Bob"), ## custom keywords - ("Song (Live)", ["live"], 5), - ("Song (Concert)", ["concert"], 5), - ("Song (Remix)", ["custom"], None), - ("Song (Custom)", ["custom"], 5), - ("Song", [], None), - ("Song (", [], None), + ("Song (Live)", ["live"], "Song ft. Bob (Live)"), + ("Song (Concert)", ["concert"], "Song ft. Bob (Concert)"), + ("Song (Remix)", ["custom"], "Song (Remix) ft. Bob"), + ("Song (Custom)", ["custom"], "Song ft. Bob (Custom)"), + ("Song", [], "Song ft. Bob"), + ("Song (", [], "Song ( ft. Bob"), # Multi-word keyword tests - ("Song (Club Mix)", ["club mix"], 5), # Positive: matches multi-word - ("Song (Club Remix)", ["club mix"], None), # Negative: no match + ("Song (Club Mix)", ["club mix"], "Song ft. Bob (Club Mix)"), # Positive: matches multi-word + ("Song (Club Remix)", ["club mix"], "Song (Club Remix) ft. Bob"), # Negative: no match ], -) -def test_find_bracket_position( +) # fmt: skip +def test_insert_ft_into_title( given: str, keywords: list[str] | None, - expected: int | None, + expected: str, ) -> None: assert ( - ftintitle.FtInTitlePlugin.find_bracket_position(given, keywords) + ftintitle.FtInTitlePlugin.insert_ft_into_title( + given, "ft. Bob", keywords + ) == expected ) From 523fa6ceaf1b0bc67d097d9fc88d6de058b1f4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 04:04:02 +0000 Subject: [PATCH 183/274] Move MusicBrainzAPI to a shared util --- beetsplug/_utils/musicbrainz.py | 122 +++++++++++++++++++++++ beetsplug/mbpseudo.py | 2 +- beetsplug/musicbrainz.py | 130 +++---------------------- test/plugins/test_mbpseudo.py | 2 +- test/plugins/test_musicbrainz.py | 95 ++---------------- test/plugins/utils/test_musicbrainz.py | 82 ++++++++++++++++ 6 files changed, 229 insertions(+), 204 deletions(-) create mode 100644 beetsplug/_utils/musicbrainz.py create mode 100644 test/plugins/utils/test_musicbrainz.py diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py new file mode 100644 index 000000000..3327269b2 --- /dev/null +++ b/beetsplug/_utils/musicbrainz.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import operator +from dataclasses import dataclass, field +from functools import cached_property, singledispatchmethod +from itertools import groupby +from typing import TYPE_CHECKING, Any + +from requests_ratelimiter import LimiterMixin + +from beets import config + +from .requests import RequestHandler, TimeoutAndRetrySession + +if TYPE_CHECKING: + from .._typing import JSONDict + + +class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): + pass + + +@dataclass +class MusicBrainzAPI(RequestHandler): + api_host: str = field(init=False) + rate_limit: float = field(init=False) + + def __post_init__(self) -> None: + mb_config = config["musicbrainz"] + mb_config.add( + { + "host": "musicbrainz.org", + "https": False, + "ratelimit": 1, + "ratelimit_interval": 1, + } + ) + + hostname = mb_config["host"].as_str() + if hostname == "musicbrainz.org": + self.api_host, self.rate_limit = "https://musicbrainz.org", 1.0 + else: + https = mb_config["https"].get(bool) + self.api_host = f"http{'s' if https else ''}://{hostname}" + self.rate_limit = ( + mb_config["ratelimit"].get(int) + / mb_config["ratelimit_interval"].as_number() + ) + + def create_session(self) -> LimiterTimeoutSession: + return LimiterTimeoutSession(per_second=self.rate_limit) + + def get_entity( + self, entity: str, includes: list[str] | None = None, **kwargs + ) -> JSONDict: + if includes: + kwargs["inc"] = "+".join(includes) + + return self._group_relations( + self.get_json( + f"{self.api_host}/ws/2/{entity}", + params={**kwargs, "fmt": "json"}, + ) + ) + + def get_release(self, id_: str, **kwargs) -> JSONDict: + return self.get_entity(f"release/{id_}", **kwargs) + + def get_recording(self, id_: str, **kwargs) -> JSONDict: + return self.get_entity(f"recording/{id_}", **kwargs) + + def browse_recordings(self, **kwargs) -> list[JSONDict]: + return self.get_entity("recording", **kwargs)["recordings"] + + @singledispatchmethod + @classmethod + def _group_relations(cls, data: Any) -> Any: + """Normalize MusicBrainz 'relations' into type-keyed fields recursively. + + This helper rewrites payloads that use a generic 'relations' list into + a structure that is easier to consume downstream. When a mapping + contains 'relations', those entries are regrouped by their 'target-type' + and stored under keys like '-relations'. The original + 'relations' key is removed to avoid ambiguous access patterns. + + The transformation is applied recursively so that nested objects and + sequences are normalized consistently, while non-container values are + left unchanged. + """ + return data + + @_group_relations.register(list) + @classmethod + def _(cls, data: list[Any]) -> list[Any]: + return [cls._group_relations(i) for i in data] + + @_group_relations.register(dict) + @classmethod + def _(cls, data: JSONDict) -> JSONDict: + for k, v in list(data.items()): + if k == "relations": + get_target_type = operator.methodcaller("get", "target-type") + for target_type, group in groupby( + sorted(v, key=get_target_type), get_target_type + ): + relations = [ + {k: v for k, v in item.items() if k != "target-type"} + for item in group + ] + data[f"{target_type}-relations"] = cls._group_relations( + relations + ) + data.pop("relations") + else: + data[k] = cls._group_relations(v) + return data + + +class MusicBrainzAPIMixin: + @cached_property + def mb_api(self) -> MusicBrainzAPI: + return MusicBrainzAPI() diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index b61af2cc7..30ef2e428 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -141,7 +141,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): if (ids := self._intercept_mb_release(release)) and ( album_id := self._extract_id(ids[0]) ): - raw_pseudo_release = self.api.get_release(album_id) + raw_pseudo_release = self.mb_api.get_release(album_id) pseudo_release = super().album_info(raw_pseudo_release) if self.config["custom_tags_only"].get(bool): diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8cab1786b..38097b2ce 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -16,17 +16,14 @@ from __future__ import annotations -import operator from collections import Counter from contextlib import suppress -from dataclasses import dataclass -from functools import cached_property, singledispatchmethod -from itertools import groupby, product +from functools import cached_property +from itertools import product from typing import TYPE_CHECKING, Any from urllib.parse import urljoin from confuse.exceptions import NotFoundError -from requests_ratelimiter import LimiterMixin import beets import beets.autotag.hooks @@ -35,11 +32,8 @@ from beets.metadata_plugins import MetadataSourcePlugin from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id -from ._utils.requests import ( - HTTPNotFoundError, - RequestHandler, - TimeoutAndRetrySession, -) +from ._utils.musicbrainz import MusicBrainzAPIMixin +from ._utils.requests import HTTPNotFoundError if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -103,86 +97,6 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 -class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): - pass - - -@dataclass -class MusicBrainzAPI(RequestHandler): - api_host: str - rate_limit: float - - def create_session(self) -> LimiterTimeoutSession: - return LimiterTimeoutSession(per_second=self.rate_limit) - - def get_entity( - self, entity: str, inc_list: list[str] | None = None, **kwargs - ) -> JSONDict: - if inc_list: - kwargs["inc"] = "+".join(inc_list) - - return self._group_relations( - self.get_json( - f"{self.api_host}/ws/2/{entity}", - params={**kwargs, "fmt": "json"}, - ) - ) - - def get_release(self, id_: str) -> JSONDict: - return self.get_entity(f"release/{id_}", inc_list=RELEASE_INCLUDES) - - def get_recording(self, id_: str) -> JSONDict: - return self.get_entity(f"recording/{id_}", inc_list=TRACK_INCLUDES) - - def browse_recordings(self, **kwargs) -> list[JSONDict]: - kwargs.setdefault("limit", BROWSE_CHUNKSIZE) - kwargs.setdefault("inc_list", BROWSE_INCLUDES) - return self.get_entity("recording", **kwargs)["recordings"] - - @singledispatchmethod - @classmethod - def _group_relations(cls, data: Any) -> Any: - """Normalize MusicBrainz 'relations' into type-keyed fields recursively. - - This helper rewrites payloads that use a generic 'relations' list into - a structure that is easier to consume downstream. When a mapping - contains 'relations', those entries are regrouped by their 'target-type' - and stored under keys like '-relations'. The original - 'relations' key is removed to avoid ambiguous access patterns. - - The transformation is applied recursively so that nested objects and - sequences are normalized consistently, while non-container values are - left unchanged. - """ - return data - - @_group_relations.register(list) - @classmethod - def _(cls, data: list[Any]) -> list[Any]: - return [cls._group_relations(i) for i in data] - - @_group_relations.register(dict) - @classmethod - def _(cls, data: JSONDict) -> JSONDict: - for k, v in list(data.items()): - if k == "relations": - get_target_type = operator.methodcaller("get", "target-type") - for target_type, group in groupby( - sorted(v, key=get_target_type), get_target_type - ): - relations = [ - {k: v for k, v in item.items() if k != "target-type"} - for item in group - ] - data[f"{target_type}-relations"] = cls._group_relations( - relations - ) - data.pop("relations") - else: - data[k] = cls._group_relations(v) - return data - - def _preferred_alias( aliases: list[JSONDict], languages: list[str] | None = None ) -> JSONDict | None: @@ -405,25 +319,11 @@ def _merge_pseudo_and_actual_album( return merged -class MusicBrainzPlugin(MetadataSourcePlugin): +class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): @cached_property def genres_field(self) -> str: return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}s" - @cached_property - def api(self) -> MusicBrainzAPI: - hostname = self.config["host"].as_str() - if hostname == "musicbrainz.org": - hostname, rate_limit = "https://musicbrainz.org", 1.0 - else: - https = self.config["https"].get(bool) - hostname = f"http{'s' if https else ''}://{hostname}" - rate_limit = ( - self.config["ratelimit"].get(int) - / self.config["ratelimit_interval"].as_number() - ) - return MusicBrainzAPI(hostname, rate_limit) - def __init__(self): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. @@ -431,10 +331,6 @@ class MusicBrainzPlugin(MetadataSourcePlugin): super().__init__() self.config.add( { - "host": "musicbrainz.org", - "https": False, - "ratelimit": 1, - "ratelimit_interval": 1, "genres": False, "genres_tag": "genre", "external_ids": { @@ -589,7 +485,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): for i in range(0, ntracks, BROWSE_CHUNKSIZE): self._log.debug("Retrieving tracks starting at {}", i) recording_list.extend( - self.api.browse_recordings(release=release["id"], offset=i) + self.mb_api.browse_recordings( + release=release["id"], offset=i + ) ) track_map = {r["id"]: r for r in recording_list} for medium in release["media"]: @@ -861,7 +759,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug( "Searching for MusicBrainz {}s with: {!r}", query_type, query ) - return self.api.get_entity( + return self.mb_api.get_entity( query_type, query=query, limit=self.config["search_limit"].get() )[f"{query_type}s"] @@ -901,7 +799,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug("Invalid MBID ({}).", album_id) return None - res = self.api.get_release(albumid) + res = self.mb_api.get_release(albumid, includes=RELEASE_INCLUDES) # resolve linked release relations actual_res = None @@ -914,7 +812,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): rel["type"] == "transl-tracklisting" and rel["direction"] == "backward" ): - actual_res = self.api.get_release(rel["release"]["id"]) + actual_res = self.mb_api.get_release( + rel["release"]["id"], includes=RELEASE_INCLUDES + ) # release is potentially a pseudo release release = self.album_info(res) @@ -937,6 +837,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin): return None with suppress(HTTPNotFoundError): - return self.track_info(self.api.get_recording(trackid)) + return self.track_info( + self.mb_api.get_recording(trackid, includes=TRACK_INCLUDES) + ) return None diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index a98a59248..6b382ab16 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -94,7 +94,7 @@ class TestMBPseudoMixin(PluginMixin): @pytest.fixture(autouse=True) def patch_get_release(self, monkeypatch, pseudo_release: JSONDict): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release", + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", lambda _, album_id: deepcopy( {pseudo_release["id"]: pseudo_release}[album_id] ), diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 30b9f7d1a..199b62ab6 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -863,7 +863,7 @@ class MBLibraryTest(MusicBrainzTestCase): ] with mock.patch( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") @@ -907,7 +907,7 @@ class MBLibraryTest(MusicBrainzTestCase): ] with mock.patch( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") @@ -951,7 +951,7 @@ class MBLibraryTest(MusicBrainzTestCase): ] with mock.patch( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") @@ -1004,7 +1004,7 @@ class MBLibraryTest(MusicBrainzTestCase): ] with mock.patch( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release" + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") @@ -1055,7 +1055,7 @@ class TestMusicBrainzPlugin(PluginMixin): def test_item_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI.get_json", + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_json", lambda *_, **__: {"recordings": [self.RECORDING]}, ) @@ -1066,11 +1066,11 @@ class TestMusicBrainzPlugin(PluginMixin): def test_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI.get_json", + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_json", lambda *_, **__: {"releases": [{"id": self.mbid}]}, ) monkeypatch.setattr( - "beetsplug.musicbrainz.MusicBrainzAPI.get_release", + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", lambda *_, **__: { "title": "hi", "id": self.mbid, @@ -1099,84 +1099,3 @@ class TestMusicBrainzPlugin(PluginMixin): assert len(candidates) == 1 assert candidates[0].tracks[0].track_id == self.RECORDING["id"] assert candidates[0].album == "hi" - - -def test_group_relations(): - raw_release = { - "id": "r1", - "relations": [ - {"target-type": "artist", "type": "vocal", "name": "A"}, - {"target-type": "url", "type": "streaming", "url": "http://s"}, - {"target-type": "url", "type": "purchase", "url": "http://p"}, - { - "target-type": "work", - "type": "performance", - "work": { - "relations": [ - { - "artist": {"name": "幾田りら"}, - "target-type": "artist", - "type": "composer", - }, - { - "target-type": "url", - "type": "lyrics", - "url": { - "resource": "https://utaten.com/lyric/tt24121002/" - }, - }, - { - "artist": {"name": "幾田りら"}, - "target-type": "artist", - "type": "lyricist", - }, - { - "target-type": "url", - "type": "lyrics", - "url": { - "resource": "https://www.uta-net.com/song/366579/" - }, - }, - ], - "title": "百花繚乱", - "type": "Song", - }, - }, - ], - } - - assert musicbrainz.MusicBrainzAPI._group_relations(raw_release) == { - "id": "r1", - "artist-relations": [{"type": "vocal", "name": "A"}], - "url-relations": [ - {"type": "streaming", "url": "http://s"}, - {"type": "purchase", "url": "http://p"}, - ], - "work-relations": [ - { - "type": "performance", - "work": { - "artist-relations": [ - {"type": "composer", "artist": {"name": "幾田りら"}}, - {"type": "lyricist", "artist": {"name": "幾田りら"}}, - ], - "url-relations": [ - { - "type": "lyrics", - "url": { - "resource": "https://utaten.com/lyric/tt24121002/" - }, - }, - { - "type": "lyrics", - "url": { - "resource": "https://www.uta-net.com/song/366579/" - }, - }, - ], - "title": "百花繚乱", - "type": "Song", - }, - }, - ], - } diff --git a/test/plugins/utils/test_musicbrainz.py b/test/plugins/utils/test_musicbrainz.py new file mode 100644 index 000000000..291f50eb5 --- /dev/null +++ b/test/plugins/utils/test_musicbrainz.py @@ -0,0 +1,82 @@ +from beetsplug._utils.musicbrainz import MusicBrainzAPI + + +def test_group_relations(): + raw_release = { + "id": "r1", + "relations": [ + {"target-type": "artist", "type": "vocal", "name": "A"}, + {"target-type": "url", "type": "streaming", "url": "http://s"}, + {"target-type": "url", "type": "purchase", "url": "http://p"}, + { + "target-type": "work", + "type": "performance", + "work": { + "relations": [ + { + "artist": {"name": "幾田りら"}, + "target-type": "artist", + "type": "composer", + }, + { + "target-type": "url", + "type": "lyrics", + "url": { + "resource": "https://utaten.com/lyric/tt24121002/" + }, + }, + { + "artist": {"name": "幾田りら"}, + "target-type": "artist", + "type": "lyricist", + }, + { + "target-type": "url", + "type": "lyrics", + "url": { + "resource": "https://www.uta-net.com/song/366579/" + }, + }, + ], + "title": "百花繚乱", + "type": "Song", + }, + }, + ], + } + + assert MusicBrainzAPI._group_relations(raw_release) == { + "id": "r1", + "artist-relations": [{"type": "vocal", "name": "A"}], + "url-relations": [ + {"type": "streaming", "url": "http://s"}, + {"type": "purchase", "url": "http://p"}, + ], + "work-relations": [ + { + "type": "performance", + "work": { + "artist-relations": [ + {"type": "composer", "artist": {"name": "幾田りら"}}, + {"type": "lyricist", "artist": {"name": "幾田りら"}}, + ], + "url-relations": [ + { + "type": "lyrics", + "url": { + "resource": "https://utaten.com/lyric/tt24121002/" + }, + }, + { + "type": "lyrics", + "url": { + "resource": "https://www.uta-net.com/song/366579/" + }, + }, + ], + "title": "百花繚乱", + "type": "Song", + }, + }, + ], + } From af96c3244e101321cead4b5a61c20a58aecb4690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 05:37:53 +0000 Subject: [PATCH 184/274] Add a minimal test for listenbrainz --- beetsplug/listenbrainz.py | 4 ++- test/plugins/test_listenbrainz.py | 55 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 test/plugins/test_listenbrainz.py diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index 2aa4e7ad6..3729001b1 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -5,10 +5,12 @@ import datetime import musicbrainzngs import requests -from beets import config, ui +from beets import __version__, config, ui from beets.plugins import BeetsPlugin from beetsplug.lastimport import process_tracks +musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") + class ListenBrainzPlugin(BeetsPlugin): """A Beets plugin for interacting with ListenBrainz.""" diff --git a/test/plugins/test_listenbrainz.py b/test/plugins/test_listenbrainz.py new file mode 100644 index 000000000..fa6c4fbab --- /dev/null +++ b/test/plugins/test_listenbrainz.py @@ -0,0 +1,55 @@ +import pytest + +from beets.test.helper import ConfigMixin +from beetsplug.listenbrainz import ListenBrainzPlugin + + +class TestListenBrainzPlugin(ConfigMixin): + @pytest.fixture(scope="class") + def plugin(self): + self.config["listenbrainz"]["token"] = "test_token" + self.config["listenbrainz"]["username"] = "test_user" + return ListenBrainzPlugin() + + @pytest.mark.parametrize( + "search_response, expected_id", + [ + ( + {"recording-count": "1", "recording-list": [{"id": "id1"}]}, + "id1", + ), + ({"recording-count": "0"}, None), + ], + ids=["found", "not_found"], + ) + def test_get_mb_recording_id( + self, monkeypatch, plugin, search_response, expected_id + ): + monkeypatch.setattr( + "musicbrainzngs.search_recordings", lambda *_, **__: search_response + ) + track = {"track_metadata": {"track_name": "S", "release_name": "A"}} + + assert plugin.get_mb_recording_id(track) == expected_id + + def test_get_track_info(self, monkeypatch, plugin): + monkeypatch.setattr( + "musicbrainzngs.get_recording_by_id", + lambda *_, **__: { + "recording": { + "title": "T", + "artist-credit": [], + "release-list": [{"title": "Al", "date": "2023-01"}], + } + }, + ) + + assert plugin.get_track_info([{"identifier": "id1"}]) == [ + { + "identifier": "id1", + "title": "T", + "artist": None, + "album": "Al", + "year": "2023", + } + ] From 36964e433ea733f620abaf9071180d56a835d21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 15:32:13 +0000 Subject: [PATCH 185/274] Migrate listenbrainz plugin to use our MusicBrainzAPI implementation --- beetsplug/_utils/musicbrainz.py | 24 ++++++++++++++++++++++- beetsplug/listenbrainz.py | 29 +++++++++++++--------------- beetsplug/musicbrainz.py | 12 ++---------- docs/plugins/listenbrainz.rst | 13 +++++++------ poetry.lock | 3 +-- pyproject.toml | 1 - test/plugins/test_listenbrainz.py | 32 ++++++++++++------------------- 7 files changed, 58 insertions(+), 56 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 3327269b2..63ffd4aa3 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -8,13 +8,15 @@ from typing import TYPE_CHECKING, Any from requests_ratelimiter import LimiterMixin -from beets import config +from beets import config, logging from .requests import RequestHandler, TimeoutAndRetrySession if TYPE_CHECKING: from .._typing import JSONDict +log = logging.getLogger(__name__) + class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): pass @@ -63,6 +65,26 @@ class MusicBrainzAPI(RequestHandler): ) ) + def search_entity( + self, entity: str, filters: dict[str, str], **kwargs + ) -> list[JSONDict]: + """Search for MusicBrainz entities matching the given filters. + + * Query is constructed by combining the provided filters using AND logic + * Each filter key-value pair is formatted as 'key:"value"' unless + - 'key' is empty, in which case only the value is used, '"value"' + - 'value' is empty, in which case the filter is ignored + * Values are lowercased and stripped of whitespace. + """ + query = " AND ".join( + ":".join(filter(None, (k, f'"{_v}"'))) + for k, v in filters.items() + if (_v := v.lower().strip()) + ) + log.debug("Searching for MusicBrainz {}s with: {!r}", entity, query) + kwargs["query"] = query + return self.get_entity(entity, **kwargs)[f"{entity}s"] + def get_release(self, id_: str, **kwargs) -> JSONDict: return self.get_entity(f"release/{id_}", **kwargs) diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index 3729001b1..d054a00cc 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -2,17 +2,16 @@ import datetime -import musicbrainzngs import requests -from beets import __version__, config, ui +from beets import config, ui from beets.plugins import BeetsPlugin from beetsplug.lastimport import process_tracks -musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") +from ._utils.musicbrainz import MusicBrainzAPIMixin -class ListenBrainzPlugin(BeetsPlugin): +class ListenBrainzPlugin(MusicBrainzAPIMixin, BeetsPlugin): """A Beets plugin for interacting with ListenBrainz.""" ROOT = "http://api.listenbrainz.org/1/" @@ -131,17 +130,16 @@ class ListenBrainzPlugin(BeetsPlugin): ) return tracks - def get_mb_recording_id(self, track): + def get_mb_recording_id(self, track) -> str | None: """Returns the MusicBrainz recording ID for a track.""" - resp = musicbrainzngs.search_recordings( - query=track["track_metadata"].get("track_name"), - release=track["track_metadata"].get("release_name"), - strict=True, + results = self.mb_api.search_entity( + "recording", + { + "": track["track_metadata"].get("track_name"), + "release": track["track_metadata"].get("release_name"), + }, ) - if resp.get("recording-count") == "1": - return resp.get("recording-list")[0].get("id") - else: - return None + return next((r["id"] for r in results), None) def get_playlists_createdfor(self, username): """Returns a list of playlists created by a user.""" @@ -209,17 +207,16 @@ class ListenBrainzPlugin(BeetsPlugin): track_info = [] for track in tracks: identifier = track.get("identifier") - resp = musicbrainzngs.get_recording_by_id( + recording = self.mb_api.get_recording( identifier, includes=["releases", "artist-credits"] ) - recording = resp.get("recording") title = recording.get("title") artist_credit = recording.get("artist-credit", []) if artist_credit: artist = artist_credit[0].get("artist", {}).get("name") else: artist = None - releases = recording.get("release-list", []) + releases = recording.get("releases", []) if releases: album = releases[0].get("title") date = releases[0].get("date") diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 38097b2ce..990f21351 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -751,17 +751,9 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): using the provided criteria. Handles API errors by converting them into MusicBrainzAPIError exceptions with contextual information. """ - query = " AND ".join( - f'{k}:"{_v}"' - for k, v in filters.items() - if (_v := v.lower().strip()) + return self.mb_api.search_entity( + query_type, filters, limit=self.config["search_limit"].get() ) - self._log.debug( - "Searching for MusicBrainz {}s with: {!r}", query_type, query - ) - return self.mb_api.get_entity( - query_type, query=query, limit=self.config["search_limit"].get() - )[f"{query_type}s"] def candidates( self, diff --git a/docs/plugins/listenbrainz.rst b/docs/plugins/listenbrainz.rst index 17926e878..ceff0e800 100644 --- a/docs/plugins/listenbrainz.rst +++ b/docs/plugins/listenbrainz.rst @@ -6,15 +6,16 @@ ListenBrainz Plugin The ListenBrainz plugin for beets allows you to interact with the ListenBrainz service. -Installation ------------- +Configuration +------------- -To use the ``listenbrainz`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install ``beets`` with ``listenbrainz`` extra +To enable the ListenBrainz plugin, add the following to your beets configuration +file (config.yaml_): -.. code-block:: bash +.. code-block:: yaml - pip install "beets[listenbrainz]" + plugins: + - listenbrainz You can then configure the plugin by providing your Listenbrainz token (see intructions here_) and username: diff --git a/poetry.lock b/poetry.lock index dbd3ecf3d..60cbceebd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4180,7 +4180,6 @@ import = ["py7zr", "rarfile"] kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] -listenbrainz = ["musicbrainzngs"] lyrics = ["beautifulsoup4", "langdetect", "requests"] mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] @@ -4199,4 +4198,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "45c7dc4ec30f4460a09554d0ec0ebcafebff097386e005e29e12830d16d223dd" +content-hash = "d9141a482e4990a4466a121a59deaeaf46e5613ff0af315f277110935e391e63" diff --git a/pyproject.toml b/pyproject.toml index bd46d3026..ed0059610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,7 +163,6 @@ import = ["py7zr", "rarfile"] kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] -listenbrainz = ["musicbrainzngs"] lyrics = ["beautifulsoup4", "langdetect", "requests"] mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] diff --git a/test/plugins/test_listenbrainz.py b/test/plugins/test_listenbrainz.py index fa6c4fbab..b94cff219 100644 --- a/test/plugins/test_listenbrainz.py +++ b/test/plugins/test_listenbrainz.py @@ -6,41 +6,33 @@ from beetsplug.listenbrainz import ListenBrainzPlugin class TestListenBrainzPlugin(ConfigMixin): @pytest.fixture(scope="class") - def plugin(self): + def plugin(self) -> ListenBrainzPlugin: self.config["listenbrainz"]["token"] = "test_token" self.config["listenbrainz"]["username"] = "test_user" return ListenBrainzPlugin() @pytest.mark.parametrize( "search_response, expected_id", - [ - ( - {"recording-count": "1", "recording-list": [{"id": "id1"}]}, - "id1", - ), - ({"recording-count": "0"}, None), - ], + [([{"id": "id1"}], "id1"), ([], None)], ids=["found", "not_found"], ) def test_get_mb_recording_id( - self, monkeypatch, plugin, search_response, expected_id + self, plugin, requests_mock, search_response, expected_id ): - monkeypatch.setattr( - "musicbrainzngs.search_recordings", lambda *_, **__: search_response + requests_mock.get( + "/ws/2/recording", json={"recordings": search_response} ) track = {"track_metadata": {"track_name": "S", "release_name": "A"}} assert plugin.get_mb_recording_id(track) == expected_id - def test_get_track_info(self, monkeypatch, plugin): - monkeypatch.setattr( - "musicbrainzngs.get_recording_by_id", - lambda *_, **__: { - "recording": { - "title": "T", - "artist-credit": [], - "release-list": [{"title": "Al", "date": "2023-01"}], - } + def test_get_track_info(self, plugin, requests_mock): + requests_mock.get( + "/ws/2/recording/id1?inc=releases%2Bartist-credits", + json={ + "title": "T", + "artist-credit": [], + "releases": [{"title": "Al", "date": "2023-01"}], }, ) From 741f5c4be1cac6fcc2252f4653a0a92f2b40302a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 15:57:18 +0000 Subject: [PATCH 186/274] parentwork: simplify work retrieval and tests --- beetsplug/parentwork.py | 77 +++++++++--------- test/plugins/test_parentwork.py | 138 +++++++++++--------------------- 2 files changed, 83 insertions(+), 132 deletions(-) diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index eb2fd8f11..6fa4bfbdb 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -16,56 +16,51 @@ and work composition date """ +from __future__ import annotations + +from typing import Any + import musicbrainzngs -from beets import ui +from beets import __version__, ui from beets.plugins import BeetsPlugin - -def direct_parent_id(mb_workid, work_date=None): - """Given a Musicbrainz work id, find the id one of the works the work is - part of and the first composition date it encounters. - """ - work_info = musicbrainzngs.get_work_by_id( - mb_workid, includes=["work-rels", "artist-rels"] - ) - if "artist-relation-list" in work_info["work"] and work_date is None: - for artist in work_info["work"]["artist-relation-list"]: - if artist["type"] == "composer": - if "end" in artist.keys(): - work_date = artist["end"] - - if "work-relation-list" in work_info["work"]: - for direct_parent in work_info["work"]["work-relation-list"]: - if ( - direct_parent["type"] == "parts" - and direct_parent.get("direction") == "backward" - ): - direct_id = direct_parent["work"]["id"] - return direct_id, work_date - return None, work_date +musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") -def work_parent_id(mb_workid): - """Find the parent work id and composition date of a work given its id.""" - work_date = None - while True: - new_mb_workid, work_date = direct_parent_id(mb_workid, work_date) - if not new_mb_workid: - return mb_workid, work_date - mb_workid = new_mb_workid - return mb_workid, work_date - - -def find_parentwork_info(mb_workid): +def find_parentwork_info(mb_workid: str) -> tuple[dict[str, Any], str | None]: """Get the MusicBrainz information dict about a parent work, including the artist relations, and the composition date for a work's parent work. """ - parent_id, work_date = work_parent_id(mb_workid) - work_info = musicbrainzngs.get_work_by_id( - parent_id, includes=["artist-rels"] - ) - return work_info, work_date + work_date = None + + parent_id: str | None = mb_workid + + while parent_id: + current_id = parent_id + work_info = musicbrainzngs.get_work_by_id( + current_id, includes=["work-rels", "artist-rels"] + )["work"] + work_date = work_date or next( + ( + end + for a in work_info.get("artist-relation-list", []) + if a["type"] == "composer" and (end := a.get("end")) + ), + None, + ) + parent_id = next( + ( + w["work"]["id"] + for w in work_info.get("work-relation-list", []) + if w["type"] == "parts" and w["direction"] == "backward" + ), + None, + ) + + return musicbrainzngs.get_work_by_id( + current_id, includes=["artist-rels"] + ), work_date class ParentWorkPlugin(BeetsPlugin): diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py index 1abe25709..809387bbc 100644 --- a/test/plugins/test_parentwork.py +++ b/test/plugins/test_parentwork.py @@ -14,74 +14,13 @@ """Tests for the 'parentwork' plugin.""" -from unittest.mock import patch +from typing import Any +from unittest.mock import Mock, patch import pytest from beets.library import Item from beets.test.helper import PluginTestCase -from beetsplug import parentwork - -work = { - "work": { - "id": "1", - "title": "work", - "work-relation-list": [ - {"type": "parts", "direction": "backward", "work": {"id": "2"}} - ], - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } -} -dp_work = { - "work": { - "id": "2", - "title": "directparentwork", - "work-relation-list": [ - {"type": "parts", "direction": "backward", "work": {"id": "3"}} - ], - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } -} -p_work = { - "work": { - "id": "3", - "title": "parentwork", - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } -} - - -def mock_workid_response(mbid, includes): - if mbid == "1": - return work - elif mbid == "2": - return dp_work - elif mbid == "3": - return p_work @pytest.mark.integration_test @@ -134,36 +73,57 @@ class ParentWorkIntegrationTest(PluginTestCase): item.load() assert item["mb_parentworkid"] == "XXX" - # test different cases, still with Matthew Passion Ouverture or Mozart - # requiem - def test_direct_parent_work_real(self): - mb_workid = "2e4a3668-458d-3b2a-8be2-0b08e0d8243a" - assert ( - "f04b42df-7251-4d86-a5ee-67cfa49580d1" - == parentwork.direct_parent_id(mb_workid)[0] - ) - assert ( - "45afb3b2-18ac-4187-bc72-beb1b1c194ba" - == parentwork.work_parent_id(mb_workid)[0] - ) +def mock_workid_response(mbid, includes): + works: list[dict[str, Any]] = [ + { + "id": "1", + "title": "work", + "work-relation-list": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "2"}, + } + ], + }, + { + "id": "2", + "title": "directparentwork", + "work-relation-list": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "3"}, + } + ], + }, + { + "id": "3", + "title": "parentwork", + }, + ] + + return { + "work": { + **next(w for w in works if mbid == w["id"]), + "artist-relation-list": [ + { + "type": "composer", + "artist": { + "name": "random composer", + "sort-name": "composer, random", + }, + } + ], + } + } +@patch("musicbrainzngs.get_work_by_id", Mock(side_effect=mock_workid_response)) class ParentWorkTest(PluginTestCase): plugin = "parentwork" - def setUp(self): - """Set up configuration""" - super().setUp() - self.patcher = patch( - "musicbrainzngs.get_work_by_id", side_effect=mock_workid_response - ) - self.patcher.start() - - def tearDown(self): - super().tearDown() - self.patcher.stop() - def test_normal_case(self): item = Item(path="/file", mb_workid="1", parentwork_workid_current="1") item.add(self.lib) @@ -204,7 +164,3 @@ class ParentWorkTest(PluginTestCase): item.load() assert item["mb_parentworkid"] == "XXX" - - def test_direct_parent_work(self): - assert "2" == parentwork.direct_parent_id("1")[0] - assert "3" == parentwork.work_parent_id("1")[0] From a33371b6efb4daddb1db59ccb3fd7479e7916626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 22 Dec 2025 16:45:15 +0000 Subject: [PATCH 187/274] Migrate parentwork to use MusicBrainzAPI --- .github/workflows/ci.yaml | 4 +- beetsplug/_utils/musicbrainz.py | 3 + beetsplug/parentwork.py | 110 +++++++++++++++----------------- docs/plugins/parentwork.rst | 10 --- poetry.lock | 3 +- pyproject.toml | 1 - test/plugins/test_parentwork.py | 97 ++++++++++++++-------------- 7 files changed, 106 insertions(+), 122 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 520a368ef..bfd05c718 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,7 +66,7 @@ jobs: - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage run: | - poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate --extras=parentwork + poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe test - if: ${{ env.IS_MAIN_PYTHON == 'true' }} @@ -74,7 +74,7 @@ jobs: env: LYRICS_UPDATED: ${{ steps.lyrics-update.outputs.any_changed }} run: | - poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate --extras=parentwork + poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe docs poe test-with-coverage diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 63ffd4aa3..cd58a8f54 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -91,6 +91,9 @@ class MusicBrainzAPI(RequestHandler): def get_recording(self, id_: str, **kwargs) -> JSONDict: return self.get_entity(f"recording/{id_}", **kwargs) + def get_work(self, id_: str, **kwargs) -> JSONDict: + return self.get_entity(f"work/{id_}", **kwargs) + def browse_recordings(self, **kwargs) -> list[JSONDict]: return self.get_entity("recording", **kwargs)["recordings"] diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index 6fa4bfbdb..15fcdefa8 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -20,50 +20,15 @@ from __future__ import annotations from typing import Any -import musicbrainzngs +import requests -from beets import __version__, ui +from beets import ui from beets.plugins import BeetsPlugin -musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") +from ._utils.musicbrainz import MusicBrainzAPIMixin -def find_parentwork_info(mb_workid: str) -> tuple[dict[str, Any], str | None]: - """Get the MusicBrainz information dict about a parent work, including - the artist relations, and the composition date for a work's parent work. - """ - work_date = None - - parent_id: str | None = mb_workid - - while parent_id: - current_id = parent_id - work_info = musicbrainzngs.get_work_by_id( - current_id, includes=["work-rels", "artist-rels"] - )["work"] - work_date = work_date or next( - ( - end - for a in work_info.get("artist-relation-list", []) - if a["type"] == "composer" and (end := a.get("end")) - ), - None, - ) - parent_id = next( - ( - w["work"]["id"] - for w in work_info.get("work-relation-list", []) - if w["type"] == "parts" and w["direction"] == "backward" - ), - None, - ) - - return musicbrainzngs.get_work_by_id( - current_id, includes=["artist-rels"] - ), work_date - - -class ParentWorkPlugin(BeetsPlugin): +class ParentWorkPlugin(MusicBrainzAPIMixin, BeetsPlugin): def __init__(self): super().__init__() @@ -125,14 +90,13 @@ class ParentWorkPlugin(BeetsPlugin): parentwork_info = {} composer_exists = False - if "artist-relation-list" in work_info["work"]: - for artist in work_info["work"]["artist-relation-list"]: - if artist["type"] == "composer": - composer_exists = True - parent_composer.append(artist["artist"]["name"]) - parent_composer_sort.append(artist["artist"]["sort-name"]) - if "end" in artist.keys(): - parentwork_info["parentwork_date"] = artist["end"] + for artist in work_info.get("artist-relations", []): + if artist["type"] == "composer": + composer_exists = True + parent_composer.append(artist["artist"]["name"]) + parent_composer_sort.append(artist["artist"]["sort-name"]) + if "end" in artist.keys(): + parentwork_info["parentwork_date"] = artist["end"] parentwork_info["parent_composer"] = ", ".join(parent_composer) parentwork_info["parent_composer_sort"] = ", ".join( @@ -144,16 +108,14 @@ class ParentWorkPlugin(BeetsPlugin): "no composer for {}; add one at " "https://musicbrainz.org/work/{}", item, - work_info["work"]["id"], + work_info["id"], ) - parentwork_info["parentwork"] = work_info["work"]["title"] - parentwork_info["mb_parentworkid"] = work_info["work"]["id"] + parentwork_info["parentwork"] = work_info["title"] + parentwork_info["mb_parentworkid"] = work_info["id"] - if "disambiguation" in work_info["work"]: - parentwork_info["parentwork_disambig"] = work_info["work"][ - "disambiguation" - ] + if "disambiguation" in work_info: + parentwork_info["parentwork_disambig"] = work_info["disambiguation"] else: parentwork_info["parentwork_disambig"] = None @@ -185,9 +147,9 @@ class ParentWorkPlugin(BeetsPlugin): work_changed = item.parentwork_workid_current != item.mb_workid if force or not hasparent or work_changed: try: - work_info, work_date = find_parentwork_info(item.mb_workid) - except musicbrainzngs.musicbrainz.WebServiceError as e: - self._log.debug("error fetching work: {}", e) + work_info, work_date = self.find_parentwork_info(item.mb_workid) + except requests.exceptions.RequestException: + self._log.debug("error fetching work", item, exc_info=True) return parent_info = self.get_info(item, work_info) parent_info["parentwork_workid_current"] = item.mb_workid @@ -228,3 +190,37 @@ class ParentWorkPlugin(BeetsPlugin): "parentwork_date", ], ) + + def find_parentwork_info( + self, mb_workid: str + ) -> tuple[dict[str, Any], str | None]: + """Get the MusicBrainz information dict about a parent work, including + the artist relations, and the composition date for a work's parent work. + """ + work_date = None + + parent_id: str | None = mb_workid + + while parent_id: + current_id = parent_id + work_info = self.mb_api.get_work( + current_id, includes=["work-rels", "artist-rels"] + ) + work_date = work_date or next( + ( + end + for a in work_info.get("artist-relations", []) + if a["type"] == "composer" and (end := a.get("end")) + ), + None, + ) + parent_id = next( + ( + w["work"]["id"] + for w in work_info.get("work-relations", []) + if w["type"] == "parts" and w["direction"] == "backward" + ), + None, + ) + + return work_info, work_date diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst index e015bed68..21b774120 100644 --- a/docs/plugins/parentwork.rst +++ b/docs/plugins/parentwork.rst @@ -38,16 +38,6 @@ This plugin adds seven tags: to keep track of recordings whose works have changed. - **parentwork_date**: The composition date of the parent work. -Installation ------------- - -To use the ``parentwork`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install ``beets`` with ``parentwork`` extra - -.. code-block:: bash - - pip install "beets[parentwork]" - Configuration ------------- diff --git a/poetry.lock b/poetry.lock index 60cbceebd..067fcf93c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4185,7 +4185,6 @@ mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] -parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = ["PyGObject"] @@ -4198,4 +4197,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "d9141a482e4990a4466a121a59deaeaf46e5613ff0af315f277110935e391e63" +content-hash = "dbe3785cbffd71f2ca758872f7654522228d6155c76a8f003bec22f03c8eada3" diff --git a/pyproject.toml b/pyproject.toml index ed0059610..658602484 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,7 +168,6 @@ mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] -parentwork = ["musicbrainzngs"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = [ diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py index 809387bbc..2218e9fd6 100644 --- a/test/plugins/test_parentwork.py +++ b/test/plugins/test_parentwork.py @@ -14,9 +14,6 @@ """Tests for the 'parentwork' plugin.""" -from typing import Any -from unittest.mock import Mock, patch - import pytest from beets.library import Item @@ -74,56 +71,56 @@ class ParentWorkIntegrationTest(PluginTestCase): assert item["mb_parentworkid"] == "XXX" -def mock_workid_response(mbid, includes): - works: list[dict[str, Any]] = [ - { - "id": "1", - "title": "work", - "work-relation-list": [ - { - "type": "parts", - "direction": "backward", - "work": {"id": "2"}, - } - ], - }, - { - "id": "2", - "title": "directparentwork", - "work-relation-list": [ - { - "type": "parts", - "direction": "backward", - "work": {"id": "3"}, - } - ], - }, - { - "id": "3", - "title": "parentwork", - }, - ] - - return { - "work": { - **next(w for w in works if mbid == w["id"]), - "artist-relation-list": [ - { - "type": "composer", - "artist": { - "name": "random composer", - "sort-name": "composer, random", - }, - } - ], - } - } - - -@patch("musicbrainzngs.get_work_by_id", Mock(side_effect=mock_workid_response)) class ParentWorkTest(PluginTestCase): plugin = "parentwork" + @pytest.fixture(autouse=True) + def patch_works(self, requests_mock): + requests_mock.get( + "/ws/2/work/1?inc=work-rels%2Bartist-rels", + json={ + "id": "1", + "title": "work", + "work-relations": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "2"}, + } + ], + }, + ) + requests_mock.get( + "/ws/2/work/2?inc=work-rels%2Bartist-rels", + json={ + "id": "2", + "title": "directparentwork", + "work-relations": [ + { + "type": "parts", + "direction": "backward", + "work": {"id": "3"}, + } + ], + }, + ) + requests_mock.get( + "/ws/2/work/3?inc=work-rels%2Bartist-rels", + json={ + "id": "3", + "title": "parentwork", + "artist-relations": [ + { + "type": "composer", + "artist": { + "name": "random composer", + "sort-name": "composer, random", + }, + } + ], + }, + ) + def test_normal_case(self): item = Item(path="/file", mb_workid="1", parentwork_workid_current="1") item.add(self.lib) From d346daf48eedd7f3b8ba81b1b139fa98b6bccb34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 23 Dec 2025 00:28:12 +0000 Subject: [PATCH 188/274] missing: add tests for --album flag --- beetsplug/missing.py | 4 ++- test/plugins/test_missing.py | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 test/plugins/test_missing.py diff --git a/beetsplug/missing.py b/beetsplug/missing.py index cbdda4599..2f883ee27 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -21,7 +21,7 @@ from collections.abc import Iterator import musicbrainzngs from musicbrainzngs.musicbrainz import MusicBrainzError -from beets import config, metadata_plugins +from beets import __version__, config, metadata_plugins from beets.dbcore import types from beets.library import Album, Item, Library from beets.plugins import BeetsPlugin @@ -29,6 +29,8 @@ from beets.ui import Subcommand, print_ MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" +musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") + def _missing_count(album): """Return number of missing items in `album`.""" diff --git a/test/plugins/test_missing.py b/test/plugins/test_missing.py new file mode 100644 index 000000000..841d5c358 --- /dev/null +++ b/test/plugins/test_missing.py @@ -0,0 +1,58 @@ +import uuid + +import pytest + +from beets.library import Album +from beets.test.helper import PluginMixin, TestHelper + + +@pytest.fixture +def helper(): + helper = TestHelper() + helper.setup_beets() + + yield helper + + helper.teardown_beets() + + +class TestMissingAlbums(PluginMixin): + plugin = "missing" + album_in_lib = Album( + album="Album", + albumartist="Artist", + mb_albumartistid=str(uuid.uuid4()), + mb_albumid="album", + ) + + @pytest.mark.parametrize( + "release_from_mb,expected_output", + [ + pytest.param( + {"id": "other", "title": "Other Album"}, + "Artist - Other Album\n", + id="missing", + ), + pytest.param( + {"id": album_in_lib.mb_albumid, "title": album_in_lib.album}, + "", + marks=pytest.mark.xfail( + reason="album in lib should not be reported as missing. Needs fixing." + ), + id="not missing", + ), + ], + ) + def test_missing_artist_albums( + self, monkeypatch, helper, release_from_mb, expected_output + ): + helper.lib.add(self.album_in_lib) + monkeypatch.setattr( + "musicbrainzngs.browse_release_groups", + lambda **__: {"release-group-list": [release_from_mb]}, + ) + + with self.configure_plugin({}): + assert ( + helper.run_with_output("missing", "--album") == expected_output + ) From 9349ad7551e6b5a05c45cd5a8c366eb52f994f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 23 Dec 2025 00:41:29 +0000 Subject: [PATCH 189/274] Migrate missing to use MusicBrainzAPI --- beetsplug/_utils/musicbrainz.py | 3 +++ beetsplug/missing.py | 21 ++++++++++----------- docs/plugins/missing.rst | 10 ---------- poetry.lock | 3 +-- pyproject.toml | 1 - test/plugins/test_missing.py | 13 ++++++++----- 6 files changed, 22 insertions(+), 29 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index cd58a8f54..aa86cccbb 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -97,6 +97,9 @@ class MusicBrainzAPI(RequestHandler): def browse_recordings(self, **kwargs) -> list[JSONDict]: return self.get_entity("recording", **kwargs)["recordings"] + def browse_release_groups(self, **kwargs) -> list[JSONDict]: + return self.get_entity("release-group", **kwargs)["release-groups"] + @singledispatchmethod @classmethod def _group_relations(cls, data: Any) -> Any: diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 2f883ee27..63a7bae22 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -18,18 +18,17 @@ from collections import defaultdict from collections.abc import Iterator -import musicbrainzngs -from musicbrainzngs.musicbrainz import MusicBrainzError +import requests -from beets import __version__, config, metadata_plugins +from beets import config, metadata_plugins from beets.dbcore import types from beets.library import Album, Item, Library from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ -MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" +from ._utils.musicbrainz import MusicBrainzAPIMixin -musicbrainzngs.set_useragent("beets", __version__, "https://beets.io/") +MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" def _missing_count(album): @@ -87,7 +86,7 @@ def _item(track_info, album_info, album_id): ) -class MissingPlugin(BeetsPlugin): +class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): """List missing tracks""" album_types = { @@ -191,19 +190,19 @@ class MissingPlugin(BeetsPlugin): calculating_total = self.config["total"].get() for (artist, artist_id), album_ids in album_ids_by_artist.items(): try: - resp = musicbrainzngs.browse_release_groups(artist=artist_id) - except MusicBrainzError as err: + resp = self.mb_api.browse_release_groups(artist=artist_id) + except requests.exceptions.RequestException: self._log.info( - "Couldn't fetch info for artist '{}' ({}) - '{}'", + "Couldn't fetch info for artist '{}' ({})", artist, artist_id, - err, + exc_info=True, ) continue missing_titles = [ f"{artist} - {rg['title']}" - for rg in resp["release-group-list"] + for rg in resp if rg["id"] not in album_ids ] diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index f6962f337..d286e43cc 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -5,16 +5,6 @@ This plugin adds a new command, ``missing`` or ``miss``, which finds and lists missing tracks for albums in your collection. Each album requires one network call to album data source. -Installation ------------- - -To use the ``missing`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install ``beets`` with ``missing`` extra - -.. code-block:: bash - - pip install "beets[missing]" - Usage ----- diff --git a/poetry.lock b/poetry.lock index 067fcf93c..e8cc4e905 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4183,7 +4183,6 @@ lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] -missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] reflink = ["reflink"] @@ -4197,4 +4196,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "dbe3785cbffd71f2ca758872f7654522228d6155c76a8f003bec22f03c8eada3" +content-hash = "a18c3047f4f395841e785ed146af3505974839ab23eccdde34a7738e216f0277" diff --git a/pyproject.toml b/pyproject.toml index 658602484..62224c8d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,7 +166,6 @@ lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] -missing = ["musicbrainzngs"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] reflink = ["reflink"] diff --git a/test/plugins/test_missing.py b/test/plugins/test_missing.py index 841d5c358..d12f2b4cf 100644 --- a/test/plugins/test_missing.py +++ b/test/plugins/test_missing.py @@ -37,19 +37,22 @@ class TestMissingAlbums(PluginMixin): {"id": album_in_lib.mb_albumid, "title": album_in_lib.album}, "", marks=pytest.mark.xfail( - reason="album in lib should not be reported as missing. Needs fixing." + reason=( + "Album in lib must not be reported as missing." + " Needs fixing." + ) ), id="not missing", ), ], ) def test_missing_artist_albums( - self, monkeypatch, helper, release_from_mb, expected_output + self, requests_mock, helper, release_from_mb, expected_output ): helper.lib.add(self.album_in_lib) - monkeypatch.setattr( - "musicbrainzngs.browse_release_groups", - lambda **__: {"release-group-list": [release_from_mb]}, + requests_mock.get( + f"/ws/2/release-group?artist={self.album_in_lib.mb_albumartistid}", + json={"release-groups": [release_from_mb]}, ) with self.configure_plugin({}): From 143cd70e2feba34c5e9fbf6a6984a88c4aafddec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 23 Dec 2025 03:28:12 +0000 Subject: [PATCH 190/274] mbcollection: Add tests --- beetsplug/mbcollection.py | 4 +- test/plugins/test_mbcollection.py | 149 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 test/plugins/test_mbcollection.py diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 2f9ef709e..376222382 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -101,9 +101,9 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): offset = 0 albums_in_collection, release_count = _fetch(offset) - for i in range(0, release_count, FETCH_CHUNK_SIZE): - albums_in_collection += _fetch(offset)[0] + for i in range(FETCH_CHUNK_SIZE, release_count, FETCH_CHUNK_SIZE): offset += FETCH_CHUNK_SIZE + albums_in_collection += _fetch(offset)[0] return albums_in_collection diff --git a/test/plugins/test_mbcollection.py b/test/plugins/test_mbcollection.py new file mode 100644 index 000000000..edf37538d --- /dev/null +++ b/test/plugins/test_mbcollection.py @@ -0,0 +1,149 @@ +import uuid +from contextlib import nullcontext as does_not_raise + +import pytest + +from beets.library import Album +from beets.test.helper import ConfigMixin +from beets.ui import UserError +from beetsplug import mbcollection + + +class TestMbCollectionAPI: + """Tests for the low-level MusicBrainz API wrapper functions.""" + + def test_submit_albums_batches(self, monkeypatch): + chunks_received = [] + + def mock_add(collection_id, chunk): + chunks_received.append(chunk) + + monkeypatch.setattr( + "musicbrainzngs.add_releases_to_collection", mock_add + ) + + # Chunk size is 200. Create 250 IDs. + ids = [f"id{i}" for i in range(250)] + mbcollection.submit_albums("coll_id", ids) + + # Verify behavioral outcome: 2 batches were sent + assert len(chunks_received) == 2 + assert len(chunks_received[0]) == 200 + assert len(chunks_received[1]) == 50 + + +class TestMbCollectionPlugin(ConfigMixin): + """Tests for the MusicBrainzCollectionPlugin class methods.""" + + COLLECTION_ID = str(uuid.uuid4()) + + @pytest.fixture + def plugin(self, monkeypatch): + # Prevent actual auth call during init + monkeypatch.setattr("musicbrainzngs.auth", lambda *a, **k: None) + + self.config["musicbrainz"]["user"] = "testuser" + self.config["musicbrainz"]["pass"] = "testpass" + + plugin = mbcollection.MusicBrainzCollectionPlugin() + plugin.config["collection"] = self.COLLECTION_ID + return plugin + + @pytest.mark.parametrize( + "user_collections,expectation", + [ + ( + [], + pytest.raises( + UserError, match=r"no collections exist for user" + ), + ), + ( + [{"id": "c1", "entity-type": "event"}], + pytest.raises(UserError, match=r"No release collection found."), + ), + ( + [{"id": "c1", "entity-type": "release"}], + pytest.raises(UserError, match=r"invalid collection ID"), + ), + ( + [{"id": COLLECTION_ID, "entity-type": "release"}], + does_not_raise(), + ), + ], + ) + def test_get_collection_validation( + self, plugin, monkeypatch, user_collections, expectation + ): + mock_resp = {"collection-list": user_collections} + monkeypatch.setattr("musicbrainzngs.get_collections", lambda: mock_resp) + + with expectation: + plugin._get_collection() + + def test_get_albums_in_collection_pagination(self, plugin, monkeypatch): + fetched_offsets = [] + + def mock_get_releases(collection_id, limit, offset): + fetched_offsets.append(offset) + count = 150 + # Return IDs based on offset to verify order/content + start = offset + end = min(offset + limit, count) + return { + "collection": { + "release-count": count, + "release-list": [ + {"id": f"r{i}"} for i in range(start, end) + ], + } + } + + monkeypatch.setattr( + "musicbrainzngs.get_releases_in_collection", mock_get_releases + ) + + albums = plugin._get_albums_in_collection("cid") + assert len(albums) == 150 + assert fetched_offsets == [0, 100] + assert albums[0] == "r0" + assert albums[149] == "r149" + + def test_update_album_list_filtering(self, plugin, monkeypatch): + ids_submitted = [] + + def mock_submit(_, album_ids): + ids_submitted.extend(album_ids) + + monkeypatch.setattr("beetsplug.mbcollection.submit_albums", mock_submit) + monkeypatch.setattr(plugin, "_get_collection", lambda: "cid") + + albums = [ + Album(mb_albumid="invalid-id"), + Album(mb_albumid="00000000-0000-0000-0000-000000000001"), + ] + + plugin.update_album_list(None, albums) + # Behavior: only valid UUID was submitted + assert ids_submitted == ["00000000-0000-0000-0000-000000000001"] + + def test_remove_missing(self, plugin, monkeypatch): + removed_ids = [] + + def mock_remove(_, chunk): + removed_ids.extend(chunk) + + monkeypatch.setattr( + "musicbrainzngs.remove_releases_from_collection", mock_remove + ) + monkeypatch.setattr( + plugin, + "_get_albums_in_collection", + lambda _: ["r1", "r2", "r3"], + ) + + lib_albums = [Album(mb_albumid="r1"), Album(mb_albumid="r2")] + + plugin.remove_missing("cid", lib_albums) + # Behavior: only 'r3' (missing from library) was removed from collection + assert removed_ids == ["r3"] From 92352574aaa4fdc85996b8f796b760833cdc6279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 03:12:09 +0000 Subject: [PATCH 191/274] Migrate mbcollection to use MusicBrainzAPI --- beetsplug/_utils/musicbrainz.py | 17 ++- beetsplug/_utils/requests.py | 9 ++ beetsplug/mbcollection.py | 180 +++++++++++++++++++----------- docs/plugins/mbcollection.rst | 15 +-- poetry.lock | 14 +-- pyproject.toml | 2 - test/plugins/conftest.py | 22 ++++ test/plugins/test_mbcollection.py | 104 ++++++++--------- test/plugins/utils/__init__.py | 0 9 files changed, 206 insertions(+), 157 deletions(-) create mode 100644 test/plugins/conftest.py create mode 100644 test/plugins/utils/__init__.py diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index aa86cccbb..17a83dd9b 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -13,6 +13,8 @@ from beets import config, logging from .requests import RequestHandler, TimeoutAndRetrySession if TYPE_CHECKING: + from requests import Response + from .._typing import JSONDict log = logging.getLogger(__name__) @@ -49,9 +51,19 @@ class MusicBrainzAPI(RequestHandler): / mb_config["ratelimit_interval"].as_number() ) + @cached_property + def api_root(self) -> str: + return f"{self.api_host}/ws/2" + def create_session(self) -> LimiterTimeoutSession: return LimiterTimeoutSession(per_second=self.rate_limit) + def request(self, *args, **kwargs) -> Response: + """Ensure all requests specify JSON response format by default.""" + kwargs.setdefault("params", {}) + kwargs["params"]["fmt"] = "json" + return super().request(*args, **kwargs) + def get_entity( self, entity: str, includes: list[str] | None = None, **kwargs ) -> JSONDict: @@ -59,10 +71,7 @@ class MusicBrainzAPI(RequestHandler): kwargs["inc"] = "+".join(includes) return self._group_relations( - self.get_json( - f"{self.api_host}/ws/2/{entity}", - params={**kwargs, "fmt": "json"}, - ) + self.get_json(f"{self.api_root}/{entity}", params=kwargs) ) def search_entity( diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 1cb4f6c2b..b8ac541e9 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -155,6 +155,7 @@ class RequestHandler: except requests.exceptions.HTTPError as e: if beets_error := self.status_to_error(e.response.status_code): raise beets_error(response=e.response) from e + raise def request(self, *args, **kwargs) -> requests.Response: @@ -170,6 +171,14 @@ class RequestHandler: """Perform HTTP GET request with automatic error handling.""" return self.request("get", *args, **kwargs) + def put(self, *args, **kwargs) -> requests.Response: + """Perform HTTP PUT request with automatic error handling.""" + return self.request("put", *args, **kwargs) + + def delete(self, *args, **kwargs) -> requests.Response: + """Perform HTTP DELETE request with automatic error handling.""" + return self.request("delete", *args, **kwargs) + def get_json(self, *args, **kwargs): """Fetch and parse JSON data from an HTTP endpoint.""" return self.get(*args, **kwargs).json() diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 376222382..83e78ca69 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -13,48 +13,112 @@ # included in all copies or substantial portions of the Software. +from __future__ import annotations + import re +from dataclasses import dataclass, field +from functools import cached_property +from typing import TYPE_CHECKING -import musicbrainzngs +from requests.auth import HTTPDigestAuth -from beets import config, ui +from beets import __version__, config, ui from beets.plugins import BeetsPlugin from beets.ui import Subcommand +from ._utils.musicbrainz import MusicBrainzAPI + +if TYPE_CHECKING: + from collections.abc import Iterator + + from requests import Response + + from ._typing import JSONDict + SUBMISSION_CHUNK_SIZE = 200 FETCH_CHUNK_SIZE = 100 UUID_REGEX = r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$" -def mb_call(func, *args, **kwargs): - """Call a MusicBrainz API function and catch exceptions.""" - try: - return func(*args, **kwargs) - except musicbrainzngs.AuthenticationError: - raise ui.UserError("authentication with MusicBrainz failed") - except (musicbrainzngs.ResponseError, musicbrainzngs.NetworkError) as exc: - raise ui.UserError(f"MusicBrainz API error: {exc}") - except musicbrainzngs.UsageError: - raise ui.UserError("MusicBrainz credentials missing") +@dataclass +class MusicBrainzUserAPI(MusicBrainzAPI): + auth: HTTPDigestAuth = field(init=False) + + @cached_property + def user(self) -> str: + return config["musicbrainz"]["user"].as_str() + + def __post_init__(self) -> None: + super().__post_init__() + config["musicbrainz"]["pass"].redact = True + self.auth = HTTPDigestAuth( + self.user, config["musicbrainz"]["pass"].as_str() + ) + + def request(self, *args, **kwargs) -> Response: + kwargs.setdefault("params", {}) + kwargs["params"]["client"] = f"beets-{__version__}" + kwargs["auth"] = self.auth + return super().request(*args, **kwargs) + + def get_collections(self) -> list[JSONDict]: + return self.get_entity( + "collection", editor=self.user, includes=["user-collections"] + ).get("collections", []) -def submit_albums(collection_id, release_ids): +@dataclass +class MBCollection: + data: JSONDict + mb_api: MusicBrainzUserAPI + + @property + def id(self) -> str: + return self.data["id"] + + @property + def release_count(self) -> int: + return self.data["release-count"] + + @property + def releases_url(self) -> str: + return f"{self.mb_api.api_root}/collection/{self.id}/releases" + + @property + def releases(self) -> list[JSONDict]: + offsets = list(range(0, self.release_count, FETCH_CHUNK_SIZE)) + return [r for offset in offsets for r in self.get_releases(offset)] + + def get_releases(self, offset: int) -> list[JSONDict]: + return self.mb_api.get_json( + self.releases_url, + params={"limit": FETCH_CHUNK_SIZE, "offset": offset}, + )["releases"] + + @staticmethod + def get_id_chunks(id_list: list[str]) -> Iterator[list[str]]: + for i in range(0, len(id_list), SUBMISSION_CHUNK_SIZE): + yield id_list[i : i + SUBMISSION_CHUNK_SIZE] + + def add_releases(self, releases: list[str]) -> None: + for chunk in self.get_id_chunks(releases): + self.mb_api.put(f"{self.releases_url}/{'%3B'.join(chunk)}") + + def remove_releases(self, releases: list[str]) -> None: + for chunk in self.get_id_chunks(releases): + self.mb_api.delete(f"{self.releases_url}/{'%3B'.join(chunk)}") + + +def submit_albums(collection: MBCollection, release_ids): """Add all of the release IDs to the indicated collection. Multiple requests are made if there are many release IDs to submit. """ - for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE): - chunk = release_ids[i : i + SUBMISSION_CHUNK_SIZE] - mb_call(musicbrainzngs.add_releases_to_collection, collection_id, chunk) + collection.add_releases(release_ids) class MusicBrainzCollectionPlugin(BeetsPlugin): def __init__(self): super().__init__() - config["musicbrainz"]["pass"].redact = True - musicbrainzngs.auth( - config["musicbrainz"]["user"].as_str(), - config["musicbrainz"]["pass"].as_str(), - ) self.config.add( { "auto": False, @@ -65,47 +129,34 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): if self.config["auto"]: self.import_stages = [self.imported] - def _get_collection(self): - collections = mb_call(musicbrainzngs.get_collections) - if not collections["collection-list"]: + @cached_property + def mb_api(self) -> MusicBrainzUserAPI: + return MusicBrainzUserAPI() + + def _get_collection(self) -> MBCollection: + if not (collections := self.mb_api.get_collections()): raise ui.UserError("no collections exist for user") # Get all release collection IDs, avoiding event collections - collection_ids = [ - x["id"] - for x in collections["collection-list"] - if x["entity-type"] == "release" - ] - if not collection_ids: + if not ( + collection_by_id := { + c["id"]: c for c in collections if c["entity-type"] == "release" + } + ): raise ui.UserError("No release collection found.") # Check that the collection exists so we can present a nice error - collection = self.config["collection"].as_str() - if collection: - if collection not in collection_ids: - raise ui.UserError(f"invalid collection ID: {collection}") - return collection + if collection_id := self.config["collection"].as_str(): + if not (collection := collection_by_id.get(collection_id)): + raise ui.UserError(f"invalid collection ID: {collection_id}") + else: + # No specified collection. Just return the first collection ID + collection = next(iter(collection_by_id.values())) - # No specified collection. Just return the first collection ID - return collection_ids[0] + return MBCollection(collection, self.mb_api) - def _get_albums_in_collection(self, id): - def _fetch(offset): - res = mb_call( - musicbrainzngs.get_releases_in_collection, - id, - limit=FETCH_CHUNK_SIZE, - offset=offset, - )["collection"] - return [x["id"] for x in res["release-list"]], res["release-count"] - - offset = 0 - albums_in_collection, release_count = _fetch(offset) - for i in range(FETCH_CHUNK_SIZE, release_count, FETCH_CHUNK_SIZE): - offset += FETCH_CHUNK_SIZE - albums_in_collection += _fetch(offset)[0] - - return albums_in_collection + def _get_albums_in_collection(self, collection: MBCollection) -> set[str]: + return {r["id"] for r in collection.releases} def commands(self): mbupdate = Subcommand("mbupdate", help="Update MusicBrainz collection") @@ -120,17 +171,10 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): mbupdate.func = self.update_collection return [mbupdate] - def remove_missing(self, collection_id, lib_albums): + def remove_missing(self, collection: MBCollection, lib_albums): lib_ids = {x.mb_albumid for x in lib_albums} - albums_in_collection = self._get_albums_in_collection(collection_id) - remove_me = list(set(albums_in_collection) - lib_ids) - for i in range(0, len(remove_me), FETCH_CHUNK_SIZE): - chunk = remove_me[i : i + FETCH_CHUNK_SIZE] - mb_call( - musicbrainzngs.remove_releases_from_collection, - collection_id, - chunk, - ) + albums_in_collection = self._get_albums_in_collection(collection) + collection.remove_releases(list(albums_in_collection - lib_ids)) def update_collection(self, lib, opts, args): self.config.set_args(opts) @@ -144,7 +188,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): def update_album_list(self, lib, album_list, remove_missing=False): """Update the MusicBrainz collection from a list of Beets albums""" - collection_id = self._get_collection() + collection = self._get_collection() # Get a list of all the album IDs. album_ids = [] @@ -157,8 +201,8 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): self._log.info("skipping invalid MBID: {}", aid) # Submit to MusicBrainz. - self._log.info("Updating MusicBrainz collection {}...", collection_id) - submit_albums(collection_id, album_ids) + self._log.info("Updating MusicBrainz collection {}...", collection.id) + submit_albums(collection, album_ids) if remove_missing: - self.remove_missing(collection_id, lib.albums()) + self.remove_missing(collection, lib.albums()) self._log.info("...MusicBrainz collection updated.") diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst index ffa86f330..87efcd6d5 100644 --- a/docs/plugins/mbcollection.rst +++ b/docs/plugins/mbcollection.rst @@ -6,18 +6,9 @@ maintain your `music collection`_ list there. .. _music collection: https://musicbrainz.org/doc/Collections -Installation ------------- - -To use the ``mbcollection`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install ``beets`` with ``mbcollection`` extra - -.. code-block:: bash - - pip install "beets[mbcollection]" - -Then, add your MusicBrainz username and password to your :doc:`configuration -file ` under a ``musicbrainz`` section: +To begin, just enable the ``mbcollection`` plugin in your configuration (see +:ref:`using-plugins`). Then, add your MusicBrainz username and password to your +:doc:`configuration file ` under a ``musicbrainz`` section: :: diff --git a/poetry.lock b/poetry.lock index e8cc4e905..47c07e14f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1818,17 +1818,6 @@ check = ["check-manifest", "flake8", "flake8-black", "isort (>=5.0.3)", "pygment test = ["coverage[toml] (>=5.2)", "coveralls (>=2.1.1)", "hypothesis", "pyannotate", "pytest", "pytest-cov"] type = ["mypy", "mypy-extensions"] -[[package]] -name = "musicbrainzngs" -version = "0.7.1" -description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"}, - {file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"}, -] - [[package]] name = "mutagen" version = "1.47.0" @@ -4181,7 +4170,6 @@ kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] -mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] @@ -4196,4 +4184,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "a18c3047f4f395841e785ed146af3505974839ab23eccdde34a7738e216f0277" +content-hash = "cd53b70a9cd746a88e80e04e67e0b010a0e5b87f745be94e901a9fd08619771a" diff --git a/pyproject.toml b/pyproject.toml index 62224c8d8..8b608a45e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,6 @@ scipy = [ # for librosa { python = "<3.13", version = ">=1.13.1", optional = true }, { python = ">=3.13", version = ">=1.16.1", optional = true }, ] -musicbrainzngs = { version = ">=0.4", optional = true } numba = [ # for librosa { python = "<3.13", version = ">=0.60", optional = true }, { python = ">=3.13", version = ">=0.62.1", optional = true }, @@ -164,7 +163,6 @@ kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] -mbcollection = ["musicbrainzngs"] metasync = ["dbus-python"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] diff --git a/test/plugins/conftest.py b/test/plugins/conftest.py new file mode 100644 index 000000000..7e443004c --- /dev/null +++ b/test/plugins/conftest.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import requests + +if TYPE_CHECKING: + from requests_mock import Mocker + + +@pytest.fixture +def requests_mock(requests_mock, monkeypatch) -> Mocker: + """Use plain session wherever MB requests are mocked. + + This avoids rate limiting requests to speed up tests. + """ + monkeypatch.setattr( + "beetsplug._utils.musicbrainz.MusicBrainzAPI.create_session", + lambda _: requests.Session(), + ) + return requests_mock diff --git a/test/plugins/test_mbcollection.py b/test/plugins/test_mbcollection.py index edf37538d..93dbcab64 100644 --- a/test/plugins/test_mbcollection.py +++ b/test/plugins/test_mbcollection.py @@ -1,3 +1,4 @@ +import re import uuid from contextlib import nullcontext as does_not_raise @@ -9,27 +10,27 @@ from beets.ui import UserError from beetsplug import mbcollection +@pytest.fixture +def collection(): + return mbcollection.MBCollection( + {"id": str(uuid.uuid4()), "release-count": 150} + ) + + class TestMbCollectionAPI: """Tests for the low-level MusicBrainz API wrapper functions.""" - def test_submit_albums_batches(self, monkeypatch): - chunks_received = [] - - def mock_add(collection_id, chunk): - chunks_received.append(chunk) - - monkeypatch.setattr( - "musicbrainzngs.add_releases_to_collection", mock_add - ) - + def test_submit_albums_batches(self, collection, requests_mock): # Chunk size is 200. Create 250 IDs. ids = [f"id{i}" for i in range(250)] - mbcollection.submit_albums("coll_id", ids) + requests_mock.put( + f"/ws/2/collection/{collection.id}/releases/{';'.join(ids[:200])}" + ) + requests_mock.put( + f"/ws/2/collection/{collection.id}/releases/{';'.join(ids[200:])}" + ) - # Verify behavioral outcome: 2 batches were sent - assert len(chunks_received) == 2 - assert len(chunks_received[0]) == 200 - assert len(chunks_received[1]) == 50 + mbcollection.submit_albums(collection, ids) class TestMbCollectionPlugin(ConfigMixin): @@ -38,10 +39,7 @@ class TestMbCollectionPlugin(ConfigMixin): COLLECTION_ID = str(uuid.uuid4()) @pytest.fixture - def plugin(self, monkeypatch): - # Prevent actual auth call during init - monkeypatch.setattr("musicbrainzngs.auth", lambda *a, **k: None) - + def plugin(self): self.config["musicbrainz"]["user"] = "testuser" self.config["musicbrainz"]["pass"] = "testpass" @@ -73,50 +71,42 @@ class TestMbCollectionPlugin(ConfigMixin): ], ) def test_get_collection_validation( - self, plugin, monkeypatch, user_collections, expectation + self, plugin, requests_mock, user_collections, expectation ): - mock_resp = {"collection-list": user_collections} - monkeypatch.setattr("musicbrainzngs.get_collections", lambda: mock_resp) + requests_mock.get( + "/ws/2/collection", json={"collections": user_collections} + ) with expectation: plugin._get_collection() - def test_get_albums_in_collection_pagination(self, plugin, monkeypatch): - fetched_offsets = [] - - def mock_get_releases(collection_id, limit, offset): - fetched_offsets.append(offset) - count = 150 - # Return IDs based on offset to verify order/content - start = offset - end = min(offset + limit, count) - return { - "collection": { - "release-count": count, - "release-list": [ - {"id": f"r{i}"} for i in range(start, end) - ], - } - } - - monkeypatch.setattr( - "musicbrainzngs.get_releases_in_collection", mock_get_releases + def test_get_albums_in_collection_pagination( + self, plugin, requests_mock, collection + ): + releases = [{"id": str(i)} for i in range(collection.release_count)] + requests_mock.get( + re.compile( + rf".*/ws/2/collection/{collection.id}/releases\b.*&offset=0.*" + ), + json={"releases": releases[:100]}, + ) + requests_mock.get( + re.compile( + rf".*/ws/2/collection/{collection.id}/releases\b.*&offset=100.*" + ), + json={"releases": releases[100:]}, ) - albums = plugin._get_albums_in_collection("cid") - assert len(albums) == 150 - assert fetched_offsets == [0, 100] - assert albums[0] == "r0" - assert albums[149] == "r149" + plugin._get_albums_in_collection(collection) - def test_update_album_list_filtering(self, plugin, monkeypatch): + def test_update_album_list_filtering(self, plugin, collection, monkeypatch): ids_submitted = [] def mock_submit(_, album_ids): ids_submitted.extend(album_ids) monkeypatch.setattr("beetsplug.mbcollection.submit_albums", mock_submit) - monkeypatch.setattr(plugin, "_get_collection", lambda: "cid") + monkeypatch.setattr(plugin, "_get_collection", lambda: collection) albums = [ Album(mb_albumid="invalid-id"), @@ -127,23 +117,21 @@ class TestMbCollectionPlugin(ConfigMixin): # Behavior: only valid UUID was submitted assert ids_submitted == ["00000000-0000-0000-0000-000000000001"] - def test_remove_missing(self, plugin, monkeypatch): + def test_remove_missing( + self, plugin, monkeypatch, requests_mock, collection + ): removed_ids = [] def mock_remove(_, chunk): removed_ids.extend(chunk) - monkeypatch.setattr( - "musicbrainzngs.remove_releases_from_collection", mock_remove + requests_mock.delete( + re.compile(rf".*/ws/2/collection/{collection.id}/releases/r3") ) monkeypatch.setattr( - plugin, - "_get_albums_in_collection", - lambda _: ["r1", "r2", "r3"], + plugin, "_get_albums_in_collection", lambda _: {"r1", "r2", "r3"} ) lib_albums = [Album(mb_albumid="r1"), Album(mb_albumid="r2")] - plugin.remove_missing("cid", lib_albums) - # Behavior: only 'r3' (missing from library) was removed from collection - assert removed_ids == ["r3"] + plugin.remove_missing(collection, lib_albums) diff --git a/test/plugins/utils/__init__.py b/test/plugins/utils/__init__.py new file mode 100644 index 000000000..e69de29bb From b49d71cb6987580167fc9f80576d6d90cf6ebe6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 03:13:37 +0000 Subject: [PATCH 192/274] mbcollection: slight refactor --- beetsplug/mbcollection.py | 69 ++++++------- test/plugins/test_mbcollection.py | 165 +++++++++++++++--------------- 2 files changed, 118 insertions(+), 116 deletions(-) diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 83e78ca69..95ceb3fcf 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -18,7 +18,7 @@ from __future__ import annotations import re from dataclasses import dataclass, field from functools import cached_property -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from requests.auth import HTTPDigestAuth @@ -29,15 +29,16 @@ from beets.ui import Subcommand from ._utils.musicbrainz import MusicBrainzAPI if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator from requests import Response + from beets.importer import ImportSession, ImportTask + from beets.library import Album, Library + from ._typing import JSONDict -SUBMISSION_CHUNK_SIZE = 200 -FETCH_CHUNK_SIZE = 100 -UUID_REGEX = r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$" +UUID_PAT = re.compile(r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$") @dataclass @@ -69,6 +70,9 @@ class MusicBrainzUserAPI(MusicBrainzAPI): @dataclass class MBCollection: + SUBMISSION_CHUNK_SIZE: ClassVar[int] = 200 + FETCH_CHUNK_SIZE: ClassVar[int] = 100 + data: JSONDict mb_api: MusicBrainzUserAPI @@ -86,19 +90,19 @@ class MBCollection: @property def releases(self) -> list[JSONDict]: - offsets = list(range(0, self.release_count, FETCH_CHUNK_SIZE)) + offsets = list(range(0, self.release_count, self.FETCH_CHUNK_SIZE)) return [r for offset in offsets for r in self.get_releases(offset)] def get_releases(self, offset: int) -> list[JSONDict]: return self.mb_api.get_json( self.releases_url, - params={"limit": FETCH_CHUNK_SIZE, "offset": offset}, + params={"limit": self.FETCH_CHUNK_SIZE, "offset": offset}, )["releases"] - @staticmethod - def get_id_chunks(id_list: list[str]) -> Iterator[list[str]]: - for i in range(0, len(id_list), SUBMISSION_CHUNK_SIZE): - yield id_list[i : i + SUBMISSION_CHUNK_SIZE] + @classmethod + def get_id_chunks(cls, id_list: list[str]) -> Iterator[list[str]]: + for i in range(0, len(id_list), cls.SUBMISSION_CHUNK_SIZE): + yield id_list[i : i + cls.SUBMISSION_CHUNK_SIZE] def add_releases(self, releases: list[str]) -> None: for chunk in self.get_id_chunks(releases): @@ -117,7 +121,7 @@ def submit_albums(collection: MBCollection, release_ids): class MusicBrainzCollectionPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( { @@ -133,7 +137,8 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): def mb_api(self) -> MusicBrainzUserAPI: return MusicBrainzUserAPI() - def _get_collection(self) -> MBCollection: + @cached_property + def collection(self) -> MBCollection: if not (collections := self.mb_api.get_collections()): raise ui.UserError("no collections exist for user") @@ -155,9 +160,6 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): return MBCollection(collection, self.mb_api) - def _get_albums_in_collection(self, collection: MBCollection) -> set[str]: - return {r["id"] for r in collection.releases} - def commands(self): mbupdate = Subcommand("mbupdate", help="Update MusicBrainz collection") mbupdate.parser.add_option( @@ -171,38 +173,33 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): mbupdate.func = self.update_collection return [mbupdate] - def remove_missing(self, collection: MBCollection, lib_albums): - lib_ids = {x.mb_albumid for x in lib_albums} - albums_in_collection = self._get_albums_in_collection(collection) - collection.remove_releases(list(albums_in_collection - lib_ids)) - - def update_collection(self, lib, opts, args): + def update_collection(self, lib: Library, opts, args) -> None: self.config.set_args(opts) remove_missing = self.config["remove"].get(bool) self.update_album_list(lib, lib.albums(), remove_missing) - def imported(self, session, task): + def imported(self, session: ImportSession, task: ImportTask) -> None: """Add each imported album to the collection.""" if task.is_album: - self.update_album_list(session.lib, [task.album]) + self.update_album_list( + session.lib, [task.album], remove_missing=False + ) - def update_album_list(self, lib, album_list, remove_missing=False): + def update_album_list( + self, lib: Library, albums: Iterable[Album], remove_missing: bool + ) -> None: """Update the MusicBrainz collection from a list of Beets albums""" - collection = self._get_collection() + collection = self.collection # Get a list of all the album IDs. - album_ids = [] - for album in album_list: - aid = album.mb_albumid - if aid: - if re.match(UUID_REGEX, aid): - album_ids.append(aid) - else: - self._log.info("skipping invalid MBID: {}", aid) + album_ids = [id_ for a in albums if UUID_PAT.match(id_ := a.mb_albumid)] # Submit to MusicBrainz. self._log.info("Updating MusicBrainz collection {}...", collection.id) - submit_albums(collection, album_ids) + collection.add_releases(album_ids) if remove_missing: - self.remove_missing(collection, lib.albums()) + lib_ids = {x.mb_albumid for x in lib.albums()} + albums_in_collection = {r["id"] for r in collection.releases} + collection.remove_releases(list(albums_in_collection - lib_ids)) + self._log.info("...MusicBrainz collection updated.") diff --git a/test/plugins/test_mbcollection.py b/test/plugins/test_mbcollection.py index 93dbcab64..adfadc103 100644 --- a/test/plugins/test_mbcollection.py +++ b/test/plugins/test_mbcollection.py @@ -5,47 +5,31 @@ from contextlib import nullcontext as does_not_raise import pytest from beets.library import Album -from beets.test.helper import ConfigMixin +from beets.test.helper import PluginMixin, TestHelper from beets.ui import UserError from beetsplug import mbcollection -@pytest.fixture -def collection(): - return mbcollection.MBCollection( - {"id": str(uuid.uuid4()), "release-count": 150} - ) - - -class TestMbCollectionAPI: - """Tests for the low-level MusicBrainz API wrapper functions.""" - - def test_submit_albums_batches(self, collection, requests_mock): - # Chunk size is 200. Create 250 IDs. - ids = [f"id{i}" for i in range(250)] - requests_mock.put( - f"/ws/2/collection/{collection.id}/releases/{';'.join(ids[:200])}" - ) - requests_mock.put( - f"/ws/2/collection/{collection.id}/releases/{';'.join(ids[200:])}" - ) - - mbcollection.submit_albums(collection, ids) - - -class TestMbCollectionPlugin(ConfigMixin): +class TestMbCollectionPlugin(PluginMixin, TestHelper): """Tests for the MusicBrainzCollectionPlugin class methods.""" + plugin = "mbcollection" + COLLECTION_ID = str(uuid.uuid4()) - @pytest.fixture - def plugin(self): + @pytest.fixture(autouse=True) + def setup_config(self): self.config["musicbrainz"]["user"] = "testuser" self.config["musicbrainz"]["pass"] = "testpass" + self.config["mbcollection"]["collection"] = self.COLLECTION_ID - plugin = mbcollection.MusicBrainzCollectionPlugin() - plugin.config["collection"] = self.COLLECTION_ID - return plugin + @pytest.fixture(autouse=True) + def helper(self): + self.setup_beets() + + yield self + + self.teardown_beets() @pytest.mark.parametrize( "user_collections,expectation", @@ -69,69 +53,90 @@ class TestMbCollectionPlugin(ConfigMixin): does_not_raise(), ), ], + ids=["no collections", "no release collections", "invalid ID", "valid"], ) def test_get_collection_validation( - self, plugin, requests_mock, user_collections, expectation + self, requests_mock, user_collections, expectation ): requests_mock.get( "/ws/2/collection", json={"collections": user_collections} ) with expectation: - plugin._get_collection() + mbcollection.MusicBrainzCollectionPlugin().collection - def test_get_albums_in_collection_pagination( - self, plugin, requests_mock, collection - ): - releases = [{"id": str(i)} for i in range(collection.release_count)] + def test_mbupdate(self, helper, requests_mock, monkeypatch): + """Verify mbupdate sync of a MusicBrainz collection with the library. + + This test ensures that the command: + - fetches collection releases using paginated requests, + - submits releases that exist locally but are missing from the remote + collection + - and removes releases from the remote collection that are not in the + local library. Small chunk sizes are forced to exercise pagination and + batching logic. + """ + for mb_albumid in [ + # already present in remote collection + "in_collection1", + "in_collection2", + # two new albums not in remote collection + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + ]: + helper.lib.add(Album(mb_albumid=mb_albumid)) + + # The relevant collection requests_mock.get( - re.compile( - rf".*/ws/2/collection/{collection.id}/releases\b.*&offset=0.*" - ), - json={"releases": releases[:100]}, - ) - requests_mock.get( - re.compile( - rf".*/ws/2/collection/{collection.id}/releases\b.*&offset=100.*" - ), - json={"releases": releases[100:]}, + "/ws/2/collection", + json={ + "collections": [ + { + "id": self.COLLECTION_ID, + "entity-type": "release", + "release-count": 3, + } + ] + }, ) - plugin._get_albums_in_collection(collection) - - def test_update_album_list_filtering(self, plugin, collection, monkeypatch): - ids_submitted = [] - - def mock_submit(_, album_ids): - ids_submitted.extend(album_ids) - - monkeypatch.setattr("beetsplug.mbcollection.submit_albums", mock_submit) - monkeypatch.setattr(plugin, "_get_collection", lambda: collection) - - albums = [ - Album(mb_albumid="invalid-id"), - Album(mb_albumid="00000000-0000-0000-0000-000000000001"), - ] - - plugin.update_album_list(None, albums) - # Behavior: only valid UUID was submitted - assert ids_submitted == ["00000000-0000-0000-0000-000000000001"] - - def test_remove_missing( - self, plugin, monkeypatch, requests_mock, collection - ): - removed_ids = [] - - def mock_remove(_, chunk): - removed_ids.extend(chunk) - - requests_mock.delete( - re.compile(rf".*/ws/2/collection/{collection.id}/releases/r3") - ) + collection_releases = f"/ws/2/collection/{self.COLLECTION_ID}/releases" + # Force small fetch chunk to require multiple paged requests. monkeypatch.setattr( - plugin, "_get_albums_in_collection", lambda _: {"r1", "r2", "r3"} + "beetsplug.mbcollection.MBCollection.FETCH_CHUNK_SIZE", 2 + ) + # 3 releases are fetched in two pages. + requests_mock.get( + re.compile(rf".*{collection_releases}\b.*&offset=0.*"), + json={ + "releases": [{"id": "in_collection1"}, {"id": "not_in_library"}] + }, + ) + requests_mock.get( + re.compile(rf".*{collection_releases}\b.*&offset=2.*"), + json={"releases": [{"id": "in_collection2"}]}, ) - lib_albums = [Album(mb_albumid="r1"), Album(mb_albumid="r2")] + # Force small submission chunk + monkeypatch.setattr( + "beetsplug.mbcollection.MBCollection.SUBMISSION_CHUNK_SIZE", 1 + ) + # so that releases are added using two requests + requests_mock.put( + re.compile( + rf".*{collection_releases}/00000000-0000-0000-0000-000000000001" + ) + ) + requests_mock.put( + re.compile( + rf".*{collection_releases}/00000000-0000-0000-0000-000000000002" + ) + ) + # and finally, one release is removed + requests_mock.delete( + re.compile(rf".*{collection_releases}/not_in_library") + ) - plugin.remove_missing(collection, lib_albums) + helper.run_command("mbupdate", "--remove") + + assert requests_mock.call_count == 6 From 34d993c043175179712f033a9a0b14a0c0d496ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 03:28:50 +0000 Subject: [PATCH 193/274] Add a changelog note --- docs/changelog.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b9e21aae9..0e2f757dc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -109,6 +109,15 @@ Other changes: unavailable, enabling ``importorskip`` usage in pytest setup. - Finally removed gmusic plugin and all related code/docs as the Google Play Music service was shut down in 2020. +- Replaced dependency on ``python-musicbrainzngs`` with a lightweight custom + MusicBrainz client implementation and updated relevant plugins accordingly: + + - :doc:`plugins/listenbrainz` + - :doc:`plugins/mbcollection` + - :doc:`plugins/mbpseudo` + - :doc:`plugins/missing` + - :doc:`plugins/musicbrainz` + - :doc:`plugins/parentwork` 2.5.1 (October 14, 2025) ------------------------ From 1447f49b72e6481ffe1c65d9b041c67ccf53df65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 21:42:48 +0000 Subject: [PATCH 194/274] Add some documentation to musicbrainz api mixins --- beetsplug/_utils/musicbrainz.py | 31 ++++++++++++++++++++++- beetsplug/mbcollection.py | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 17a83dd9b..47a2550f0 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -1,3 +1,13 @@ +"""Helpers for communicating with the MusicBrainz webservice. + +Provides rate-limited HTTP session and convenience methods to fetch and +normalize API responses. + +This module centralizes request handling and response shaping so callers can +work with consistently structured data without embedding HTTP or rate-limit +logic throughout the codebase. +""" + from __future__ import annotations import operator @@ -21,11 +31,22 @@ log = logging.getLogger(__name__) class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): - pass + """HTTP session that enforces rate limits.""" @dataclass class MusicBrainzAPI(RequestHandler): + """High-level interface to the MusicBrainz WS/2 API. + + Responsibilities: + - Configure the API host and request rate from application configuration. + - Offer helpers to fetch common entity types and to run searches. + - Normalize MusicBrainz responses so relation lists are grouped by target + type for easier downstream consumption. + + Documentation: https://musicbrainz.org/doc/MusicBrainz_API + """ + api_host: str = field(init=False) rate_limit: float = field(init=False) @@ -67,6 +88,12 @@ class MusicBrainzAPI(RequestHandler): def get_entity( self, entity: str, includes: list[str] | None = None, **kwargs ) -> JSONDict: + """Retrieve and normalize data from the API entity endpoint. + + If requested, includes are appended to the request. The response is + passed through a normalizer that groups relation entries by their + target type so that callers receive a consistently structured mapping. + """ if includes: kwargs["inc"] = "+".join(includes) @@ -154,6 +181,8 @@ class MusicBrainzAPI(RequestHandler): class MusicBrainzAPIMixin: + """Mixin that provides a cached MusicBrainzAPI helper instance.""" + @cached_property def mb_api(self) -> MusicBrainzAPI: return MusicBrainzAPI() diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 95ceb3fcf..25f16228a 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -43,6 +43,19 @@ UUID_PAT = re.compile(r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$") @dataclass class MusicBrainzUserAPI(MusicBrainzAPI): + """MusicBrainz API client with user authentication. + + In order to retrieve private user collections and modify them, we need to + authenticate the requests with the user's MusicBrainz credentials. + + See documentation for authentication details: + https://musicbrainz.org/doc/MusicBrainz_API#Authentication + + Note that the documentation misleadingly states HTTP 'basic' authentication, + and I had to reverse-engineer musicbrainzngs to discover that it actually + uses HTTP 'digest' authentication. + """ + auth: HTTPDigestAuth = field(init=False) @cached_property @@ -57,12 +70,18 @@ class MusicBrainzUserAPI(MusicBrainzAPI): ) def request(self, *args, **kwargs) -> Response: + """Authenticate and include required client param in all requests.""" kwargs.setdefault("params", {}) kwargs["params"]["client"] = f"beets-{__version__}" kwargs["auth"] = self.auth return super().request(*args, **kwargs) def get_collections(self) -> list[JSONDict]: + """Get all collections for the authenticated user. + + Note that both URL parameters must be included to retrieve private + collections. + """ return self.get_entity( "collection", editor=self.user, includes=["user-collections"] ).get("collections", []) @@ -70,6 +89,13 @@ class MusicBrainzUserAPI(MusicBrainzAPI): @dataclass class MBCollection: + """Representation of a user's MusicBrainz collection. + + Provides convenient, chunked operations for retrieving releases and updating + the collection via the MusicBrainz web API. Fetch and submission limits are + controlled by class-level constants to avoid oversized requests. + """ + SUBMISSION_CHUNK_SIZE: ClassVar[int] = 200 FETCH_CHUNK_SIZE: ClassVar[int] = 100 @@ -78,22 +104,31 @@ class MBCollection: @property def id(self) -> str: + """Unique identifier assigned to the collection by MusicBrainz.""" return self.data["id"] @property def release_count(self) -> int: + """Total number of releases recorded in the collection.""" return self.data["release-count"] @property def releases_url(self) -> str: + """Complete API endpoint URL for listing releases in this collection.""" return f"{self.mb_api.api_root}/collection/{self.id}/releases" @property def releases(self) -> list[JSONDict]: + """Retrieve all releases in the collection, fetched in successive pages. + + The fetch is performed in chunks and returns a flattened sequence of + release records. + """ offsets = list(range(0, self.release_count, self.FETCH_CHUNK_SIZE)) return [r for offset in offsets for r in self.get_releases(offset)] def get_releases(self, offset: int) -> list[JSONDict]: + """Fetch a single page of releases beginning at a given position.""" return self.mb_api.get_json( self.releases_url, params={"limit": self.FETCH_CHUNK_SIZE, "offset": offset}, @@ -101,15 +136,24 @@ class MBCollection: @classmethod def get_id_chunks(cls, id_list: list[str]) -> Iterator[list[str]]: + """Yield successive sublists of identifiers sized for safe submission. + + Splits a long sequence of identifiers into batches that respect the + service's submission limits to avoid oversized requests. + """ for i in range(0, len(id_list), cls.SUBMISSION_CHUNK_SIZE): yield id_list[i : i + cls.SUBMISSION_CHUNK_SIZE] def add_releases(self, releases: list[str]) -> None: + """Add releases to the collection in batches.""" for chunk in self.get_id_chunks(releases): + # Need to escape semicolons: https://github.com/psf/requests/issues/6990 self.mb_api.put(f"{self.releases_url}/{'%3B'.join(chunk)}") def remove_releases(self, releases: list[str]) -> None: + """Remove releases from the collection in chunks.""" for chunk in self.get_id_chunks(releases): + # Need to escape semicolons: https://github.com/psf/requests/issues/6990 self.mb_api.delete(f"{self.releases_url}/{'%3B'.join(chunk)}") From 55b9c1c145954c8be9f2e4792068287c7e3f4a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 24 Dec 2025 22:19:13 +0000 Subject: [PATCH 195/274] Retry on server errors too --- beetsplug/_utils/requests.py | 15 +++++++++++++-- test/plugins/utils/test_request_handler.py | 13 +++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index b8ac541e9..313ed13b4 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -67,7 +67,7 @@ class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta): * default beets User-Agent header * default request timeout - * automatic retries on transient connection errors + * automatic retries on transient connection or server errors * raises exceptions for HTTP error status codes """ @@ -75,7 +75,18 @@ class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta): super().__init__(*args, **kwargs) self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/" - retry = Retry(connect=2, total=2, backoff_factor=1) + retry = Retry( + connect=2, + total=2, + backoff_factor=1, + # Retry on server errors + status_forcelist=[ + HTTPStatus.INTERNAL_SERVER_ERROR, + HTTPStatus.BAD_GATEWAY, + HTTPStatus.SERVICE_UNAVAILABLE, + HTTPStatus.GATEWAY_TIMEOUT, + ], + ) adapter = HTTPAdapter(max_retries=retry) self.mount("https://", adapter) self.mount("http://", adapter) diff --git a/test/plugins/utils/test_request_handler.py b/test/plugins/utils/test_request_handler.py index c17a9387b..6887283dc 100644 --- a/test/plugins/utils/test_request_handler.py +++ b/test/plugins/utils/test_request_handler.py @@ -48,11 +48,20 @@ class TestRequestHandlerRetry: assert response.status_code == HTTPStatus.OK @pytest.mark.parametrize( - "last_response", [ConnectionResetError], ids=["conn_error"] + "last_response", + [ + ConnectionResetError, + HTTPResponse( + body=io.BytesIO(b"Server Error"), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + preload_content=False, + ), + ], + ids=["conn_error", "server_error"], ) def test_retry_exhaustion(self, request_handler): """Verify that the handler raises an error after exhausting retries.""" with pytest.raises( - requests.exceptions.ConnectionError, match="Max retries exceeded" + requests.exceptions.RequestException, match="Max retries exceeded" ): request_handler.get("http://example.com/api") From 59b02bc49b60ae41a040b51fc4bf783804f876b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 25 Dec 2025 22:20:44 +0000 Subject: [PATCH 196/274] Type MusicBrainzAPI properly --- beetsplug/_utils/musicbrainz.py | 140 +++++++++++++++++++++++++++----- beetsplug/listenbrainz.py | 2 +- beetsplug/mbcollection.py | 21 ++--- beetsplug/musicbrainz.py | 2 +- 4 files changed, 129 insertions(+), 36 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 47a2550f0..2fc821df9 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -12,17 +12,20 @@ from __future__ import annotations import operator from dataclasses import dataclass, field -from functools import cached_property, singledispatchmethod +from functools import cached_property, singledispatchmethod, wraps from itertools import groupby -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypedDict, TypeVar from requests_ratelimiter import LimiterMixin +from typing_extensions import NotRequired, Unpack from beets import config, logging from .requests import RequestHandler, TimeoutAndRetrySession if TYPE_CHECKING: + from collections.abc import Callable + from requests import Response from .._typing import JSONDict @@ -34,11 +37,80 @@ class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): """HTTP session that enforces rate limits.""" +Entity = Literal[ + "area", + "artist", + "collection", + "event", + "genre", + "instrument", + "label", + "place", + "recording", + "release", + "release-group", + "series", + "work", + "url", +] + + +class LookupKwargs(TypedDict, total=False): + includes: NotRequired[list[str]] + + +class PagingKwargs(TypedDict, total=False): + limit: NotRequired[int] + offset: NotRequired[int] + + +class SearchKwargs(PagingKwargs): + query: NotRequired[str] + + +class BrowseKwargs(LookupKwargs, PagingKwargs, total=False): + pass + + +class BrowseReleaseGroupsKwargs(BrowseKwargs, total=False): + artist: NotRequired[str] + collection: NotRequired[str] + release: NotRequired[str] + + +class BrowseRecordingsKwargs(BrowseReleaseGroupsKwargs, total=False): + work: NotRequired[str] + + +P = ParamSpec("P") +R = TypeVar("R") + + +def require_one_of(*keys: str) -> Callable[[Callable[P, R]], Callable[P, R]]: + required = frozenset(keys) + + def deco(func: Callable[P, R]) -> Callable[P, R]: + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + # kwargs is a real dict at runtime; safe to inspect here + if not required & kwargs.keys(): + required_str = ", ".join(sorted(required)) + raise ValueError( + f"At least one of {required_str} filter is required" + ) + return func(*args, **kwargs) + + return wrapper + + return deco + + @dataclass class MusicBrainzAPI(RequestHandler): """High-level interface to the MusicBrainz WS/2 API. Responsibilities: + - Configure the API host and request rate from application configuration. - Offer helpers to fetch common entity types and to run searches. - Normalize MusicBrainz responses so relation lists are grouped by target @@ -85,10 +157,10 @@ class MusicBrainzAPI(RequestHandler): kwargs["params"]["fmt"] = "json" return super().request(*args, **kwargs) - def get_entity( - self, entity: str, includes: list[str] | None = None, **kwargs + def _get_resource( + self, resource: str, includes: list[str] | None = None, **kwargs ) -> JSONDict: - """Retrieve and normalize data from the API entity endpoint. + """Retrieve and normalize data from the API resource endpoint. If requested, includes are appended to the request. The response is passed through a normalizer that groups relation entries by their @@ -98,11 +170,22 @@ class MusicBrainzAPI(RequestHandler): kwargs["inc"] = "+".join(includes) return self._group_relations( - self.get_json(f"{self.api_root}/{entity}", params=kwargs) + self.get_json(f"{self.api_root}/{resource}", params=kwargs) ) - def search_entity( - self, entity: str, filters: dict[str, str], **kwargs + def _lookup( + self, entity: Entity, id_: str, **kwargs: Unpack[LookupKwargs] + ) -> JSONDict: + return self._get_resource(f"{entity}/{id_}", **kwargs) + + def _browse(self, entity: Entity, **kwargs) -> list[JSONDict]: + return self._get_resource(entity, **kwargs).get(f"{entity}s", []) + + def search( + self, + entity: Entity, + filters: dict[str, str], + **kwargs: Unpack[SearchKwargs], ) -> list[JSONDict]: """Search for MusicBrainz entities matching the given filters. @@ -119,22 +202,41 @@ class MusicBrainzAPI(RequestHandler): ) log.debug("Searching for MusicBrainz {}s with: {!r}", entity, query) kwargs["query"] = query - return self.get_entity(entity, **kwargs)[f"{entity}s"] + return self._get_resource(entity, **kwargs)[f"{entity}s"] - def get_release(self, id_: str, **kwargs) -> JSONDict: - return self.get_entity(f"release/{id_}", **kwargs) + def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict: + """Retrieve a release by its MusicBrainz ID.""" + return self._lookup("release", id_, **kwargs) - def get_recording(self, id_: str, **kwargs) -> JSONDict: - return self.get_entity(f"recording/{id_}", **kwargs) + def get_recording( + self, id_: str, **kwargs: Unpack[LookupKwargs] + ) -> JSONDict: + """Retrieve a recording by its MusicBrainz ID.""" + return self._lookup("recording", id_, **kwargs) - def get_work(self, id_: str, **kwargs) -> JSONDict: - return self.get_entity(f"work/{id_}", **kwargs) + def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict: + """Retrieve a work by its MusicBrainz ID.""" + return self._lookup("work", id_, **kwargs) - def browse_recordings(self, **kwargs) -> list[JSONDict]: - return self.get_entity("recording", **kwargs)["recordings"] + @require_one_of("artist", "collection", "release", "work") + def browse_recordings( + self, **kwargs: Unpack[BrowseRecordingsKwargs] + ) -> list[JSONDict]: + """Browse recordings related to the given entities. - def browse_release_groups(self, **kwargs) -> list[JSONDict]: - return self.get_entity("release-group", **kwargs)["release-groups"] + At least one of artist, collection, release, or work must be provided. + """ + return self._browse("recording", **kwargs) + + @require_one_of("artist", "collection", "release") + def browse_release_groups( + self, **kwargs: Unpack[BrowseReleaseGroupsKwargs] + ) -> list[JSONDict]: + """Browse release groups related to the given entities. + + At least one of artist, collection, or release must be provided. + """ + return self._get_resource("release-group", **kwargs)["release-groups"] @singledispatchmethod @classmethod diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index d054a00cc..fa73bd6b8 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -132,7 +132,7 @@ class ListenBrainzPlugin(MusicBrainzAPIMixin, BeetsPlugin): def get_mb_recording_id(self, track) -> str | None: """Returns the MusicBrainz recording ID for a track.""" - results = self.mb_api.search_entity( + results = self.mb_api.search( "recording", { "": track["track_metadata"].get("track_name"), diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 25f16228a..f89670dd3 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -58,15 +58,12 @@ class MusicBrainzUserAPI(MusicBrainzAPI): auth: HTTPDigestAuth = field(init=False) - @cached_property - def user(self) -> str: - return config["musicbrainz"]["user"].as_str() - def __post_init__(self) -> None: super().__post_init__() config["musicbrainz"]["pass"].redact = True self.auth = HTTPDigestAuth( - self.user, config["musicbrainz"]["pass"].as_str() + config["musicbrainz"]["user"].as_str(), + config["musicbrainz"]["pass"].as_str(), ) def request(self, *args, **kwargs) -> Response: @@ -76,15 +73,9 @@ class MusicBrainzUserAPI(MusicBrainzAPI): kwargs["auth"] = self.auth return super().request(*args, **kwargs) - def get_collections(self) -> list[JSONDict]: - """Get all collections for the authenticated user. - - Note that both URL parameters must be included to retrieve private - collections. - """ - return self.get_entity( - "collection", editor=self.user, includes=["user-collections"] - ).get("collections", []) + def browse_collections(self) -> list[JSONDict]: + """Get all collections for the authenticated user.""" + return self._browse("collection") @dataclass @@ -183,7 +174,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): @cached_property def collection(self) -> MBCollection: - if not (collections := self.mb_api.get_collections()): + if not (collections := self.mb_api.browse_collections()): raise ui.UserError("no collections exist for user") # Get all release collection IDs, avoiding event collections diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 990f21351..3e194c067 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -751,7 +751,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): using the provided criteria. Handles API errors by converting them into MusicBrainzAPIError exceptions with contextual information. """ - return self.mb_api.search_entity( + return self.mb_api.search( query_type, filters, limit=self.config["search_limit"].get() ) From d4b00ab4f47785c24fa03a14fb1bf3e1ad4e5d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 25 Dec 2025 22:21:22 +0000 Subject: [PATCH 197/274] Add request handler utils to the docs --- beetsplug/_utils/requests.py | 15 +- docs/_templates/autosummary/class.rst | 11 + docs/api/index.rst | 1 + docs/api/plugin_utilities.rst | 16 + docs/changelog.rst | 24 +- docs/conf.py | 13 + poetry.lock | 403 +++++++++++++++++++++++++- pyproject.toml | 2 + 8 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 docs/api/plugin_utilities.rst diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 313ed13b4..92d52c9d6 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -113,18 +113,20 @@ class RequestHandler: subclasses. Usage: - Subclass and override :class:`RequestHandler.session_type`, + Subclass and override :class:`RequestHandler.create_session`, :class:`RequestHandler.explicit_http_errors` or :class:`RequestHandler.status_to_error()` to customize behavior. - Use - * :class:`RequestHandler.get_json()` to get JSON response data - * :class:`RequestHandler.get()` to get HTTP response object - * :class:`RequestHandler.request()` to invoke arbitrary HTTP methods + Use - Feel free to define common methods that are used in multiple plugins. + - :class:`RequestHandler.get_json()` to get JSON response data + - :class:`RequestHandler.get()` to get HTTP response object + - :class:`RequestHandler.request()` to invoke arbitrary HTTP methods + + Feel free to define common methods that are used in multiple plugins. """ + #: List of custom exceptions to be raised for specific status codes. explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [ HTTPNotFoundError ] @@ -138,7 +140,6 @@ class RequestHandler: @cached_property def session(self) -> TimeoutAndRetrySession: - """Lazily initialize and cache the HTTP session.""" return self.create_session() def status_to_error( diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index 586b207b7..3259e9279 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -25,3 +25,14 @@ {% endblock %} .. rubric:: {{ _('Methods definition') }} + +{% if objname in related_typeddicts %} +Related TypedDicts +------------------ + +{% for typeddict in related_typeddicts[objname] %} +.. autotypeddict:: {{ typeddict }} + :show-inheritance: + +{% endfor %} +{% endif %} diff --git a/docs/api/index.rst b/docs/api/index.rst index edec5fe96..a1ecc4f72 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -6,4 +6,5 @@ API Reference :titlesonly: plugins + plugin_utilities database diff --git a/docs/api/plugin_utilities.rst b/docs/api/plugin_utilities.rst new file mode 100644 index 000000000..8c4355a43 --- /dev/null +++ b/docs/api/plugin_utilities.rst @@ -0,0 +1,16 @@ +Plugin Utilities +================ + +.. currentmodule:: beetsplug._utils.requests + +.. autosummary:: + :toctree: generated/ + + RequestHandler + +.. currentmodule:: beetsplug._utils.musicbrainz + +.. autosummary:: + :toctree: generated/ + + MusicBrainzAPI diff --git a/docs/changelog.rst b/docs/changelog.rst index 0e2f757dc..dda437b40 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -91,6 +91,21 @@ For plugin developers: - A new plugin event, ``album_matched``, is sent when an album that is being imported has been matched to its metadata and the corresponding distance has been calculated. +- Added a reusable requests handler which can be used by plugins to make HTTP + requests with built-in retry and backoff logic. It uses beets user-agent and + configures timeouts. See :class:`~beetsplug._utils.requests.RequestHandler` + for documentation. +- Replaced dependency on ``python-musicbrainzngs`` with a lightweight custom + MusicBrainz client implementation and updated relevant plugins accordingly: + + - :doc:`plugins/listenbrainz` + - :doc:`plugins/mbcollection` + - :doc:`plugins/mbpseudo` + - :doc:`plugins/missing` + - :doc:`plugins/musicbrainz` + - :doc:`plugins/parentwork` + + See :class:`~beetsplug._utils.musicbrainz.MusicBrainzAPI` for documentation. For packagers: @@ -109,15 +124,6 @@ Other changes: unavailable, enabling ``importorskip`` usage in pytest setup. - Finally removed gmusic plugin and all related code/docs as the Google Play Music service was shut down in 2020. -- Replaced dependency on ``python-musicbrainzngs`` with a lightweight custom - MusicBrainz client implementation and updated relevant plugins accordingly: - - - :doc:`plugins/listenbrainz` - - :doc:`plugins/mbcollection` - - :doc:`plugins/mbpseudo` - - :doc:`plugins/missing` - - :doc:`plugins/musicbrainz` - - :doc:`plugins/parentwork` 2.5.1 (October 14, 2025) ------------------------ diff --git a/docs/conf.py b/docs/conf.py index 8d2bae130..c04e034ab 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,9 +32,22 @@ extensions = [ "sphinx_design", "sphinx_copybutton", "conf", + "sphinx_toolbox.more_autodoc.autotypeddict", ] autosummary_generate = True +autosummary_context = { + "related_typeddicts": { + "MusicBrainzAPI": [ + "beetsplug._utils.musicbrainz.LookupKwargs", + "beetsplug._utils.musicbrainz.SearchKwargs", + "beetsplug._utils.musicbrainz.BrowseKwargs", + "beetsplug._utils.musicbrainz.BrowseRecordingsKwargs", + "beetsplug._utils.musicbrainz.BrowseReleaseGroupsKwargs", + ], + } +} +autodoc_member_order = "bysource" exclude_patterns = ["_build"] templates_path = ["_templates"] source_suffix = {".rst": "restructuredtext", ".md": "markdown"} diff --git a/poetry.lock b/poetry.lock index 47c07e14f..5a0832399 100644 --- a/poetry.lock +++ b/poetry.lock @@ -49,6 +49,42 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.31.0)"] +[[package]] +name = "apeye" +version = "1.4.1" +description = "Handy tools for working with URLs and APIs." +optional = true +python-versions = ">=3.6.1" +files = [ + {file = "apeye-1.4.1-py3-none-any.whl", hash = "sha256:44e58a9104ec189bf42e76b3a7fe91e2b2879d96d48e9a77e5e32ff699c9204e"}, + {file = "apeye-1.4.1.tar.gz", hash = "sha256:14ea542fad689e3bfdbda2189a354a4908e90aee4bf84c15ab75d68453d76a36"}, +] + +[package.dependencies] +apeye-core = ">=1.0.0b2" +domdf-python-tools = ">=2.6.0" +platformdirs = ">=2.3.0" +requests = ">=2.24.0" + +[package.extras] +all = ["cachecontrol[filecache] (>=0.12.6)", "lockfile (>=0.12.2)"] +limiter = ["cachecontrol[filecache] (>=0.12.6)", "lockfile (>=0.12.2)"] + +[[package]] +name = "apeye-core" +version = "1.1.5" +description = "Core (offline) functionality for the apeye library." +optional = true +python-versions = ">=3.6.1" +files = [ + {file = "apeye_core-1.1.5-py3-none-any.whl", hash = "sha256:dc27a93f8c9e246b3b238c5ea51edf6115ab2618ef029b9f2d9a190ec8228fbf"}, + {file = "apeye_core-1.1.5.tar.gz", hash = "sha256:5de72ed3d00cc9b20fea55e54b7ab8f5ef8500eb33a5368bc162a5585e238a55"}, +] + +[package.dependencies] +domdf-python-tools = ">=2.6.0" +idna = ">=2.5" + [[package]] name = "appdirs" version = "1.4.4" @@ -138,6 +174,20 @@ gi = ["pygobject (>=3.54.2,<4.0.0)"] mad = ["pymad[mad] (>=0.11.3,<0.12.0)"] test = ["pytest (>=8.4.2)", "pytest-cov (>=7.0.0)"] +[[package]] +name = "autodocsumm" +version = "0.2.14" +description = "Extended sphinx autodoc including automatic autosummaries" +optional = true +python-versions = ">=3.7" +files = [ + {file = "autodocsumm-0.2.14-py3-none-any.whl", hash = "sha256:3bad8717fc5190802c60392a7ab04b9f3c97aa9efa8b3780b3d81d615bfe5dc0"}, + {file = "autodocsumm-0.2.14.tar.gz", hash = "sha256:2839a9d4facc3c4eccd306c08695540911042b46eeafcdc3203e6d0bab40bc77"}, +] + +[package.dependencies] +Sphinx = ">=4.0,<9.0" + [[package]] name = "babel" version = "2.17.0" @@ -405,6 +455,27 @@ files = [ [package.dependencies] cffi = ">=1.0.0" +[[package]] +name = "cachecontrol" +version = "0.14.4" +description = "httplib2 caching for requests" +optional = true +python-versions = ">=3.10" +files = [ + {file = "cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b"}, + {file = "cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1"}, +] + +[package.dependencies] +filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["cachecontrol[filecache,redis]", "cheroot (>=11.1.2)", "cherrypy", "codespell", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + [[package]] name = "certifi" version = "2025.10.5" @@ -795,6 +866,24 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "cssutils" +version = "2.11.1" +description = "A CSS Cascading Style Sheets library for Python" +optional = true +python-versions = ">=3.8" +files = [ + {file = "cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1"}, + {file = "cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["cssselect", "importlib-resources", "jaraco.test (>=5.1)", "lxml", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + [[package]] name = "dbus-python" version = "1.4.0" @@ -820,6 +909,21 @@ files = [ {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, ] +[[package]] +name = "dict2css" +version = "0.3.0.post1" +description = "A μ-library for constructing cascading style sheets from Python dictionaries." +optional = true +python-versions = ">=3.6" +files = [ + {file = "dict2css-0.3.0.post1-py3-none-any.whl", hash = "sha256:f006a6b774c3e31869015122ae82c491fd25e7de4a75607a62aa3e798f837e0d"}, + {file = "dict2css-0.3.0.post1.tar.gz", hash = "sha256:89c544c21c4ca7472c3fffb9d37d3d926f606329afdb751dc1de67a411b70719"}, +] + +[package.dependencies] +cssutils = ">=2.2.0" +domdf-python-tools = ">=2.2.0" + [[package]] name = "docstrfmt" version = "1.11.1" @@ -860,6 +964,25 @@ files = [ {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] +[[package]] +name = "domdf-python-tools" +version = "3.10.0" +description = "Helpful functions for Python 🐍 🛠️" +optional = true +python-versions = ">=3.6" +files = [ + {file = "domdf_python_tools-3.10.0-py3-none-any.whl", hash = "sha256:5e71c1be71bbcc1f881d690c8984b60e64298ec256903b3147f068bc33090c36"}, + {file = "domdf_python_tools-3.10.0.tar.gz", hash = "sha256:2ae308d2f4f1e9145f5f4ba57f840fbfd1c2983ee26e4824347789649d3ae298"}, +] + +[package.dependencies] +natsort = ">=7.0.1" +typing-extensions = ">=3.7.4.1" + +[package.extras] +all = ["pytz (>=2019.1)"] +dates = ["pytz (>=2019.1)"] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -877,6 +1000,17 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "filelock" +version = "3.20.2" +description = "A platform independent file lock." +optional = true +python-versions = ">=3.10" +files = [ + {file = "filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8"}, + {file = "filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64"}, +] + [[package]] name = "filetype" version = "1.2.0" @@ -937,6 +1071,27 @@ files = [ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "html5lib" +version = "1.1" +description = "HTML parser based on the WHATWG HTML specification" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d"}, + {file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"}, +] + +[package.dependencies] +six = ">=1.9" +webencodings = "*" + +[package.extras] +all = ["chardet (>=2.2)", "genshi", "lxml"] +chardet = ["chardet (>=2.2)"] +genshi = ["genshi"] +lxml = ["lxml"] + [[package]] name = "httpcore" version = "1.0.9" @@ -1731,6 +1886,17 @@ mutagen = ">=1.46" [package.extras] test = ["tox"] +[[package]] +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = true +python-versions = ">=3.9" +files = [ + {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, + {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, +] + [[package]] name = "msgpack" version = "1.1.2" @@ -1900,6 +2066,21 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "natsort" +version = "8.4.0" +description = "Simple yet flexible natural sorting in Python." +optional = true +python-versions = ">=3.7" +files = [ + {file = "natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c"}, + {file = "natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581"}, +] + +[package.extras] +fast = ["fastnumbers (>=2.0.0)"] +icu = ["PyICU (>=1.0.0)"] + [[package]] name = "numba" version = "0.62.1" @@ -3292,6 +3473,94 @@ files = [ {file = "roman-5.1.tar.gz", hash = "sha256:3a86572e9bc9183e771769601189e5fa32f1620ffeceebb9eca836affb409986"}, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.16" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = true +python-versions = ">=3.8" +files = [ + {file = "ruamel.yaml-0.18.16-py3-none-any.whl", hash = "sha256:048f26d64245bae57a4f9ef6feb5b552a386830ef7a826f235ffb804c59efbba"}, + {file = "ruamel.yaml-0.18.16.tar.gz", hash = "sha256:a6e587512f3c998b2225d68aa1f35111c29fad14aed561a26e73fab729ec5e5a"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""} + +[package.extras] +docs = ["mercurial (>5.7)", "ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = true +python-versions = ">=3.9" +files = [ + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win32.whl", hash = "sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win_amd64.whl", hash = "sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:923816815974425fbb1f1bf57e85eca6e14d8adc313c66db21c094927ad01815"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dcc7f3162d3711fd5d52e2267e44636e3e566d1e5675a5f0b30e98f2c4af7974"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d3c9210219cbc0f22706f19b154c9a798ff65a6beeafbf77fc9c057ec806f7d"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bb7b728fd9f405aa00b4a0b17ba3f3b810d0ccc5f77f7373162e9b5f0ff75d5"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cb75a3c14f1d6c3c2a94631e362802f70e83e20d1f2b2ef3026c05b415c4900"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:badd1d7283f3e5894779a6ea8944cc765138b96804496c91812b2829f70e18a7"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0ba6604bbc3dfcef844631932d06a1a4dcac3fee904efccf582261948431628a"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8220fd4c6f98485e97aea65e1df76d4fed1678ede1fe1d0eed2957230d287c4"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win32.whl", hash = "sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win_amd64.whl", hash = "sha256:27dc656e84396e6d687f97c6e65fb284d100483628f02d95464fd731743a4afe"}, + {file = "ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600"}, +] + [[package]] name = "ruff" version = "0.14.3" @@ -3680,6 +3949,24 @@ docs = ["sphinxcontrib-websupport"] lint = ["flake8 (>=6.0)", "mypy (==1.11.1)", "pyright (==1.1.384)", "pytest (>=6.0)", "ruff (==0.6.9)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.18.0.20240506)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241005)", "types-requests (==2.32.0.20240914)", "types-urllib3 (==1.26.25.14)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.0.1" +description = "Type hints (PEP 484) support for the Sphinx autodoc extension" +optional = true +python-versions = ">=3.10" +files = [ + {file = "sphinx_autodoc_typehints-3.0.1-py3-none-any.whl", hash = "sha256:4b64b676a14b5b79cefb6628a6dc8070e320d4963e8ff640a2f3e9390ae9045a"}, + {file = "sphinx_autodoc_typehints-3.0.1.tar.gz", hash = "sha256:b9b40dd15dee54f6f810c924f863f9cf1c54f9f3265c495140ea01be7f44fa55"}, +] + +[package.dependencies] +sphinx = ">=8.1.3" + +[package.extras] +docs = ["furo (>=2024.8.6)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "defusedxml (>=0.7.1)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "sphobjinv (>=2.3.1.2)", "typing-extensions (>=4.12.2)"] + [[package]] name = "sphinx-copybutton" version = "0.5.2" @@ -3723,6 +4010,22 @@ theme-pydata = ["pydata-sphinx-theme (>=0.15.2,<0.16.0)"] theme-rtd = ["sphinx-rtd-theme (>=2.0,<3.0)"] theme-sbt = ["sphinx-book-theme (>=1.1,<2.0)"] +[[package]] +name = "sphinx-jinja2-compat" +version = "0.4.1" +description = "Patches Jinja2 v3 to restore compatibility with earlier Sphinx versions." +optional = true +python-versions = ">=3.6" +files = [ + {file = "sphinx_jinja2_compat-0.4.1-py3-none-any.whl", hash = "sha256:64ca0d46f0d8029fbe69ea612793a55e6ef0113e1bba4a85d402158c09f17a14"}, + {file = "sphinx_jinja2_compat-0.4.1.tar.gz", hash = "sha256:0188f0802d42c3da72997533b55a00815659a78d3f81d4b4747b1fb15a5728e6"}, +] + +[package.dependencies] +jinja2 = ">=2.10" +markupsafe = ">=1" +standard-imghdr = {version = "3.10.14", markers = "python_version >= \"3.13\""} + [[package]] name = "sphinx-lint" version = "1.0.1" @@ -3741,6 +4044,80 @@ regex = "*" [package.extras] tests = ["pytest", "pytest-cov"] +[[package]] +name = "sphinx-prompt" +version = "1.9.0" +description = "Sphinx directive to add unselectable prompt" +optional = true +python-versions = ">=3.10" +files = [ + {file = "sphinx_prompt-1.9.0-py3-none-any.whl", hash = "sha256:fd731446c03f043d1ff6df9f22414495b23067c67011cc21658ea8d36b3575fc"}, + {file = "sphinx_prompt-1.9.0.tar.gz", hash = "sha256:471b3c6d466dce780a9b167d9541865fd4e9a80ed46e31b06a52a0529ae995a1"}, +] + +[package.dependencies] +certifi = "*" +docutils = "*" +idna = "*" +pygments = "*" +Sphinx = ">=8.0.0,<9.0.0" +urllib3 = "*" + +[[package]] +name = "sphinx-tabs" +version = "3.4.5" +description = "Tabbed views for Sphinx" +optional = true +python-versions = "~=3.7" +files = [ + {file = "sphinx-tabs-3.4.5.tar.gz", hash = "sha256:ba9d0c1e3e37aaadd4b5678449eb08176770e0fc227e769b6ce747df3ceea531"}, + {file = "sphinx_tabs-3.4.5-py3-none-any.whl", hash = "sha256:92cc9473e2ecf1828ca3f6617d0efc0aa8acb06b08c56ba29d1413f2f0f6cf09"}, +] + +[package.dependencies] +docutils = "*" +pygments = "*" +sphinx = "*" + +[package.extras] +code-style = ["pre-commit (==2.13.0)"] +testing = ["bs4", "coverage", "pygments", "pytest (>=7.1,<8)", "pytest-cov", "pytest-regressions", "rinohtype"] + +[[package]] +name = "sphinx-toolbox" +version = "4.1.1" +description = "Box of handy tools for Sphinx 🧰 📔" +optional = true +python-versions = ">=3.7" +files = [ + {file = "sphinx_toolbox-4.1.1-py3-none-any.whl", hash = "sha256:1ee2616091453430ffe41e8371e0ddd22a5c1f504ba2dfb306f50870f3f7672a"}, + {file = "sphinx_toolbox-4.1.1.tar.gz", hash = "sha256:1bb1750bf9e1f72a54161b0867caf3b6bf2ee216ecb9f8c519f0a9348824954a"}, +] + +[package.dependencies] +apeye = ">=0.4.0" +autodocsumm = ">=0.2.0" +beautifulsoup4 = ">=4.9.1" +cachecontrol = {version = ">=0.13.0", extras = ["filecache"]} +dict2css = ">=0.2.3" +docutils = ">=0.16" +domdf-python-tools = ">=2.9.0" +filelock = ">=3.8.0" +html5lib = ">=1.1" +roman = ">4.0" +"ruamel.yaml" = ">=0.16.12,<=0.18.16" +sphinx = ">=3.2.0" +sphinx-autodoc-typehints = ">=1.11.1" +sphinx-jinja2-compat = ">=0.1.0" +sphinx-prompt = ">=1.1.0" +sphinx-tabs = ">=1.2.1,<3.4.7" +tabulate = ">=0.8.7" +typing-extensions = ">=3.7.4.3,<3.10.0.1 || >3.10.0.1" + +[package.extras] +all = ["coincidence (>=0.4.3)", "pygments (>=2.7.4,<=2.13.0)"] +testing = ["coincidence (>=0.4.3)", "pygments (>=2.7.4,<=2.13.0)"] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" @@ -3861,6 +4238,17 @@ files = [ {file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"}, ] +[[package]] +name = "standard-imghdr" +version = "3.10.14" +description = "Standard library imghdr redistribution. \"dead battery\"." +optional = true +python-versions = "*" +files = [ + {file = "standard_imghdr-3.10.14-py3-none-any.whl", hash = "sha256:cdf6883163349624dee9a81d2853a20260337c4cd41c04e99c082e01833a08e2"}, + {file = "standard_imghdr-3.10.14.tar.gz", hash = "sha256:2598fe2e7c540dbda34b233295e10957ab8dc8ac6f3bd9eaa8d38be167232e52"}, +] + [[package]] name = "standard-sunau" version = "3.13.0" @@ -4122,6 +4510,17 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = true +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + [[package]] name = "werkzeug" version = "3.1.3" @@ -4161,7 +4560,7 @@ beatport = ["requests-oauthlib"] bpd = ["PyGObject"] chroma = ["pyacoustid"] discogs = ["python3-discogs-client"] -docs = ["docutils", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"] +docs = ["docutils", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx-toolbox"] embedart = ["Pillow"] embyupdate = ["requests"] fetchart = ["Pillow", "beautifulsoup4", "langdetect", "requests"] @@ -4184,4 +4583,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "cd53b70a9cd746a88e80e04e67e0b010a0e5b87f745be94e901a9fd08619771a" +content-hash = "8a1714daca55eab559558f2d4bd63d4857686eb607bf4b24f1ea6dbd412e6641" diff --git a/pyproject.toml b/pyproject.toml index 8b608a45e..dbfc2715b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,7 @@ pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } sphinx-design = { version = ">=0.6.1", optional = true } sphinx-copybutton = { version = ">=0.5.2", optional = true } +sphinx-toolbox = { version = ">=4.1.0", optional = true } titlecase = { version = "^2.4.1", optional = true } [tool.poetry.group.test.dependencies] @@ -151,6 +152,7 @@ docs = [ "sphinx-lint", "sphinx-design", "sphinx-copybutton", + "sphinx-toolbox", ] discogs = ["python3-discogs-client"] embedart = ["Pillow"] # ImageMagick From a4058218283709edb192be2c31147693e5d690ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 25 Dec 2025 22:23:55 +0000 Subject: [PATCH 198/274] Fix changelog formatting --- docs/changelog.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index dda437b40..13dd15737 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,23 +20,23 @@ New features: - :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and album artist are the same in ftintitle. - :doc:`plugins/play`: Added `$playlist` marker to precisely edit the playlist - filepath into the command calling the player program. -- :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed - to receive extra verbose logging around last.fm results and how they are - resolved. The ``extended_debug`` config setting and ``--debug`` option - have been removed. + filepath into the command calling the player program. +- :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed to + receive extra verbose logging around last.fm results and how they are + resolved. The ``extended_debug`` config setting and ``--debug`` option have + been removed. - :doc:`plugins/importsource`: Added new plugin that tracks original import paths and optionally suggests removing source files when items are removed from the library. - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive - MusicBrainz pseudo-releases as recommendations during import. + MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. - :doc:`/plugins/convert`: ``force`` can be passed to override checks like no_convert, never_convert_lossy_files, same format, and max_bitrate -- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to - resolve differences in metadata source styles. +- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to resolve + differences in metadata source styles. - :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, - saving all contributing artists to the respective fields. + saving all contributing artists to the respective fields. - :doc:`plugins/ftintitle`: Featured artists are now inserted before brackets containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead of being appended at the end. This improves formatting for titles like "Song 1 From a801afd8b6b17db7af499a109b88101f312ef084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 25 Dec 2025 22:24:38 +0000 Subject: [PATCH 199/274] Update git blame ignore revs --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index c8cb065f5..7aea1f81a 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -85,3 +85,5 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 a59e41a88365e414db3282658d2aa456e0b3468a # pyupgrade Python 3.10 301637a1609831947cb5dd90270ed46c24b1ab1b +# Fix changelog formatting +658b184c59388635787b447983ecd3a575f4fe56 From 74a10266f01eb261adfdc9f3e4bec36e0afce760 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Tue, 6 Jan 2026 22:43:08 +0100 Subject: [PATCH 200/274] Add documentation and changelog --- docs/changelog.rst | 4 ++++ docs/plugins/embedart.rst | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b9a5c1f3f..1c1d6a2b6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,6 +31,10 @@ New features: no_convert, never_convert_lossy_files, same format, and max_bitrate - :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to resolve differences in metadata source styles. +- :doc:`plugins/embedart`: Embedded arts can now be cleared during import with the + ``clearart_on_import`` config option. Also, ``beet clearart`` is only going to + update the files matching the query and with an embedded art, leaving + untouched the files without. Bug fixes: diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index abbe2460d..b8de07cc7 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -70,6 +70,8 @@ file. The available options are: :doc:`FetchArt ` plugin to download art with the purpose of directly embedding it into the file's metadata without an "intermediate" album art file. Default: ``no``. +- **clearart_on_import**: Enable automatic embedded art clearing. Default: ``no``. + Note: ``compare_threshold`` option requires ImageMagick_, and ``maxwidth`` requires either ImageMagick_ or Pillow_. @@ -110,4 +112,7 @@ embedded album art: automatically. - ``beet clearart QUERY``: removes all embedded images from all items matching the query. The command prompts for confirmation before making the change - unless you specify the ``-y`` (``--yes``) option. + unless you specify the ``-y`` (``--yes``) option. The files listed for + confirmation are the ones matching the query independently of having an + embedded art. However, only the files with an embedded art are updated, + leaving untouched the files without. From b53aff9b15681ddc730f11494bb8b9ba28e1c339 Mon Sep 17 00:00:00 2001 From: Aidan Epstein Date: Wed, 7 Jan 2026 09:12:55 -0800 Subject: [PATCH 201/274] Fix fetchart colors broken by 67e668d81ff03d7ce14671e68676a7ad9d0ed94a --- beetsplug/fetchart.py | 4 ++-- docs/changelog.rst | 1 + test/plugins/test_fetchart.py | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 9f5ed69fb..f1cc85f44 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -1588,7 +1588,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): message = ui.colorize( "text_highlight_minor", "has album art" ) - self._log.info("{}: {}", album, message) + ui.print_(f"{album}: {message}") else: # In ordinary invocations, look for images on the # filesystem. When forcing, however, always go to the Web @@ -1601,4 +1601,4 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): message = ui.colorize("text_success", "found album art") else: message = ui.colorize("text_error", "no art found") - self._log.info("{}: {}", album, message) + ui.print_(f"{album}: {message}") diff --git a/docs/changelog.rst b/docs/changelog.rst index 13dd15737..8d3f2b079 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,6 +37,7 @@ New features: differences in metadata source styles. - :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, saving all contributing artists to the respective fields. +- :doc:`plugins/fetchart`: Fix colorized output text. - :doc:`plugins/ftintitle`: Featured artists are now inserted before brackets containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead of being appended at the end. This improves formatting for titles like "Song 1 diff --git a/test/plugins/test_fetchart.py b/test/plugins/test_fetchart.py index 853820d92..96d882e9a 100644 --- a/test/plugins/test_fetchart.py +++ b/test/plugins/test_fetchart.py @@ -98,3 +98,8 @@ class FetchartCliTest(PluginTestCase): self.run_command("fetchart") self.album.load() self.check_cover_is_stored() + + def test_colorization(self): + self.config["ui"]["color"] = True + out = self.run_with_output("fetchart") + assert " - the älbum: \x1b[1;31mno art found\x1b[39;49;00m\n" == out From fe8dc1e65ee3fc45870a8d0cce004f034eb9cb51 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Wed, 7 Jan 2026 20:47:04 +0100 Subject: [PATCH 202/274] Align lint --- docs/changelog.rst | 6 +++--- docs/plugins/embedart.rst | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index dd495de48..9c79a85bc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -44,9 +44,9 @@ New features: of brackets are supported and a new ``bracket_keywords`` configuration option allows customizing the keywords. Setting ``bracket_keywords`` to an empty list matches any bracket content regardless of keywords. -- :doc:`plugins/embedart`: Embedded arts can now be cleared during import with the - ``clearart_on_import`` config option. Also, ``beet clearart`` is only going to - update the files matching the query and with an embedded art, leaving +- :doc:`plugins/embedart`: Embedded arts can now be cleared during import with + the ``clearart_on_import`` config option. Also, ``beet clearart`` is only + going to update the files matching the query and with an embedded art, leaving untouched the files without. Bug fixes: diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index b8de07cc7..0140ceff8 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -70,8 +70,8 @@ file. The available options are: :doc:`FetchArt ` plugin to download art with the purpose of directly embedding it into the file's metadata without an "intermediate" album art file. Default: ``no``. -- **clearart_on_import**: Enable automatic embedded art clearing. Default: ``no``. - +- **clearart_on_import**: Enable automatic embedded art clearing. Default: + ``no``. Note: ``compare_threshold`` option requires ImageMagick_, and ``maxwidth`` requires either ImageMagick_ or Pillow_. From dd3ecec57942c2592ca17888f15329e5feb684dc Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 7 Jan 2026 18:54:39 -0500 Subject: [PATCH 203/274] Updated Spotify API credentials --- beetsplug/spotify.py | 4 ++-- docs/changelog.rst | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 6f85b1397..65a4edf7f 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -143,8 +143,8 @@ class SpotifyPlugin( "show_failures": False, "region_filter": None, "regex": [], - "client_id": "4e414367a1d14c75a5c5129a627fcab8", - "client_secret": "f82bdc09b2254f1a8286815d02fd46dc", + "client_id": "78f38736bff14e3cafb16b93ed35113d", + "client_secret": "5c33d3e75bbc4d31a080ec0ef092d15c", "tokenfile": "spotify_token.json", } ) diff --git a/docs/changelog.rst b/docs/changelog.rst index 13dd15737..8230ce549 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -47,6 +47,7 @@ New features: Bug fixes: +- :doc:`/plugins/spotify`: Updated Spotify API credentials. :bug:`6270` - :doc:`/plugins/smartplaylist`: Fixed an issue where multiple queries in a playlist configuration were not preserving their order, causing items to appear in database order rather than the order specified in the config. From cff631f9c94ebc120379eb5d0c3ef6be83477b20 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sun, 11 Jan 2026 09:20:03 -0500 Subject: [PATCH 204/274] updated credentials --- beetsplug/spotify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 65a4edf7f..ab920cdd4 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -143,8 +143,8 @@ class SpotifyPlugin( "show_failures": False, "region_filter": None, "regex": [], - "client_id": "78f38736bff14e3cafb16b93ed35113d", - "client_secret": "5c33d3e75bbc4d31a080ec0ef092d15c", + "client_id": "4e414367a1d14c75a5c5129a627fcab8", + "client_secret": "4a9b5b7848e54e118a7523b1c7c3e1e5", "tokenfile": "spotify_token.json", } ) From 3ea4bb79414bc54577dd3dcab91a574763a0429f Mon Sep 17 00:00:00 2001 From: David Logie Date: Sat, 10 Jan 2026 14:20:45 +0000 Subject: [PATCH 205/274] Fix bug in fetching preferred release event. With the changes to how data is fetched from MusicBrainz, empty releases are now `None` instead of an empty dict. --- beetsplug/musicbrainz.py | 5 +++-- test/plugins/test_musicbrainz.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 3e194c067..137189cdc 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -247,8 +247,9 @@ def _preferred_release_event( for country in preferred_countries: for event in release.get("release-events", {}): try: - if country in event["area"]["iso-3166-1-codes"]: - return country, event["date"] + if area := event.get("area"): + if country in area["iso-3166-1-codes"]: + return country, event["date"] except KeyError: pass diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 199b62ab6..733287204 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -29,6 +29,7 @@ class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() self.mb = musicbrainz.MusicBrainzPlugin() + self.config["match"]["preferred"]["countries"] = ["US"] class MBAlbumInfoTest(MusicBrainzTestCase): @@ -80,6 +81,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): "country": "COUNTRY", "status": "STATUS", "barcode": "BARCODE", + "release-events": [{"area": None, "date": "2021-03-26"}], } if multi_artist_credit: From 7685e9439aebc477cff56d9bd7edc8a806f55484 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 7 May 2024 23:55:44 +0200 Subject: [PATCH 206/274] db: disable DQS on Python >= 3.12 --- beets/dbcore/db.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 110cd70d0..5d721a121 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -1124,6 +1124,16 @@ class Database: # call conn.close() in _close() check_same_thread=False, ) + + if sys.version_info >= (3, 12) and sqlite3.sqlite_version_info >= ( + 3, + 29, + 0, + ): + # If possible, disable double-quoted strings + conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DDL, 0) + conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DML, 0) + self.add_functions(conn) if self.supports_extensions: From b964d8b7ebeb2b74ca506170f94628afe24dcbd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 27 Dec 2025 23:25:42 +0000 Subject: [PATCH 207/274] Configure future-annotations --- beets/autotag/match.py | 3 ++- beets/dbcore/db.py | 31 +++++++++++----------------- beets/dbcore/query.py | 4 +++- beets/importer/session.py | 3 ++- beets/importer/tasks.py | 4 +++- beets/logging.py | 2 +- beets/ui/commands/import_/display.py | 3 ++- beets/ui/commands/move.py | 14 ++++++------- beets/util/__init__.py | 4 ++-- beetsplug/albumtypes.py | 8 ++++++- beetsplug/aura.py | 14 +++++++++---- beetsplug/chroma.py | 10 +++++++-- beetsplug/deezer.py | 7 ++----- beetsplug/mbpseudo.py | 3 ++- beetsplug/missing.py | 11 ++++++++-- beetsplug/playlist.py | 7 +++++-- beetsplug/replace.py | 7 ++++++- beetsplug/smartplaylist.py | 7 +++++-- beetsplug/spotify.py | 7 ++----- beetsplug/titlecase.py | 13 ++++++++---- poetry.lock | 2 +- pyproject.toml | 7 ++++--- test/plugins/test_albumtypes.py | 7 ++++++- test/plugins/test_aura.py | 8 +++++-- test/plugins/test_convert.py | 7 +++++-- test/plugins/test_ftintitle.py | 12 ++++++++--- test/plugins/test_lyrics.py | 11 ++++++++-- test/plugins/test_mbpseudo.py | 10 +++++++-- 28 files changed, 147 insertions(+), 79 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 8adbaeda1..374ea3c13 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -25,7 +25,7 @@ import lap import numpy as np from beets import config, logging, metadata_plugins, plugins -from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks +from beets.autotag import AlbumMatch, TrackMatch, hooks from beets.util import get_most_common_tags from .distance import VA_ARTISTS, distance, track_distance @@ -33,6 +33,7 @@ from .distance import VA_ARTISTS, distance, track_distance if TYPE_CHECKING: from collections.abc import Iterable, Sequence + from beets.autotag import AlbumInfo, TrackInfo from beets.library import Item # Global logger. diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 5d721a121..08664bdf2 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -26,16 +26,9 @@ import threading import time from abc import ABC from collections import defaultdict -from collections.abc import ( - Callable, - Generator, - Iterable, - Iterator, - Mapping, - Sequence, -) +from collections.abc import Mapping from functools import cached_property -from sqlite3 import Connection, sqlite_version_info +from sqlite3 import sqlite_version_info from typing import TYPE_CHECKING, Any, AnyStr, Generic from typing_extensions import ( @@ -48,20 +41,20 @@ import beets from ..util import cached_classproperty, functemplate from . import types -from .query import ( - FieldQueryType, - FieldSort, - MatchQuery, - NullSort, - Query, - Sort, - TrueQuery, -) +from .query import MatchQuery, NullSort, TrueQuery if TYPE_CHECKING: + from collections.abc import ( + Callable, + Generator, + Iterable, + Iterator, + Sequence, + ) + from sqlite3 import Connection from types import TracebackType - from .query import SQLiteType + from .query import FieldQueryType, FieldSort, Query, Sort, SQLiteType D = TypeVar("D", bound="Database", default=Any) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index dfeb42707..9556cdf77 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -20,7 +20,7 @@ import os import re import unicodedata from abc import ABC, abstractmethod -from collections.abc import Iterator, MutableSequence, Sequence +from collections.abc import Sequence from datetime import datetime, timedelta from functools import cached_property, reduce from operator import mul, or_ @@ -31,6 +31,8 @@ from beets import util from beets.util.units import raw_seconds_short if TYPE_CHECKING: + from collections.abc import Iterator, MutableSequence + from beets.dbcore.db import AnyModel, Model P = TypeVar("P", default=Any) diff --git a/beets/importer/session.py b/beets/importer/session.py index 83c5ad4e3..123cc7248 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -17,7 +17,7 @@ import os import time from typing import TYPE_CHECKING -from beets import config, dbcore, library, logging, plugins, util +from beets import config, logging, plugins, util from beets.importer.tasks import Action from beets.util import displayable_path, normpath, pipeline, syspath @@ -27,6 +27,7 @@ from .state import ImportState if TYPE_CHECKING: from collections.abc import Sequence + from beets import dbcore, library from beets.util import PathBytes from .tasks import ImportTask diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 3a9c044b2..f6417401b 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -20,7 +20,7 @@ import re import shutil import time from collections import defaultdict -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Callable from enum import Enum from tempfile import mkdtemp from typing import TYPE_CHECKING, Any @@ -33,6 +33,8 @@ from beets.dbcore.query import PathQuery from .state import ImportState if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from beets.autotag.match import Recommendation from .session import ImportSession diff --git a/beets/logging.py b/beets/logging.py index 5a837cd80..ecde9b33d 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -35,7 +35,6 @@ from logging import ( Handler, Logger, NullHandler, - RootLogger, StreamHandler, ) from typing import TYPE_CHECKING, Any, TypeVar, Union, overload @@ -56,6 +55,7 @@ __all__ = [ if TYPE_CHECKING: from collections.abc import Mapping + from logging import RootLogger T = TypeVar("T") from types import TracebackType diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index 113462d19..7858c7152 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, TypedDict from typing_extensions import NotRequired -from beets import autotag, config, ui +from beets import config, ui from beets.autotag import hooks from beets.util import displayable_path from beets.util.units import human_seconds_short @@ -17,6 +17,7 @@ if TYPE_CHECKING: import confuse + from beets import autotag from beets.autotag.distance import Distance from beets.library.models import Item from beets.ui import ColorName diff --git a/beets/ui/commands/move.py b/beets/ui/commands/move.py index 40a9d1b83..206c24dcf 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -1,18 +1,18 @@ """The 'move' command: Move/copy files to the library or a new base directory.""" +from __future__ import annotations + import os +from typing import TYPE_CHECKING from beets import logging, ui -from beets.util import ( - MoveOperation, - PathLike, - displayable_path, - normpath, - syspath, -) +from beets.util import MoveOperation, displayable_path, normpath, syspath from .utils import do_query +if TYPE_CHECKING: + from beets.util import PathLike + # Global logger. log = logging.getLogger("beets") diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 517e076de..10508aaaf 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -28,7 +28,7 @@ import sys import tempfile import traceback from collections import Counter -from collections.abc import Callable, Sequence +from collections.abc import Sequence from contextlib import suppress from enum import Enum from functools import cache @@ -54,7 +54,7 @@ import beets from beets.util import hidden if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Callable, Iterable, Iterator from logging import Logger from beets.library import Item diff --git a/beetsplug/albumtypes.py b/beetsplug/albumtypes.py index 180773f58..3b6535d85 100644 --- a/beetsplug/albumtypes.py +++ b/beetsplug/albumtypes.py @@ -14,11 +14,17 @@ """Adds an album template field for formatted album types.""" -from beets.library import Album +from __future__ import annotations + +from typing import TYPE_CHECKING + from beets.plugins import BeetsPlugin from .musicbrainz import VARIOUS_ARTISTS_ID +if TYPE_CHECKING: + from beets.library import Album + class AlbumTypesPlugin(BeetsPlugin): """Adds an album template field for formatted album types.""" diff --git a/beetsplug/aura.py b/beetsplug/aura.py index 7b75f31e5..c1877db82 100644 --- a/beetsplug/aura.py +++ b/beetsplug/aura.py @@ -14,12 +14,13 @@ """An AURA server using Flask.""" +from __future__ import annotations + import os import re -from collections.abc import Mapping from dataclasses import dataclass from mimetypes import guess_type -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from flask import ( Blueprint, @@ -40,12 +41,17 @@ from beets.dbcore.query import ( NotQuery, RegexpQuery, SlowFieldSort, - SQLiteType, ) -from beets.library import Album, Item, LibModel, Library +from beets.library import Album, Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, _open_library +if TYPE_CHECKING: + from collections.abc import Mapping + + from beets.dbcore.query import SQLiteType + from beets.library import LibModel, Library + # Constants # AURA server information diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 1e9835789..748e6f5cd 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -16,20 +16,26 @@ autotagger. Requires the pyacoustid library. """ +from __future__ import annotations + import re from collections import defaultdict -from collections.abc import Iterable from functools import cached_property, partial +from typing import TYPE_CHECKING import acoustid import confuse from beets import config, ui, util from beets.autotag.distance import Distance -from beets.autotag.hooks import TrackInfo from beets.metadata_plugins import MetadataSourcePlugin from beetsplug.musicbrainz import MusicBrainzPlugin +if TYPE_CHECKING: + from collections.abc import Iterable + + from beets.autotag.hooks import TrackInfo + API_KEY = "1vOwZtEn" SCORE_THRESH = 0.5 TRACK_ID_WEIGHT = 10.0 diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index ef27dddc7..f113dcca2 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -25,16 +25,13 @@ import requests from beets import ui from beets.autotag import AlbumInfo, TrackInfo from beets.dbcore import types -from beets.metadata_plugins import ( - IDResponse, - SearchApiMetadataSourcePlugin, - SearchFilter, -) +from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin if TYPE_CHECKING: from collections.abc import Sequence from beets.library import Item, Library + from beets.metadata_plugins import SearchFilter from ._typing import JSONDict diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 30ef2e428..d084d1531 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -24,7 +24,7 @@ import mediafile from typing_extensions import override from beets import config -from beets.autotag.distance import Distance, distance +from beets.autotag.distance import distance from beets.autotag.hooks import AlbumInfo from beets.autotag.match import assign_items from beets.plugins import find_plugins @@ -39,6 +39,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence from beets.autotag import AlbumMatch + from beets.autotag.distance import Distance from beets.library import Item from beetsplug._typing import JSONDict diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 63a7bae22..081a73dcd 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -15,19 +15,26 @@ """List missing tracks.""" +from __future__ import annotations + from collections import defaultdict -from collections.abc import Iterator +from typing import TYPE_CHECKING import requests from beets import config, metadata_plugins from beets.dbcore import types -from beets.library import Album, Item, Library +from beets.library import Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ from ._utils.musicbrainz import MusicBrainzAPIMixin +if TYPE_CHECKING: + from collections.abc import Iterator + + from beets.library import Album, Library + MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 07c12e0e0..34e7a2fe3 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -10,17 +10,20 @@ # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. - +from __future__ import annotations import os import tempfile -from collections.abc import Sequence from pathlib import Path +from typing import TYPE_CHECKING import beets from beets.dbcore.query import BLOB_TYPE, InQuery from beets.util import path_as_posix +if TYPE_CHECKING: + from collections.abc import Sequence + def is_m3u_file(path: str) -> bool: return Path(path).suffix.lower() in {".m3u", ".m3u8"} diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 0c570877b..b585a13c1 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -1,12 +1,17 @@ +from __future__ import annotations + import shutil from pathlib import Path +from typing import TYPE_CHECKING import mediafile from beets import ui, util -from beets.library import Item, Library from beets.plugins import BeetsPlugin +if TYPE_CHECKING: + from beets.library import Item, Library + class ReplacePlugin(BeetsPlugin): def commands(self): diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index ed417f2b9..e22a65787 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -17,13 +17,13 @@ from __future__ import annotations import os -from typing import Any, TypeAlias +from typing import TYPE_CHECKING, Any, TypeAlias from urllib.parse import quote from urllib.request import pathname2url from beets import ui from beets.dbcore.query import ParsingError, Query, Sort -from beets.library import Album, Item, Library, parse_query_string +from beets.library import Album, Item, parse_query_string from beets.plugins import BeetsPlugin from beets.plugins import send as send_event from beets.util import ( @@ -36,6 +36,9 @@ from beets.util import ( syspath, ) +if TYPE_CHECKING: + from beets.library import Library + QueryAndSort = tuple[Query, Sort] PlaylistQuery = Query | tuple[QueryAndSort, ...] | None PlaylistMatch: TypeAlias = tuple[ diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index ab920cdd4..4a55dea5d 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -36,16 +36,13 @@ from beets import ui from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.dbcore import types from beets.library import Library -from beets.metadata_plugins import ( - IDResponse, - SearchApiMetadataSourcePlugin, - SearchFilter, -) +from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin if TYPE_CHECKING: from collections.abc import Sequence from beets.library import Library + from beets.metadata_plugins import SearchFilter from beetsplug._typing import JSONDict DEFAULT_WAITING_TIME = 5 diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py index e7003fd28..d722d4d16 100644 --- a/beetsplug/titlecase.py +++ b/beetsplug/titlecase.py @@ -16,18 +16,23 @@ Title case logic is derived from the python-titlecase library. Provides a template function and a tag modification function.""" +from __future__ import annotations + import re from functools import cached_property -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict from titlecase import titlecase from beets import ui -from beets.autotag.hooks import AlbumInfo, Info -from beets.importer import ImportSession, ImportTask -from beets.library import Item +from beets.autotag.hooks import AlbumInfo from beets.plugins import BeetsPlugin +if TYPE_CHECKING: + from beets.autotag.hooks import Info + from beets.importer import ImportSession, ImportTask + from beets.library import Item + __author__ = "henryoberholtzer@gmail.com" __version__ = "1.0" diff --git a/poetry.lock b/poetry.lock index 5a0832399..8eb7c74ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4583,4 +4583,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "8a1714daca55eab559558f2d4bd63d4857686eb607bf4b24f1ea6dbd412e6641" +content-hash = "f8ce55ae74c5e3c5d1d330582f83dae30ef963a0b8dd8c8b79f16c3bcfdb525a" diff --git a/pyproject.toml b/pyproject.toml index dbfc2715b..1e98b189a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,7 @@ titlecase = "^2.4.1" [tool.poetry.group.lint.dependencies] docstrfmt = ">=1.11.1" -ruff = ">=0.6.4" +ruff = ">=0.13.0" sphinx-lint = ">=1.0.0" [tool.poetry.group.typing.dependencies] @@ -226,7 +226,7 @@ cmd = "make -C docs $COMMANDS" [tool.poe.tasks.format] help = "Format the codebase" -cmd = "ruff format" +cmd = "ruff format --config=pyproject.toml" [tool.poe.tasks.format-docs] help = "Format the documentation" @@ -234,7 +234,7 @@ cmd = "docstrfmt docs *.rst" [tool.poe.tasks.lint] help = "Check the code for linting issues. Accepts ruff options." -cmd = "ruff check" +cmd = "ruff check --config=pyproject.toml" [tool.poe.tasks.lint-docs] help = "Lint the documentation" @@ -294,6 +294,7 @@ target-version = "py39" line-length = 80 [tool.ruff.lint] +future-annotations = true select = [ # "ARG", # flake8-unused-arguments # "C4", # flake8-comprehensions diff --git a/test/plugins/test_albumtypes.py b/test/plugins/test_albumtypes.py index 0a9d53349..371bf0415 100644 --- a/test/plugins/test_albumtypes.py +++ b/test/plugins/test_albumtypes.py @@ -14,12 +14,17 @@ """Tests for the 'albumtypes' plugin.""" -from collections.abc import Sequence +from __future__ import annotations + +from typing import TYPE_CHECKING from beets.test.helper import PluginTestCase from beetsplug.albumtypes import AlbumTypesPlugin from beetsplug.musicbrainz import VARIOUS_ARTISTS_ID +if TYPE_CHECKING: + from collections.abc import Sequence + class AlbumTypesPluginTest(PluginTestCase): """Tests for albumtypes plugin.""" diff --git a/test/plugins/test_aura.py b/test/plugins/test_aura.py index 7e840008e..188c44c9e 100644 --- a/test/plugins/test_aura.py +++ b/test/plugins/test_aura.py @@ -1,13 +1,17 @@ +from __future__ import annotations + import os from http import HTTPStatus from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pytest -from flask.testing import Client from beets.test.helper import TestHelper +if TYPE_CHECKING: + from flask.testing import Client + @pytest.fixture(scope="session", autouse=True) def helper(): diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 9ae0ebf6d..2a1a3b94d 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -11,14 +11,14 @@ # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. - +from __future__ import annotations import fnmatch import os.path import re import sys import unittest -from pathlib import Path +from typing import TYPE_CHECKING import pytest from mediafile import MediaFile @@ -35,6 +35,9 @@ from beets.test.helper import ( ) from beetsplug import convert +if TYPE_CHECKING: + from pathlib import Path + def shell_quote(text): import shlex diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 51bd4f9c8..aff4dda18 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -14,15 +14,21 @@ """Tests for the 'ftintitle' plugin.""" -from collections.abc import Generator -from typing import TypeAlias +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeAlias import pytest -from beets.library.models import Album, Item +from beets.library.models import Album from beets.test.helper import PluginTestCase from beetsplug import ftintitle +if TYPE_CHECKING: + from collections.abc import Generator + + from beets.library.models import Item + ConfigValue: TypeAlias = str | bool | list[str] diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index 945a7158c..23db03fef 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -14,11 +14,13 @@ """Tests for the 'lyrics' plugin.""" +from __future__ import annotations + import re import textwrap from functools import partial from http import HTTPStatus -from pathlib import Path +from typing import TYPE_CHECKING import pytest @@ -26,7 +28,12 @@ from beets.library import Item from beets.test.helper import PluginMixin, TestHelper from beetsplug import lyrics -from .lyrics_pages import LyricsPage, lyrics_pages +from .lyrics_pages import lyrics_pages + +if TYPE_CHECKING: + from pathlib import Path + + from .lyrics_pages import LyricsPage PHRASE_BY_TITLE = { "Lady Madonna": "friday night arrives without a suitcase", diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index 6b382ab16..2fb6321b3 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import json -import pathlib from copy import deepcopy +from typing import TYPE_CHECKING import pytest @@ -9,13 +11,17 @@ from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item from beets.test.helper import PluginMixin -from beetsplug._typing import JSONDict from beetsplug.mbpseudo import ( _STATUS_PSEUDO, MusicBrainzPseudoReleasePlugin, PseudoAlbumInfo, ) +if TYPE_CHECKING: + import pathlib + + from beetsplug._typing import JSONDict + @pytest.fixture(scope="module") def rsrc_dir(pytestconfig: pytest.Config): From 078ffc1c579fc917d1afd60d5a970a87996cfc09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 27 Dec 2025 23:38:55 +0000 Subject: [PATCH 208/274] Configure ruff for py310 --- beets/dbcore/query.py | 4 ++-- beets/logging.py | 34 ++++++++++++++++----------------- beets/util/__init__.py | 5 ++--- beetsplug/lastgenre/__init__.py | 3 ++- beetsplug/spotify.py | 6 ++---- pyproject.toml | 4 ++-- 6 files changed, 27 insertions(+), 29 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 9556cdf77..52aed43b2 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -25,7 +25,7 @@ from datetime import datetime, timedelta from functools import cached_property, reduce from operator import mul, or_ from re import Pattern -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union +from typing import TYPE_CHECKING, Any, Generic, TypeVar from beets import util from beets.util.units import raw_seconds_short @@ -124,7 +124,7 @@ class Query(ABC): return hash(type(self)) -SQLiteType = Union[str, bytes, float, int, memoryview, None] +SQLiteType = str | bytes | float | int | memoryview | None AnySQLiteType = TypeVar("AnySQLiteType", bound=SQLiteType) FieldQueryType = type["FieldQuery"] diff --git a/beets/logging.py b/beets/logging.py index ecde9b33d..0fc3a13e7 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -37,7 +37,23 @@ from logging import ( NullHandler, StreamHandler, ) -from typing import TYPE_CHECKING, Any, TypeVar, Union, overload +from typing import TYPE_CHECKING, Any, TypeVar, overload + +if TYPE_CHECKING: + from collections.abc import Mapping + from logging import RootLogger + from types import TracebackType + + T = TypeVar("T") + + # see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi + _SysExcInfoType = ( + tuple[type[BaseException], BaseException, TracebackType | None] + | tuple[None, None, None] + ) + _ExcInfoType = _SysExcInfoType | BaseException | bool | None + _ArgsType = tuple[object, ...] | Mapping[str, object] + __all__ = [ "DEBUG", @@ -53,22 +69,6 @@ __all__ = [ "getLogger", ] -if TYPE_CHECKING: - from collections.abc import Mapping - from logging import RootLogger - - T = TypeVar("T") - from types import TracebackType - - # see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi - _SysExcInfoType = Union[ - tuple[type[BaseException], BaseException, Union[TracebackType, None]], - tuple[None, None, None], - ] - _ExcInfoType = Union[None, bool, _SysExcInfoType, BaseException] - _ArgsType = Union[tuple[object, ...], Mapping[str, object]] - - # Regular expression to match: # - C0 control characters (0x00-0x1F) except useful whitespace (\t, \n, \r) # - DEL control character (0x7f) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 10508aaaf..ea08bb65d 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -44,7 +44,6 @@ from typing import ( Generic, NamedTuple, TypeVar, - Union, cast, ) @@ -63,8 +62,8 @@ if TYPE_CHECKING: MAX_FILENAME_LENGTH = 200 WINDOWS_MAGIC_PREFIX = "\\\\?\\" T = TypeVar("T") -PathLike = Union[str, bytes, Path] -StrPath = Union[str, Path] +StrPath = str | Path +PathLike = StrPath | bytes Replacements = Sequence[tuple[Pattern[str], str]] # Here for now to allow for a easy replace later on diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index e622096cf..1bb874c04 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -28,7 +28,7 @@ import os import traceback from functools import singledispatchmethod from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any import pylast import yaml @@ -39,6 +39,7 @@ from beets.util import plurality, unique_list if TYPE_CHECKING: import optparse + from collections.abc import Callable from beets.library import LibModel diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 4a55dea5d..a778cf1e2 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -27,7 +27,7 @@ import re import threading import time import webbrowser -from typing import TYPE_CHECKING, Any, Literal, Union +from typing import TYPE_CHECKING, Any, Literal import confuse import requests @@ -86,9 +86,7 @@ class AudioFeaturesUnavailableError(Exception): class SpotifyPlugin( - SearchApiMetadataSourcePlugin[ - Union[SearchResponseAlbums, SearchResponseTracks] - ] + SearchApiMetadataSourcePlugin[SearchResponseAlbums | SearchResponseTracks] ): item_types = { "spotify_track_popularity": types.INTEGER, diff --git a/pyproject.toml b/pyproject.toml index 1e98b189a..6cdc19285 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -290,7 +290,7 @@ extend-exclude = [ ] [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 80 [tool.ruff.lint] @@ -308,7 +308,7 @@ select = [ "PT", # flake8-pytest-style # "RUF", # ruff "UP", # pyupgrade - "TCH", # flake8-type-checking + "TC", # flake8-type-checking "W", # pycodestyle ] ignore = [ From c52656fb0ad08438a5fc78684cd1b2601be38228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Dec 2025 01:05:02 +0000 Subject: [PATCH 209/274] Enable RUF rules --- beets/dbcore/queryparse.py | 2 +- beets/importer/__init__.py | 6 +++--- beets/importer/stages.py | 2 +- beets/importer/tasks.py | 4 ++-- beets/library/__init__.py | 10 +++++----- beets/plugins.py | 2 +- beets/test/helper.py | 2 +- beets/ui/commands/import_/display.py | 12 ++++-------- beets/ui/commands/import_/session.py | 14 ++++++-------- beets/ui/commands/write.py | 2 +- beets/util/artresizer.py | 24 +++++++++++++++--------- beets/util/pipeline.py | 4 ++-- beetsplug/bpd/__init__.py | 2 +- beetsplug/bpd/gstplayer.py | 6 +++--- beetsplug/bpsync.py | 2 +- beetsplug/convert.py | 2 +- beetsplug/discogs.py | 2 +- beetsplug/export.py | 2 +- beetsplug/fetchart.py | 2 +- beetsplug/fromfilename.py | 2 +- beetsplug/keyfinder.py | 2 +- beetsplug/lastgenre/__init__.py | 4 ++-- beetsplug/mbsubmit.py | 2 +- beetsplug/mbsync.py | 2 +- beetsplug/replaygain.py | 8 ++++---- beetsplug/the.py | 4 ++-- pyproject.toml | 4 +++- test/autotag/test_distance.py | 12 ++++++------ test/plugins/test_edit.py | 8 ++++---- test/plugins/test_lyrics.py | 4 ++-- test/plugins/test_random.py | 4 ++-- test/test_art_resize.py | 3 ++- test/test_dbcore.py | 2 +- test/test_library.py | 8 ++++---- test/test_plugins.py | 17 ++++++++++++----- test/ui/commands/test_completion.py | 2 +- test/ui/commands/test_modify.py | 12 ++++-------- test/ui/commands/test_utils.py | 8 ++++---- test/ui/test_ui.py | 2 +- test/util/test_id_extractors.py | 18 +++++++++--------- 40 files changed, 118 insertions(+), 112 deletions(-) diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py index f84ed7436..f14420448 100644 --- a/beets/dbcore/queryparse.py +++ b/beets/dbcore/queryparse.py @@ -250,7 +250,7 @@ def parse_sorted_query( # Split up query in to comma-separated subqueries, each representing # an AndQuery, which need to be joined together in one OrQuery subquery_parts = [] - for part in parts + [","]: + for part in [*parts, ","]: if part.endswith(","): # Ensure we can catch "foo, bar" as well as "foo , bar" last_subquery_part = part[:-1] diff --git a/beets/importer/__init__.py b/beets/importer/__init__.py index 586b238e6..6e49ba9e2 100644 --- a/beets/importer/__init__.py +++ b/beets/importer/__init__.py @@ -28,11 +28,11 @@ from .tasks import ( # Note: Stages are not exposed to the public API __all__ = [ - "ImportSession", - "ImportAbortError", "Action", - "ImportTask", "ArchiveImportTask", + "ImportAbortError", + "ImportSession", + "ImportTask", "SentinelImportTask", "SingletonImportTask", ] diff --git a/beets/importer/stages.py b/beets/importer/stages.py index 5474053d0..0f8cf922b 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -388,5 +388,5 @@ def _extend_pipeline(tasks, *stages): else: task_iter = tasks - ipl = pipeline.Pipeline([task_iter] + list(stages)) + ipl = pipeline.Pipeline([task_iter, *list(stages)]) return pipeline.multiple(ipl.pull()) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index f6417401b..646b64e7f 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -234,7 +234,7 @@ class ImportTask(BaseImportTask): or APPLY (in which case the data comes from the choice). """ if self.choice_flag in (Action.ASIS, Action.RETAG): - likelies, consensus = util.get_most_common_tags(self.items) + likelies, _ = util.get_most_common_tags(self.items) return likelies elif self.choice_flag is Action.APPLY and self.match: return self.match.info.copy() @@ -892,7 +892,7 @@ class ArchiveImportTask(SentinelImportTask): # The (0, 0, -1) is added to date_time because the # function time.mktime expects a 9-element tuple. # The -1 indicates that the DST flag is unknown. - date_time = time.mktime(f.date_time + (0, 0, -1)) + date_time = time.mktime((*f.date_time, 0, 0, -1)) fullpath = os.path.join(extract_to, f.filename) os.utime(fullpath, (date_time, date_time)) diff --git a/beets/library/__init__.py b/beets/library/__init__.py index 22416ecb5..0f3d7d155 100644 --- a/beets/library/__init__.py +++ b/beets/library/__init__.py @@ -17,13 +17,13 @@ def __getattr__(name: str): __all__ = [ - "Library", - "LibModel", "Album", - "Item", - "parse_query_parts", - "parse_query_string", "FileOperationError", + "Item", + "LibModel", + "Library", "ReadError", "WriteError", + "parse_query_parts", + "parse_query_string", ] diff --git a/beets/plugins.py b/beets/plugins.py index 0dc2754b9..c41541132 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -161,7 +161,7 @@ class BeetsPlugin(metaclass=abc.ABCMeta): import_stages: list[ImportStageFunc] def __init_subclass__(cls) -> None: - """Enable legacy metadata‐source plugins to work with the new interface. + """Enable legacy metadata source plugins to work with the new interface. When a plugin subclass of BeetsPlugin defines a `data_source` attribute but does not inherit from MetadataSourcePlugin, this hook: diff --git a/beets/test/helper.py b/beets/test/helper.py index 3cb1e4c3c..adc64088d 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -120,7 +120,7 @@ def capture_stdout(): def has_program(cmd, args=["--version"]): """Returns `True` if `cmd` can be executed.""" - full_cmd = [cmd] + args + full_cmd = [cmd, *args] try: with open(os.devnull, "wb") as devnull: subprocess.check_call( diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index 7858c7152..bdc44d51f 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -339,13 +339,9 @@ class ChangeRepresentation: max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines) max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines) - if ( - (max_width_l <= col_width) - and (max_width_r <= col_width) - or ( - ((max_width_l > col_width) or (max_width_r > col_width)) - and ((max_width_l + max_width_r) <= col_width * 2) - ) + if ((max_width_l <= col_width) and (max_width_r <= col_width)) or ( + ((max_width_l > col_width) or (max_width_r > col_width)) + and ((max_width_l + max_width_r) <= col_width * 2) ): # All content fits. Either both maximum widths are below column # widths, or one of the columns is larger than allowed but the @@ -559,7 +555,7 @@ def penalty_string(distance: Distance, limit: int | None = None) -> str: penalties.append(key) if penalties: if limit and len(penalties) > limit: - penalties = penalties[:limit] + ["..."] + penalties = [*penalties[:limit], "..."] # Prefix penalty string with U+2260: Not Equal To penalty_string = f"\u2260 {', '.join(penalties)}" return ui.colorize("changed", penalty_string) diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 9c8c8dd62..42a809634 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -256,13 +256,11 @@ class TerminalImportSession(importer.ImportSession): # Add a "dummy" choice for the other baked-in option, for # duplicate checking. - all_choices = ( - [ - PromptChoice("a", "Apply", None), - ] - + choices - + extra_choices - ) + all_choices = [ + PromptChoice("a", "Apply", None), + *choices, + *extra_choices, + ] # Check for conflicts. short_letters = [c.short for c in all_choices] @@ -501,7 +499,7 @@ def choose_candidate( if config["import"]["bell"]: ui.print_("\a", end="") sel = ui.input_options( - ("Apply", "More candidates") + choice_opts, + ("Apply", "More candidates", *choice_opts), require=require, default=default, ) diff --git a/beets/ui/commands/write.py b/beets/ui/commands/write.py index 05c3c7565..87fba8236 100644 --- a/beets/ui/commands/write.py +++ b/beets/ui/commands/write.py @@ -15,7 +15,7 @@ def write_items(lib, query, pretend, force): """Write tag information from the database to the respective files in the filesystem. """ - items, albums = do_query(lib, query, False, False) + items, _ = do_query(lib, query, False, False) for item in items: # Item deleted? diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 72007d0b5..6fec62774 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -268,7 +268,8 @@ class IMBackend(LocalBackend): # with regards to the height. # ImageMagick already seems to default to no interlace, but we include # it here for the sake of explicitness. - cmd: list[str] = self.convert_cmd + [ + cmd: list[str] = [ + *self.convert_cmd, syspath(path_in, prefix=False), "-resize", f"{maxwidth}x>", @@ -298,7 +299,8 @@ class IMBackend(LocalBackend): return path_out def get_size(self, path_in: bytes) -> tuple[int, int] | None: - cmd: list[str] = self.identify_cmd + [ + cmd: list[str] = [ + *self.identify_cmd, "-format", "%w %h", syspath(path_in, prefix=False), @@ -336,7 +338,8 @@ class IMBackend(LocalBackend): if not path_out: path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in) - cmd = self.convert_cmd + [ + cmd = [ + *self.convert_cmd, syspath(path_in, prefix=False), "-interlace", "none", @@ -351,7 +354,7 @@ class IMBackend(LocalBackend): return path_in def get_format(self, path_in: bytes) -> str | None: - cmd = self.identify_cmd + ["-format", "%[magick]", syspath(path_in)] + cmd = [*self.identify_cmd, "-format", "%[magick]", syspath(path_in)] try: # Image formats should really only be ASCII strings such as "PNG", @@ -368,7 +371,8 @@ class IMBackend(LocalBackend): target: bytes, deinterlaced: bool, ) -> bytes: - cmd = self.convert_cmd + [ + cmd = [ + *self.convert_cmd, syspath(source), *(["-interlace", "none"] if deinterlaced else []), syspath(target), @@ -400,14 +404,16 @@ class IMBackend(LocalBackend): # to grayscale and then pipe them into the `compare` command. # On Windows, ImageMagick doesn't support the magic \\?\ prefix # on paths, so we pass `prefix=False` to `syspath`. - convert_cmd = self.convert_cmd + [ + convert_cmd = [ + *self.convert_cmd, syspath(im2, prefix=False), syspath(im1, prefix=False), "-colorspace", "gray", "MIFF:-", ] - compare_cmd = self.compare_cmd + [ + compare_cmd = [ + *self.compare_cmd, "-define", "phash:colorspaces=sRGB,HCLp", "-metric", @@ -487,7 +493,7 @@ class IMBackend(LocalBackend): ("-set", k, v) for k, v in metadata.items() ) str_file = os.fsdecode(file) - command = self.convert_cmd + [str_file, *assignments, str_file] + command = [*self.convert_cmd, str_file, *assignments, str_file] util.command_output(command) @@ -828,7 +834,7 @@ class ArtResizer: "jpeg": "jpg", }.get(new_format, new_format) - fname, ext = os.path.splitext(path_in) + fname, _ = os.path.splitext(path_in) path_new = fname + b"." + new_format.encode("utf8") # allows the exception to propagate, while still making sure a changed diff --git a/beets/util/pipeline.py b/beets/util/pipeline.py index 2ed593904..2c1e72e53 100644 --- a/beets/util/pipeline.py +++ b/beets/util/pipeline.py @@ -192,7 +192,7 @@ def stage( task: R | T | None = None while True: task = yield task - task = func(*(args + (task,))) + task = func(*args, task) return coro @@ -216,7 +216,7 @@ def mutator_stage(func: Callable[[Unpack[A], T], R]): task = None while True: task = yield task - func(*(args + (task,))) + func(*args, task) return coro diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 0359259b7..ea2e561b3 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -1037,7 +1037,7 @@ class Command: raise BPDError(ERROR_PERMISSION, "insufficient privileges") try: - args = [conn] + self.args + args = [conn, *self.args] results = func(*args) if results: for data in results: diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index f356b3066..e4f38af88 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -37,7 +37,7 @@ except ValueError as e: # makes it so the test collector functions as inteded. raise ImportError from e -from gi.repository import GLib, Gst # noqa: E402 +from gi.repository import GLib, Gst Gst.init(None) @@ -115,7 +115,7 @@ class GstPlayer: elif message.type == Gst.MessageType.ERROR: # error self.player.set_state(Gst.State.NULL) - err, debug = message.parse_error() + err, _ = message.parse_error() print(f"Error: {err}") self.playing = False @@ -205,7 +205,7 @@ class GstPlayer: def seek(self, position): """Seeks to position (in seconds).""" - cur_pos, cur_len = self.time() + _, cur_len = self.time() if position > cur_len: self.stop() return diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py index fbdf8cc70..34cb08cce 100644 --- a/beetsplug/bpsync.py +++ b/beetsplug/bpsync.py @@ -73,7 +73,7 @@ class BPSyncPlugin(BeetsPlugin): """Retrieve and apply info from the autotagger for items matched by query. """ - for item in lib.items(query + ["singleton:true"]): + for item in lib.items([*query, "singleton:true"]): if not item.mb_trackid: self._log.info( "Skipping singleton with no mb_trackid: {}", item diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 74ced8ae3..2e837c77f 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -274,7 +274,7 @@ class ConvertPlugin(BeetsPlugin): pretend, hardlink, link, - playlist, + _, force, ) = self._get_opts_and_config(empty_opts) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 29600a676..08d437d2d 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -572,7 +572,7 @@ class DiscogsPlugin(MetadataSourcePlugin): processed = self._process_clean_tracklist( clean_tracklist, album_artist_data ) - tracks, index_tracks, index, divisions, next_divisions = processed + tracks, index_tracks, *_ = processed # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None diff --git a/beetsplug/export.py b/beetsplug/export.py index e6c2b88c7..21db190b1 100644 --- a/beetsplug/export.py +++ b/beetsplug/export.py @@ -148,7 +148,7 @@ class ExportPlugin(BeetsPlugin): album=opts.album, ): try: - data, item = data_emitter(included_keys or "*") + data, _ = data_emitter(included_keys or "*") except (mediafile.UnreadableFileError, OSError) as ex: self._log.error("cannot read file: {}", ex) continue diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index f1cc85f44..ab5a17228 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -867,7 +867,7 @@ class ITunesStore(RemoteArtSource): ) except KeyError as e: self._log.debug( - "Malformed itunes candidate: {} not found in {}", # NOQA E501 + "Malformed itunes candidate: {} not found in {}", e, list(c.keys()), ) diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index c3fb4bc6b..be7fee23a 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -88,7 +88,7 @@ def apply_matches(d, log): """Given a mapping from items to field dicts, apply the fields to the objects. """ - some_map = list(d.values())[0] + some_map = next(iter(d.values())) keys = some_map.keys() # Only proceed if the "tag" field is equal across all filenames. diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index e2aff24e5..e0e9b8740 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -62,7 +62,7 @@ class KeyFinderPlugin(BeetsPlugin): try: output = util.command_output( - command + [util.syspath(item.path)] + [*command, util.syspath(item.path)] ).stdout except (subprocess.CalledProcessError, OSError) as exc: self._log.error("execution failed: {}", exc) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 1bb874c04..121d76596 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -68,12 +68,12 @@ def flatten_tree( if isinstance(elem, dict): for k, v in elem.items(): - flatten_tree(v, path + [k], branches) + flatten_tree(v, [*path, k], branches) elif isinstance(elem, list): for sub in elem: flatten_tree(sub, path, branches) else: - branches.append(path + [str(elem)]) + branches.append([*path, str(elem)]) def find_parents(candidate: str, branches: list[list[str]]) -> list[str]: diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index f6d197256..7136f4c29 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -69,7 +69,7 @@ class MBSubmitPlugin(BeetsPlugin): paths.append(displayable_path(p)) try: picard_path = self.config["picard_path"].as_str() - subprocess.Popen([picard_path] + paths) + subprocess.Popen([picard_path, *paths]) self._log.info("launched picard from\n{}", picard_path) except OSError as exc: self._log.error("Could not open picard, got error:\n{}", exc) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 5b74b67c9..45f34e865 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -71,7 +71,7 @@ class MBSyncPlugin(BeetsPlugin): """Retrieve and apply info from the autotagger for items matched by query. """ - for item in lib.items(query + ["singleton:true"]): + for item in lib.items([*query, "singleton:true"]): if not item.mb_trackid: self._log.info( "Skipping singleton with no mb_trackid: {}", item diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index a8c887caa..4e8b429ea 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -642,11 +642,11 @@ class CommandBackend(Backend): cmd: list[str] = [self.command, "-o", "-s", "s"] if self.noclip: # Adjust to avoid clipping. - cmd = cmd + ["-k"] + cmd = [*cmd, "-k"] else: # Disable clipping warning. - cmd = cmd + ["-c"] - cmd = cmd + ["-d", str(int(target_level - 89))] + cmd = [*cmd, "-c"] + cmd = [*cmd, "-d", str(int(target_level - 89))] cmd = cmd + [syspath(i.path) for i in items] self._log.debug("analyzing {} files", len(items)) @@ -1105,7 +1105,7 @@ class AudioToolsBackend(Backend): # The first item is taken and opened to get the sample rate to # initialize the replaygain object. The object is used for all the # tracks in the album to get the album values. - item = list(task.items)[0] + item = next(iter(task.items)) audiofile = self.open_audio_file(item) rg = self.init_replaygain(audiofile, item) diff --git a/beetsplug/the.py b/beetsplug/the.py index 664d4c01e..b29fc728d 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -58,9 +58,9 @@ class ThePlugin(BeetsPlugin): p, ) if self.config["a"]: - self.patterns = [PATTERN_A] + self.patterns + self.patterns = [PATTERN_A, *self.patterns] if self.config["the"]: - self.patterns = [PATTERN_THE] + self.patterns + self.patterns = [PATTERN_THE, *self.patterns] if not self.patterns: self._log.warning("no patterns defined!") diff --git a/pyproject.toml b/pyproject.toml index 6cdc19285..b14f442ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -306,7 +306,7 @@ select = [ "ISC", # flake8-implicit-str-concat "N", # pep8-naming "PT", # flake8-pytest-style - # "RUF", # ruff + "RUF", # ruff "UP", # pyupgrade "TC", # flake8-type-checking "W", # pycodestyle @@ -320,6 +320,8 @@ ignore = [ "test/plugins/test_ftintitle.py" = ["E501"] "test/test_util.py" = ["E501"] "test/ui/test_field_diff.py" = ["E501"] +"test/util/test_id_extractors.py" = ["E501"] +"test/**" = ["RUF001"] # we use Unicode characters in tests [tool.ruff.lint.isort] split-on-trailing-comma = false diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 3686f82c9..ac0864564 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -337,15 +337,15 @@ class TestDataSourceDistance: _p("Original", "Original", 0.5, 1.0, True, MATCH, id="match"), _p("Original", "Other", 0.5, 1.0, True, MISMATCH, id="mismatch"), _p("Other", "Original", 0.5, 1.0, True, MISMATCH, id="mismatch"), - _p("Original", "unknown", 0.5, 1.0, True, MISMATCH, id="mismatch-unknown"), # noqa: E501 - _p("Original", None, 0.5, 1.0, True, MISMATCH, id="mismatch-no-info"), # noqa: E501 + _p("Original", "unknown", 0.5, 1.0, True, MISMATCH, id="mismatch-unknown"), + _p("Original", None, 0.5, 1.0, True, MISMATCH, id="mismatch-no-info"), _p(None, "Other", 0.5, 1.0, True, MISMATCH, id="mismatch-no-original-multiple-sources"), # noqa: E501 _p(None, "Other", 0.5, 1.0, False, MATCH, id="match-no-original-but-single-source"), # noqa: E501 _p("unknown", "unknown", 0.5, 1.0, True, MATCH, id="match-unknown"), - _p("Original", "Other", 1.0, 1.0, True, 0.25, id="mismatch-max-penalty"), # noqa: E501 - _p("Original", "Other", 0.5, 5.0, True, 0.3125, id="mismatch-high-weight"), # noqa: E501 - _p("Original", "Other", 0.0, 1.0, True, MATCH, id="match-no-penalty"), # noqa: E501 - _p("Original", "Other", 0.5, 0.0, True, MATCH, id="match-no-weight"), # noqa: E501 + _p("Original", "Other", 1.0, 1.0, True, 0.25, id="mismatch-max-penalty"), + _p("Original", "Other", 0.5, 5.0, True, 0.3125, id="mismatch-high-weight"), + _p("Original", "Other", 0.0, 1.0, True, MATCH, id="match-no-penalty"), + _p("Original", "Other", 0.5, 0.0, True, MATCH, id="match-no-weight"), ], ) # fmt: skip def test_distance(self, item, info, expected_distance): diff --git a/test/plugins/test_edit.py b/test/plugins/test_edit.py index d0e03d0e5..f715fd9e8 100644 --- a/test/plugins/test_edit.py +++ b/test/plugins/test_edit.py @@ -350,8 +350,8 @@ class EditDuringImporterNonSingletonTest(EditDuringImporterTestCase): self.lib.items(), self.items_orig, ["title"], - self.IGNORED - + [ + [ + *self.IGNORED, "albumartist", "mb_albumartistid", "mb_albumartistids", @@ -378,7 +378,7 @@ class EditDuringImporterNonSingletonTest(EditDuringImporterTestCase): self.lib.items(), self.items_orig, [], - self.IGNORED + ["albumartist", "mb_albumartistid"], + [*self.IGNORED, "albumartist", "mb_albumartistid"], ) assert all("Tag Track" in i.title for i in self.lib.items()) @@ -490,6 +490,6 @@ class EditDuringImporterSingletonTest(EditDuringImporterTestCase): self.lib.items(), self.items_orig, ["title"], - self.IGNORED + ["albumartist", "mb_albumartistid"], + [*self.IGNORED, "albumartist", "mb_albumartistid"], ) assert all("Edited Track" in i.title for i in self.lib.items()) diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index 23db03fef..376f0b9f2 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -431,7 +431,7 @@ class TestTekstowoLyrics(LyricsBackendTest): [ ("tekstowopl/piosenka24kgoldncityofangels1", True), ( - "tekstowopl/piosenkabeethovenbeethovenpianosonata17tempestthe3rdmovement", # noqa: E501 + "tekstowopl/piosenkabeethovenbeethovenpianosonata17tempestthe3rdmovement", False, ), ], @@ -614,7 +614,7 @@ class TestTranslation: [00:00:50] [00:01.00] Some more synced lyrics / Quelques paroles plus synchronisées - Source: https://lrclib.net/api/123""", # noqa: E501 + Source: https://lrclib.net/api/123""", id="synced", ), pytest.param( diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index 9bcf8e59b..cb21edf47 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -72,8 +72,8 @@ class RandomTest(TestHelper, unittest.TestCase): print(f"{i:2d} {'*' * positions.count(i)}") return self._stats(positions) - mean1, stdev1, median1 = experiment("artist") - mean2, stdev2, median2 = experiment("track") + _, stdev1, median1 = experiment("artist") + _, stdev2, median2 = experiment("track") assert 0 == pytest.approx(median1, abs=1) assert len(self.items) // 2 == pytest.approx(median2, abs=1) assert stdev2 > stdev1 diff --git a/test/test_art_resize.py b/test/test_art_resize.py index 0ccbb0eae..55deb8cb6 100644 --- a/test/test_art_resize.py +++ b/test/test_art_resize.py @@ -136,7 +136,8 @@ class ArtResizerFileSizeTest(CleanupModulesMixin, BeetsTestCase): """ im = IMBackend() path = im.deinterlace(self.IMG_225x225) - cmd = im.identify_cmd + [ + cmd = [ + *im.identify_cmd, "-format", "%[interlace]", syspath(path, prefix=False), diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 653adf298..74e378275 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -411,7 +411,7 @@ class ModelTest(unittest.TestCase): def test_computed_field(self): model = ModelFixtureWithGetters() assert model.aComputedField == "thing" - with pytest.raises(KeyError, match="computed field .+ deleted"): + with pytest.raises(KeyError, match=r"computed field .+ deleted"): del model.aComputedField def test_items(self): diff --git a/test/test_library.py b/test/test_library.py index 7c0529001..4acf34746 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1056,7 +1056,7 @@ class PathStringTest(BeetsTestCase): assert isinstance(self.i.path, bytes) def test_fetched_item_path_is_bytestring(self): - i = list(self.lib.items())[0] + i = next(iter(self.lib.items())) assert isinstance(i.path, bytes) def test_unicode_path_becomes_bytestring(self): @@ -1070,14 +1070,14 @@ class PathStringTest(BeetsTestCase): """, (self.i.id, "somepath"), ) - i = list(self.lib.items())[0] + i = next(iter(self.lib.items())) assert isinstance(i.path, bytes) def test_special_chars_preserved_in_database(self): path = "b\xe1r".encode() self.i.path = path self.i.store() - i = list(self.lib.items())[0] + i = next(iter(self.lib.items())) assert i.path == path def test_special_char_path_added_to_database(self): @@ -1086,7 +1086,7 @@ class PathStringTest(BeetsTestCase): i = item() i.path = path self.lib.add(i) - i = list(self.lib.items())[0] + i = next(iter(self.lib.items())) assert i.path == path def test_destination_returns_bytestring(self): diff --git a/test/test_plugins.py b/test/test_plugins.py index 6f7026718..e161a4de6 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -308,7 +308,9 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "Enter search", "enter Id", "aBort", - ) + ("Foo", "baR") + "Foo", + "baR", + ) self.importer.add_choice(Action.SKIP) self.importer.run() @@ -342,7 +344,9 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "Enter search", "enter Id", "aBort", - ) + ("Foo", "baR") + "Foo", + "baR", + ) config["import"]["singletons"] = True self.importer.add_choice(Action.SKIP) @@ -381,7 +385,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "Enter search", "enter Id", "aBort", - ) + ("baZ",) + "baZ", + ) self.importer.add_choice(Action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with( @@ -416,7 +421,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "Enter search", "enter Id", "aBort", - ) + ("Foo",) + "Foo", + ) # DummyPlugin.foo() should be called once with patch.object(DummyPlugin, "foo", autospec=True) as mock_foo: @@ -458,7 +464,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "Enter search", "enter Id", "aBort", - ) + ("Foo",) + "Foo", + ) # DummyPlugin.foo() should be called once with helper.control_stdin("f\n"): diff --git a/test/ui/commands/test_completion.py b/test/ui/commands/test_completion.py index f1e53f238..ee2881a0e 100644 --- a/test/ui/commands/test_completion.py +++ b/test/ui/commands/test_completion.py @@ -56,7 +56,7 @@ class CompletionTest(IOMixin, TestPluginTestCase): test_script_name = os.path.join(_common.RSRC, b"test_completion.sh") with open(test_script_name, "rb") as test_script_file: tester.stdin.writelines(test_script_file) - out, err = tester.communicate() + out, _ = tester.communicate() assert tester.returncode == 0 assert out == b"completion tests passed\n", ( "test/test_completion.sh did not execute properly. " diff --git a/test/ui/commands/test_modify.py b/test/ui/commands/test_modify.py index b9cc1524d..77d378032 100644 --- a/test/ui/commands/test_modify.py +++ b/test/ui/commands/test_modify.py @@ -190,27 +190,23 @@ class ModifyTest(BeetsTestCase): assert mediafile.initial_key is None def test_arg_parsing_colon_query(self): - (query, mods, dels) = modify_parse_args( - ["title:oldTitle", "title=newTitle"] - ) + query, mods, _ = modify_parse_args(["title:oldTitle", "title=newTitle"]) assert query == ["title:oldTitle"] assert mods == {"title": "newTitle"} def test_arg_parsing_delete(self): - (query, mods, dels) = modify_parse_args(["title:oldTitle", "title!"]) + query, _, dels = modify_parse_args(["title:oldTitle", "title!"]) assert query == ["title:oldTitle"] assert dels == ["title"] def test_arg_parsing_query_with_exclaimation(self): - (query, mods, dels) = modify_parse_args( + query, mods, _ = modify_parse_args( ["title:oldTitle!", "title=newTitle!"] ) assert query == ["title:oldTitle!"] assert mods == {"title": "newTitle!"} def test_arg_parsing_equals_in_value(self): - (query, mods, dels) = modify_parse_args( - ["title:foo=bar", "title=newTitle"] - ) + query, mods, _ = modify_parse_args(["title:foo=bar", "title=newTitle"]) assert query == ["title:foo=bar"] assert mods == {"title": "newTitle"} diff --git a/test/ui/commands/test_utils.py b/test/ui/commands/test_utils.py index bd07a27c7..075f522a7 100644 --- a/test/ui/commands/test_utils.py +++ b/test/ui/commands/test_utils.py @@ -19,7 +19,7 @@ class QueryTest(BeetsTestCase): ) item = library.Item.from_path(itempath) self.lib.add(item) - return item, itempath + return item def add_album(self, items): album = self.lib.add_album(items) @@ -47,13 +47,13 @@ class QueryTest(BeetsTestCase): self.check_do_query(2, 0, album=False) def test_query_album(self): - item, itempath = self.add_item() + item = self.add_item() self.add_album([item]) self.check_do_query(1, 1, album=True) self.check_do_query(0, 1, album=True, also_items=False) - item, itempath = self.add_item() - item2, itempath = self.add_item() + item = self.add_item() + item2 = self.add_item() self.add_album([item, item2]) self.check_do_query(3, 2, album=True) self.check_do_query(0, 2, album=True, also_items=False) diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py index a37d4bb29..a0bf2e598 100644 --- a/test/ui/test_ui.py +++ b/test/ui/test_ui.py @@ -374,7 +374,7 @@ class ShowModelChangeTest(IOMixin, unittest.TestCase): def test_both_values_shown(self): self.a.title = "foo" self.b.title = "bar" - change, out = self._show() + _, out = self._show() assert "foo" in out assert "bar" in out diff --git a/test/util/test_id_extractors.py b/test/util/test_id_extractors.py index 4918b4361..e510dd5d8 100644 --- a/test/util/test_id_extractors.py +++ b/test/util/test_id_extractors.py @@ -10,26 +10,26 @@ from beets.util.id_extractors import extract_release_id [ ("spotify", "39WqpoPgZxygo6YQjehLJJ", "39WqpoPgZxygo6YQjehLJJ"), ("spotify", "blah blah", None), - ("spotify", "https://open.spotify.com/album/39WqpoPgZxygo6YQjehLJJ", "39WqpoPgZxygo6YQjehLJJ"), # noqa: E501 + ("spotify", "https://open.spotify.com/album/39WqpoPgZxygo6YQjehLJJ", "39WqpoPgZxygo6YQjehLJJ"), ("deezer", "176356382", "176356382"), ("deezer", "blah blah", None), ("deezer", "https://www.deezer.com/album/176356382", "176356382"), ("beatport", "3089651", "3089651"), ("beatport", "blah blah", None), - ("beatport", "https://www.beatport.com/release/album-name/3089651", "3089651"), # noqa: E501 - ("discogs", "http://www.discogs.com/G%C3%BCnther-Lause-Meru-Ep/release/4354798", "4354798"), # noqa: E501 - ("discogs", "http://www.discogs.com/release/4354798-G%C3%BCnther-Lause-Meru-Ep", "4354798"), # noqa: E501 - ("discogs", "http://www.discogs.com/G%C3%BCnther-4354798Lause-Meru-Ep/release/4354798", "4354798"), # noqa: E501 - ("discogs", "http://www.discogs.com/release/4354798-G%C3%BCnther-4354798Lause-Meru-Ep/", "4354798"), # noqa: E501 + ("beatport", "https://www.beatport.com/release/album-name/3089651", "3089651"), + ("discogs", "http://www.discogs.com/G%C3%BCnther-Lause-Meru-Ep/release/4354798", "4354798"), + ("discogs", "http://www.discogs.com/release/4354798-G%C3%BCnther-Lause-Meru-Ep", "4354798"), + ("discogs", "http://www.discogs.com/G%C3%BCnther-4354798Lause-Meru-Ep/release/4354798", "4354798"), + ("discogs", "http://www.discogs.com/release/4354798-G%C3%BCnther-4354798Lause-Meru-Ep/", "4354798"), ("discogs", "[r4354798]", "4354798"), ("discogs", "r4354798", "4354798"), ("discogs", "4354798", "4354798"), ("discogs", "yet-another-metadata-provider.org/foo/12345", None), ("discogs", "005b84a0-ecd6-39f1-b2f6-6eb48756b268", None), - ("musicbrainz", "28e32c71-1450-463e-92bf-e0a46446fc11", "28e32c71-1450-463e-92bf-e0a46446fc11"), # noqa: E501 + ("musicbrainz", "28e32c71-1450-463e-92bf-e0a46446fc11", "28e32c71-1450-463e-92bf-e0a46446fc11"), ("musicbrainz", "blah blah", None), - ("musicbrainz", "https://musicbrainz.org/entity/28e32c71-1450-463e-92bf-e0a46446fc11", "28e32c71-1450-463e-92bf-e0a46446fc11"), # noqa: E501 - ("bandcamp", "https://nameofartist.bandcamp.com/album/nameofalbum", "https://nameofartist.bandcamp.com/album/nameofalbum"), # noqa: E501 + ("musicbrainz", "https://musicbrainz.org/entity/28e32c71-1450-463e-92bf-e0a46446fc11", "28e32c71-1450-463e-92bf-e0a46446fc11"), + ("bandcamp", "https://nameofartist.bandcamp.com/album/nameofalbum", "https://nameofartist.bandcamp.com/album/nameofalbum"), ], ) # fmt: skip def test_extract_release_id(source, id_string, expected): From 1c20e4bd4e4be890f8de3846fce6e1c3fc135db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 28 Dec 2025 03:11:39 +0000 Subject: [PATCH 210/274] Address RUF012 --- beets/dbcore/db.py | 6 +++--- beets/dbcore/query.py | 9 +++++++-- beets/dbcore/types.py | 4 ++-- beets/library/models.py | 14 ++++++++------ beets/plugins.py | 27 +++++++++++++++------------ beets/test/helper.py | 4 ++-- beetsplug/acousticbrainz.py | 3 ++- beetsplug/bpd/__init__.py | 4 ++-- beetsplug/deezer.py | 4 ++-- beetsplug/fetchart.py | 8 ++++---- beetsplug/lyrics.py | 15 +++++++++------ beetsplug/metasync/__init__.py | 9 ++++++++- beetsplug/metasync/amarok.py | 3 ++- beetsplug/metasync/itunes.py | 3 ++- beetsplug/missing.py | 4 ++-- beetsplug/mpdstats.py | 3 ++- beetsplug/playlist.py | 8 ++++++-- beetsplug/spotify.py | 6 +++--- beetsplug/the.py | 3 ++- docs/extensions/conf.py | 8 ++++---- test/plugins/lyrics_pages.py | 2 +- test/plugins/test_bpd.py | 7 ++++--- test/plugins/test_edit.py | 3 ++- test/plugins/test_hook.py | 4 ++-- test/plugins/test_mpdstats.py | 6 +++--- test/plugins/test_musicbrainz.py | 7 ++++++- test/test_dbcore.py | 15 ++++++++------- test/test_plugins.py | 7 +++++-- 28 files changed, 118 insertions(+), 78 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 08664bdf2..33d5dd5f2 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -29,7 +29,7 @@ from collections import defaultdict from collections.abc import Mapping from functools import cached_property from sqlite3 import sqlite_version_info -from typing import TYPE_CHECKING, Any, AnyStr, Generic +from typing import TYPE_CHECKING, Any, AnyStr, ClassVar, Generic from typing_extensions import ( Self, @@ -299,7 +299,7 @@ class Model(ABC, Generic[D]): """The flex field SQLite table name. """ - _fields: dict[str, types.Type] = {} + _fields: ClassVar[dict[str, types.Type]] = {} """A mapping indicating available "fixed" fields on this type. The keys are field names and the values are `Type` objects. """ @@ -314,7 +314,7 @@ class Model(ABC, Generic[D]): """Optional types for non-fixed (flexible and computed) fields.""" return {} - _sorts: dict[str, type[FieldSort]] = {} + _sorts: ClassVar[dict[str, type[FieldSort]]] = {} """Optional named sort criteria. The keys are strings and the values are subclasses of `Sort`. """ diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 52aed43b2..f486df672 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -25,7 +25,7 @@ from datetime import datetime, timedelta from functools import cached_property, reduce from operator import mul, or_ from re import Pattern -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar from beets import util from beets.util.units import raw_seconds_short @@ -691,7 +691,12 @@ class Period: ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M"), # minute ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"), # second ) - relative_units = {"y": 365, "m": 30, "w": 7, "d": 1} + relative_units: ClassVar[dict[str, int]] = { + "y": 365, + "m": 30, + "w": 7, + "d": 1, + } relative_re = "(?P[+|-]?)(?P[0-9]+)(?P[y|m|w|d])" def __init__(self, date: datetime, precision: str): diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 3b4badd33..61336d9ce 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -20,7 +20,7 @@ import re import time import typing from abc import ABC -from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast import beets from beets import util @@ -406,7 +406,7 @@ class MusicalKey(String): The standard format is C, Cm, C#, C#m, etc. """ - ENHARMONIC = { + ENHARMONIC: ClassVar[dict[str, str]] = { r"db": "c#", r"eb": "d#", r"gb": "f#", diff --git a/beets/library/models.py b/beets/library/models.py index 9609989bc..aee055134 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -7,7 +7,7 @@ import time import unicodedata from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from mediafile import MediaFile, UnreadableFileError @@ -229,7 +229,7 @@ class Album(LibModel): _table = "albums" _flex_table = "album_attributes" _always_dirty = True - _fields = { + _fields: ClassVar[dict[str, types.Type]] = { "id": types.PRIMARY_ID, "artpath": types.NullPathType(), "added": types.DATE, @@ -281,13 +281,13 @@ class Album(LibModel): def _types(cls) -> dict[str, types.Type]: return {**super()._types, "path": types.PathType()} - _sorts = { + _sorts: ClassVar[dict[str, type[dbcore.query.FieldSort]]] = { "albumartist": dbcore.query.SmartArtistSort, "artist": dbcore.query.SmartArtistSort, } # List of keys that are set on an album's items. - item_keys = [ + item_keys: ClassVar[list[str]] = [ "added", "albumartist", "albumartists", @@ -624,7 +624,7 @@ class Item(LibModel): _table = "items" _flex_table = "item_attributes" - _fields = { + _fields: ClassVar[dict[str, types.Type]] = { "id": types.PRIMARY_ID, "path": types.PathType(), "album_id": types.FOREIGN_ID, @@ -744,7 +744,9 @@ class Item(LibModel): _formatter = FormattedItemMapping - _sorts = {"artist": dbcore.query.SmartArtistSort} + _sorts: ClassVar[dict[str, type[dbcore.query.FieldSort]]] = { + "artist": dbcore.query.SmartArtistSort + } @cached_classproperty def _queries(cls) -> dict[str, FieldQueryType]: diff --git a/beets/plugins.py b/beets/plugins.py index c41541132..ec3f999c4 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -141,7 +141,13 @@ class PluginLogFilter(logging.Filter): # Managing the plugins themselves. -class BeetsPlugin(metaclass=abc.ABCMeta): +class BeetsPluginMeta(abc.ABCMeta): + template_funcs: ClassVar[TFuncMap[str]] = {} + template_fields: ClassVar[TFuncMap[Item]] = {} + album_template_fields: ClassVar[TFuncMap[Album]] = {} + + +class BeetsPlugin(metaclass=BeetsPluginMeta): """The base class for all beets plugins. Plugins provide functionality by defining a subclass of BeetsPlugin and overriding the abstract methods defined here. @@ -151,9 +157,10 @@ class BeetsPlugin(metaclass=abc.ABCMeta): list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) - template_funcs: ClassVar[TFuncMap[str]] | TFuncMap[str] = {} # type: ignore[valid-type] - template_fields: ClassVar[TFuncMap[Item]] | TFuncMap[Item] = {} # type: ignore[valid-type] - album_template_fields: ClassVar[TFuncMap[Album]] | TFuncMap[Album] = {} # type: ignore[valid-type] + + template_funcs: TFuncMap[str] + template_fields: TFuncMap[Item] + album_template_fields: TFuncMap[Album] name: str config: ConfigView @@ -220,14 +227,10 @@ class BeetsPlugin(metaclass=abc.ABCMeta): self.name = name or self.__module__.split(".")[-1] self.config = beets.config[self.name] - # If the class attributes are not set, initialize as instance attributes. - # TODO: Revise with v3.0.0, see also type: ignore[valid-type] above - if not self.template_funcs: - self.template_funcs = {} - if not self.template_fields: - self.template_fields = {} - if not self.album_template_fields: - self.album_template_fields = {} + # create per-instance storage for template fields and functions + self.template_funcs = {} + self.template_fields = {} + self.album_template_fields = {} self.early_import_stages = [] self.import_stages = [] diff --git a/beets/test/helper.py b/beets/test/helper.py index adc64088d..207b0e491 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -524,7 +524,7 @@ class ImportHelper(TestHelper): autotagging library and several assertions for the library. """ - default_import_config = { + default_import_config: ClassVar[dict[str, bool]] = { "autotag": True, "copy": True, "hardlink": False, @@ -880,7 +880,7 @@ class FetchImageHelper: def run(self, *args, **kwargs): super().run(*args, **kwargs) - IMAGEHEADER: dict[str, bytes] = { + IMAGEHEADER: ClassVar[dict[str, bytes]] = { "image/jpeg": b"\xff\xd8\xff\x00\x00\x00JFIF", "image/png": b"\211PNG\r\n\032\n", "image/gif": b"GIF89a", diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py index 92a1976a1..09a56e0a7 100644 --- a/beetsplug/acousticbrainz.py +++ b/beetsplug/acousticbrainz.py @@ -15,6 +15,7 @@ """Fetch various AcousticBrainz metadata using MBID.""" from collections import defaultdict +from typing import ClassVar import requests @@ -55,7 +56,7 @@ ABSCHEME = { class AcousticPlugin(plugins.BeetsPlugin): - item_types = { + item_types: ClassVar[dict[str, types.Type]] = { "average_loudness": types.Float(6), "chords_changes_rate": types.Float(6), "chords_key": types.STRING, diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index ea2e561b3..30126f370 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -26,7 +26,7 @@ import sys import time import traceback from string import Template -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import beets import beets.ui @@ -1344,7 +1344,7 @@ class Server(BaseServer): # Searching. - tagtype_map = { + tagtype_map: ClassVar[dict[str, str]] = { "Artist": "artist", "ArtistSort": "artist_sort", "Album": "album", diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index f113dcca2..61b028361 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -18,7 +18,7 @@ from __future__ import annotations import collections import time -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, ClassVar, Literal import requests @@ -37,7 +37,7 @@ if TYPE_CHECKING: class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): - item_types = { + item_types: ClassVar[dict[str, types.Type]] = { "deezer_track_rank": types.INTEGER, "deezer_track_id": types.INTEGER, "deezer_updated": types.DATE, diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index ab5a17228..ef311cbbd 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -355,7 +355,7 @@ class ArtSource(RequestMixin, ABC): # Specify whether this source fetches local or remote images LOC: ClassVar[SourceLocation] # A list of methods to match metadata, sorted by descending accuracy - VALID_MATCHING_CRITERIA: list[str] = ["default"] + VALID_MATCHING_CRITERIA: ClassVar[list[str]] = ["default"] # A human-readable name for the art source NAME: ClassVar[str] # The key to select the art source in the config. This value will also be @@ -518,8 +518,8 @@ class RemoteArtSource(ArtSource): class CoverArtArchive(RemoteArtSource): NAME = "Cover Art Archive" ID = "coverart" - VALID_MATCHING_CRITERIA = ["release", "releasegroup"] - VALID_THUMBNAIL_SIZES = [250, 500, 1200] + VALID_MATCHING_CRITERIA: ClassVar[list[str]] = ["release", "releasegroup"] + VALID_THUMBNAIL_SIZES: ClassVar[list[int]] = [250, 500, 1200] URL = "https://coverartarchive.org/release/{mbid}" GROUP_URL = "https://coverartarchive.org/release-group/{mbid}" @@ -1128,7 +1128,7 @@ class LastFM(RemoteArtSource): ID = "lastfm" # Sizes in priority order. - SIZES = OrderedDict( + SIZES: ClassVar[dict[str, tuple[int, int]]] = OrderedDict( [ ("mega", (300, 300)), ("extralarge", (300, 300)), diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index d6e14c175..7995daefc 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -26,7 +26,7 @@ from functools import cached_property, partial, total_ordering from html import unescape from itertools import groupby from pathlib import Path -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, ClassVar, NamedTuple from urllib.parse import quote, quote_plus, urlencode, urlparse import langdetect @@ -367,7 +367,7 @@ class LRCLib(Backend): class MusiXmatch(Backend): URL_TEMPLATE = "https://www.musixmatch.com/lyrics/{}/{}" - REPLACEMENTS = { + REPLACEMENTS: ClassVar[dict[str, str]] = { r"\s+": "-", "<": "Less_Than", ">": "Greater_Than", @@ -600,7 +600,7 @@ class Google(SearchBackend): SEARCH_URL = "https://www.googleapis.com/customsearch/v1" #: Exclude some letras.mus.br pages which do not contain lyrics. - EXCLUDE_PAGES = [ + EXCLUDE_PAGES: ClassVar[list[str]] = [ "significado.html", "traduccion.html", "traducao.html", @@ -630,9 +630,12 @@ class Google(SearchBackend): #: Split cleaned up URL title into artist and title parts. URL_TITLE_PARTS_RE = re.compile(r" +(?:[ :|-]+|par|by) +|, ") - SOURCE_DIST_FACTOR = {"www.azlyrics.com": 0.5, "www.songlyrics.com": 0.6} + SOURCE_DIST_FACTOR: ClassVar[dict[str, float]] = { + "www.azlyrics.com": 0.5, + "www.songlyrics.com": 0.6, + } - ignored_domains: set[str] = set() + ignored_domains: ClassVar[set[str]] = set() @classmethod def pre_process_html(cls, html: str) -> str: @@ -937,7 +940,7 @@ class RestFiles: class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin): - BACKEND_BY_NAME = { + BACKEND_BY_NAME: ClassVar[dict[str, type[Backend]]] = { b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch] } diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py index d4e31851e..22cc8145e 100644 --- a/beetsplug/metasync/__init__.py +++ b/beetsplug/metasync/__init__.py @@ -14,14 +14,20 @@ """Synchronize information from music player libraries""" +from __future__ import annotations + from abc import ABCMeta, abstractmethod from importlib import import_module +from typing import TYPE_CHECKING, ClassVar from confuse import ConfigValueError from beets import ui from beets.plugins import BeetsPlugin +if TYPE_CHECKING: + from beets.dbcore import types + METASYNC_MODULE = "beetsplug.metasync" # Dictionary to map the MODULE and the CLASS NAME of meta sources @@ -32,8 +38,9 @@ SOURCES = { class MetaSource(metaclass=ABCMeta): + item_types: ClassVar[dict[str, types.Type]] + def __init__(self, config, log): - self.item_types = {} self.config = config self._log = log diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py index 47e6a1a65..f092dd59c 100644 --- a/beetsplug/metasync/amarok.py +++ b/beetsplug/metasync/amarok.py @@ -17,6 +17,7 @@ from datetime import datetime from os.path import basename from time import mktime +from typing import ClassVar from xml.sax.saxutils import quoteattr from beets.dbcore import types @@ -35,7 +36,7 @@ dbus = import_dbus() class Amarok(MetaSource): - item_types = { + item_types: ClassVar[dict[str, types.Type]] = { "amarok_rating": types.INTEGER, "amarok_score": types.FLOAT, "amarok_uid": types.STRING, diff --git a/beetsplug/metasync/itunes.py b/beetsplug/metasync/itunes.py index 6f441ef8b..88582622d 100644 --- a/beetsplug/metasync/itunes.py +++ b/beetsplug/metasync/itunes.py @@ -20,6 +20,7 @@ import shutil import tempfile from contextlib import contextmanager from time import mktime +from typing import ClassVar from urllib.parse import unquote, urlparse from confuse import ConfigValueError @@ -58,7 +59,7 @@ def _norm_itunes_path(path): class Itunes(MetaSource): - item_types = { + item_types: ClassVar[dict[str, types.Type]] = { "itunes_rating": types.INTEGER, # 0..100 scale "itunes_playcount": types.INTEGER, "itunes_skipcount": types.INTEGER, diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 081a73dcd..d2aae14e9 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -18,7 +18,7 @@ from __future__ import annotations from collections import defaultdict -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import requests @@ -96,7 +96,7 @@ def _item(track_info, album_info, album_id): class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): """List missing tracks""" - album_types = { + album_types: ClassVar[dict[str, types.Type]] = { "missing": types.INTEGER, } diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index 0a3e1de02..f195df290 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -15,6 +15,7 @@ import os import time +from typing import ClassVar import mpd @@ -318,7 +319,7 @@ class MPDStats: class MPDStatsPlugin(plugins.BeetsPlugin): - item_types = { + item_types: ClassVar[dict[str, types.Type]] = { "play_count": types.INTEGER, "skip_count": types.INTEGER, "last_played": types.DATE, diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 34e7a2fe3..a1f9fff39 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -15,7 +15,7 @@ from __future__ import annotations import os import tempfile from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import beets from beets.dbcore.query import BLOB_TYPE, InQuery @@ -24,6 +24,8 @@ from beets.util import path_as_posix if TYPE_CHECKING: from collections.abc import Sequence + from beets.dbcore.query import FieldQueryType + def is_m3u_file(path: str) -> bool: return Path(path).suffix.lower() in {".m3u", ".m3u8"} @@ -85,7 +87,9 @@ class PlaylistQuery(InQuery[bytes]): class PlaylistPlugin(beets.plugins.BeetsPlugin): - item_queries = {"playlist": PlaylistQuery} + item_queries: ClassVar[dict[str, FieldQueryType]] = { + "playlist": PlaylistQuery + } def __init__(self): super().__init__() diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index a778cf1e2..9b26b1e49 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -27,7 +27,7 @@ import re import threading import time import webbrowser -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal import confuse import requests @@ -88,7 +88,7 @@ class AudioFeaturesUnavailableError(Exception): class SpotifyPlugin( SearchApiMetadataSourcePlugin[SearchResponseAlbums | SearchResponseTracks] ): - item_types = { + item_types: ClassVar[dict[str, types.Type]] = { "spotify_track_popularity": types.INTEGER, "spotify_acousticness": types.FLOAT, "spotify_danceability": types.FLOAT, @@ -114,7 +114,7 @@ class SpotifyPlugin( track_url = "https://api.spotify.com/v1/tracks/" audio_features_url = "https://api.spotify.com/v1/audio-features/" - spotify_audio_features = { + spotify_audio_features: ClassVar[dict[str, str]] = { "acousticness": "spotify_acousticness", "danceability": "spotify_danceability", "energy": "spotify_energy", diff --git a/beetsplug/the.py b/beetsplug/the.py index b29fc728d..94dc7ee52 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -15,6 +15,7 @@ """Moves patterns in path formats (suitable for moving articles).""" import re +from typing import ClassVar from beets.plugins import BeetsPlugin @@ -27,7 +28,7 @@ FORMAT = "{}, {}" class ThePlugin(BeetsPlugin): - patterns: list[str] = [] + patterns: ClassVar[list[str]] = [] def __init__(self): super().__init__() diff --git a/docs/extensions/conf.py b/docs/extensions/conf.py index 308d28be2..e69103f59 100644 --- a/docs/extensions/conf.py +++ b/docs/extensions/conf.py @@ -72,10 +72,10 @@ class ConfDomain(Domain): name = "conf" label = "Simple Configuration" - object_types = {"conf": ObjType("conf", "conf")} - directives = {"conf": Conf} - roles = {"conf": XRefRole()} - initial_data: dict[str, Any] = {"objects": {}} + object_types = {"conf": ObjType("conf", "conf")} # noqa: RUF012 + directives = {"conf": Conf} # noqa: RUF012 + roles = {"conf": XRefRole()} # noqa: RUF012 + initial_data: dict[str, Any] = {"objects": {}} # noqa: RUF012 def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]: """Return an iterable of object tuples for the inventory.""" diff --git a/test/plugins/lyrics_pages.py b/test/plugins/lyrics_pages.py index 15cb812a1..047b6e443 100644 --- a/test/plugins/lyrics_pages.py +++ b/test/plugins/lyrics_pages.py @@ -24,7 +24,7 @@ class LyricsPage(NamedTuple): artist: str = "The Beatles" track_title: str = "Lady Madonna" url_title: str | None = None # only relevant to the Google backend - marks: list[str] = [] # markers for pytest.param + marks: list[str] = [] # markers for pytest.param # noqa: RUF012 def __str__(self) -> str: """Return name of this test case.""" diff --git a/test/plugins/test_bpd.py b/test/plugins/test_bpd.py index 16e424d7e..157569bbe 100644 --- a/test/plugins/test_bpd.py +++ b/test/plugins/test_bpd.py @@ -22,6 +22,7 @@ import threading import time import unittest from contextlib import contextmanager +from typing import ClassVar from unittest.mock import MagicMock, patch import confuse @@ -837,7 +838,7 @@ class BPDQueueTest(BPDTestHelper): fail=True, ) - METADATA = {"Pos", "Time", "Id", "file", "duration"} + METADATA: ClassVar[set[str]] = {"Pos", "Time", "Id", "file", "duration"} def test_cmd_add(self): with self.run_bpd() as client: @@ -1032,7 +1033,7 @@ class BPDConnectionTest(BPDTestHelper): } ) - ALL_MPD_TAGTYPES = { + ALL_MPD_TAGTYPES: ClassVar[set[str]] = { "Artist", "ArtistSort", "Album", @@ -1057,7 +1058,7 @@ class BPDConnectionTest(BPDTestHelper): "MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ_WORKID", } - UNSUPPORTED_TAGTYPES = { + UNSUPPORTED_TAGTYPES: ClassVar[set[str]] = { "MUSICBRAINZ_WORKID", # not tracked by beets "Performer", # not tracked by beets "AlbumSort", # not tracked by beets diff --git a/test/plugins/test_edit.py b/test/plugins/test_edit.py index f715fd9e8..564b2ff1a 100644 --- a/test/plugins/test_edit.py +++ b/test/plugins/test_edit.py @@ -13,6 +13,7 @@ # included in all copies or substantial portions of the Software. import codecs +from typing import ClassVar from unittest.mock import patch from beets.dbcore.query import TrueQuery @@ -319,7 +320,7 @@ class EditDuringImporterTestCase( matching = AutotagStub.GOOD - IGNORED = ["added", "album_id", "id", "mtime", "path"] + IGNORED: ClassVar[list[str]] = ["added", "album_id", "id", "mtime", "path"] def setUp(self): super().setUp() diff --git a/test/plugins/test_hook.py b/test/plugins/test_hook.py index 033e1ea64..d47162666 100644 --- a/test/plugins/test_hook.py +++ b/test/plugins/test_hook.py @@ -19,7 +19,7 @@ import os import sys import unittest from contextlib import contextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from beets import plugins from beets.test.helper import PluginTestCase, capture_log @@ -70,7 +70,7 @@ class HookLogsTest(HookTestCase): class HookCommandTest(HookTestCase): - EVENTS: list[plugins.EventType] = ["write", "after_write"] + EVENTS: ClassVar[list[plugins.EventType]] = ["write", "after_write"] def setUp(self): super().setUp() diff --git a/test/plugins/test_mpdstats.py b/test/plugins/test_mpdstats.py index 6f5d3f3ce..def1f77b2 100644 --- a/test/plugins/test_mpdstats.py +++ b/test/plugins/test_mpdstats.py @@ -13,6 +13,7 @@ # included in all copies or substantial portions of the Software. +from typing import Any, ClassVar from unittest.mock import ANY, Mock, call, patch from beets import util @@ -46,9 +47,8 @@ class MPDStatsTest(PluginTestCase): assert mpdstats.get_item("/some/non-existing/path") is None assert "item not found:" in log.info.call_args[0][0] - FAKE_UNKNOWN_STATE = "some-unknown-one" - STATUSES = [ - {"state": FAKE_UNKNOWN_STATE}, + STATUSES: ClassVar[list[dict[str, Any]]] = [ + {"state": "some-unknown-one"}, {"state": "pause"}, {"state": "play", "songid": 1, "time": "0:1"}, {"state": "stop"}, diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 733287204..f21c03c97 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -15,6 +15,7 @@ """Tests for MusicBrainz API wrapper.""" import unittest +from typing import ClassVar from unittest import mock import pytest @@ -1017,7 +1018,11 @@ class TestMusicBrainzPlugin(PluginMixin): plugin = "musicbrainz" mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99" - RECORDING = {"title": "foo", "id": "bar", "length": 42} + RECORDING: ClassVar[dict[str, int | str]] = { + "title": "foo", + "id": "bar", + "length": 42, + } @pytest.fixture def plugin_config(self): diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 74e378275..b73bca818 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -19,6 +19,7 @@ import shutil import sqlite3 import unittest from tempfile import mkstemp +from typing import ClassVar import pytest @@ -57,13 +58,13 @@ class QueryFixture(dbcore.query.FieldQuery): class ModelFixture1(LibModel): _table = "test" _flex_table = "testflex" - _fields = { + _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "field_one": dbcore.types.INTEGER, "field_two": dbcore.types.STRING, } - _sorts = { + _sorts: ClassVar[dict[str, type[dbcore.query.FieldSort]]] = { "some_sort": SortFixture, } @@ -92,7 +93,7 @@ class DatabaseFixture1(dbcore.Database): class ModelFixture2(ModelFixture1): - _fields = { + _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "field_one": dbcore.types.INTEGER, "field_two": dbcore.types.INTEGER, @@ -104,7 +105,7 @@ class DatabaseFixture2(dbcore.Database): class ModelFixture3(ModelFixture1): - _fields = { + _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "field_one": dbcore.types.INTEGER, "field_two": dbcore.types.INTEGER, @@ -117,7 +118,7 @@ class DatabaseFixture3(dbcore.Database): class ModelFixture4(ModelFixture1): - _fields = { + _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "field_one": dbcore.types.INTEGER, "field_two": dbcore.types.INTEGER, @@ -133,14 +134,14 @@ class DatabaseFixture4(dbcore.Database): class AnotherModelFixture(ModelFixture1): _table = "another" _flex_table = "anotherflex" - _fields = { + _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "foo": dbcore.types.INTEGER, } class ModelFixture5(ModelFixture1): - _fields = { + _fields: ClassVar[dict[str, dbcore.types.Type]] = { "some_string_field": dbcore.types.STRING, "some_float_field": dbcore.types.FLOAT, "some_boolean_field": dbcore.types.BOOLEAN, diff --git a/test/test_plugins.py b/test/test_plugins.py index e161a4de6..53f24c13d 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -19,6 +19,7 @@ import logging import os import pkgutil import sys +from typing import ClassVar from unittest.mock import ANY, Mock, patch import pytest @@ -46,7 +47,7 @@ from beets.util import PromptChoice, displayable_path, syspath class TestPluginRegistration(PluginTestCase): class RatingPlugin(plugins.BeetsPlugin): - item_types = { + item_types: ClassVar[dict[str, types.Type]] = { "rating": types.Float(), "multi_value": types.MULTI_VALUE_DSV, } @@ -70,7 +71,9 @@ class TestPluginRegistration(PluginTestCase): def test_duplicate_type(self): class DuplicateTypePlugin(plugins.BeetsPlugin): - item_types = {"rating": types.INTEGER} + item_types: ClassVar[dict[str, types.Type]] = { + "rating": types.INTEGER + } self.register_plugin(DuplicateTypePlugin) with pytest.raises( From c9625f8fb3381ac26080156a1d403618400da3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 13 Jan 2026 20:54:45 +0000 Subject: [PATCH 211/274] Update git blame ignore revs --- .git-blame-ignore-revs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 7aea1f81a..4137fe11e 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -81,9 +81,17 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 59c93e70139f70e9fd1c6f3c1bceb005945bec33 # Moved ui.commands._utils into ui.commands.utils 25ae330044abf04045e3f378f72bbaed739fb30d -# Refactor test_ui_command.py into multiple modules +# Refactor test_ui_command.py into multiple modules a59e41a88365e414db3282658d2aa456e0b3468a # pyupgrade Python 3.10 301637a1609831947cb5dd90270ed46c24b1ab1b # Fix changelog formatting 658b184c59388635787b447983ecd3a575f4fe56 +# Configure future-annotations +ac7f3d9da95c2d0a32e5c908ea68480518a1582d +# Configure ruff for py310 +c46069654628040316dea9db85d01b263db3ba9e +# Enable RUF rules +4749599913a42e02e66b37db9190de11d6be2cdf +# Address RUF012 +bc71ec308eb938df1d349f6857634ddf2a82e339 From ebd0e70012f7e7d55e6fd9cbb564b9e4a5fdab1a Mon Sep 17 00:00:00 2001 From: m_igashi <@M_Igashi> Date: Wed, 14 Jan 2026 01:37:55 +0100 Subject: [PATCH 212/274] Add mp3rgain support to ReplayGain command backend mp3rgain is a modern Rust rewrite of mp3gain that provides: - CLI-compatible drop-in replacement for mp3gain - Support for both MP3 and AAC/M4A formats (like aacgain) - Fixes for CVE-2021-34085 (Critical, CVSS 9.8) and CVE-2019-18359 (Medium) - Memory-safe implementation in Rust - Works on modern systems (Windows 11, macOS Apple Silicon) Changes: - Add mp3rgain to the command search list (prioritized first) - Update format_supported() with more robust command name detection using os.path.basename() and startswith() instead of substring matching - Update documentation with installation instructions See: https://github.com/M-Igashi/mp3rgain --- beetsplug/replaygain.py | 20 ++++++++++++++------ docs/plugins/replaygain.rst | 32 +++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 4e8b429ea..af5dcd001 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -565,7 +565,7 @@ class CommandBackend(Backend): ) else: # Check whether the program is in $PATH. - for cmd in ("mp3gain", "aacgain"): + for cmd in ("mp3rgain", "mp3gain", "aacgain"): try: call([cmd, "-v"], self._log) self.command = cmd @@ -573,7 +573,7 @@ class CommandBackend(Backend): pass if not self.command: raise FatalReplayGainError( - "no replaygain command found: install mp3gain or aacgain" + "no replaygain command found: install mp3rgain, mp3gain, or aacgain" ) self.noclip = config["noclip"].get(bool) @@ -608,10 +608,18 @@ class CommandBackend(Backend): def format_supported(self, item: Item) -> bool: """Checks whether the given item is supported by the selected tool.""" - if "mp3gain" in self.command and item.format != "MP3": - return False - elif "aacgain" in self.command and item.format not in ("MP3", "AAC"): - return False + # Get the base name of the command for comparison + cmd_name = os.path.basename(self.command).lower() + + if cmd_name.startswith("mp3rgain"): + # mp3rgain supports MP3 and AAC/M4A formats + return item.format in ("MP3", "AAC") + elif cmd_name.startswith("aacgain"): + # aacgain supports MP3 and AAC formats + return item.format in ("MP3", "AAC") + elif cmd_name.startswith("mp3gain"): + # mp3gain only supports MP3 + return item.format == "MP3" return True def compute_gain( diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index c7e51d25d..16f4e3088 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -10,9 +10,9 @@ Installation ------------ This plugin can use one of many backends to compute the ReplayGain values: -GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools or ffmpeg. -ffmpeg and mp3gain can be easier to install. mp3gain supports less audio formats -than the other backend. +GStreamer, mp3gain (and its cousins, aacgain and mp3rgain), Python Audio Tools +or ffmpeg. ffmpeg and mp3gain can be easier to install. mp3gain supports fewer +audio formats than the other backends. Once installed, this plugin analyzes all files during the import process. This can be a slow process; to instead analyze after the fact, disable automatic @@ -51,16 +51,24 @@ configuration file: The GStreamer backend does not support parallel analysis. -mp3gain and aacgain -~~~~~~~~~~~~~~~~~~~ +mp3gain, aacgain, and mp3rgain +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In order to use this backend, you will need to install the mp3gain_ command-line -tool or the aacgain_ fork thereof. Here are some hints: +tool, the aacgain_ fork, or mp3rgain_. Here are some hints: -- On Mac OS X, you can use Homebrew_. Type ``brew install aacgain``. +- On Mac OS X, you can use Homebrew_. Type ``brew install aacgain`` or + ``brew install mp3rgain``. - On Linux, mp3gain_ is probably in your repositories. On Debian or Ubuntu, for - example, you can run ``apt-get install mp3gain``. -- On Windows, download and install the original mp3gain_. + example, you can run ``apt-get install mp3gain``. Alternatively, mp3rgain is + available via Nix (``nix-env -iA nixpkgs.mp3rgain``) or AUR for Arch Linux. +- On Windows, download and install mp3rgain_ (recommended) or the original + mp3gain_. + +mp3rgain_ is a modern Rust rewrite of mp3gain that also supports AAC/M4A files. +It addresses security vulnerabilities (CVE-2021-34085, CVE-2019-18359) present +in the original mp3gain and works on modern systems including Windows 11 and +macOS with Apple Silicon. .. _aacgain: https://aacgain.altosdesign.com @@ -68,6 +76,8 @@ tool or the aacgain_ fork thereof. Here are some hints: .. _mp3gain: http://mp3gain.sourceforge.net/download.php +.. _mp3rgain: https://github.com/M-Igashi/mp3rgain + Then, enable the plugin (see :ref:`using-plugins`) and specify the "command" backend in your configuration file: @@ -144,8 +154,8 @@ file. The available options are: These options only work with the "command" backend: -- **command**: The path to the ``mp3gain`` or ``aacgain`` executable (if beets - cannot find it by itself). For example: +- **command**: The path to the ``mp3rgain``, ``mp3gain``, or ``aacgain`` + executable (if beets cannot find it by itself). For example: ``/Applications/MacMP3Gain.app/Contents/Resources/aacgain``. Default: Search in your ``$PATH``. - **noclip**: Reduce the amount of ReplayGain adjustment to whatever amount From fdfeb3507692ab15d6fd4299171e6e1c8191c185 Mon Sep 17 00:00:00 2001 From: rdy2go <47011689+rdy2go@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:07:54 +0100 Subject: [PATCH 213/274] add changelog for and to resolve PR #5828 --- docs/changelog.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9f30ffd9a..84bb0cc02 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -90,6 +90,8 @@ Bug fixes: - :doc:`/plugins/ftintitle`: Fixed artist name splitting to prioritize explicit featuring tokens (feat, ft, featuring) over generic separators (&, and), preventing incorrect splits when both are present. +- :doc:`reference/cli`: Fix 'from_scratch' option for singleton imports: delete + all (old) metadata when new metadata is applied. :bug:`3706` For plugin developers: @@ -292,11 +294,8 @@ Bug fixes: - Fix ``HiddenFileTest`` by using ``bytestring_path()``. - tests: Fix tests failing without ``langdetect`` (by making it required). :bug:`5797` -- :doc:`plugins/musicbrainz`: Fix the MusicBrainz search not taking into - account the album/recording aliases -- :doc:`reference/cli`: Fix 'from_scratch' option for singleton imports: delete - all (old) metadata when new metadata is applied. - :bug:`3706` +- :doc:`plugins/musicbrainz`: Fix the MusicBrainz search not taking into account + the album/recording aliases - :doc:`/plugins/spotify`: Fix the issue with that every query to spotify was ascii encoded. This resulted in bad matches for queries that contained special e.g. non latin characters as 盗作. If you want to keep the legacy behavior set From 1ff254215a4dde9fe28bcc284163e05d38fc20c4 Mon Sep 17 00:00:00 2001 From: frigginbrownie Date: Wed, 18 Dec 2024 22:55:05 -0600 Subject: [PATCH 214/274] Update convert.py --- beetsplug/convert.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 2e837c77f..af1279299 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -279,6 +279,10 @@ class ConvertPlugin(BeetsPlugin): ) = self._get_opts_and_config(empty_opts) items = task.imported_items() + + # Filter items based on should_transcode function + items = [item for item in items if should_transcode(item, fmt)] + self._parallel_convert( dest, False, From bfb24da51ceb3dffcb8f6d2fcf06a8f334d27f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 15 Jan 2026 15:53:06 +0000 Subject: [PATCH 215/274] Add note to the changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 84bb0cc02..640e46988 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -92,6 +92,9 @@ Bug fixes: preventing incorrect splits when both are present. - :doc:`reference/cli`: Fix 'from_scratch' option for singleton imports: delete all (old) metadata when new metadata is applied. :bug:`3706` +- :doc:`/plugins/convert`: ``auto_keep`` now respects ``no_convert`` and + ``never_convert_lossy_files`` when deciding whether to copy/transcode items, + avoiding extra lossy duplicates. For plugin developers: From e85f67ac7b91be7792bfbc610adcfed0d1cf3b0c Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:43:01 +0530 Subject: [PATCH 216/274] refactor: suppress OSError when unlinking temporary files in ArtResizer --- beets/util/artresizer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 23dce3c9f..b9401cca8 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -21,6 +21,7 @@ import os.path import platform import re import subprocess +from contextlib import suppress from itertools import chain from urllib.parse import urlencode @@ -660,10 +661,8 @@ class ArtResizer(metaclass=Shareable): ) finally: if result_path != path_in: - try: + with suppress(OSError): os.unlink(path_in) - except OSError: - pass return result_path @property From b0bce805189dbcc84a4421108ca0c340d8adcf4c Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:50:09 +0530 Subject: [PATCH 217/274] remove changelog not related to pr --- docs/changelog.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 881b9faa2..097e3bc3b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -474,15 +474,8 @@ Bug fixes: result. Update the default ``sources`` configuration to prioritize ``lrclib`` over other sources since it returns reliable results quicker than others. :bug:`5102` -* :doc:`plugins/lyrics`: Fix the issue with ``genius`` backend not being able - to match lyrics when there is a slight variation in the artist name. - :bug:`4791` -* :doc:`plugins/lyrics`: Fix plugin crash when ``genius`` backend returns empty - lyrics. - :bug:`5583` * Handle potential OSError when unlinking temporary files in ArtResizer. :bug:`5615` -* ImageMagick 7.1.1-44 is now supported. - :doc:`plugins/lyrics`: Fix the issue with ``genius`` backend not being able to match lyrics when there is a slight variation in the artist name. :bug:`4791` - :doc:`plugins/lyrics`: Fix plugin crash when ``genius`` backend returns empty From 4ad5871ef020633d9f438d9112288b9b3d6f54f6 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:53:34 +0530 Subject: [PATCH 218/274] fix: sort imports --- beets/util/artresizer.py | 2 +- docs/changelog.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 6f6a7b99e..ae1476101 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -24,8 +24,8 @@ import platform import re import subprocess from abc import ABC, abstractmethod -from enum import Enum from contextlib import suppress +from enum import Enum from itertools import chain from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import urlencode diff --git a/docs/changelog.rst b/docs/changelog.rst index 097e3bc3b..f7a6f9d48 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -474,7 +474,7 @@ Bug fixes: result. Update the default ``sources`` configuration to prioritize ``lrclib`` over other sources since it returns reliable results quicker than others. :bug:`5102` -* Handle potential OSError when unlinking temporary files in ArtResizer. +- Handle potential OSError when unlinking temporary files in ArtResizer. :bug:`5615` - :doc:`plugins/lyrics`: Fix the issue with ``genius`` backend not being able to match lyrics when there is a slight variation in the artist name. :bug:`4791` From 179dc7d0701e0252da4c35d627e8da1da1f6ff90 Mon Sep 17 00:00:00 2001 From: m_igashi <@M_Igashi> Date: Fri, 16 Jan 2026 16:06:17 +0100 Subject: [PATCH 219/274] style: remove trailing whitespace from blank line --- beetsplug/replaygain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index af5dcd001..7197971c2 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -610,7 +610,7 @@ class CommandBackend(Backend): """Checks whether the given item is supported by the selected tool.""" # Get the base name of the command for comparison cmd_name = os.path.basename(self.command).lower() - + if cmd_name.startswith("mp3rgain"): # mp3rgain supports MP3 and AAC/M4A formats return item.format in ("MP3", "AAC") From 683da049a09586ce0485a4d4823d2d4cce441887 Mon Sep 17 00:00:00 2001 From: m_igashi <@M_Igashi> Date: Fri, 16 Jan 2026 16:17:45 +0100 Subject: [PATCH 220/274] style: format replaygain.rst with docstrfmt --- docs/plugins/replaygain.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 16f4e3088..6fa456bb5 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -57,8 +57,8 @@ mp3gain, aacgain, and mp3rgain In order to use this backend, you will need to install the mp3gain_ command-line tool, the aacgain_ fork, or mp3rgain_. Here are some hints: -- On Mac OS X, you can use Homebrew_. Type ``brew install aacgain`` or - ``brew install mp3rgain``. +- On Mac OS X, you can use Homebrew_. Type ``brew install aacgain`` or ``brew + install mp3rgain``. - On Linux, mp3gain_ is probably in your repositories. On Debian or Ubuntu, for example, you can run ``apt-get install mp3gain``. Alternatively, mp3rgain is available via Nix (``nix-env -iA nixpkgs.mp3rgain``) or AUR for Arch Linux. From 5ea41b3fbb26d89de9efd39edfc00b9b9f41c8a5 Mon Sep 17 00:00:00 2001 From: m_igashi <@M_Igashi> Date: Sat, 17 Jan 2026 02:10:29 +0100 Subject: [PATCH 221/274] refactor: simplify CommandBackend with SUPPORTED_FORMATS_BY_TOOL - Add Tool type alias and SUPPORTED_FORMATS_BY_TOOL class variable - Refactor __init__ to use shutil.which() and set cmd_name early - Simplify format_supported() to use dictionary lookup --- beetsplug/replaygain.py | 65 +++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 7197971c2..25472b6e6 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -28,7 +28,9 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from multiprocessing.pool import ThreadPool from threading import Event, Thread -from typing import TYPE_CHECKING, Any, TypeVar +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar from beets import ui from beets.plugins import BeetsPlugin @@ -542,10 +544,20 @@ class FfmpegBackend(Backend): # mpgain/aacgain CLI tool backend. +Tool = Literal["mp3rgain", "aacgain", "mp3gain"] + + class CommandBackend(Backend): NAME = "command" + SUPPORTED_FORMATS_BY_TOOL: ClassVar[dict[Tool, set[str]]] = { + "mp3rgain": {"AAC", "MP3"}, + "aacgain": {"AAC", "MP3"}, + "mp3gain": {"MP3"}, + } do_parallel = True + cmd_name: Tool + def __init__(self, config: ConfigView, log: Logger): super().__init__(config, log) config.add( @@ -555,26 +567,35 @@ class CommandBackend(Backend): } ) - self.command: str = config["command"].as_str() + cmd_path: Path = Path(config["command"].as_str()) + supported_tools = set(self.SUPPORTED_FORMATS_BY_TOOL) - if self.command: - # Explicit executable path. - if not os.path.isfile(self.command): + if cmd_path.name: + # Explicit command specified + if cmd_path.name not in supported_tools: raise FatalReplayGainError( - f"replaygain command does not exist: {self.command}" + f"replaygain.command must be one of {supported_tools!r}," + f" not {cmd_path.name!r}" + ) + if command_exec := shutil.which(str(cmd_path)): + self.command = command_exec + self.cmd_name = cmd_path.name # type: ignore[assignment] + else: + raise FatalReplayGainError( + f"replaygain command not found: {cmd_path}" ) else: # Check whether the program is in $PATH. for cmd in ("mp3rgain", "mp3gain", "aacgain"): - try: - call([cmd, "-v"], self._log) - self.command = cmd - except OSError: - pass - if not self.command: - raise FatalReplayGainError( - "no replaygain command found: install mp3rgain, mp3gain, or aacgain" - ) + if command_exec := shutil.which(cmd): + self.command = command_exec + self.cmd_name = cmd # type: ignore[assignment] + break + else: + raise FatalReplayGainError( + "no replaygain command found: install mp3rgain, mp3gain, " + "or aacgain" + ) self.noclip = config["noclip"].get(bool) @@ -608,19 +629,7 @@ class CommandBackend(Backend): def format_supported(self, item: Item) -> bool: """Checks whether the given item is supported by the selected tool.""" - # Get the base name of the command for comparison - cmd_name = os.path.basename(self.command).lower() - - if cmd_name.startswith("mp3rgain"): - # mp3rgain supports MP3 and AAC/M4A formats - return item.format in ("MP3", "AAC") - elif cmd_name.startswith("aacgain"): - # aacgain supports MP3 and AAC formats - return item.format in ("MP3", "AAC") - elif cmd_name.startswith("mp3gain"): - # mp3gain only supports MP3 - return item.format == "MP3" - return True + return item.format in self.SUPPORTED_FORMATS_BY_TOOL[self.cmd_name] def compute_gain( self, From 29e1c283eba0f3c2e8a196fd30eefc675a7f1a46 Mon Sep 17 00:00:00 2001 From: m_igashi <@M_Igashi> Date: Sat, 17 Jan 2026 02:15:43 +0100 Subject: [PATCH 222/274] fix: sort imports alphabetically --- beetsplug/replaygain.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 25472b6e6..5a5cb96e8 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -20,6 +20,7 @@ import enum import math import os import queue +import shutil import signal import subprocess import sys @@ -27,9 +28,8 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass from multiprocessing.pool import ThreadPool -from threading import Event, Thread -import shutil from pathlib import Path +from threading import Event, Thread from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar from beets import ui From 52284ff7ed9ae38a25c9c5e76c697e47e7c6d4a5 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:30:22 +0530 Subject: [PATCH 223/274] fix: changelog entry --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f7a6f9d48..5408d2a5c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -48,6 +48,8 @@ New features: Bug fixes: +- Handle potential OSError when unlinking temporary files in ArtResizer. + :bug:`5615` - :doc:`/plugins/spotify`: Updated Spotify API credentials. :bug:`6270` - :doc:`/plugins/smartplaylist`: Fixed an issue where multiple queries in a playlist configuration were not preserving their order, causing items to @@ -474,8 +476,6 @@ Bug fixes: result. Update the default ``sources`` configuration to prioritize ``lrclib`` over other sources since it returns reliable results quicker than others. :bug:`5102` -- Handle potential OSError when unlinking temporary files in ArtResizer. - :bug:`5615` - :doc:`plugins/lyrics`: Fix the issue with ``genius`` backend not being able to match lyrics when there is a slight variation in the artist name. :bug:`4791` - :doc:`plugins/lyrics`: Fix plugin crash when ``genius`` backend returns empty From 545e7eb0b6983d7bc76a702cdfe9488a0a51a3f3 Mon Sep 17 00:00:00 2001 From: m_igashi <@M_Igashi> Date: Sun, 18 Jan 2026 10:52:41 +0100 Subject: [PATCH 224/274] refactor: simplify CommandBackend and improve documentation - Remove auto-detection of command tools, require explicit command config - Simplify __init__ method by removing redundant else branch - Reorganize docs with separate sections for mp3gain, aacgain, mp3rgain - Fix CVE reference (CVE-2021-34085 is fixed in mp3gain 1.6.2) - Update command option description per review feedback --- beetsplug/replaygain.py | 37 ++++++----------- docs/plugins/replaygain.rst | 83 ++++++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 5a5cb96e8..e83345059 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -570,32 +570,19 @@ class CommandBackend(Backend): cmd_path: Path = Path(config["command"].as_str()) supported_tools = set(self.SUPPORTED_FORMATS_BY_TOOL) - if cmd_path.name: - # Explicit command specified - if cmd_path.name not in supported_tools: - raise FatalReplayGainError( - f"replaygain.command must be one of {supported_tools!r}," - f" not {cmd_path.name!r}" - ) - if command_exec := shutil.which(str(cmd_path)): - self.command = command_exec - self.cmd_name = cmd_path.name # type: ignore[assignment] - else: - raise FatalReplayGainError( - f"replaygain command not found: {cmd_path}" - ) + if (cmd_name := cmd_path.name) not in supported_tools: + raise FatalReplayGainError( + f"replaygain.command must be one of {supported_tools!r}," + f" not {cmd_name!r}" + ) + + if command_exec := shutil.which(str(cmd_path)): + self.command = command_exec + self.cmd_name = cmd_name # type: ignore[assignment] else: - # Check whether the program is in $PATH. - for cmd in ("mp3rgain", "mp3gain", "aacgain"): - if command_exec := shutil.which(cmd): - self.command = command_exec - self.cmd_name = cmd # type: ignore[assignment] - break - else: - raise FatalReplayGainError( - "no replaygain command found: install mp3rgain, mp3gain, " - "or aacgain" - ) + raise FatalReplayGainError( + f"replaygain command not found: {cmd_path}" + ) self.noclip = config["noclip"].get(bool) diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 6fa456bb5..2973dd959 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -51,24 +51,59 @@ configuration file: The GStreamer backend does not support parallel analysis. -mp3gain, aacgain, and mp3rgain +Supported ``command`` backends ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -In order to use this backend, you will need to install the mp3gain_ command-line -tool, the aacgain_ fork, or mp3rgain_. Here are some hints: +In order to use this backend, you will need to install a supported command-line +tool: + +- mp3gain_ (MP3 only) +- aacgain_ (MP3, AAC/M4A) +- mp3rgain_ (MP3, AAC/M4A) + +mp3gain ++++++++ -- On Mac OS X, you can use Homebrew_. Type ``brew install aacgain`` or ``brew - install mp3rgain``. - On Linux, mp3gain_ is probably in your repositories. On Debian or Ubuntu, for - example, you can run ``apt-get install mp3gain``. Alternatively, mp3rgain is - available via Nix (``nix-env -iA nixpkgs.mp3rgain``) or AUR for Arch Linux. -- On Windows, download and install mp3rgain_ (recommended) or the original - mp3gain_. + example, you can run ``apt-get install mp3gain``. +- On Windows, download and install mp3gain_. -mp3rgain_ is a modern Rust rewrite of mp3gain that also supports AAC/M4A files. -It addresses security vulnerabilities (CVE-2021-34085, CVE-2019-18359) present -in the original mp3gain and works on modern systems including Windows 11 and -macOS with Apple Silicon. +aacgain ++++++++ + +- On macOS, install via Homebrew_: ``brew install aacgain``. +- For other platforms, download from aacgain_ or use a compatible fork if + available for your system. + +mp3rgain +++++++++ + +mp3rgain_ is a modern Rust rewrite of ``mp3gain`` that also supports AAC/M4A +files. It addresses security vulnerability CVE-2019-18359 present in the +original mp3gain and works on modern systems including Windows 11 and macOS with +Apple Silicon. + +- On macOS, install via Homebrew_: ``brew install mp3rgain``. +- On Linux, install via Nix: ``nix-env -iA nixpkgs.mp3rgain`` or from your + distribution packaging (for example, AUR on Arch Linux). +- On Windows, download and install mp3rgain_. + +Configuration ++++++++++++++ + +.. code-block:: yaml + + replaygain: + backend: command + command: # mp3rgain, mp3gain, or aacgain + +If beets doesn't automatically find the command executable, you can configure +the path explicitly like so: + +.. code-block:: yaml + + replaygain: + command: /Applications/MacMP3Gain.app/Contents/Resources/aacgain .. _aacgain: https://aacgain.altosdesign.com @@ -78,22 +113,6 @@ macOS with Apple Silicon. .. _mp3rgain: https://github.com/M-Igashi/mp3rgain -Then, enable the plugin (see :ref:`using-plugins`) and specify the "command" -backend in your configuration file: - -:: - - replaygain: - backend: command - -If beets doesn't automatically find the ``mp3gain`` or ``aacgain`` executable, -you can configure the path explicitly like so: - -:: - - replaygain: - command: /Applications/MacMP3Gain.app/Contents/Resources/aacgain - Python Audio Tools ~~~~~~~~~~~~~~~~~~ @@ -154,10 +173,8 @@ file. The available options are: These options only work with the "command" backend: -- **command**: The path to the ``mp3rgain``, ``mp3gain``, or ``aacgain`` - executable (if beets cannot find it by itself). For example: - ``/Applications/MacMP3Gain.app/Contents/Resources/aacgain``. Default: Search - in your ``$PATH``. +- **command**: Name or path to your command backend of choice: either of + ``mp3gain``, ``aacgain`` or ``mp3rgain``. - **noclip**: Reduce the amount of ReplayGain adjustment to whatever amount would keep clipping from occurring. Default: ``yes``. From 9efe87101ce03706660129633e03147a222765cf Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 22 Nov 2025 17:13:08 -0800 Subject: [PATCH 225/274] Fix #6177, remove derived types, refactor coalesce tracks --- beetsplug/discogs.py | 217 +++++++++++++++++++---------------- docs/changelog.rst | 2 + test/plugins/test_discogs.py | 2 +- 3 files changed, 121 insertions(+), 100 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 08d437d2d..6941cf891 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -27,7 +27,7 @@ import time import traceback from functools import cache from string import ascii_lowercase -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import confuse from discogs_client import Client, Master, Release @@ -102,23 +102,7 @@ class Track(TypedDict): duration: str artists: list[Artist] extraartists: NotRequired[list[Artist]] - - -class TrackWithSubtracks(Track): - sub_tracks: list[TrackWithSubtracks] - - -class IntermediateTrackInfo(TrackInfo): - """Allows work with string mediums from - get_track_info""" - - def __init__( - self, - medium_str: str | None, - **kwargs, - ) -> None: - self.medium_str = medium_str - super().__init__(**kwargs) + sub_tracks: NotRequired[list[Track]] class DiscogsPlugin(MetadataSourcePlugin): @@ -520,9 +504,19 @@ class DiscogsPlugin(MetadataSourcePlugin): self, clean_tracklist: list[Track], album_artist_data: tuple[str, str, str | None], - ) -> tuple[list[TrackInfo], dict[int, str], int, list[str], list[str]]: + ) -> tuple[ + list[TrackInfo], + dict[int, str], + int, + list[str], + list[str], + list[str | None], + list[str | None], + ]: # Distinct works and intra-work divisions, as defined by index tracks. tracks: list[TrackInfo] = [] + mediums: list[str | None] = [] + medium_indices: list[str | None] = [] index_tracks = {} index = 0 divisions: list[str] = [] @@ -536,11 +530,19 @@ class DiscogsPlugin(MetadataSourcePlugin): # divisions. divisions += next_divisions del next_divisions[:] - track_info = self.get_track_info( + track_info, medium, medium_index = self.get_track_info( track, index, divisions, album_artist_data ) track_info.track_alt = track["position"] tracks.append(track_info) + if medium: + mediums.append(medium) + else: + mediums.append(None) + if medium_index: + medium_indices.append(medium_index) + else: + medium_indices.append(None) else: next_divisions.append(track["title"]) # We expect new levels of division at the beginning of the @@ -550,7 +552,15 @@ class DiscogsPlugin(MetadataSourcePlugin): except IndexError: pass index_tracks[index + 1] = track["title"] - return tracks, index_tracks, index, divisions, next_divisions + return ( + tracks, + index_tracks, + index, + divisions, + next_divisions, + mediums, + medium_indices, + ) def get_tracks( self, @@ -559,9 +569,7 @@ class DiscogsPlugin(MetadataSourcePlugin): ) -> list[TrackInfo]: """Returns a list of TrackInfo objects for a discogs tracklist.""" try: - clean_tracklist: list[Track] = self.coalesce_tracks( - cast(list[TrackWithSubtracks], tracklist) - ) + clean_tracklist: list[Track] = self.coalesce_tracks(tracklist) except Exception as exc: # FIXME: this is an extra precaution for making sure there are no # side effects after #2222. It should be removed after further @@ -572,7 +580,15 @@ class DiscogsPlugin(MetadataSourcePlugin): processed = self._process_clean_tracklist( clean_tracklist, album_artist_data ) - tracks, index_tracks, *_ = processed + ( + tracks, + index_tracks, + index, + divisions, + next_divisions, + mediums, + medium_indices, + ) = processed # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None @@ -581,32 +597,34 @@ class DiscogsPlugin(MetadataSourcePlugin): # If a medium has two sides (ie. vinyl or cassette), each pair of # consecutive sides should belong to the same medium. - if all([track.medium_str is not None for track in tracks]): - m = sorted({track.medium_str.lower() for track in tracks}) + if all([medium is not None for medium in mediums]): + m = sorted({medium.lower() if medium else "" for medium in mediums}) # If all track.medium are single consecutive letters, assume it is # a 2-sided medium. if "".join(m) in ascii_lowercase: sides_per_medium = 2 - for track in tracks: + for i, track in enumerate(tracks): # Handle special case where a different medium does not indicate a # new disc, when there is no medium_index and the ordinal of medium # is not sequential. For example, I, II, III, IV, V. Assume these # are the track index, not the medium. # side_count is the number of mediums or medium sides (in the case # of two-sided mediums) that were seen before. + medium_str = mediums[i] + medium_index = medium_indices[i] medium_is_index = ( - track.medium_str - and not track.medium_index + medium_str + and not medium_index and ( - len(track.medium_str) != 1 + len(medium_str) != 1 or # Not within standard incremental medium values (A, B, C, ...). - ord(track.medium_str) - 64 != side_count + 1 + ord(medium_str) - 64 != side_count + 1 ) ) - if not medium_is_index and medium != track.medium_str: + if not medium_is_index and medium != medium_str: side_count += 1 if sides_per_medium == 2: if side_count % sides_per_medium: @@ -617,7 +635,7 @@ class DiscogsPlugin(MetadataSourcePlugin): # Medium changed. Reset index_count. medium_count += 1 index_count = 0 - medium = track.medium_str + medium = medium_str index_count += 1 medium_count = 1 if medium_count == 0 else medium_count @@ -633,61 +651,17 @@ class DiscogsPlugin(MetadataSourcePlugin): disctitle = None track.disctitle = disctitle - return cast(list[TrackInfo], tracks) + return tracks - def coalesce_tracks( - self, raw_tracklist: list[TrackWithSubtracks] - ) -> list[Track]: + def coalesce_tracks(self, raw_tracklist: list[Track]) -> list[Track]: """Pre-process a tracklist, merging subtracks into a single track. The title for the merged track is the one from the previous index track, if present; otherwise it is a combination of the subtracks titles. """ - - def add_merged_subtracks( - tracklist: list[TrackWithSubtracks], - subtracks: list[TrackWithSubtracks], - ) -> None: - """Modify `tracklist` in place, merging a list of `subtracks` into - a single track into `tracklist`.""" - # Calculate position based on first subtrack, without subindex. - idx, medium_idx, sub_idx = self.get_track_index( - subtracks[0]["position"] - ) - position = f"{idx or ''}{medium_idx or ''}" - - if tracklist and not tracklist[-1]["position"]: - # Assume the previous index track contains the track title. - if sub_idx: - # "Convert" the track title to a real track, discarding the - # subtracks assuming they are logical divisions of a - # physical track (12.2.9 Subtracks). - tracklist[-1]["position"] = position - else: - # Promote the subtracks to real tracks, discarding the - # index track, assuming the subtracks are physical tracks. - index_track = tracklist.pop() - # Fix artists when they are specified on the index track. - if index_track.get("artists"): - for subtrack in subtracks: - if not subtrack.get("artists"): - subtrack["artists"] = index_track["artists"] - # Concatenate index with track title when index_tracks - # option is set - if self.config["index_tracks"]: - for subtrack in subtracks: - subtrack["title"] = ( - f"{index_track['title']}: {subtrack['title']}" - ) - tracklist.extend(subtracks) - else: - # Merge the subtracks, pick a title, and append the new track. - track = subtracks[0].copy() - track["title"] = " / ".join([t["title"] for t in subtracks]) - tracklist.append(track) - # Pre-process the tracklist, trying to identify subtracks. - subtracks: list[TrackWithSubtracks] = [] - tracklist: list[TrackWithSubtracks] = [] + + subtracks: list[Track] = [] + tracklist: list[Track] = [] prev_subindex = "" for track in raw_tracklist: # Regular subtrack (track with subindex). @@ -699,7 +673,7 @@ class DiscogsPlugin(MetadataSourcePlugin): subtracks.append(track) else: # Subtrack part of a new group (..., 1.3, *2.1*, ...). - add_merged_subtracks(tracklist, subtracks) + self._add_merged_subtracks(tracklist, subtracks) subtracks = [track] prev_subindex = subindex.rjust(len(raw_tracklist)) continue @@ -708,21 +682,64 @@ class DiscogsPlugin(MetadataSourcePlugin): if not track["position"] and "sub_tracks" in track: # Append the index track, assuming it contains the track title. tracklist.append(track) - add_merged_subtracks(tracklist, track["sub_tracks"]) + self._add_merged_subtracks(tracklist, track["sub_tracks"]) continue # Regular track or index track without nested sub_tracks. if subtracks: - add_merged_subtracks(tracklist, subtracks) + self._add_merged_subtracks(tracklist, subtracks) subtracks = [] prev_subindex = "" tracklist.append(track) # Merge and add the remaining subtracks, if any. if subtracks: - add_merged_subtracks(tracklist, subtracks) + self._add_merged_subtracks(tracklist, subtracks) - return cast(list[Track], tracklist) + return tracklist + + def _add_merged_subtracks( + self, + tracklist: list[Track], + subtracks: list[Track], + ) -> None: + """Modify `tracklist` in place, merging a list of `subtracks` into + a single track into `tracklist`.""" + # Calculate position based on first subtrack, without subindex. + idx, medium_idx, sub_idx = self.get_track_index( + subtracks[0]["position"] + ) + position = f"{idx or ''}{medium_idx or ''}" + + if tracklist and not tracklist[-1]["position"]: + # Assume the previous index track contains the track title. + if sub_idx: + # "Convert" the track title to a real track, discarding the + # subtracks assuming they are logical divisions of a + # physical track (12.2.9 Subtracks). + tracklist[-1]["position"] = position + else: + # Promote the subtracks to real tracks, discarding the + # index track, assuming the subtracks are physical tracks. + index_track = tracklist.pop() + # Fix artists when they are specified on the index track. + if index_track.get("artists"): + for subtrack in subtracks: + if not subtrack.get("artists"): + subtrack["artists"] = index_track["artists"] + # Concatenate index with track title when index_tracks + # option is set + if self.config["index_tracks"]: + for subtrack in subtracks: + subtrack["title"] = ( + f"{index_track['title']}: {subtrack['title']}" + ) + tracklist.extend(subtracks) + else: + # Merge the subtracks, pick a title, and append the new track. + track = subtracks[0].copy() + track["title"] = " / ".join([t["title"] for t in subtracks]) + tracklist.append(track) def strip_disambiguation(self, text: str) -> str: """Removes discogs specific disambiguations from a string. @@ -738,7 +755,7 @@ class DiscogsPlugin(MetadataSourcePlugin): index: int, divisions: list[str], album_artist_data: tuple[str, str, str | None], - ) -> IntermediateTrackInfo: + ) -> tuple[TrackInfo, str | None, str | None]: """Returns a TrackInfo object for a discogs track.""" artist, artist_anv, artist_id = album_artist_data @@ -784,16 +801,18 @@ class DiscogsPlugin(MetadataSourcePlugin): artist_credit += ( f" {self.config['featured_string']} {featured_credit}" ) - return IntermediateTrackInfo( - title=title, - track_id=track_id, - artist_credit=artist_credit, - artist=artist, - artist_id=artist_id, - length=length, - index=index, - medium_str=medium, - medium_index=medium_index, + return ( + TrackInfo( + title=title, + track_id=track_id, + artist_credit=artist_credit, + artist=artist, + artist_id=artist_id, + length=length, + index=index, + ), + medium, + medium_index, ) @staticmethod diff --git a/docs/changelog.rst b/docs/changelog.rst index 5408d2a5c..93606cf1e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -97,6 +97,8 @@ Bug fixes: - :doc:`/plugins/convert`: ``auto_keep`` now respects ``no_convert`` and ``never_convert_lossy_files`` when deciding whether to copy/transcode items, avoiding extra lossy duplicates. +- :doc:`plugins/discogs`: Fixed unexpected flex attr from the Discogs plugin. + :bug:`6177` For plugin developers: diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index eb65bc588..fd820ab43 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -608,7 +608,7 @@ def test_parse_featured_artists(track, expected_artist): """Tests the plugins ability to parse a featured artist. Initial check with one featured artist, two featured artists, and three. Ignores artists that are not listed as featured.""" - t = DiscogsPlugin().get_track_info( + t, _, _ = DiscogsPlugin().get_track_info( track, 1, 1, ("ARTIST", "ARTIST CREDIT", 2) ) assert t.artist == expected_artist From 1d6e05709e758a2522f9260ef28150c1d3ee90ab Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 7 Dec 2025 14:37:12 -0800 Subject: [PATCH 226/274] Fix #6068 - Multivalue fields are now supported & tested. --- beetsplug/discogs.py | 212 +++++++++++++++++++++++++---------- docs/changelog.rst | 1 + test/plugins/test_discogs.py | 103 ++++++++++++++--- 3 files changed, 238 insertions(+), 78 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 6941cf891..eb8465960 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -18,6 +18,7 @@ python3-discogs-client library. from __future__ import annotations +import copy import http.client import json import os @@ -105,6 +106,24 @@ class Track(TypedDict): sub_tracks: NotRequired[list[Track]] +class ArtistInfo(TypedDict): + artist: str + artists: list[str] + artist_credit: str + artists_credit: list[str] + artist_id: str + artists_ids: list[str] + + +class AlbumArtistInfo(ArtistInfo): + albumartist: str + albumartists: list[str] + albumartist_credit: str + albumartists_credit: list[str] + albumartist_id: str + albumartists_ids: list[str] + + class DiscogsPlugin(MetadataSourcePlugin): def __init__(self): super().__init__() @@ -261,7 +280,6 @@ class DiscogsPlugin(MetadataSourcePlugin): for track in album.tracks: if track.track_id == track_id: return track - return None def get_albums(self, query: str) -> Iterable[AlbumInfo]: @@ -346,6 +364,121 @@ class DiscogsPlugin(MetadataSourcePlugin): artist, artist_id = self.get_artist(artist_list, join_key="join") return self.strip_disambiguation(artist), artist_id + def build_albumartistinfo(self, artists: list[Artist]) -> AlbumArtistInfo: + info = self.build_artistinfo(artists, album_artist=True) + albumartist: AlbumArtistInfo = { + **info, + "albumartist": info["artist"], + "albumartist_id": info["artist_id"], + "albumartists": info["artists"], + "albumartists_ids": info["artists_ids"], + "albumartist_credit": info["artist_credit"], + "albumartists_credit": info["artists_credit"], + } + return albumartist + + def build_artistinfo( + self, + given_artists: list[Artist], + given_info: ArtistInfo | None = None, + album_artist: bool = False, + ) -> ArtistInfo: + """Iterates through a discogs result and builds + up the artist fields. Does not contribute to + artist_sort as Discogs does not define that. + + :param artists: A list of Discogs Artist objects + + :param album_artist: If building an album artist, + we need to account for the album_artist anv parameter. + :return an ArtistInfo dictionary. + """ + info: ArtistInfo = { + "artist": "", + "artist_id": "", + "artists": [], + "artists_ids": [], + "artist_credit": "", + "artists_credit": [], + } + if given_info: + info = copy.deepcopy(given_info) + + a_anv: bool = self.config["anv"]["artist"].get(bool) + ac_anv: bool = self.config["anv"]["artist_credit"].get(bool) + aa_anv: bool = self.config["anv"]["album_artist"].get(bool) + feat_str: str = f" {self.config['featured_string'].get(str)} " + + artist = "" + artist_anv = "" + artists: list[str] = [] + artists_anv: list[str] = [] + + join = "" + featured_flag = False + # Iterate through building the artist strings + for a in given_artists: + # Get the artist name + name = self.strip_disambiguation(a["name"]) + discogs_id = a["id"] + anv = a.get("anv", name) + role = a.get("role", "").lower() + # Check if the artist is Various + if name.lower() == "various": + name = config["va_name"].as_str() + anv = name + + if "featuring" in role: + if not featured_flag: + artist += feat_str + artist_anv += feat_str + artist += name + artist_anv += anv + featured_flag = True + else: + artist = self._join_artist(artist, name, join) + artist_anv = self._join_artist(artist_anv, anv, join) + elif role and "featuring" not in role: + continue + else: + artist = self._join_artist(artist, name, join) + artist_anv = self._join_artist(artist_anv, anv, join) + artists.append(name) + artists_anv.append(anv) + # Only the first ID is set for the singular field + if not info["artist_id"]: + info["artist_id"] = discogs_id + info["artists_ids"].append(discogs_id) + # Update join for the next artist + join = a.get("join", "") + # Assign fields as necessary + if (a_anv and not album_artist) or (aa_anv and album_artist): + info["artist"] += artist_anv + info["artists"] += artists_anv + else: + info["artist"] += artist + info["artists"] += artists + + if ac_anv: + info["artist_credit"] += artist_anv + info["artists_credit"] += artists_anv + else: + info["artist_credit"] += artist + info["artists_credit"] += artists + return info + + def _join_artist(self, base: str, artist: str, join: str) -> str: + # Expand the artist field + if not base: + base = artist + else: + if join: + base += f" {join} " + else: + base += ", " + base += artist + return base + def get_album_info(self, result: Release) -> AlbumInfo | None: """Returns an AlbumInfo object for a discogs Release object.""" # Explicitly reload the `Release` fields, as they might not be yet @@ -375,11 +508,8 @@ class DiscogsPlugin(MetadataSourcePlugin): return None artist_data = [a.data for a in result.artists] - album_artist, album_artist_id = self.get_artist_with_anv(artist_data) - album_artist_anv, _ = self.get_artist_with_anv( - artist_data, use_anv=True - ) - artist_credit = album_artist_anv + # Information for the album artist + albumartist: AlbumArtistInfo = self.build_albumartistinfo(artist_data) album = re.sub(r" +", " ", result.title) album_id = result.data["id"] @@ -388,18 +518,11 @@ class DiscogsPlugin(MetadataSourcePlugin): # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks( - result.data["tracklist"], - (album_artist, album_artist_anv, album_artist_id), + result.data["tracklist"], self.build_artistinfo(artist_data) ) - # Assign ANV to the proper fields for tagging - if not self.config["anv"]["artist_credit"]: - artist_credit = album_artist - if self.config["anv"]["album_artist"]: - album_artist = album_artist_anv - # Extract information for the optional AlbumInfo fields, if possible. - va = result.data["artists"][0].get("name", "").lower() == "various" + va = albumartist["albumartist"] == config["va_name"].as_str() year = result.data.get("year") mediums = [t.medium for t in tracks] country = result.data.get("country") @@ -431,11 +554,7 @@ class DiscogsPlugin(MetadataSourcePlugin): cover_art_url = self.select_cover_art(result) # Additional cleanups - # (various artists name, catalog number, media, disambiguation). - if va: - va_name = config["va_name"].as_str() - album_artist = va_name - artist_credit = va_name + # (catalog number, media, disambiguation). if catalogno == "none": catalogno = None # Explicitly set the `media` for the tracks, since it is expected by @@ -458,9 +577,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return AlbumInfo( album=album, album_id=album_id, - artist=album_artist, - artist_credit=artist_credit, - artist_id=album_artist_id, + **albumartist, # Unpacks values to satisfy the keyword arguments tracks=tracks, albumtype=albumtype, va=va, @@ -478,7 +595,7 @@ class DiscogsPlugin(MetadataSourcePlugin): data_url=data_url, discogs_albumid=discogs_albumid, discogs_labelid=labelid, - discogs_artistid=album_artist_id, + discogs_artistid=albumartist["albumartist_id"], cover_art_url=cover_art_url, ) @@ -503,7 +620,7 @@ class DiscogsPlugin(MetadataSourcePlugin): def _process_clean_tracklist( self, clean_tracklist: list[Track], - album_artist_data: tuple[str, str, str | None], + albumartistinfo: ArtistInfo, ) -> tuple[ list[TrackInfo], dict[int, str], @@ -531,7 +648,7 @@ class DiscogsPlugin(MetadataSourcePlugin): divisions += next_divisions del next_divisions[:] track_info, medium, medium_index = self.get_track_info( - track, index, divisions, album_artist_data + track, index, divisions, albumartistinfo ) track_info.track_alt = track["position"] tracks.append(track_info) @@ -565,7 +682,7 @@ class DiscogsPlugin(MetadataSourcePlugin): def get_tracks( self, tracklist: list[Track], - album_artist_data: tuple[str, str, str | None], + albumartistinfo: ArtistInfo, ) -> list[TrackInfo]: """Returns a list of TrackInfo objects for a discogs tracklist.""" try: @@ -578,7 +695,7 @@ class DiscogsPlugin(MetadataSourcePlugin): self._log.error("uncaught exception in coalesce_tracks: {}", exc) clean_tracklist = tracklist processed = self._process_clean_tracklist( - clean_tracklist, album_artist_data + clean_tracklist, albumartistinfo ) ( tracks, @@ -754,16 +871,11 @@ class DiscogsPlugin(MetadataSourcePlugin): track: Track, index: int, divisions: list[str], - album_artist_data: tuple[str, str, str | None], + albumartistinfo: ArtistInfo, ) -> tuple[TrackInfo, str | None, str | None]: """Returns a TrackInfo object for a discogs track.""" - artist, artist_anv, artist_id = album_artist_data - artist_credit = artist_anv - if not self.config["anv"]["artist_credit"]: - artist_credit = artist - if self.config["anv"]["artist"]: - artist = artist_anv + artistinfo = albumartistinfo.copy() title = track["title"] if self.config["index_tracks"]: @@ -775,39 +887,19 @@ class DiscogsPlugin(MetadataSourcePlugin): # If artists are found on the track, we will use those instead if artists := track.get("artists", []): - artist, artist_id = self.get_artist_with_anv( - artists, self.config["anv"]["artist"] - ) - artist_credit, _ = self.get_artist_with_anv( - artists, self.config["anv"]["artist_credit"] - ) + artistinfo = self.build_artistinfo(artists) + length = self.get_track_length(track["duration"]) # Add featured artists if extraartists := track.get("extraartists", []): - featured_list = [ - artist - for artist in extraartists - if "Featuring" in artist["role"] - ] - featured, _ = self.get_artist_with_anv( - featured_list, self.config["anv"]["artist"] - ) - featured_credit, _ = self.get_artist_with_anv( - featured_list, self.config["anv"]["artist_credit"] - ) - if featured: - artist += f" {self.config['featured_string']} {featured}" - artist_credit += ( - f" {self.config['featured_string']} {featured_credit}" - ) + artistinfo = self.build_artistinfo(extraartists, artistinfo) + return ( TrackInfo( title=title, track_id=track_id, - artist_credit=artist_credit, - artist=artist, - artist_id=artist_id, + **artistinfo, length=length, index=index, ), diff --git a/docs/changelog.rst b/docs/changelog.rst index 93606cf1e..3400c8893 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -45,6 +45,7 @@ New features: of brackets are supported and a new ``bracket_keywords`` configuration option allows customizing the keywords. Setting ``bracket_keywords`` to an empty list matches any bracket content regardless of keywords. +- :doc:`plugins/discogs`: Added support for multi value fields. :bug:`6068` Bug fixes: diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index fd820ab43..c11148c13 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -409,7 +409,9 @@ class DGAlbumInfoTest(BeetsTestCase): ) d = DiscogsPlugin().get_album_info(release) assert d.artist == "ARTIST NAME & OTHER ARTIST" + assert d.artists == ["ARTIST NAME", "OTHER ARTIST"] assert d.tracks[0].artist == "TEST ARTIST" + assert d.tracks[0].artists == ["TEST ARTIST"] assert d.label == "LABEL NAME" def test_strip_disambiguation_false(self): @@ -448,35 +450,62 @@ class DGAlbumInfoTest(BeetsTestCase): ) d = DiscogsPlugin().get_album_info(release) assert d.artist == "ARTIST NAME (2) & OTHER ARTIST (5)" + assert d.artists == ["ARTIST NAME (2)", "OTHER ARTIST (5)"] assert d.tracks[0].artist == "TEST ARTIST (5)" + assert d.tracks[0].artists == ["TEST ARTIST (5)"] assert d.label == "LABEL NAME (5)" config["discogs"]["strip_disambiguation"] = True @pytest.mark.parametrize( - "track_artist_anv,track_artist", - [(False, "ARTIST Feat. PERFORMER"), (True, "VARIATION Feat. VARIATION")], -) -@pytest.mark.parametrize( - "album_artist_anv,album_artist", - [(False, "ARTIST & SOLOIST"), (True, "VARIATION & VARIATION")], -) -@pytest.mark.parametrize( - "artist_credit_anv,track_artist_credit,album_artist_credit", + "track_artist_anv,track_artist,track_artists", [ - (False, "ARTIST Feat. PERFORMER", "ARTIST & SOLOIST"), - (True, "VARIATION Feat. VARIATION", "VARIATION & VARIATION"), + (False, "ARTIST Feat. PERFORMER", ["ARTIST", "PEFORMER"]), + (True, "VARIATION Feat. VARIATION", ["VARIATION", "VARIATION"]), + ], +) +@pytest.mark.parametrize( + "album_artist_anv,album_artist,album_artists", + [ + (False, "ARTIST & SOLOIST", ["ARTIST", "SOLOIST"]), + (True, "VARIATION & VARIATION", ["VARIATION", "VARIATION"]), + ], +) +@pytest.mark.parametrize( + ( + "artist_credit_anv,track_artist_credit," + "track_artists_credit,album_artist_credit,album_artists_credit" + ), + [ + ( + False, + "ARTIST Feat. PERFORMER", + ["ARTIST", "PEFORMER"], + "ARTIST & SOLOIST", + ["ARTIST", "SOLOIST"], + ), + ( + True, + "VARIATION Feat. VARIATION", + ["VARIATION", "VARIATION"], + "VARIATION & VARIATION", + ["VARIATION", "VARIATION"], + ), ], ) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_anv( track_artist_anv, track_artist, + track_artists, album_artist_anv, album_artist, + album_artists, artist_credit_anv, track_artist_credit, + track_artists_credit, album_artist_credit, + album_artists_credit, ): """Test using artist name variations.""" data = { @@ -558,13 +587,21 @@ def test_anv_album_artist(): config["discogs"]["anv"]["artist_credit"] = False r = DiscogsPlugin().get_album_info(release) assert r.artist == "ARTIST" + assert r.artists == ["ARTIST"] + assert r.albumartist == "ARTIST" + assert r.albumartist_credit == "ARTIST" + assert r.albumartists == ["ARTIST"] + assert r.albumartists_credit == ["ARTIST"] assert r.artist_credit == "ARTIST" + assert r.artists_credit == ["ARTIST"] assert r.tracks[0].artist == "VARIATION" + assert r.tracks[0].artists == ["VARIATION"] assert r.tracks[0].artist_credit == "ARTIST" + assert r.tracks[0].artists_credit == ["ARTIST"] @pytest.mark.parametrize( - "track, expected_artist", + "track, expected_artist, expected_artists", [ ( { @@ -600,18 +637,25 @@ def test_anv_album_artist(): ], }, "NEW ARTIST, VOCALIST Feat. SOLOIST, PERFORMER, MUSICIAN", + ["NEW ARTIST", "VOCALIST", "SOLOIST", "PERFORMER", "MUSICIAN"], ), ], ) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) -def test_parse_featured_artists(track, expected_artist): +def test_parse_featured_artists(track, expected_artist, expected_artists): """Tests the plugins ability to parse a featured artist. - Initial check with one featured artist, two featured artists, - and three. Ignores artists that are not listed as featured.""" - t, _, _ = DiscogsPlugin().get_track_info( - track, 1, 1, ("ARTIST", "ARTIST CREDIT", 2) - ) + Ignores artists that are not listed as featured.""" + artistinfo = { + "artist": "ARTIST", + "artist_id": "1", + "artists": ["ARTIST"], + "artists_ids": ["1"], + "artist_credit": "ARTIST", + "artists_credit": ["ARTIST"], + } + t, _, _ = DiscogsPlugin().get_track_info(track, 1, 1, artistinfo) assert t.artist == expected_artist + assert t.artists == expected_artists @pytest.mark.parametrize( @@ -637,6 +681,29 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): assert result == (expected_media, expected_albumtype) +@pytest.mark.parametrize( + "given_artists,expected_info,config_va_name", + [ + ( + [{"name": "Various", "id": "1"}], + { + "artist": "VARIOUS ARTISTS", + "artist_id": "1", + "artists": ["VARIOUS ARTISTS"], + "artists_ids": ["1"], + "artist_credit": "VARIOUS ARTISTS", + "artists_credit": ["VARIOUS ARTISTS"], + }, + "VARIOUS ARTISTS", + ) + ], +) +@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) +def test_va_buildartistinfo(given_artists, expected_info, config_va_name): + config["va_name"] = config_va_name + assert DiscogsPlugin().build_artistinfo(given_artists) == expected_info + + @pytest.mark.parametrize( "position, medium, index, subindex", [ From f0aef6e213384dc50c909b8f19422d09879aca0a Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 9 Dec 2025 21:24:36 -0800 Subject: [PATCH 227/274] Cleanup for #6177, #6068 --- beetsplug/discogs.py | 106 +++++++++++++---------------------- test/plugins/test_discogs.py | 13 ++++- 2 files changed, 51 insertions(+), 68 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index eb8465960..0a86d245d 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -121,7 +121,17 @@ class AlbumArtistInfo(ArtistInfo): albumartist_credit: str albumartists_credit: list[str] albumartist_id: str - albumartists_ids: list[str] + + +class TracklistInfo: + def __init__(self): + self.index: int = 0 + self.index_tracks: dict[int, str] = {} + self.tracks: list[TrackInfo] = [] + self.divisions: list[str] = [] + self.next_divisions: list[str] = [] + self.mediums: list[str | None] = [] + self.medium_indices: list[str | None] = [] class DiscogsPlugin(MetadataSourcePlugin): @@ -371,7 +381,6 @@ class DiscogsPlugin(MetadataSourcePlugin): "albumartist": info["artist"], "albumartist_id": info["artist_id"], "albumartists": info["artists"], - "albumartists_ids": info["artists_ids"], "albumartist_credit": info["artist_credit"], "albumartists_credit": info["artists_credit"], } @@ -386,12 +395,6 @@ class DiscogsPlugin(MetadataSourcePlugin): """Iterates through a discogs result and builds up the artist fields. Does not contribute to artist_sort as Discogs does not define that. - - :param artists: A list of Discogs Artist objects - - :param album_artist: If building an album artist, - we need to account for the album_artist anv parameter. - :return an ArtistInfo dictionary. """ info: ArtistInfo = { "artist": "", @@ -420,7 +423,7 @@ class DiscogsPlugin(MetadataSourcePlugin): for a in given_artists: # Get the artist name name = self.strip_disambiguation(a["name"]) - discogs_id = a["id"] + discogs_id = str(a["id"]) anv = a.get("anv", name) role = a.get("role", "").lower() # Check if the artist is Various @@ -621,63 +624,41 @@ class DiscogsPlugin(MetadataSourcePlugin): self, clean_tracklist: list[Track], albumartistinfo: ArtistInfo, - ) -> tuple[ - list[TrackInfo], - dict[int, str], - int, - list[str], - list[str], - list[str | None], - list[str | None], - ]: + ) -> TracklistInfo: # Distinct works and intra-work divisions, as defined by index tracks. - tracks: list[TrackInfo] = [] - mediums: list[str | None] = [] - medium_indices: list[str | None] = [] - index_tracks = {} - index = 0 - divisions: list[str] = [] - next_divisions: list[str] = [] + t = TracklistInfo() for track in clean_tracklist: # Only real tracks have `position`. Otherwise, it's an index track. if track["position"]: - index += 1 - if next_divisions: + t.index += 1 + if t.next_divisions: # End of a block of index tracks: update the current # divisions. - divisions += next_divisions - del next_divisions[:] + t.divisions += t.next_divisions + del t.next_divisions[:] track_info, medium, medium_index = self.get_track_info( - track, index, divisions, albumartistinfo + track, t.index, t.divisions, albumartistinfo ) track_info.track_alt = track["position"] - tracks.append(track_info) + t.tracks.append(track_info) if medium: - mediums.append(medium) + t.mediums.append(medium) else: - mediums.append(None) + t.mediums.append(None) if medium_index: - medium_indices.append(medium_index) + t.medium_indices.append(medium_index) else: - medium_indices.append(None) + t.medium_indices.append(None) else: - next_divisions.append(track["title"]) + t.next_divisions.append(track["title"]) # We expect new levels of division at the beginning of the # tracklist (and possibly elsewhere). try: - divisions.pop() + t.divisions.pop() except IndexError: pass - index_tracks[index + 1] = track["title"] - return ( - tracks, - index_tracks, - index, - divisions, - next_divisions, - mediums, - medium_indices, - ) + t.index_tracks[t.index + 1] = track["title"] + return t def get_tracks( self, @@ -694,18 +675,9 @@ class DiscogsPlugin(MetadataSourcePlugin): self._log.debug("{}", traceback.format_exc()) self._log.error("uncaught exception in coalesce_tracks: {}", exc) clean_tracklist = tracklist - processed = self._process_clean_tracklist( + t: TracklistInfo = self._process_clean_tracklist( clean_tracklist, albumartistinfo ) - ( - tracks, - index_tracks, - index, - divisions, - next_divisions, - mediums, - medium_indices, - ) = processed # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None @@ -714,22 +686,24 @@ class DiscogsPlugin(MetadataSourcePlugin): # If a medium has two sides (ie. vinyl or cassette), each pair of # consecutive sides should belong to the same medium. - if all([medium is not None for medium in mediums]): - m = sorted({medium.lower() if medium else "" for medium in mediums}) + if all([medium is not None for medium in t.mediums]): + m = sorted( + {medium.lower() if medium else "" for medium in t.mediums} + ) # If all track.medium are single consecutive letters, assume it is # a 2-sided medium. if "".join(m) in ascii_lowercase: sides_per_medium = 2 - for i, track in enumerate(tracks): + for i, track in enumerate(t.tracks): # Handle special case where a different medium does not indicate a # new disc, when there is no medium_index and the ordinal of medium # is not sequential. For example, I, II, III, IV, V. Assume these # are the track index, not the medium. # side_count is the number of mediums or medium sides (in the case # of two-sided mediums) that were seen before. - medium_str = mediums[i] - medium_index = medium_indices[i] + medium_str = t.mediums[i] + medium_index = t.medium_indices[i] medium_is_index = ( medium_str and not medium_index @@ -760,15 +734,15 @@ class DiscogsPlugin(MetadataSourcePlugin): # Get `disctitle` from Discogs index tracks. Assume that an index track # before the first track of each medium is a disc title. - for track in tracks: + for track in t.tracks: if track.medium_index == 1: - if track.index in index_tracks: - disctitle = index_tracks[track.index] + if track.index in t.index_tracks: + disctitle = t.index_tracks[track.index] else: disctitle = None track.disctitle = disctitle - return tracks + return t.tracks def coalesce_tracks(self, raw_tracklist: list[Track]) -> list[Track]: """Pre-process a tracklist, merging subtracks into a single track. The diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index c11148c13..a34b8aee4 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -410,8 +410,11 @@ class DGAlbumInfoTest(BeetsTestCase): d = DiscogsPlugin().get_album_info(release) assert d.artist == "ARTIST NAME & OTHER ARTIST" assert d.artists == ["ARTIST NAME", "OTHER ARTIST"] + assert d.artists_ids == ["321", "321"] assert d.tracks[0].artist == "TEST ARTIST" assert d.tracks[0].artists == ["TEST ARTIST"] + assert d.tracks[0].artist_id == "11146" + assert d.tracks[0].artists_ids == ["11146"] assert d.label == "LABEL NAME" def test_strip_disambiguation_false(self): @@ -460,7 +463,7 @@ class DGAlbumInfoTest(BeetsTestCase): @pytest.mark.parametrize( "track_artist_anv,track_artist,track_artists", [ - (False, "ARTIST Feat. PERFORMER", ["ARTIST", "PEFORMER"]), + (False, "ARTIST Feat. PERFORMER", ["ARTIST", "PERFORMER"]), (True, "VARIATION Feat. VARIATION", ["VARIATION", "VARIATION"]), ], ) @@ -480,7 +483,7 @@ class DGAlbumInfoTest(BeetsTestCase): ( False, "ARTIST Feat. PERFORMER", - ["ARTIST", "PEFORMER"], + ["ARTIST", "PERFORMER"], "ARTIST & SOLOIST", ["ARTIST", "SOLOIST"], ), @@ -551,9 +554,14 @@ def test_anv( config["discogs"]["anv"]["artist_credit"] = artist_credit_anv r = DiscogsPlugin().get_album_info(release) assert r.artist == album_artist + assert r.albumartists == album_artists assert r.artist_credit == album_artist_credit + assert r.albumartist_credit == album_artist_credit + assert r.albumartists_credit == album_artists_credit assert r.tracks[0].artist == track_artist + assert r.tracks[0].artists == track_artists assert r.tracks[0].artist_credit == track_artist_credit + assert r.tracks[0].artists_credit == track_artists_credit @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) @@ -590,6 +598,7 @@ def test_anv_album_artist(): assert r.artists == ["ARTIST"] assert r.albumartist == "ARTIST" assert r.albumartist_credit == "ARTIST" + assert r.albumartist_id == "321" assert r.albumartists == ["ARTIST"] assert r.albumartists_credit == ["ARTIST"] assert r.artist_credit == "ARTIST" From 08a2c248b9153f2863b69dcaba5cff38d36b27ee Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer Date: Mon, 15 Dec 2025 14:05:29 -0800 Subject: [PATCH 228/274] Fix handling of commas and semicolons in artist join --- beetsplug/discogs.py | 6 +++++- test/plugins/test_discogs.py | 27 ++++++++++++++------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 0a86d245d..dcf5bd77a 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -476,7 +476,11 @@ class DiscogsPlugin(MetadataSourcePlugin): base = artist else: if join: - base += f" {join} " + join = join.strip() + if join in ";,": + base += f"{join} " + else: + base += f" {join} " else: base += ", " base += artist diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index a34b8aee4..ca3959f19 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -464,14 +464,14 @@ class DGAlbumInfoTest(BeetsTestCase): "track_artist_anv,track_artist,track_artists", [ (False, "ARTIST Feat. PERFORMER", ["ARTIST", "PERFORMER"]), - (True, "VARIATION Feat. VARIATION", ["VARIATION", "VARIATION"]), + (True, "ART Feat. PERF", ["ART", "PERF"]), ], ) @pytest.mark.parametrize( "album_artist_anv,album_artist,album_artists", [ - (False, "ARTIST & SOLOIST", ["ARTIST", "SOLOIST"]), - (True, "VARIATION & VARIATION", ["VARIATION", "VARIATION"]), + (False, "DRUMMER, ARTIST & SOLOIST", ["DRUMMER", "ARTIST", "SOLOIST"]), + (True, "DRUM, ARTY & SOLO", ["DRUM", "ARTY", "SOLO"]), ], ) @pytest.mark.parametrize( @@ -484,15 +484,15 @@ class DGAlbumInfoTest(BeetsTestCase): False, "ARTIST Feat. PERFORMER", ["ARTIST", "PERFORMER"], - "ARTIST & SOLOIST", - ["ARTIST", "SOLOIST"], + "DRUMMER, ARTIST & SOLOIST", + ["DRUMMER", "ARTIST", "SOLOIST"], ), ( True, - "VARIATION Feat. VARIATION", - ["VARIATION", "VARIATION"], - "VARIATION & VARIATION", - ["VARIATION", "VARIATION"], + "ART Feat. PERF", + ["ART", "PERF"], + "DRUM, ARTY & SOLO", + ["DRUM", "ARTY", "SOLO"], ), ], ) @@ -524,7 +524,7 @@ def test_anv( { "name": "ARTIST", "tracks": "", - "anv": "VARIATION", + "anv": "ART", "id": 11146, } ], @@ -532,15 +532,16 @@ def test_anv( { "name": "PERFORMER", "role": "Featuring", - "anv": "VARIATION", + "anv": "PERF", "id": 787, } ], } ], "artists": [ - {"name": "ARTIST (4)", "anv": "VARIATION", "id": 321, "join": "&"}, - {"name": "SOLOIST", "anv": "VARIATION", "id": 445, "join": ""}, + {"name": "DRUMMER", "anv": "DRUM", "id": 445, "join": ", "}, + {"name": "ARTIST (4)", "anv": "ARTY", "id": 321, "join": "&"}, + {"name": "SOLOIST", "anv": "SOLO", "id": 445, "join": ""}, ], "title": "title", } From 459fd39768b5c8e8531799881b3596b8ca98fd8e Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 19 Dec 2025 18:24:26 -0800 Subject: [PATCH 229/274] Fix behavior when ANV does not exist --- beetsplug/discogs.py | 4 ++- test/plugins/test_discogs.py | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index dcf5bd77a..8887b8811 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -424,7 +424,9 @@ class DiscogsPlugin(MetadataSourcePlugin): # Get the artist name name = self.strip_disambiguation(a["name"]) discogs_id = str(a["id"]) - anv = a.get("anv", name) + anv = a.get("anv", "") + if not anv: + anv = name role = a.get("role", "").lower() # Check if the artist is Various if name.lower() == "various": diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index ca3959f19..393dc4cc0 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -565,6 +565,56 @@ def test_anv( assert r.tracks[0].artists_credit == track_artists_credit +@pytest.mark.parametrize("artist_anv", [True, False]) +@pytest.mark.parametrize("albumartist_anv", [True, False]) +@pytest.mark.parametrize("artistcredit_anv", [True, False]) +@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) +def test_anv_no_variation(artist_anv, albumartist_anv, artistcredit_anv): + """Test behavior when there is no ANV but the anv field is set""" + data = { + "id": 123, + "uri": "https://www.discogs.com/release/123456-something", + "tracklist": [ + { + "title": "track", + "position": "A", + "type_": "track", + "duration": "5:44", + "artists": [ + { + "name": "PERFORMER", + "tracks": "", + "anv": "", + "id": 1, + } + ], + } + ], + "artists": [ + {"name": "ARTIST", "anv": "", "id": 2}, + ], + "title": "title", + } + release = Bag( + data=data, + title=data["title"], + artists=[Bag(data=d) for d in data["artists"]], + ) + config["discogs"]["anv"]["album_artist"] = albumartist_anv + config["discogs"]["anv"]["artist"] = artist_anv + config["discogs"]["anv"]["artist_credit"] = artistcredit_anv + r = DiscogsPlugin().get_album_info(release) + assert r.artist == "ARTIST" + assert r.albumartists == ["ARTIST"] + assert r.artist_credit == "ARTIST" + assert r.albumartist_credit == "ARTIST" + assert r.albumartists_credit == ["ARTIST"] + assert r.tracks[0].artist == "PERFORMER" + assert r.tracks[0].artists == ["PERFORMER"] + assert r.tracks[0].artist_credit == "PERFORMER" + assert r.tracks[0].artists_credit == ["PERFORMER"] + + @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_anv_album_artist(): """Test using artist name variations when the album artist From 2d406a3ca5812992598d836a959814aca0a5ab54 Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer Date: Tue, 30 Dec 2025 11:49:20 -0800 Subject: [PATCH 230/274] Add comments, clean up types. --- beetsplug/discogs.py | 172 +++++++++++++++++++++-------------- test/plugins/test_discogs.py | 2 +- 2 files changed, 104 insertions(+), 70 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 8887b8811..f38384751 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -123,15 +123,14 @@ class AlbumArtistInfo(ArtistInfo): albumartist_id: str -class TracklistInfo: - def __init__(self): - self.index: int = 0 - self.index_tracks: dict[int, str] = {} - self.tracks: list[TrackInfo] = [] - self.divisions: list[str] = [] - self.next_divisions: list[str] = [] - self.mediums: list[str | None] = [] - self.medium_indices: list[str | None] = [] +class TracklistInfo(TypedDict): + index: int + index_tracks: dict[int, str] + tracks: list[TrackInfo] + divisions: list[str] + next_divisions: list[str] + mediums: list[str | None] + medium_indices: list[str | None] class DiscogsPlugin(MetadataSourcePlugin): @@ -374,8 +373,8 @@ class DiscogsPlugin(MetadataSourcePlugin): artist, artist_id = self.get_artist(artist_list, join_key="join") return self.strip_disambiguation(artist), artist_id - def build_albumartistinfo(self, artists: list[Artist]) -> AlbumArtistInfo: - info = self.build_artistinfo(artists, album_artist=True) + def _build_albumartistinfo(self, artists: list[Artist]) -> AlbumArtistInfo: + info = self._build_artistinfo(artists, for_album_artist=True) albumartist: AlbumArtistInfo = { **info, "albumartist": info["artist"], @@ -386,11 +385,11 @@ class DiscogsPlugin(MetadataSourcePlugin): } return albumartist - def build_artistinfo( + def _build_artistinfo( self, given_artists: list[Artist], given_info: ArtistInfo | None = None, - album_artist: bool = False, + for_album_artist: bool = False, ) -> ArtistInfo: """Iterates through a discogs result and builds up the artist fields. Does not contribute to @@ -404,19 +403,18 @@ class DiscogsPlugin(MetadataSourcePlugin): "artist_credit": "", "artists_credit": [], } + # If starting information is given we start from there + # Often used for cases with album artists. + # Deepcopy is used to prevent unintentional + # extra modifications if given_info: info = copy.deepcopy(given_info) - - a_anv: bool = self.config["anv"]["artist"].get(bool) - ac_anv: bool = self.config["anv"]["artist_credit"].get(bool) - aa_anv: bool = self.config["anv"]["album_artist"].get(bool) - feat_str: str = f" {self.config['featured_string'].get(str)} " - artist = "" artist_anv = "" artists: list[str] = [] artists_anv: list[str] = [] + feat_str: str = f" {self.config['featured_string'].as_str()} " join = "" featured_flag = False # Iterate through building the artist strings @@ -424,15 +422,13 @@ class DiscogsPlugin(MetadataSourcePlugin): # Get the artist name name = self.strip_disambiguation(a["name"]) discogs_id = str(a["id"]) - anv = a.get("anv", "") - if not anv: - anv = name + anv = a.get("anv", "") or name role = a.get("role", "").lower() # Check if the artist is Various if name.lower() == "various": name = config["va_name"].as_str() anv = name - + # If the artist is listed as featured if "featuring" in role: if not featured_flag: artist += feat_str @@ -440,10 +436,16 @@ class DiscogsPlugin(MetadataSourcePlugin): artist += name artist_anv += anv featured_flag = True + # Set the featured_flag + # to indicate we no longer need to + # prefix the marker for a featured + # artist else: artist = self._join_artist(artist, name, join) artist_anv = self._join_artist(artist_anv, anv, join) elif role and "featuring" not in role: + # Current artists that are in the credits + # and are not credited as featuring are ignored. continue else: artist = self._join_artist(artist, name, join) @@ -456,21 +458,9 @@ class DiscogsPlugin(MetadataSourcePlugin): info["artists_ids"].append(discogs_id) # Update join for the next artist join = a.get("join", "") - # Assign fields as necessary - if (a_anv and not album_artist) or (aa_anv and album_artist): - info["artist"] += artist_anv - info["artists"] += artists_anv - else: - info["artist"] += artist - info["artists"] += artists - - if ac_anv: - info["artist_credit"] += artist_anv - info["artists_credit"] += artists_anv - else: - info["artist_credit"] += artist - info["artists_credit"] += artists - return info + return self._assign_anv( + info, artist, artists, artist_anv, artists_anv, for_album_artist + ) def _join_artist(self, base: str, artist: str, join: str) -> str: # Expand the artist field @@ -488,6 +478,42 @@ class DiscogsPlugin(MetadataSourcePlugin): base += artist return base + def _assign_anv( + self, + info: ArtistInfo, + artist: str, + artists: list[str], + artist_anv: str, + artists_anv: list[str], + for_album_artist: bool, + ) -> ArtistInfo: + """Assign artist and variation fields based on + configuration settings. + """ + # Fetch configuration options for artist name variations + use_artist_anv: bool = self.config["anv"]["artist"].get(bool) + use_artistcredit_anv: bool = self.config["anv"]["artist_credit"].get( + bool + ) + use_albumartist_anv: bool = self.config["anv"]["album_artist"].get(bool) + + if (use_artist_anv and not for_album_artist) or ( + use_albumartist_anv and for_album_artist + ): + info["artist"] += artist_anv + info["artists"] += artists_anv + else: + info["artist"] += artist + info["artists"] += artists + + if use_artistcredit_anv: + info["artist_credit"] += artist_anv + info["artists_credit"] += artists_anv + else: + info["artist_credit"] += artist + info["artists_credit"] += artists + return info + def get_album_info(self, result: Release) -> AlbumInfo | None: """Returns an AlbumInfo object for a discogs Release object.""" # Explicitly reload the `Release` fields, as they might not be yet @@ -518,7 +544,7 @@ class DiscogsPlugin(MetadataSourcePlugin): artist_data = [a.data for a in result.artists] # Information for the album artist - albumartist: AlbumArtistInfo = self.build_albumartistinfo(artist_data) + albumartist: AlbumArtistInfo = self._build_albumartistinfo(artist_data) album = re.sub(r" +", " ", result.title) album_id = result.data["id"] @@ -527,13 +553,13 @@ class DiscogsPlugin(MetadataSourcePlugin): # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks( - result.data["tracklist"], self.build_artistinfo(artist_data) + result.data["tracklist"], self._build_artistinfo(artist_data) ) # Extract information for the optional AlbumInfo fields, if possible. va = albumartist["albumartist"] == config["va_name"].as_str() year = result.data.get("year") - mediums = [t.medium for t in tracks] + mediums = [t["medium"] for t in tracks] country = result.data.get("country") data_url = result.data.get("uri") style = self.format(result.data.get("styles")) @@ -632,38 +658,46 @@ class DiscogsPlugin(MetadataSourcePlugin): albumartistinfo: ArtistInfo, ) -> TracklistInfo: # Distinct works and intra-work divisions, as defined by index tracks. - t = TracklistInfo() + t: TracklistInfo = { + "index": 0, + "index_tracks": {}, + "tracks": [], + "divisions": [], + "next_divisions": [], + "mediums": [], + "medium_indices": [], + } for track in clean_tracklist: # Only real tracks have `position`. Otherwise, it's an index track. if track["position"]: - t.index += 1 - if t.next_divisions: + t["index"] += 1 + if t["next_divisions"]: # End of a block of index tracks: update the current # divisions. - t.divisions += t.next_divisions - del t.next_divisions[:] + t["divisions"] += t["next_divisions"] + del t["next_divisions"][:] track_info, medium, medium_index = self.get_track_info( - track, t.index, t.divisions, albumartistinfo + track, t["index"], t["divisions"], albumartistinfo ) track_info.track_alt = track["position"] - t.tracks.append(track_info) + t["tracks"].append(track_info) if medium: - t.mediums.append(medium) + t["mediums"].append(medium) else: - t.mediums.append(None) + t["mediums"].append(None) if medium_index: - t.medium_indices.append(medium_index) + t["medium_indices"].append(medium_index) else: - t.medium_indices.append(None) + t["medium_indices"].append(None) else: - t.next_divisions.append(track["title"]) + t["next_divisions"].append(track["title"]) # We expect new levels of division at the beginning of the # tracklist (and possibly elsewhere). try: - t.divisions.pop() + t["divisions"].pop() except IndexError: pass - t.index_tracks[t.index + 1] = track["title"] + t["index_tracks"][t["index"] + 1] = track["title"] return t def get_tracks( @@ -673,13 +707,13 @@ class DiscogsPlugin(MetadataSourcePlugin): ) -> list[TrackInfo]: """Returns a list of TrackInfo objects for a discogs tracklist.""" try: - clean_tracklist: list[Track] = self.coalesce_tracks(tracklist) + clean_tracklist: list[Track] = self._coalesce_tracks(tracklist) except Exception as exc: # FIXME: this is an extra precaution for making sure there are no # side effects after #2222. It should be removed after further # testing. self._log.debug("{}", traceback.format_exc()) - self._log.error("uncaught exception in coalesce_tracks: {}", exc) + self._log.error("uncaught exception in _coalesce_tracks: {}", exc) clean_tracklist = tracklist t: TracklistInfo = self._process_clean_tracklist( clean_tracklist, albumartistinfo @@ -692,24 +726,24 @@ class DiscogsPlugin(MetadataSourcePlugin): # If a medium has two sides (ie. vinyl or cassette), each pair of # consecutive sides should belong to the same medium. - if all([medium is not None for medium in t.mediums]): + if all([medium is not None for medium in t["mediums"]]): m = sorted( - {medium.lower() if medium else "" for medium in t.mediums} + {medium.lower() if medium else "" for medium in t["mediums"]} ) # If all track.medium are single consecutive letters, assume it is # a 2-sided medium. if "".join(m) in ascii_lowercase: sides_per_medium = 2 - for i, track in enumerate(t.tracks): + for i, track in enumerate(t["tracks"]): # Handle special case where a different medium does not indicate a # new disc, when there is no medium_index and the ordinal of medium # is not sequential. For example, I, II, III, IV, V. Assume these # are the track index, not the medium. # side_count is the number of mediums or medium sides (in the case # of two-sided mediums) that were seen before. - medium_str = t.mediums[i] - medium_index = t.medium_indices[i] + medium_str = t["mediums"][i] + medium_index = t["medium_indices"][i] medium_is_index = ( medium_str and not medium_index @@ -740,17 +774,17 @@ class DiscogsPlugin(MetadataSourcePlugin): # Get `disctitle` from Discogs index tracks. Assume that an index track # before the first track of each medium is a disc title. - for track in t.tracks: + for track in t["tracks"]: if track.medium_index == 1: - if track.index in t.index_tracks: - disctitle = t.index_tracks[track.index] + if track.index in t["index_tracks"]: + disctitle = t["index_tracks"][track.index] else: disctitle = None track.disctitle = disctitle - return t.tracks + return t["tracks"] - def coalesce_tracks(self, raw_tracklist: list[Track]) -> list[Track]: + def _coalesce_tracks(self, raw_tracklist: list[Track]) -> list[Track]: """Pre-process a tracklist, merging subtracks into a single track. The title for the merged track is the one from the previous index track, if present; otherwise it is a combination of the subtracks titles. @@ -867,13 +901,13 @@ class DiscogsPlugin(MetadataSourcePlugin): # If artists are found on the track, we will use those instead if artists := track.get("artists", []): - artistinfo = self.build_artistinfo(artists) + artistinfo = self._build_artistinfo(artists) length = self.get_track_length(track["duration"]) # Add featured artists if extraartists := track.get("extraartists", []): - artistinfo = self.build_artistinfo(extraartists, artistinfo) + artistinfo = self._build_artistinfo(extraartists, artistinfo) return ( TrackInfo( diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 393dc4cc0..3beed628a 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -761,7 +761,7 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_va_buildartistinfo(given_artists, expected_info, config_va_name): config["va_name"] = config_va_name - assert DiscogsPlugin().build_artistinfo(given_artists) == expected_info + assert DiscogsPlugin()._build_artistinfo(given_artists) == expected_info @pytest.mark.parametrize( From 0e48c65171de864b9a340490e2fd50341c74d32e Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer Date: Wed, 7 Jan 2026 12:17:36 -0800 Subject: [PATCH 231/274] Clarify variable in _process_clean_tracklist --- beetsplug/discogs.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index f38384751..9357b633d 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -658,7 +658,7 @@ class DiscogsPlugin(MetadataSourcePlugin): albumartistinfo: ArtistInfo, ) -> TracklistInfo: # Distinct works and intra-work divisions, as defined by index tracks. - t: TracklistInfo = { + info: TracklistInfo = { "index": 0, "index_tracks": {}, "tracks": [], @@ -670,35 +670,35 @@ class DiscogsPlugin(MetadataSourcePlugin): for track in clean_tracklist: # Only real tracks have `position`. Otherwise, it's an index track. if track["position"]: - t["index"] += 1 - if t["next_divisions"]: + info["index"] += 1 + if info["next_divisions"]: # End of a block of index tracks: update the current # divisions. - t["divisions"] += t["next_divisions"] - del t["next_divisions"][:] + info["divisions"] += info["next_divisions"] + del info["next_divisions"][:] track_info, medium, medium_index = self.get_track_info( - track, t["index"], t["divisions"], albumartistinfo + track, info["index"], info["divisions"], albumartistinfo ) track_info.track_alt = track["position"] - t["tracks"].append(track_info) + info["tracks"].append(track_info) if medium: - t["mediums"].append(medium) + info["mediums"].append(medium) else: - t["mediums"].append(None) + info["mediums"].append(None) if medium_index: - t["medium_indices"].append(medium_index) + info["medium_indices"].append(medium_index) else: - t["medium_indices"].append(None) + info["medium_indices"].append(None) else: - t["next_divisions"].append(track["title"]) + info["next_divisions"].append(track["title"]) # We expect new levels of division at the beginning of the # tracklist (and possibly elsewhere). try: - t["divisions"].pop() + info["divisions"].pop() except IndexError: pass - t["index_tracks"][t["index"] + 1] = track["title"] - return t + info["index_tracks"][info["index"] + 1] = track["title"] + return info def get_tracks( self, From 59e7c591729f2112ce172bcf0b8d2cb3636b5419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 8 Jan 2026 17:28:30 +0000 Subject: [PATCH 232/274] Move building logic to dataclasses --- beetsplug/discogs.py | 442 ++++++++++++++++------------------- test/plugins/test_discogs.py | 38 ++- 2 files changed, 219 insertions(+), 261 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 9357b633d..d2de50091 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -18,7 +18,6 @@ python3-discogs-client library. from __future__ import annotations -import copy import http.client import json import os @@ -26,6 +25,7 @@ import re import socket import time import traceback +from dataclasses import asdict, dataclass, field from functools import cache from string import ascii_lowercase from typing import TYPE_CHECKING @@ -115,14 +115,6 @@ class ArtistInfo(TypedDict): artists_ids: list[str] -class AlbumArtistInfo(ArtistInfo): - albumartist: str - albumartists: list[str] - albumartist_credit: str - albumartists_credit: list[str] - albumartist_id: str - - class TracklistInfo(TypedDict): index: int index_tracks: dict[int, str] @@ -133,6 +125,184 @@ class TracklistInfo(TypedDict): medium_indices: list[str | None] +@dataclass +class ArtistState: + artist: str = "" + artists: list[str] = field(default_factory=list) + artist_credit: str = "" + artists_credit: list[str] = field(default_factory=list) + artist_id: str = "" + artists_ids: list[str] = field(default_factory=list) + + @property + def info(self) -> ArtistInfo: + return asdict(self) # type: ignore[return-value] + + def clone(self) -> ArtistState: + return ArtistState(**asdict(self)) + + @classmethod + def build( + cls, + plugin: DiscogsPlugin, + given_artists: list[Artist], + given_state: ArtistState | None = None, + for_album_artist: bool = False, + ) -> ArtistState: + """Iterates through a discogs result and builds + up the artist fields. Does not contribute to + artist_sort as Discogs does not define that. + """ + state = given_state.clone() if given_state else cls() + + artist = "" + artist_anv = "" + artists: list[str] = [] + artists_anv: list[str] = [] + + feat_str: str = f" {plugin.config['featured_string'].as_str()} " + join = "" + featured_flag = False + for a in given_artists: + name = plugin.strip_disambiguation(a["name"]) + discogs_id = str(a["id"]) + anv = a.get("anv", "") or name + role = a.get("role", "").lower() + if name.lower() == "various": + name = config["va_name"].as_str() + anv = name + if "featuring" in role: + if not featured_flag: + artist += feat_str + artist_anv += feat_str + artist += name + artist_anv += anv + featured_flag = True + else: + artist = cls.join_artist(artist, name, join) + artist_anv = cls.join_artist(artist_anv, anv, join) + elif role and "featuring" not in role: + continue + else: + artist = cls.join_artist(artist, name, join) + artist_anv = cls.join_artist(artist_anv, anv, join) + artists.append(name) + artists_anv.append(anv) + if not state.artist_id: + state.artist_id = discogs_id + state.artists_ids.append(discogs_id) + join = a.get("join", "") + cls._assign_anv( + plugin, + state, + artist, + artists, + artist_anv, + artists_anv, + for_album_artist, + ) + return state + + @staticmethod + def join_artist(base: str, artist: str, join: str) -> str: + # Expand the artist field + if not base: + base = artist + else: + if join: + join = join.strip() + if join in ";,": + base += f"{join} " + else: + base += f" {join} " + else: + base += ", " + base += artist + return base + + @staticmethod + def _assign_anv( + plugin: DiscogsPlugin, + state: ArtistState, + artist: str, + artists: list[str], + artist_anv: str, + artists_anv: list[str], + for_album_artist: bool, + ) -> None: + """Assign artist and variation fields based on + configuration settings. + """ + use_artist_anv: bool = plugin.config["anv"]["artist"].get(bool) + use_artistcredit_anv: bool = plugin.config["anv"]["artist_credit"].get( + bool + ) + use_albumartist_anv: bool = plugin.config["anv"]["album_artist"].get( + bool + ) + + if (use_artist_anv and not for_album_artist) or ( + use_albumartist_anv and for_album_artist + ): + state.artist += artist_anv + state.artists += artists_anv + else: + state.artist += artist + state.artists += artists + + if use_artistcredit_anv: + state.artist_credit += artist_anv + state.artists_credit += artists_anv + else: + state.artist_credit += artist + state.artists_credit += artists + + +@dataclass +class TracklistState: + index: int = 0 + index_tracks: dict[int, str] = field(default_factory=dict) + tracks: list[TrackInfo] = field(default_factory=list) + divisions: list[str] = field(default_factory=list) + next_divisions: list[str] = field(default_factory=list) + mediums: list[str | None] = field(default_factory=list) + medium_indices: list[str | None] = field(default_factory=list) + + @property + def info(self) -> TracklistInfo: + return asdict(self) # type: ignore[return-value] + + @classmethod + def build( + cls, + plugin: DiscogsPlugin, + clean_tracklist: list[Track], + albumartistinfo: ArtistState, + ) -> TracklistState: + state = cls() + for track in clean_tracklist: + if track["position"]: + state.index += 1 + if state.next_divisions: + state.divisions += state.next_divisions + state.next_divisions.clear() + track_info, medium, medium_index = plugin.get_track_info( + track, state.index, state.divisions, albumartistinfo + ) + track_info.track_alt = track["position"] + state.tracks.append(track_info) + state.mediums.append(medium or None) + state.medium_indices.append(medium_index or None) + else: + state.next_divisions.append(track["title"]) + try: + state.divisions.pop() + except IndexError: + pass + state.index_tracks[state.index + 1] = track["title"] + return state + + class DiscogsPlugin(MetadataSourcePlugin): def __init__(self): super().__init__() @@ -354,166 +524,6 @@ class DiscogsPlugin(MetadataSourcePlugin): return media, albumtype - def get_artist_with_anv( - self, artists: list[Artist], use_anv: bool = False - ) -> tuple[str, str | None]: - """Iterates through a discogs result, fetching data - if the artist anv is to be used, maps that to the name. - Calls the parent class get_artist method.""" - artist_list: list[dict[str | int, str]] = [] - for artist_data in artists: - a: dict[str | int, str] = { - "name": artist_data["name"], - "id": artist_data["id"], - "join": artist_data.get("join", ""), - } - if use_anv and (anv := artist_data.get("anv", "")): - a["name"] = anv - artist_list.append(a) - artist, artist_id = self.get_artist(artist_list, join_key="join") - return self.strip_disambiguation(artist), artist_id - - def _build_albumartistinfo(self, artists: list[Artist]) -> AlbumArtistInfo: - info = self._build_artistinfo(artists, for_album_artist=True) - albumartist: AlbumArtistInfo = { - **info, - "albumartist": info["artist"], - "albumartist_id": info["artist_id"], - "albumartists": info["artists"], - "albumartist_credit": info["artist_credit"], - "albumartists_credit": info["artists_credit"], - } - return albumartist - - def _build_artistinfo( - self, - given_artists: list[Artist], - given_info: ArtistInfo | None = None, - for_album_artist: bool = False, - ) -> ArtistInfo: - """Iterates through a discogs result and builds - up the artist fields. Does not contribute to - artist_sort as Discogs does not define that. - """ - info: ArtistInfo = { - "artist": "", - "artist_id": "", - "artists": [], - "artists_ids": [], - "artist_credit": "", - "artists_credit": [], - } - # If starting information is given we start from there - # Often used for cases with album artists. - # Deepcopy is used to prevent unintentional - # extra modifications - if given_info: - info = copy.deepcopy(given_info) - artist = "" - artist_anv = "" - artists: list[str] = [] - artists_anv: list[str] = [] - - feat_str: str = f" {self.config['featured_string'].as_str()} " - join = "" - featured_flag = False - # Iterate through building the artist strings - for a in given_artists: - # Get the artist name - name = self.strip_disambiguation(a["name"]) - discogs_id = str(a["id"]) - anv = a.get("anv", "") or name - role = a.get("role", "").lower() - # Check if the artist is Various - if name.lower() == "various": - name = config["va_name"].as_str() - anv = name - # If the artist is listed as featured - if "featuring" in role: - if not featured_flag: - artist += feat_str - artist_anv += feat_str - artist += name - artist_anv += anv - featured_flag = True - # Set the featured_flag - # to indicate we no longer need to - # prefix the marker for a featured - # artist - else: - artist = self._join_artist(artist, name, join) - artist_anv = self._join_artist(artist_anv, anv, join) - elif role and "featuring" not in role: - # Current artists that are in the credits - # and are not credited as featuring are ignored. - continue - else: - artist = self._join_artist(artist, name, join) - artist_anv = self._join_artist(artist_anv, anv, join) - artists.append(name) - artists_anv.append(anv) - # Only the first ID is set for the singular field - if not info["artist_id"]: - info["artist_id"] = discogs_id - info["artists_ids"].append(discogs_id) - # Update join for the next artist - join = a.get("join", "") - return self._assign_anv( - info, artist, artists, artist_anv, artists_anv, for_album_artist - ) - - def _join_artist(self, base: str, artist: str, join: str) -> str: - # Expand the artist field - if not base: - base = artist - else: - if join: - join = join.strip() - if join in ";,": - base += f"{join} " - else: - base += f" {join} " - else: - base += ", " - base += artist - return base - - def _assign_anv( - self, - info: ArtistInfo, - artist: str, - artists: list[str], - artist_anv: str, - artists_anv: list[str], - for_album_artist: bool, - ) -> ArtistInfo: - """Assign artist and variation fields based on - configuration settings. - """ - # Fetch configuration options for artist name variations - use_artist_anv: bool = self.config["anv"]["artist"].get(bool) - use_artistcredit_anv: bool = self.config["anv"]["artist_credit"].get( - bool - ) - use_albumartist_anv: bool = self.config["anv"]["album_artist"].get(bool) - - if (use_artist_anv and not for_album_artist) or ( - use_albumartist_anv and for_album_artist - ): - info["artist"] += artist_anv - info["artists"] += artists_anv - else: - info["artist"] += artist - info["artists"] += artists - - if use_artistcredit_anv: - info["artist_credit"] += artist_anv - info["artists_credit"] += artists_anv - else: - info["artist_credit"] += artist - info["artists_credit"] += artists - return info - def get_album_info(self, result: Release) -> AlbumInfo | None: """Returns an AlbumInfo object for a discogs Release object.""" # Explicitly reload the `Release` fields, as they might not be yet @@ -544,7 +554,9 @@ class DiscogsPlugin(MetadataSourcePlugin): artist_data = [a.data for a in result.artists] # Information for the album artist - albumartist: AlbumArtistInfo = self._build_albumartistinfo(artist_data) + albumartist = ArtistState.build( + self, artist_data, for_album_artist=True + ) album = re.sub(r" +", " ", result.title) album_id = result.data["id"] @@ -553,11 +565,11 @@ class DiscogsPlugin(MetadataSourcePlugin): # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks( - result.data["tracklist"], self._build_artistinfo(artist_data) + result.data["tracklist"], ArtistState.build(self, artist_data) ) # Extract information for the optional AlbumInfo fields, if possible. - va = albumartist["albumartist"] == config["va_name"].as_str() + va = albumartist.artist == config["va_name"].as_str() year = result.data.get("year") mediums = [t["medium"] for t in tracks] country = result.data.get("country") @@ -612,7 +624,7 @@ class DiscogsPlugin(MetadataSourcePlugin): return AlbumInfo( album=album, album_id=album_id, - **albumartist, # Unpacks values to satisfy the keyword arguments + **albumartist.info, # Unpacks values to satisfy the keyword arguments tracks=tracks, albumtype=albumtype, va=va, @@ -630,7 +642,7 @@ class DiscogsPlugin(MetadataSourcePlugin): data_url=data_url, discogs_albumid=discogs_albumid, discogs_labelid=labelid, - discogs_artistid=albumartist["albumartist_id"], + discogs_artistid=albumartist.artist_id, cover_art_url=cover_art_url, ) @@ -652,58 +664,10 @@ class DiscogsPlugin(MetadataSourcePlugin): else: return None - def _process_clean_tracklist( - self, - clean_tracklist: list[Track], - albumartistinfo: ArtistInfo, - ) -> TracklistInfo: - # Distinct works and intra-work divisions, as defined by index tracks. - info: TracklistInfo = { - "index": 0, - "index_tracks": {}, - "tracks": [], - "divisions": [], - "next_divisions": [], - "mediums": [], - "medium_indices": [], - } - for track in clean_tracklist: - # Only real tracks have `position`. Otherwise, it's an index track. - if track["position"]: - info["index"] += 1 - if info["next_divisions"]: - # End of a block of index tracks: update the current - # divisions. - info["divisions"] += info["next_divisions"] - del info["next_divisions"][:] - track_info, medium, medium_index = self.get_track_info( - track, info["index"], info["divisions"], albumartistinfo - ) - track_info.track_alt = track["position"] - info["tracks"].append(track_info) - if medium: - info["mediums"].append(medium) - else: - info["mediums"].append(None) - if medium_index: - info["medium_indices"].append(medium_index) - else: - info["medium_indices"].append(None) - else: - info["next_divisions"].append(track["title"]) - # We expect new levels of division at the beginning of the - # tracklist (and possibly elsewhere). - try: - info["divisions"].pop() - except IndexError: - pass - info["index_tracks"][info["index"] + 1] = track["title"] - return info - def get_tracks( self, tracklist: list[Track], - albumartistinfo: ArtistInfo, + albumartistinfo: ArtistState, ) -> list[TrackInfo]: """Returns a list of TrackInfo objects for a discogs tracklist.""" try: @@ -715,9 +679,7 @@ class DiscogsPlugin(MetadataSourcePlugin): self._log.debug("{}", traceback.format_exc()) self._log.error("uncaught exception in _coalesce_tracks: {}", exc) clean_tracklist = tracklist - t: TracklistInfo = self._process_clean_tracklist( - clean_tracklist, albumartistinfo - ) + t = TracklistState.build(self, clean_tracklist, albumartistinfo) # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None @@ -726,24 +688,24 @@ class DiscogsPlugin(MetadataSourcePlugin): # If a medium has two sides (ie. vinyl or cassette), each pair of # consecutive sides should belong to the same medium. - if all([medium is not None for medium in t["mediums"]]): + if all([medium is not None for medium in t.mediums]): m = sorted( - {medium.lower() if medium else "" for medium in t["mediums"]} + {medium.lower() if medium else "" for medium in t.mediums} ) # If all track.medium are single consecutive letters, assume it is # a 2-sided medium. if "".join(m) in ascii_lowercase: sides_per_medium = 2 - for i, track in enumerate(t["tracks"]): + for i, track in enumerate(t.tracks): # Handle special case where a different medium does not indicate a # new disc, when there is no medium_index and the ordinal of medium # is not sequential. For example, I, II, III, IV, V. Assume these # are the track index, not the medium. # side_count is the number of mediums or medium sides (in the case # of two-sided mediums) that were seen before. - medium_str = t["mediums"][i] - medium_index = t["medium_indices"][i] + medium_str = t.mediums[i] + medium_index = t.medium_indices[i] medium_is_index = ( medium_str and not medium_index @@ -774,15 +736,15 @@ class DiscogsPlugin(MetadataSourcePlugin): # Get `disctitle` from Discogs index tracks. Assume that an index track # before the first track of each medium is a disc title. - for track in t["tracks"]: + for track in t.tracks: if track.medium_index == 1: - if track.index in t["index_tracks"]: - disctitle = t["index_tracks"][track.index] + if track.index in t.index_tracks: + disctitle = t.index_tracks[track.index] else: disctitle = None track.disctitle = disctitle - return t["tracks"] + return t.tracks def _coalesce_tracks(self, raw_tracklist: list[Track]) -> list[Track]: """Pre-process a tracklist, merging subtracks into a single track. The @@ -885,11 +847,11 @@ class DiscogsPlugin(MetadataSourcePlugin): track: Track, index: int, divisions: list[str], - albumartistinfo: ArtistInfo, + albumartistinfo: ArtistState, ) -> tuple[TrackInfo, str | None, str | None]: """Returns a TrackInfo object for a discogs track.""" - artistinfo = albumartistinfo.copy() + artistinfo = albumartistinfo.clone() title = track["title"] if self.config["index_tracks"]: @@ -901,19 +863,19 @@ class DiscogsPlugin(MetadataSourcePlugin): # If artists are found on the track, we will use those instead if artists := track.get("artists", []): - artistinfo = self._build_artistinfo(artists) + artistinfo = ArtistState.build(self, artists) length = self.get_track_length(track["duration"]) # Add featured artists if extraartists := track.get("extraartists", []): - artistinfo = self._build_artistinfo(extraartists, artistinfo) + artistinfo = ArtistState.build(self, extraartists, artistinfo) return ( TrackInfo( title=title, track_id=track_id, - **artistinfo, + **artistinfo.info, length=length, index=index, ), diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 3beed628a..35bd15c9e 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -21,7 +21,7 @@ import pytest from beets import config from beets.test._common import Bag from beets.test.helper import BeetsTestCase, capture_log -from beetsplug.discogs import DiscogsPlugin +from beetsplug.discogs import ArtistState, DiscogsPlugin @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) @@ -555,10 +555,9 @@ def test_anv( config["discogs"]["anv"]["artist_credit"] = artist_credit_anv r = DiscogsPlugin().get_album_info(release) assert r.artist == album_artist - assert r.albumartists == album_artists + assert r.artists == album_artists assert r.artist_credit == album_artist_credit - assert r.albumartist_credit == album_artist_credit - assert r.albumartists_credit == album_artists_credit + assert r.artists_credit == album_artists_credit assert r.tracks[0].artist == track_artist assert r.tracks[0].artists == track_artists assert r.tracks[0].artist_credit == track_artist_credit @@ -605,10 +604,9 @@ def test_anv_no_variation(artist_anv, albumartist_anv, artistcredit_anv): config["discogs"]["anv"]["artist_credit"] = artistcredit_anv r = DiscogsPlugin().get_album_info(release) assert r.artist == "ARTIST" - assert r.albumartists == ["ARTIST"] + assert r.artists == ["ARTIST"] assert r.artist_credit == "ARTIST" - assert r.albumartist_credit == "ARTIST" - assert r.albumartists_credit == ["ARTIST"] + assert r.artists_credit == ["ARTIST"] assert r.tracks[0].artist == "PERFORMER" assert r.tracks[0].artists == ["PERFORMER"] assert r.tracks[0].artist_credit == "PERFORMER" @@ -647,12 +645,8 @@ def test_anv_album_artist(): r = DiscogsPlugin().get_album_info(release) assert r.artist == "ARTIST" assert r.artists == ["ARTIST"] - assert r.albumartist == "ARTIST" - assert r.albumartist_credit == "ARTIST" - assert r.albumartist_id == "321" - assert r.albumartists == ["ARTIST"] - assert r.albumartists_credit == ["ARTIST"] assert r.artist_credit == "ARTIST" + assert r.artist_id == "321" assert r.artists_credit == ["ARTIST"] assert r.tracks[0].artist == "VARIATION" assert r.tracks[0].artists == ["VARIATION"] @@ -705,14 +699,14 @@ def test_anv_album_artist(): def test_parse_featured_artists(track, expected_artist, expected_artists): """Tests the plugins ability to parse a featured artist. Ignores artists that are not listed as featured.""" - artistinfo = { - "artist": "ARTIST", - "artist_id": "1", - "artists": ["ARTIST"], - "artists_ids": ["1"], - "artist_credit": "ARTIST", - "artists_credit": ["ARTIST"], - } + artistinfo = ArtistState( + artist="ARTIST", + artist_id="1", + artists=["ARTIST"], + artists_ids=["1"], + artist_credit="ARTIST", + artists_credit=["ARTIST"], + ) t, _, _ = DiscogsPlugin().get_track_info(track, 1, 1, artistinfo) assert t.artist == expected_artist assert t.artists == expected_artists @@ -761,7 +755,9 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_va_buildartistinfo(given_artists, expected_info, config_va_name): config["va_name"] = config_va_name - assert DiscogsPlugin()._build_artistinfo(given_artists) == expected_info + assert ( + ArtistState.build(DiscogsPlugin(), given_artists).info == expected_info + ) @pytest.mark.parametrize( From b3183a73e0b2527f85214f8fac70520ccb6a40ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 9 Jan 2026 00:52:34 +0000 Subject: [PATCH 233/274] Simplify building artist --- beetsplug/discogs.py | 236 ++++++++++++++++------------------- test/plugins/test_discogs.py | 15 +-- 2 files changed, 110 insertions(+), 141 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index d2de50091..a7206c7d6 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -26,9 +26,9 @@ import socket import time import traceback from dataclasses import asdict, dataclass, field -from functools import cache +from functools import cache, cached_property from string import ascii_lowercase -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple import confuse from discogs_client import Client, Master, Release @@ -127,135 +127,111 @@ class TracklistInfo(TypedDict): @dataclass class ArtistState: - artist: str = "" - artists: list[str] = field(default_factory=list) - artist_credit: str = "" - artists_credit: list[str] = field(default_factory=list) - artist_id: str = "" - artists_ids: list[str] = field(default_factory=list) + class ValidArtist(NamedTuple): + id: str + name: str + credit: str + join: str + is_feat: bool + + def get_artist(self, property_name: str) -> str: + return getattr(self, property_name) + ( + {",": ", ", "": ""}.get(self.join, f" {self.join} ") + ) + + raw_artists: list[Artist] + use_anv: bool + use_credit_anv: bool + featured_string: str + should_strip_disambiguation: bool @property def info(self) -> ArtistInfo: - return asdict(self) # type: ignore[return-value] + return {k: getattr(self, k) for k in ArtistInfo.__annotations__} # type: ignore[return-value] - def clone(self) -> ArtistState: - return ArtistState(**asdict(self)) + def strip_disambiguation(self, text: str) -> str: + """Removes discogs specific disambiguations from a string. + Turns 'Label Name (5)' to 'Label Name' or 'Artist (1) & Another Artist (2)' + to 'Artist & Another Artist'. Does nothing if strip_disambiguation is False.""" + if self.should_strip_disambiguation: + return DISAMBIGUATION_RE.sub("", text) + return text + + @cached_property + def valid_artists(self) -> list[ValidArtist]: + va_name = config["va_name"].as_str() + return [ + self.ValidArtist( + str(a["id"]), + self.strip_disambiguation(anv if self.use_anv else name), + self.strip_disambiguation(anv if self.use_credit_anv else name), + a["join"], + is_feat, + ) + for a in self.raw_artists + if ( + (name := va_name if a["name"] == "Various" else a["name"]) + and (anv := a["anv"] or name) + and ( + (is_feat := ("featuring" in a["role"].lower())) + or not a["role"] + ) + ) + ] + + @property + def artists_ids(self) -> list[str]: + return [a.id for a in self.valid_artists] + + @property + def artist_id(self) -> str: + return self.artists_ids[0] + + @property + def artists(self) -> list[str]: + return [a.name for a in self.valid_artists] + + @property + def artists_credit(self) -> list[str]: + return [a.credit for a in self.valid_artists] + + @property + def artist(self) -> str: + return self.join_artists("name") + + @property + def artist_credit(self) -> str: + return self.join_artists("credit") + + def join_artists(self, property_name: str) -> str: + non_featured = [a for a in self.valid_artists if not a.is_feat] + featured = [a for a in self.valid_artists if a.is_feat] + + artist = "".join(a.get_artist(property_name) for a in non_featured) + if featured: + if "feat" not in artist: + artist += f" {self.featured_string} " + + artist += ", ".join(a.get_artist(property_name) for a in featured) + + return artist @classmethod - def build( + def from_plugin( cls, plugin: DiscogsPlugin, - given_artists: list[Artist], - given_state: ArtistState | None = None, + artists: list[Artist], for_album_artist: bool = False, ) -> ArtistState: - """Iterates through a discogs result and builds - up the artist fields. Does not contribute to - artist_sort as Discogs does not define that. - """ - state = given_state.clone() if given_state else cls() - - artist = "" - artist_anv = "" - artists: list[str] = [] - artists_anv: list[str] = [] - - feat_str: str = f" {plugin.config['featured_string'].as_str()} " - join = "" - featured_flag = False - for a in given_artists: - name = plugin.strip_disambiguation(a["name"]) - discogs_id = str(a["id"]) - anv = a.get("anv", "") or name - role = a.get("role", "").lower() - if name.lower() == "various": - name = config["va_name"].as_str() - anv = name - if "featuring" in role: - if not featured_flag: - artist += feat_str - artist_anv += feat_str - artist += name - artist_anv += anv - featured_flag = True - else: - artist = cls.join_artist(artist, name, join) - artist_anv = cls.join_artist(artist_anv, anv, join) - elif role and "featuring" not in role: - continue - else: - artist = cls.join_artist(artist, name, join) - artist_anv = cls.join_artist(artist_anv, anv, join) - artists.append(name) - artists_anv.append(anv) - if not state.artist_id: - state.artist_id = discogs_id - state.artists_ids.append(discogs_id) - join = a.get("join", "") - cls._assign_anv( - plugin, - state, - artist, + return cls( artists, - artist_anv, - artists_anv, - for_album_artist, + plugin.config["anv"][ + "album_artist" if for_album_artist else "artist" + ].get(bool), + plugin.config["anv"]["artist_credit"].get(bool), + plugin.config["featured_string"].as_str(), + plugin.config["strip_disambiguation"].get(bool), ) - return state - - @staticmethod - def join_artist(base: str, artist: str, join: str) -> str: - # Expand the artist field - if not base: - base = artist - else: - if join: - join = join.strip() - if join in ";,": - base += f"{join} " - else: - base += f" {join} " - else: - base += ", " - base += artist - return base - - @staticmethod - def _assign_anv( - plugin: DiscogsPlugin, - state: ArtistState, - artist: str, - artists: list[str], - artist_anv: str, - artists_anv: list[str], - for_album_artist: bool, - ) -> None: - """Assign artist and variation fields based on - configuration settings. - """ - use_artist_anv: bool = plugin.config["anv"]["artist"].get(bool) - use_artistcredit_anv: bool = plugin.config["anv"]["artist_credit"].get( - bool - ) - use_albumartist_anv: bool = plugin.config["anv"]["album_artist"].get( - bool - ) - - if (use_artist_anv and not for_album_artist) or ( - use_albumartist_anv and for_album_artist - ): - state.artist += artist_anv - state.artists += artists_anv - else: - state.artist += artist - state.artists += artists - - if use_artistcredit_anv: - state.artist_credit += artist_anv - state.artists_credit += artists_anv - else: - state.artist_credit += artist - state.artists_credit += artists @dataclass @@ -554,7 +530,7 @@ class DiscogsPlugin(MetadataSourcePlugin): artist_data = [a.data for a in result.artists] # Information for the album artist - albumartist = ArtistState.build( + albumartist = ArtistState.from_plugin( self, artist_data, for_album_artist=True ) @@ -565,7 +541,7 @@ class DiscogsPlugin(MetadataSourcePlugin): # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks( - result.data["tracklist"], ArtistState.build(self, artist_data) + result.data["tracklist"], ArtistState.from_plugin(self, artist_data) ) # Extract information for the optional AlbumInfo fields, if possible. @@ -851,8 +827,6 @@ class DiscogsPlugin(MetadataSourcePlugin): ) -> tuple[TrackInfo, str | None, str | None]: """Returns a TrackInfo object for a discogs track.""" - artistinfo = albumartistinfo.clone() - title = track["title"] if self.config["index_tracks"]: prefix = ", ".join(divisions) @@ -861,15 +835,15 @@ class DiscogsPlugin(MetadataSourcePlugin): track_id = None medium, medium_index, _ = self.get_track_index(track["position"]) - # If artists are found on the track, we will use those instead - if artists := track.get("artists", []): - artistinfo = ArtistState.build(self, artists) - length = self.get_track_length(track["duration"]) - - # Add featured artists - if extraartists := track.get("extraartists", []): - artistinfo = ArtistState.build(self, extraartists, artistinfo) + # If artists are found on the track, we will use those instead + artistinfo = ArtistState.from_plugin( + self, + [ + *(track.get("artists") or albumartistinfo.raw_artists), + *track.get("extraartists", []), + ], + ) return ( TrackInfo( diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 35bd15c9e..54ff8dd75 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -699,15 +699,9 @@ def test_anv_album_artist(): def test_parse_featured_artists(track, expected_artist, expected_artists): """Tests the plugins ability to parse a featured artist. Ignores artists that are not listed as featured.""" - artistinfo = ArtistState( - artist="ARTIST", - artist_id="1", - artists=["ARTIST"], - artists_ids=["1"], - artist_credit="ARTIST", - artists_credit=["ARTIST"], - ) - t, _, _ = DiscogsPlugin().get_track_info(track, 1, 1, artistinfo) + plugin = DiscogsPlugin() + artistinfo = ArtistState.from_plugin(plugin, [_artist("ARTIST")]) + t, _, _ = plugin.get_track_info(track, 1, 1, artistinfo) assert t.artist == expected_artist assert t.artists == expected_artists @@ -756,7 +750,8 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): def test_va_buildartistinfo(given_artists, expected_info, config_va_name): config["va_name"] = config_va_name assert ( - ArtistState.build(DiscogsPlugin(), given_artists).info == expected_info + ArtistState.from_plugin(DiscogsPlugin(), given_artists).info + == expected_info ) From 7d83a68bddd7847977e5b4824aa0986fddfa5b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 9 Jan 2026 00:53:41 +0000 Subject: [PATCH 234/274] Ensure all fields in artist dicts in tests --- test/plugins/test_discogs.py | 112 ++++++++++++++--------------------- 1 file changed, 43 insertions(+), 69 deletions(-) diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 54ff8dd75..66cbe9371 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -24,6 +24,18 @@ from beets.test.helper import BeetsTestCase, capture_log from beetsplug.discogs import ArtistState, DiscogsPlugin +def _artist(name: str, **kwargs): + return { + "id": 1, + "name": name, + "join": "", + "role": "", + "anv": "", + "tracks": "", + "resource_url": "", + } | kwargs + + @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) class DGAlbumInfoTest(BeetsTestCase): def _make_release(self, tracks=None): @@ -35,9 +47,7 @@ class DGAlbumInfoTest(BeetsTestCase): "uri": "https://www.discogs.com/release/release/13633721", "title": "ALBUM TITLE", "year": "3001", - "artists": [ - {"name": "ARTIST NAME", "id": "ARTIST ID", "join": ","} - ], + "artists": [_artist("ARTIST NAME", id="ARTIST ID", join=",")], "formats": [ { "descriptions": ["FORMAT DESC 1", "FORMAT DESC 2"], @@ -325,7 +335,7 @@ class DGAlbumInfoTest(BeetsTestCase): "id": 123, "uri": "https://www.discogs.com/release/123456-something", "tracklist": [self._make_track("A", "1", "01:01")], - "artists": [{"name": "ARTIST NAME", "id": 321, "join": ""}], + "artists": [_artist("ARTIST NAME", id=321)], "title": "TITLE", } release = Bag( @@ -385,14 +395,12 @@ class DGAlbumInfoTest(BeetsTestCase): "position": "A", "type_": "track", "duration": "5:44", - "artists": [ - {"name": "TEST ARTIST (5)", "tracks": "", "id": 11146} - ], + "artists": [_artist("TEST ARTIST (5)", id=11146)], } ], "artists": [ - {"name": "ARTIST NAME (2)", "id": 321, "join": "&"}, - {"name": "OTHER ARTIST (5)", "id": 321, "join": ""}, + _artist("ARTIST NAME (2)", id=321, join="&"), + _artist("OTHER ARTIST (5)", id=321), ], "title": "title", "labels": [ @@ -429,14 +437,12 @@ class DGAlbumInfoTest(BeetsTestCase): "position": "A", "type_": "track", "duration": "5:44", - "artists": [ - {"name": "TEST ARTIST (5)", "tracks": "", "id": 11146} - ], + "artists": [_artist("TEST ARTIST (5)", id=11146)], } ], "artists": [ - {"name": "ARTIST NAME (2)", "id": 321, "join": "&"}, - {"name": "OTHER ARTIST (5)", "id": 321, "join": ""}, + _artist("ARTIST NAME (2)", id=321, join="&"), + _artist("OTHER ARTIST (5)", id=321), ], "title": "title", "labels": [ @@ -520,28 +526,21 @@ def test_anv( "position": "A", "type_": "track", "duration": "5:44", - "artists": [ - { - "name": "ARTIST", - "tracks": "", - "anv": "ART", - "id": 11146, - } - ], + "artists": [_artist("ARTIST", id=11146, anv="ART")], "extraartists": [ - { - "name": "PERFORMER", - "role": "Featuring", - "anv": "PERF", - "id": 787, - } + _artist( + "PERFORMER", + id=787, + role="Featuring", + anv="PERF", + ) ], } ], "artists": [ - {"name": "DRUMMER", "anv": "DRUM", "id": 445, "join": ", "}, - {"name": "ARTIST (4)", "anv": "ARTY", "id": 321, "join": "&"}, - {"name": "SOLOIST", "anv": "SOLO", "id": 445, "join": ""}, + _artist("DRUMMER", id=445, anv="DRUM", join=", "), + _artist("ARTIST (4)", id=321, anv="ARTY", join="&"), + _artist("SOLOIST", id=445, anv="SOLO"), ], "title": "title", } @@ -579,19 +578,10 @@ def test_anv_no_variation(artist_anv, albumartist_anv, artistcredit_anv): "position": "A", "type_": "track", "duration": "5:44", - "artists": [ - { - "name": "PERFORMER", - "tracks": "", - "anv": "", - "id": 1, - } - ], + "artists": [_artist("PERFORMER", id=1)], } ], - "artists": [ - {"name": "ARTIST", "anv": "", "id": 2}, - ], + "artists": [_artist("ARTIST", id=2)], "title": "title", } release = Bag( @@ -629,9 +619,7 @@ def test_anv_album_artist(): "duration": "5:44", } ], - "artists": [ - {"name": "ARTIST (4)", "anv": "VARIATION", "id": 321}, - ], + "artists": [_artist("ARTIST (4)", id=321, anv="VARIATION")], "title": "title", } release = Bag( @@ -664,33 +652,19 @@ def test_anv_album_artist(): "position": "1", "duration": "5:00", "artists": [ - {"name": "NEW ARTIST", "tracks": "", "id": 11146}, - {"name": "VOCALIST", "tracks": "", "id": 344, "join": "&"}, + _artist("NEW ARTIST", id=11146, join="&"), + _artist("VOCALIST", id=344, join="feat."), ], "extraartists": [ - { - "name": "SOLOIST", - "id": 3, - "role": "Featuring", - }, - { - "name": "PERFORMER (1)", - "id": 5, - "role": "Other Role, Featuring", - }, - { - "name": "RANDOM", - "id": 8, - "role": "Written-By", - }, - { - "name": "MUSICIAN", - "id": 10, - "role": "Featuring [Uncredited]", - }, + _artist("SOLOIST", id=3, role="Featuring"), + _artist( + "PERFORMER (1)", id=5, role="Other Role, Featuring" + ), + _artist("RANDOM", id=8, role="Written-By"), + _artist("MUSICIAN", id=10, role="Featuring [Uncredited]"), ], }, - "NEW ARTIST, VOCALIST Feat. SOLOIST, PERFORMER, MUSICIAN", + "NEW ARTIST & VOCALIST feat. SOLOIST, PERFORMER, MUSICIAN", ["NEW ARTIST", "VOCALIST", "SOLOIST", "PERFORMER", "MUSICIAN"], ), ], @@ -733,7 +707,7 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): "given_artists,expected_info,config_va_name", [ ( - [{"name": "Various", "id": "1"}], + [_artist("Various")], { "artist": "VARIOUS ARTISTS", "artist_id": "1", From 5523ca94a293fa64cd56659133afded333c9a8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 10 Jan 2026 02:28:18 +0000 Subject: [PATCH 235/274] Document ArtistState --- beetsplug/discogs.py | 61 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index a7206c7d6..017969e27 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -127,7 +127,23 @@ class TracklistInfo(TypedDict): @dataclass class ArtistState: + """Represent Discogs artist credits. + + This object centralizes the plugin's policy for which Discogs artist fields + to prefer (name vs. ANV), how to treat 'Various', how to format join + phrases, and how to separate featured artists. It exposes both per-artist + components and fully joined strings for common tag targets like 'artist' and + 'artist_credit'. + """ + class ValidArtist(NamedTuple): + """A normalized, render-ready artist entry extracted from Discogs data. + + Instances represent the subset of Discogs artist information needed for + tagging, including the join token following the artist and whether the + entry is considered a featured appearance. + """ + id: str name: str credit: str @@ -135,9 +151,14 @@ class ArtistState: is_feat: bool def get_artist(self, property_name: str) -> str: - return getattr(self, property_name) + ( - {",": ", ", "": ""}.get(self.join, f" {self.join} ") - ) + """Return the requested display field with its trailing join token. + + The join token is normalized so commas become ', ' and other join + phrases are surrounded with spaces, producing a single fragment that + can be concatenated to form a full artist string. + """ + join = {",": ", ", "": ""}.get(self.join, f" {self.join} ") + return f"{getattr(self, property_name)}{join}" raw_artists: list[Artist] use_anv: bool @@ -147,25 +168,38 @@ class ArtistState: @property def info(self) -> ArtistInfo: + """Expose the state in the shape expected by downstream tag mapping.""" return {k: getattr(self, k) for k in ArtistInfo.__annotations__} # type: ignore[return-value] def strip_disambiguation(self, text: str) -> str: - """Removes discogs specific disambiguations from a string. - Turns 'Label Name (5)' to 'Label Name' or 'Artist (1) & Another Artist (2)' - to 'Artist & Another Artist'. Does nothing if strip_disambiguation is False.""" + """Strip Discogs disambiguation suffixes from an artist or label string. + + This removes Discogs-specific numeric suffixes like 'Name (5)' and can + be applied to multi-artist strings as well (e.g., 'A (1) & B (2)'). When + the feature is disabled, the input is returned unchanged. + """ if self.should_strip_disambiguation: return DISAMBIGUATION_RE.sub("", text) return text @cached_property def valid_artists(self) -> list[ValidArtist]: + """Build the ordered, filtered list of artists used for rendering. + + The resulting list normalizes Discogs entries by: + - substituting the configured 'Various Artists' name when Discogs uses + 'Various' + - choosing between name and ANV according to plugin settings + - excluding non-empty roles unless they indicate a featured appearance + - capturing join tokens so the original credit formatting is preserved + """ va_name = config["va_name"].as_str() return [ self.ValidArtist( str(a["id"]), self.strip_disambiguation(anv if self.use_anv else name), self.strip_disambiguation(anv if self.use_credit_anv else name), - a["join"], + a["join"].strip(), is_feat, ) for a in self.raw_artists @@ -181,29 +215,42 @@ class ArtistState: @property def artists_ids(self) -> list[str]: + """Return Discogs artist IDs for all valid artists, preserving order.""" return [a.id for a in self.valid_artists] @property def artist_id(self) -> str: + """Return the primary Discogs artist ID.""" return self.artists_ids[0] @property def artists(self) -> list[str]: + """Return the per-artist display names used for the 'artist' field.""" return [a.name for a in self.valid_artists] @property def artists_credit(self) -> list[str]: + """Return the per-artist display names used for the credit field.""" return [a.credit for a in self.valid_artists] @property def artist(self) -> str: + """Return the fully rendered artist string using display names.""" return self.join_artists("name") @property def artist_credit(self) -> str: + """Return the fully rendered artist credit string.""" return self.join_artists("credit") def join_artists(self, property_name: str) -> str: + """Render a single artist string with join phrases and featured artists. + + Non-featured artists are concatenated using their join tokens. Featured + artists are appended after the configured 'featured' marker, preserving + Discogs order while keeping featured credits separate from the main + artist string. + """ non_featured = [a for a in self.valid_artists if not a.is_feat] featured = [a for a in self.valid_artists if a.is_feat] From 2cfd1df3c12c72b711ce47392f3951c1355ce585 Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer Date: Mon, 12 Jan 2026 12:03:36 -0800 Subject: [PATCH 236/274] Split discogs.py into smaller and more workable modules. --- beetsplug/{discogs.py => discogs/__init__.py} | 287 +----------------- beetsplug/discogs/states.py | 232 ++++++++++++++ beetsplug/discogs/types.py | 67 ++++ beetsplug/discogs/utils.py | 47 +++ 4 files changed, 353 insertions(+), 280 deletions(-) rename beetsplug/{discogs.py => discogs/__init__.py} (73%) create mode 100644 beetsplug/discogs/states.py create mode 100644 beetsplug/discogs/types.py create mode 100644 beetsplug/discogs/utils.py diff --git a/beetsplug/discogs.py b/beetsplug/discogs/__init__.py similarity index 73% rename from beetsplug/discogs.py rename to beetsplug/discogs/__init__.py index 017969e27..23e2267df 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs/__init__.py @@ -18,23 +18,18 @@ python3-discogs-client library. from __future__ import annotations -import http.client import json import os import re -import socket import time import traceback -from dataclasses import asdict, dataclass, field -from functools import cache, cached_property +from functools import cache from string import ascii_lowercase -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING import confuse from discogs_client import Client, Master, Release from discogs_client.exceptions import DiscogsAPIError -from requests.exceptions import ConnectionError -from typing_extensions import NotRequired, TypedDict import beets import beets.ui @@ -43,288 +38,20 @@ from beets.autotag.distance import string_dist from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin +from .states import ArtistState, TracklistState +from .utils import CONNECTION_ERRORS, DISAMBIGUATION_RE, TRACK_INDEX_RE + if TYPE_CHECKING: from collections.abc import Callable, Iterable, Sequence from beets.library import Item + from .types import ReleaseFormat, Track + USER_AGENT = f"beets/{beets.__version__} +https://beets.io/" API_KEY = "rAzVUQYRaoFjeBjyWuWZ" API_SECRET = "plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy" -# Exceptions that discogs_client should really handle but does not. -CONNECTION_ERRORS = ( - ConnectionError, - socket.error, - http.client.HTTPException, - ValueError, # JSON decoding raises a ValueError. - DiscogsAPIError, -) - - -TRACK_INDEX_RE = re.compile( - r""" - (.*?) # medium: everything before medium_index. - (\d*?) # medium_index: a number at the end of - # `position`, except if followed by a subtrack index. - # subtrack_index: can only be matched if medium - # or medium_index have been matched, and can be - ( - (?<=\w)\.[\w]+ # a dot followed by a string (A.1, 2.A) - | (?<=\d)[A-Z]+ # a string that follows a number (1A, B2a) - )? - """, - re.VERBOSE, -) - -DISAMBIGUATION_RE = re.compile(r" \(\d+\)") - - -class ReleaseFormat(TypedDict): - name: str - qty: int - descriptions: list[str] | None - - -class Artist(TypedDict): - name: str - anv: str - join: str - role: str - tracks: str - id: str - resource_url: str - - -class Track(TypedDict): - position: str - type_: str - title: str - duration: str - artists: list[Artist] - extraartists: NotRequired[list[Artist]] - sub_tracks: NotRequired[list[Track]] - - -class ArtistInfo(TypedDict): - artist: str - artists: list[str] - artist_credit: str - artists_credit: list[str] - artist_id: str - artists_ids: list[str] - - -class TracklistInfo(TypedDict): - index: int - index_tracks: dict[int, str] - tracks: list[TrackInfo] - divisions: list[str] - next_divisions: list[str] - mediums: list[str | None] - medium_indices: list[str | None] - - -@dataclass -class ArtistState: - """Represent Discogs artist credits. - - This object centralizes the plugin's policy for which Discogs artist fields - to prefer (name vs. ANV), how to treat 'Various', how to format join - phrases, and how to separate featured artists. It exposes both per-artist - components and fully joined strings for common tag targets like 'artist' and - 'artist_credit'. - """ - - class ValidArtist(NamedTuple): - """A normalized, render-ready artist entry extracted from Discogs data. - - Instances represent the subset of Discogs artist information needed for - tagging, including the join token following the artist and whether the - entry is considered a featured appearance. - """ - - id: str - name: str - credit: str - join: str - is_feat: bool - - def get_artist(self, property_name: str) -> str: - """Return the requested display field with its trailing join token. - - The join token is normalized so commas become ', ' and other join - phrases are surrounded with spaces, producing a single fragment that - can be concatenated to form a full artist string. - """ - join = {",": ", ", "": ""}.get(self.join, f" {self.join} ") - return f"{getattr(self, property_name)}{join}" - - raw_artists: list[Artist] - use_anv: bool - use_credit_anv: bool - featured_string: str - should_strip_disambiguation: bool - - @property - def info(self) -> ArtistInfo: - """Expose the state in the shape expected by downstream tag mapping.""" - return {k: getattr(self, k) for k in ArtistInfo.__annotations__} # type: ignore[return-value] - - def strip_disambiguation(self, text: str) -> str: - """Strip Discogs disambiguation suffixes from an artist or label string. - - This removes Discogs-specific numeric suffixes like 'Name (5)' and can - be applied to multi-artist strings as well (e.g., 'A (1) & B (2)'). When - the feature is disabled, the input is returned unchanged. - """ - if self.should_strip_disambiguation: - return DISAMBIGUATION_RE.sub("", text) - return text - - @cached_property - def valid_artists(self) -> list[ValidArtist]: - """Build the ordered, filtered list of artists used for rendering. - - The resulting list normalizes Discogs entries by: - - substituting the configured 'Various Artists' name when Discogs uses - 'Various' - - choosing between name and ANV according to plugin settings - - excluding non-empty roles unless they indicate a featured appearance - - capturing join tokens so the original credit formatting is preserved - """ - va_name = config["va_name"].as_str() - return [ - self.ValidArtist( - str(a["id"]), - self.strip_disambiguation(anv if self.use_anv else name), - self.strip_disambiguation(anv if self.use_credit_anv else name), - a["join"].strip(), - is_feat, - ) - for a in self.raw_artists - if ( - (name := va_name if a["name"] == "Various" else a["name"]) - and (anv := a["anv"] or name) - and ( - (is_feat := ("featuring" in a["role"].lower())) - or not a["role"] - ) - ) - ] - - @property - def artists_ids(self) -> list[str]: - """Return Discogs artist IDs for all valid artists, preserving order.""" - return [a.id for a in self.valid_artists] - - @property - def artist_id(self) -> str: - """Return the primary Discogs artist ID.""" - return self.artists_ids[0] - - @property - def artists(self) -> list[str]: - """Return the per-artist display names used for the 'artist' field.""" - return [a.name for a in self.valid_artists] - - @property - def artists_credit(self) -> list[str]: - """Return the per-artist display names used for the credit field.""" - return [a.credit for a in self.valid_artists] - - @property - def artist(self) -> str: - """Return the fully rendered artist string using display names.""" - return self.join_artists("name") - - @property - def artist_credit(self) -> str: - """Return the fully rendered artist credit string.""" - return self.join_artists("credit") - - def join_artists(self, property_name: str) -> str: - """Render a single artist string with join phrases and featured artists. - - Non-featured artists are concatenated using their join tokens. Featured - artists are appended after the configured 'featured' marker, preserving - Discogs order while keeping featured credits separate from the main - artist string. - """ - non_featured = [a for a in self.valid_artists if not a.is_feat] - featured = [a for a in self.valid_artists if a.is_feat] - - artist = "".join(a.get_artist(property_name) for a in non_featured) - if featured: - if "feat" not in artist: - artist += f" {self.featured_string} " - - artist += ", ".join(a.get_artist(property_name) for a in featured) - - return artist - - @classmethod - def from_plugin( - cls, - plugin: DiscogsPlugin, - artists: list[Artist], - for_album_artist: bool = False, - ) -> ArtistState: - return cls( - artists, - plugin.config["anv"][ - "album_artist" if for_album_artist else "artist" - ].get(bool), - plugin.config["anv"]["artist_credit"].get(bool), - plugin.config["featured_string"].as_str(), - plugin.config["strip_disambiguation"].get(bool), - ) - - -@dataclass -class TracklistState: - index: int = 0 - index_tracks: dict[int, str] = field(default_factory=dict) - tracks: list[TrackInfo] = field(default_factory=list) - divisions: list[str] = field(default_factory=list) - next_divisions: list[str] = field(default_factory=list) - mediums: list[str | None] = field(default_factory=list) - medium_indices: list[str | None] = field(default_factory=list) - - @property - def info(self) -> TracklistInfo: - return asdict(self) # type: ignore[return-value] - - @classmethod - def build( - cls, - plugin: DiscogsPlugin, - clean_tracklist: list[Track], - albumartistinfo: ArtistState, - ) -> TracklistState: - state = cls() - for track in clean_tracklist: - if track["position"]: - state.index += 1 - if state.next_divisions: - state.divisions += state.next_divisions - state.next_divisions.clear() - track_info, medium, medium_index = plugin.get_track_info( - track, state.index, state.divisions, albumartistinfo - ) - track_info.track_alt = track["position"] - state.tracks.append(track_info) - state.mediums.append(medium or None) - state.medium_indices.append(medium_index or None) - else: - state.next_divisions.append(track["title"]) - try: - state.divisions.pop() - except IndexError: - pass - state.index_tracks[state.index + 1] = track["title"] - return state - class DiscogsPlugin(MetadataSourcePlugin): def __init__(self): diff --git a/beetsplug/discogs/states.py b/beetsplug/discogs/states.py new file mode 100644 index 000000000..265c92c4e --- /dev/null +++ b/beetsplug/discogs/states.py @@ -0,0 +1,232 @@ +# This file is part of beets. +# Copyright 2025, Sarunas Nejus, Henry Oberholtzer. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Dataclasses for managing artist credits and tracklists from Discogs.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from functools import cached_property +from typing import TYPE_CHECKING, NamedTuple + +from beets import config + +from .types import Artist, ArtistInfo, Track, TracklistInfo +from .utils import DISAMBIGUATION_RE + +if TYPE_CHECKING: + from beets.autotag.hooks import TrackInfo + + from . import DiscogsPlugin + + +@dataclass +class ArtistState: + """Represent Discogs artist credits. + + This object centralizes the plugin's policy for which Discogs artist fields + to prefer (name vs. ANV), how to treat 'Various', how to format join + phrases, and how to separate featured artists. It exposes both per-artist + components and fully joined strings for common tag targets like 'artist' and + 'artist_credit'. + """ + + class ValidArtist(NamedTuple): + """A normalized, render-ready artist entry extracted from Discogs data. + + Instances represent the subset of Discogs artist information needed for + tagging, including the join token following the artist and whether the + entry is considered a featured appearance. + """ + + id: str + name: str + credit: str + join: str + is_feat: bool + + def get_artist(self, property_name: str) -> str: + """Return the requested display field with its trailing join token. + + The join token is normalized so commas become ', ' and other join + phrases are surrounded with spaces, producing a single fragment that + can be concatenated to form a full artist string. + """ + join = {",": ", ", "": ""}.get(self.join, f" {self.join} ") + return f"{getattr(self, property_name)}{join}" + + raw_artists: list[Artist] + use_anv: bool + use_credit_anv: bool + featured_string: str + should_strip_disambiguation: bool + + @property + def info(self) -> ArtistInfo: + """Expose the state in the shape expected by downstream tag mapping.""" + return {k: getattr(self, k) for k in ArtistInfo.__annotations__} # type: ignore[return-value] + + def strip_disambiguation(self, text: str) -> str: + """Strip Discogs disambiguation suffixes from an artist or label string. + + This removes Discogs-specific numeric suffixes like 'Name (5)' and can + be applied to multi-artist strings as well (e.g., 'A (1) & B (2)'). When + the feature is disabled, the input is returned unchanged. + """ + if self.should_strip_disambiguation: + return DISAMBIGUATION_RE.sub("", text) + return text + + @cached_property + def valid_artists(self) -> list[ValidArtist]: + """Build the ordered, filtered list of artists used for rendering. + + The resulting list normalizes Discogs entries by: + - substituting the configured 'Various Artists' name when Discogs uses + 'Various' + - choosing between name and ANV according to plugin settings + - excluding non-empty roles unless they indicate a featured appearance + - capturing join tokens so the original credit formatting is preserved + """ + va_name = config["va_name"].as_str() + return [ + self.ValidArtist( + str(a["id"]), + self.strip_disambiguation(anv if self.use_anv else name), + self.strip_disambiguation(anv if self.use_credit_anv else name), + a["join"].strip(), + is_feat, + ) + for a in self.raw_artists + if ( + (name := va_name if a["name"] == "Various" else a["name"]) + and (anv := a["anv"] or name) + and ( + (is_feat := ("featuring" in a["role"].lower())) + or not a["role"] + ) + ) + ] + + @property + def artists_ids(self) -> list[str]: + """Return Discogs artist IDs for all valid artists, preserving order.""" + return [a.id for a in self.valid_artists] + + @property + def artist_id(self) -> str: + """Return the primary Discogs artist ID.""" + return self.artists_ids[0] + + @property + def artists(self) -> list[str]: + """Return the per-artist display names used for the 'artist' field.""" + return [a.name for a in self.valid_artists] + + @property + def artists_credit(self) -> list[str]: + """Return the per-artist display names used for the credit field.""" + return [a.credit for a in self.valid_artists] + + @property + def artist(self) -> str: + """Return the fully rendered artist string using display names.""" + return self.join_artists("name") + + @property + def artist_credit(self) -> str: + """Return the fully rendered artist credit string.""" + return self.join_artists("credit") + + def join_artists(self, property_name: str) -> str: + """Render a single artist string with join phrases and featured artists. + + Non-featured artists are concatenated using their join tokens. Featured + artists are appended after the configured 'featured' marker, preserving + Discogs order while keeping featured credits separate from the main + artist string. + """ + non_featured = [a for a in self.valid_artists if not a.is_feat] + featured = [a for a in self.valid_artists if a.is_feat] + + artist = "".join(a.get_artist(property_name) for a in non_featured) + if featured: + if "feat" not in artist: + artist += f" {self.featured_string} " + + artist += ", ".join(a.get_artist(property_name) for a in featured) + + return artist + + @classmethod + def from_plugin( + cls, + plugin: DiscogsPlugin, + artists: list[Artist], + for_album_artist: bool = False, + ) -> ArtistState: + return cls( + artists, + plugin.config["anv"][ + "album_artist" if for_album_artist else "artist" + ].get(bool), + plugin.config["anv"]["artist_credit"].get(bool), + plugin.config["featured_string"].as_str(), + plugin.config["strip_disambiguation"].get(bool), + ) + + +@dataclass +class TracklistState: + index: int = 0 + index_tracks: dict[int, str] = field(default_factory=dict) + tracks: list[TrackInfo] = field(default_factory=list) + divisions: list[str] = field(default_factory=list) + next_divisions: list[str] = field(default_factory=list) + mediums: list[str | None] = field(default_factory=list) + medium_indices: list[str | None] = field(default_factory=list) + + @property + def info(self) -> TracklistInfo: + return asdict(self) # type: ignore[return-value] + + @classmethod + def build( + cls, + plugin: DiscogsPlugin, + clean_tracklist: list[Track], + albumartistinfo: ArtistState, + ) -> TracklistState: + state = cls() + for track in clean_tracklist: + if track["position"]: + state.index += 1 + if state.next_divisions: + state.divisions += state.next_divisions + state.next_divisions.clear() + track_info, medium, medium_index = plugin.get_track_info( + track, state.index, state.divisions, albumartistinfo + ) + track_info.track_alt = track["position"] + state.tracks.append(track_info) + state.mediums.append(medium or None) + state.medium_indices.append(medium_index or None) + else: + state.next_divisions.append(track["title"]) + try: + state.divisions.pop() + except IndexError: + pass + state.index_tracks[state.index + 1] = track["title"] + return state diff --git a/beetsplug/discogs/types.py b/beetsplug/discogs/types.py new file mode 100644 index 000000000..e06f51ed5 --- /dev/null +++ b/beetsplug/discogs/types.py @@ -0,0 +1,67 @@ +# This file is part of beets. +# Copyright 2025, Sarunas Nejus, Henry Oberholtzer. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from typing_extensions import NotRequired, TypedDict + +if TYPE_CHECKING: + from beets.autotag.hooks import TrackInfo + + +class ReleaseFormat(TypedDict): + name: str + qty: int + descriptions: list[str] | None + + +class Artist(TypedDict): + name: str + anv: str + join: str + role: str + tracks: str + id: str + resource_url: str + + +class Track(TypedDict): + position: str + type_: str + title: str + duration: str + artists: list[Artist] + extraartists: NotRequired[list[Artist]] + sub_tracks: NotRequired[list[Track]] + + +class ArtistInfo(TypedDict): + artist: str + artists: list[str] + artist_credit: str + artists_credit: list[str] + artist_id: str + artists_ids: list[str] + + +class TracklistInfo(TypedDict): + index: int + index_tracks: dict[int, str] + tracks: list[TrackInfo] + divisions: list[str] + next_divisions: list[str] + mediums: list[str | None] + medium_indices: list[str | None] diff --git a/beetsplug/discogs/utils.py b/beetsplug/discogs/utils.py new file mode 100644 index 000000000..fdb1f0058 --- /dev/null +++ b/beetsplug/discogs/utils.py @@ -0,0 +1,47 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +"""Utility resources for the Discogs plugin.""" + +import http.client +import re +import socket + +from discogs_client.exceptions import DiscogsAPIError +from requests.exceptions import ConnectionError + +# Exceptions that discogs_client should really handle but does not. +CONNECTION_ERRORS = ( + ConnectionError, + socket.error, + http.client.HTTPException, + ValueError, # JSON decoding raises a ValueError. + DiscogsAPIError, +) + +DISAMBIGUATION_RE = re.compile(r" \(\d+\)") + +TRACK_INDEX_RE = re.compile( + r""" + (.*?) # medium: everything before medium_index. + (\d*?) # medium_index: a number at the end of + # `position`, except if followed by a subtrack index. + # subtrack_index: can only be matched if medium + # or medium_index have been matched, and can be + ( + (?<=\w)\.[\w]+ # a dot followed by a string (A.1, 2.A) + | (?<=\d)[A-Z]+ # a string that follows a number (1A, B2a) + )? + """, + re.VERBOSE, +) From ff95ce5d2034d829fd9517adee9aac7a9bbb48e9 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 18 Jan 2026 18:54:47 -0800 Subject: [PATCH 237/274] Remove utils, rework from_plugin method in ArtistState to from_config --- beetsplug/discogs/__init__.py | 42 +++++++++++++++++++++++++------ beetsplug/discogs/states.py | 22 +++++++++------- beetsplug/discogs/utils.py | 47 ----------------------------------- test/plugins/test_discogs.py | 4 +-- 4 files changed, 50 insertions(+), 65 deletions(-) delete mode 100644 beetsplug/discogs/utils.py diff --git a/beetsplug/discogs/__init__.py b/beetsplug/discogs/__init__.py index 23e2267df..dc88e0f14 100644 --- a/beetsplug/discogs/__init__.py +++ b/beetsplug/discogs/__init__.py @@ -18,9 +18,11 @@ python3-discogs-client library. from __future__ import annotations +import http.client import json import os import re +import socket import time import traceback from functools import cache @@ -30,6 +32,7 @@ from typing import TYPE_CHECKING import confuse from discogs_client import Client, Master, Release from discogs_client.exceptions import DiscogsAPIError +from requests.exceptions import ConnectionError import beets import beets.ui @@ -38,8 +41,7 @@ from beets.autotag.distance import string_dist from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin -from .states import ArtistState, TracklistState -from .utils import CONNECTION_ERRORS, DISAMBIGUATION_RE, TRACK_INDEX_RE +from .states import DISAMBIGUATION_RE, ArtistState, TracklistState if TYPE_CHECKING: from collections.abc import Callable, Iterable, Sequence @@ -53,6 +55,31 @@ API_KEY = "rAzVUQYRaoFjeBjyWuWZ" API_SECRET = "plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy" +# Exceptions that discogs_client should really handle but does not. +CONNECTION_ERRORS = ( + ConnectionError, + socket.error, + http.client.HTTPException, + ValueError, # JSON decoding raises a ValueError. + DiscogsAPIError, +) + +TRACK_INDEX_RE = re.compile( + r""" + (.*?) # medium: everything before medium_index. + (\d*?) # medium_index: a number at the end of + # `position`, except if followed by a subtrack index. + # subtrack_index: can only be matched if medium + # or medium_index have been matched, and can be + ( + (?<=\w)\.[\w]+ # a dot followed by a string (A.1, 2.A) + | (?<=\d)[A-Z]+ # a string that follows a number (1A, B2a) + )? + """, + re.VERBOSE, +) + + class DiscogsPlugin(MetadataSourcePlugin): def __init__(self): super().__init__() @@ -304,8 +331,8 @@ class DiscogsPlugin(MetadataSourcePlugin): artist_data = [a.data for a in result.artists] # Information for the album artist - albumartist = ArtistState.from_plugin( - self, artist_data, for_album_artist=True + albumartist = ArtistState.from_config( + self.config, artist_data, for_album_artist=True ) album = re.sub(r" +", " ", result.title) @@ -315,7 +342,8 @@ class DiscogsPlugin(MetadataSourcePlugin): # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks( - result.data["tracklist"], ArtistState.from_plugin(self, artist_data) + result.data["tracklist"], + ArtistState.from_config(self.config, artist_data), ) # Extract information for the optional AlbumInfo fields, if possible. @@ -611,8 +639,8 @@ class DiscogsPlugin(MetadataSourcePlugin): length = self.get_track_length(track["duration"]) # If artists are found on the track, we will use those instead - artistinfo = ArtistState.from_plugin( - self, + artistinfo = ArtistState.from_config( + self.config, [ *(track.get("artists") or albumartistinfo.raw_artists), *track.get("extraartists", []), diff --git a/beetsplug/discogs/states.py b/beetsplug/discogs/states.py index 265c92c4e..4f59404ca 100644 --- a/beetsplug/discogs/states.py +++ b/beetsplug/discogs/states.py @@ -16,6 +16,7 @@ from __future__ import annotations +import re from dataclasses import asdict, dataclass, field from functools import cached_property from typing import TYPE_CHECKING, NamedTuple @@ -23,13 +24,16 @@ from typing import TYPE_CHECKING, NamedTuple from beets import config from .types import Artist, ArtistInfo, Track, TracklistInfo -from .utils import DISAMBIGUATION_RE if TYPE_CHECKING: + from confuse import ConfigView + from beets.autotag.hooks import TrackInfo from . import DiscogsPlugin +DISAMBIGUATION_RE = re.compile(r" \(\d+\)") + @dataclass class ArtistState: @@ -170,20 +174,20 @@ class ArtistState: return artist @classmethod - def from_plugin( + def from_config( cls, - plugin: DiscogsPlugin, + config: ConfigView, artists: list[Artist], for_album_artist: bool = False, ) -> ArtistState: return cls( artists, - plugin.config["anv"][ - "album_artist" if for_album_artist else "artist" - ].get(bool), - plugin.config["anv"]["artist_credit"].get(bool), - plugin.config["featured_string"].as_str(), - plugin.config["strip_disambiguation"].get(bool), + config["anv"]["album_artist" if for_album_artist else "artist"].get( + bool + ), + config["anv"]["artist_credit"].get(bool), + config["featured_string"].as_str(), + config["strip_disambiguation"].get(bool), ) diff --git a/beetsplug/discogs/utils.py b/beetsplug/discogs/utils.py deleted file mode 100644 index fdb1f0058..000000000 --- a/beetsplug/discogs/utils.py +++ /dev/null @@ -1,47 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -"""Utility resources for the Discogs plugin.""" - -import http.client -import re -import socket - -from discogs_client.exceptions import DiscogsAPIError -from requests.exceptions import ConnectionError - -# Exceptions that discogs_client should really handle but does not. -CONNECTION_ERRORS = ( - ConnectionError, - socket.error, - http.client.HTTPException, - ValueError, # JSON decoding raises a ValueError. - DiscogsAPIError, -) - -DISAMBIGUATION_RE = re.compile(r" \(\d+\)") - -TRACK_INDEX_RE = re.compile( - r""" - (.*?) # medium: everything before medium_index. - (\d*?) # medium_index: a number at the end of - # `position`, except if followed by a subtrack index. - # subtrack_index: can only be matched if medium - # or medium_index have been matched, and can be - ( - (?<=\w)\.[\w]+ # a dot followed by a string (A.1, 2.A) - | (?<=\d)[A-Z]+ # a string that follows a number (1A, B2a) - )? - """, - re.VERBOSE, -) diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 66cbe9371..15d47db6c 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -674,7 +674,7 @@ def test_parse_featured_artists(track, expected_artist, expected_artists): """Tests the plugins ability to parse a featured artist. Ignores artists that are not listed as featured.""" plugin = DiscogsPlugin() - artistinfo = ArtistState.from_plugin(plugin, [_artist("ARTIST")]) + artistinfo = ArtistState.from_config(plugin.config, [_artist("ARTIST")]) t, _, _ = plugin.get_track_info(track, 1, 1, artistinfo) assert t.artist == expected_artist assert t.artists == expected_artists @@ -724,7 +724,7 @@ def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): def test_va_buildartistinfo(given_artists, expected_info, config_va_name): config["va_name"] = config_va_name assert ( - ArtistState.from_plugin(DiscogsPlugin(), given_artists).info + ArtistState.from_config(DiscogsPlugin().config, given_artists).info == expected_info ) From 9b1bd5df7afacc88625c6c3d2d5ff71da93cff14 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 19 Jan 2026 12:46:22 -0800 Subject: [PATCH 238/274] Adjust type annotation, rebase. --- beetsplug/discogs/states.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/discogs/states.py b/beetsplug/discogs/states.py index 4f59404ca..2a8173ba5 100644 --- a/beetsplug/discogs/states.py +++ b/beetsplug/discogs/states.py @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, NamedTuple from beets import config -from .types import Artist, ArtistInfo, Track, TracklistInfo +from .types import ArtistInfo if TYPE_CHECKING: from confuse import ConfigView @@ -31,6 +31,7 @@ if TYPE_CHECKING: from beets.autotag.hooks import TrackInfo from . import DiscogsPlugin + from .types import Artist, Track, TracklistInfo DISAMBIGUATION_RE = re.compile(r" \(\d+\)") From 958b36b29847ff4e9c171f5cf75028c3feeec29a Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Sun, 3 Aug 2025 16:38:01 -0700 Subject: [PATCH 239/274] fish: complete files in more places --- beetsplug/fish.py | 12 ++++++------ docs/changelog.rst | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/beetsplug/fish.py b/beetsplug/fish.py index b1518f1c4..82e035eb4 100644 --- a/beetsplug/fish.py +++ b/beetsplug/fish.py @@ -183,16 +183,16 @@ def get_basic_beet_options(): BL_NEED2.format("-l format-item", "-f -d 'print with custom format'") + BL_NEED2.format("-l format-album", "-f -d 'print with custom format'") + BL_NEED2.format( - "-s l -l library", "-f -r -d 'library database file to use'" + "-s l -l library", "-F -r -d 'library database file to use'" ) + BL_NEED2.format( - "-s d -l directory", "-f -r -d 'destination music directory'" + "-s d -l directory", "-F -r -d 'destination music directory'" ) + BL_NEED2.format( "-s v -l verbose", "-f -d 'print debugging information'" ) + BL_NEED2.format( - "-s c -l config", "-f -r -d 'path to configuration file'" + "-s c -l config", "-F -r -d 'path to configuration file'" ) + BL_NEED2.format( "-s h -l help", "-f -d 'print this help message and exit'" @@ -216,7 +216,7 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues): word += BL_USE3.format( cmdname, f"-a {wrap('$FIELDS')}", - f"-f -d {wrap('fieldname')}", + f"-d {wrap('fieldname')}", ) if extravalues: @@ -270,7 +270,7 @@ def get_all_commands(beetcmds): word += " ".join( BL_USE3.format( name, - f"{cmd_need_arg}{cmd_s}{cmd_l} -f {cmd_arglist}", + f"{cmd_need_arg}{cmd_s}{cmd_l} {cmd_arglist}", cmd_helpstr, ).split() ) @@ -278,7 +278,7 @@ def get_all_commands(beetcmds): word = word + BL_USE3.format( name, - "-s h -l help -f", + "-s h -l help", f"-d {wrap('print help')}", ) return word diff --git a/docs/changelog.rst b/docs/changelog.rst index c87f1eaf4..d59c7ba1f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -50,6 +50,8 @@ New features: the ``clearart_on_import`` config option. Also, ``beet clearart`` is only going to update the files matching the query and with an embedded art, leaving untouched the files without. +- :doc:`plugins/fish`: Filenames are now completed in more places, like after + ``beet import``. Bug fixes: From 8769f8f8f039c9f2b49f8dc4ad60d94f9031c0c9 Mon Sep 17 00:00:00 2001 From: David Logie Date: Thu, 15 Jan 2026 12:06:52 +0000 Subject: [PATCH 240/274] Gracefully handle 404s when importing from MusicBrainz. A 404 error can be raised when fetching from MusicBrainz in the case of re-importing an album that has since been deleted from MusicBrainz. --- beetsplug/musicbrainz.py | 8 +++++++- test/plugins/test_musicbrainz.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 137189cdc..4257e52ef 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -792,7 +792,13 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): self._log.debug("Invalid MBID ({}).", album_id) return None - res = self.mb_api.get_release(albumid, includes=RELEASE_INCLUDES) + # A 404 error here is fine. e.g. re-importing a release that has + # been deleted on MusicBrainz. + try: + res = self.mb_api.get_release(albumid, includes=RELEASE_INCLUDES) + except HTTPNotFoundError: + self._log.debug("Release {} not found on MusicBrainz.", albumid) + return None # resolve linked release relations actual_res = None diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index f21c03c97..09127d169 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -15,10 +15,12 @@ """Tests for MusicBrainz API wrapper.""" import unittest +import uuid from typing import ClassVar from unittest import mock import pytest +import requests from beets import config from beets.library import Item @@ -1106,3 +1108,32 @@ class TestMusicBrainzPlugin(PluginMixin): assert len(candidates) == 1 assert candidates[0].tracks[0].track_id == self.RECORDING["id"] assert candidates[0].album == "hi" + + def test_import_handles_404_gracefully(self, mb, requests_mock): + id_ = uuid.uuid4() + response = requests.Response() + response.status_code = 404 + requests_mock.get( + f"/ws/2/release/{id_}", + exc=requests.exceptions.HTTPError(response=response), + ) + res = mb.album_for_id(str(id_)) + assert res is None + + def test_import_propagates_non_404_errors(self, mb): + class DummyResponse: + status_code = 500 + + error = requests.exceptions.HTTPError(response=DummyResponse()) + + def raise_error(*args, **kwargs): + raise error + + # Simulate mb.mb_api.get_release raising a non-404 HTTP error + mb.mb_api.get_release = raise_error + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + mb.album_for_id(str(uuid.uuid4())) + + # Ensure the exact error is propagated, not swallowed + assert excinfo.value is error From 9dafaf05029a0e8e5ea4f9b21b09167af75b5310 Mon Sep 17 00:00:00 2001 From: "Kirill A. Korinsky" Date: Sat, 29 Jun 2024 23:18:27 +0100 Subject: [PATCH 241/274] Add missed ANSI codes for 4 bit colors --- beets/ui/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 5eeef815d..f9c760b83 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -488,6 +488,14 @@ CODE_BY_COLOR = { "magenta": 35, "cyan": 36, "white": 37, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, # Background colors. "bg_black": 40, "bg_red": 41, @@ -497,6 +505,14 @@ CODE_BY_COLOR = { "bg_magenta": 45, "bg_cyan": 46, "bg_white": 47, + "bg_bright_black": 100, + "bg_bright_red": 101, + "bg_bright_green": 102, + "bg_bright_yellow": 103, + "bg_bright_blue": 104, + "bg_bright_magenta": 105, + "bg_bright_cyan": 106, + "bg_bright_white": 107, } RESET_COLOR = f"{COLOR_ESCAPE}[39;49;00m" # Precompile common ANSI-escape regex patterns From 1dd2cd019f0ba94874de2548ddd51cd5b490efb4 Mon Sep 17 00:00:00 2001 From: "Kirill A. Korinsky" Date: Sun, 25 Jan 2026 12:05:40 +0100 Subject: [PATCH 242/274] Update color docs with bright_* and bg_bright_* entries --- beets/ui/__init__.py | 10 +++++----- docs/reference/config.rst | 12 +++++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index f9c760b83..8db4dd79f 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -472,13 +472,13 @@ CODE_BY_COLOR = { "normal": 0, "bold": 1, "faint": 2, - # "italic": 3, + "italic": 3, "underline": 4, - # "blink_slow": 5, - # "blink_rapid": 6, + "blink_slow": 5, + "blink_rapid": 6, "inverse": 7, - # "conceal": 8, - # "crossed_out": 9 + "conceal": 8, + "crossed_out": 9, # Text colors. "black": 30, "red": 31, diff --git a/docs/reference/config.rst b/docs/reference/config.rst index b4874416c..b654c118f 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -467,14 +467,20 @@ Available attributes: Foreground colors ``black``, ``red``, ``green``, ``yellow``, ``blue``, ``magenta``, ``cyan``, - ``white`` + ``white``, ``bright_black``, ``bright_red``, ``bright_green``, + ``bright_yellow``, ``bright_blue``, ``bright_magenta``, ``bright_cyan``, + ``bright_white`` Background colors ``bg_black``, ``bg_red``, ``bg_green``, ``bg_yellow``, ``bg_blue``, - ``bg_magenta``, ``bg_cyan``, ``bg_white`` + ``bg_magenta``, ``bg_cyan``, ``bg_white``, ``bg_bright_black``, + ``bg_bright_red``, ``bg_bright_green``, ``bg_bright_yellow``, + ``bg_bright_blue``, ``bg_bright_magenta``, ``bg_bright_cyan``, + ``bg_bright_white`` Text styles - ``normal``, ``bold``, ``faint``, ``underline``, ``reverse`` + ``normal``, ``bold``, ``faint``, ``italic``, ``underline``, ``blink_slow``, + ``blink_rapid``, ``inverse``, ``conceal``, ``crossed_out`` terminal_width ~~~~~~~~~~~~~~ From 5a942093648b9bf22f22a7745036400bca917926 Mon Sep 17 00:00:00 2001 From: "Kirill A. Korinsky" Date: Tue, 27 Jan 2026 22:56:18 +0100 Subject: [PATCH 243/274] Update changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d59c7ba1f..6126f70aa 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -145,6 +145,7 @@ Other changes: unavailable, enabling ``importorskip`` usage in pytest setup. - Finally removed gmusic plugin and all related code/docs as the Google Play Music service was shut down in 2020. +- Updated color documentation with ``bright_*`` and ``bg_bright_*`` entries. 2.5.1 (October 14, 2025) ------------------------ From 1165758e1e2d8873b18e0e80e37e42ea6fd77aba Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 9 Aug 2025 13:40:57 +0200 Subject: [PATCH 244/274] Moved functions from random.py into random plugin. Removed random.py --- beets/random.py | 112 -------------------------------------------- beetsplug/random.py | 98 +++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 113 deletions(-) delete mode 100644 beets/random.py diff --git a/beets/random.py b/beets/random.py deleted file mode 100644 index f3318054c..000000000 --- a/beets/random.py +++ /dev/null @@ -1,112 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Philippe Mongeau. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Get a random song or album from the library.""" - -import random -from itertools import groupby -from operator import attrgetter - - -def _length(obj, album): - """Get the duration of an item or album.""" - if album: - return sum(i.length for i in obj.items()) - else: - return obj.length - - -def _equal_chance_permutation(objs, field="albumartist", random_gen=None): - """Generate (lazily) a permutation of the objects where every group - with equal values for `field` have an equal chance of appearing in - any given position. - """ - rand = random_gen or random - - # Group the objects by artist so we can sample from them. - key = attrgetter(field) - objs.sort(key=key) - objs_by_artists = {} - for artist, v in groupby(objs, key): - objs_by_artists[artist] = list(v) - - # While we still have artists with music to choose from, pick one - # randomly and pick a track from that artist. - while objs_by_artists: - # Choose an artist and an object for that artist, removing - # this choice from the pool. - artist = rand.choice(list(objs_by_artists.keys())) - objs_from_artist = objs_by_artists[artist] - i = rand.randint(0, len(objs_from_artist) - 1) - yield objs_from_artist.pop(i) - - # Remove the artist if we've used up all of its objects. - if not objs_from_artist: - del objs_by_artists[artist] - - -def _take(iter, num): - """Return a list containing the first `num` values in `iter` (or - fewer, if the iterable ends early). - """ - out = [] - for val in iter: - out.append(val) - num -= 1 - if num <= 0: - break - return out - - -def _take_time(iter, secs, album): - """Return a list containing the first values in `iter`, which should - be Item or Album objects, that add up to the given amount of time in - seconds. - """ - out = [] - total_time = 0.0 - for obj in iter: - length = _length(obj, album) - if total_time + length <= secs: - out.append(obj) - total_time += length - return out - - -def random_objs( - objs, album, number=1, time=None, equal_chance=False, random_gen=None -): - """Get a random subset of the provided `objs`. - - If `number` is provided, produce that many matches. Otherwise, if - `time` is provided, instead select a list whose total time is close - to that number of minutes. If `equal_chance` is true, give each - artist an equal chance of being included so that artists with more - songs are not represented disproportionately. - """ - rand = random_gen or random - - # Permute the objects either in a straightforward way or an - # artist-balanced way. - if equal_chance: - perm = _equal_chance_permutation(objs) - else: - perm = objs - rand.shuffle(perm) # N.B. This shuffles the original list. - - # Select objects by time our count. - if time: - return _take_time(perm, time * 60, album) - else: - return _take(perm, number) diff --git a/beetsplug/random.py b/beetsplug/random.py index c791af414..330dc78e4 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -14,8 +14,11 @@ """Get a random song or album from the library.""" +import random +from itertools import groupby +from operator import attrgetter + from beets.plugins import BeetsPlugin -from beets.random import random_objs from beets.ui import Subcommand, print_ @@ -64,3 +67,96 @@ random_cmd.func = random_func class Random(BeetsPlugin): def commands(self): return [random_cmd] + + +def _length(obj, album): + """Get the duration of an item or album.""" + if album: + return sum(i.length for i in obj.items()) + else: + return obj.length + + +def _equal_chance_permutation(objs, field="albumartist", random_gen=None): + """Generate (lazily) a permutation of the objects where every group + with equal values for `field` have an equal chance of appearing in + any given position. + """ + rand = random_gen or random + + # Group the objects by artist so we can sample from them. + key = attrgetter(field) + objs.sort(key=key) + objs_by_artists = {} + for artist, v in groupby(objs, key): + objs_by_artists[artist] = list(v) + + # While we still have artists with music to choose from, pick one + # randomly and pick a track from that artist. + while objs_by_artists: + # Choose an artist and an object for that artist, removing + # this choice from the pool. + artist = rand.choice(list(objs_by_artists.keys())) + objs_from_artist = objs_by_artists[artist] + i = rand.randint(0, len(objs_from_artist) - 1) + yield objs_from_artist.pop(i) + + # Remove the artist if we've used up all of its objects. + if not objs_from_artist: + del objs_by_artists[artist] + + +def _take(iter, num): + """Return a list containing the first `num` values in `iter` (or + fewer, if the iterable ends early). + """ + out = [] + for val in iter: + out.append(val) + num -= 1 + if num <= 0: + break + return out + + +def _take_time(iter, secs, album): + """Return a list containing the first values in `iter`, which should + be Item or Album objects, that add up to the given amount of time in + seconds. + """ + out = [] + total_time = 0.0 + for obj in iter: + length = _length(obj, album) + if total_time + length <= secs: + out.append(obj) + total_time += length + return out + + +def random_objs( + objs, album, number=1, time=None, equal_chance=False, random_gen=None +): + """Get a random subset of the provided `objs`. + + If `number` is provided, produce that many matches. Otherwise, if + `time` is provided, instead select a list whose total time is close + to that number of minutes. If `equal_chance` is true, give each + artist an equal chance of being included so that artists with more + songs are not represented disproportionately. + """ + rand = random_gen or random + + # Permute the objects either in a straightforward way or an + # artist-balanced way. + if equal_chance: + perm = _equal_chance_permutation(objs) + else: + perm = objs + rand.shuffle(perm) # N.B. This shuffles the original list. + + # Select objects by time our count. + if time: + return _take_time(perm, time * 60, album) + else: + return _take(perm, number) From 34e0de3e1f469da3817a0e124020774c653eec6a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 9 Aug 2025 14:26:15 +0200 Subject: [PATCH 245/274] Added typehints and some more tests. --- beetsplug/random.py | 59 +++++++++++++++++++----------- docs/changelog.rst | 1 + test/plugins/test_random.py | 73 ++++++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 23 deletions(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index 330dc78e4..fcd72c83e 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -14,10 +14,14 @@ """Get a random song or album from the library.""" -import random -from itertools import groupby -from operator import attrgetter +from __future__ import annotations +import random +from itertools import groupby, islice +from operator import attrgetter +from typing import Iterable, Sequence, TypeVar + +from beets.library import Album, Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ @@ -69,15 +73,19 @@ class Random(BeetsPlugin): return [random_cmd] -def _length(obj, album): +def _length(obj: Item | Album) -> float: """Get the duration of an item or album.""" - if album: + if isinstance(obj, Album): return sum(i.length for i in obj.items()) else: return obj.length -def _equal_chance_permutation(objs, field="albumartist", random_gen=None): +def _equal_chance_permutation( + objs: Sequence[Item | Album], + field: str = "albumartist", + random_gen: random.Random | None = None, +) -> Iterable[Item | Album]: """Generate (lazily) a permutation of the objects where every group with equal values for `field` have an equal chance of appearing in any given position. @@ -86,7 +94,7 @@ def _equal_chance_permutation(objs, field="albumartist", random_gen=None): # Group the objects by artist so we can sample from them. key = attrgetter(field) - objs.sort(key=key) + objs = sorted(objs, key=key) objs_by_artists = {} for artist, v in groupby(objs, key): objs_by_artists[artist] = list(v) @@ -106,28 +114,31 @@ def _equal_chance_permutation(objs, field="albumartist", random_gen=None): del objs_by_artists[artist] -def _take(iter, num): +T = TypeVar("T") + + +def _take( + iter: Iterable[T], + num: int, +) -> list[T]: """Return a list containing the first `num` values in `iter` (or fewer, if the iterable ends early). """ - out = [] - for val in iter: - out.append(val) - num -= 1 - if num <= 0: - break - return out + return list(islice(iter, num)) -def _take_time(iter, secs, album): +def _take_time( + iter: Iterable[Item | Album], + secs: float, +) -> list[Item | Album]: """Return a list containing the first values in `iter`, which should be Item or Album objects, that add up to the given amount of time in seconds. """ - out = [] + out: list[Item | Album] = [] total_time = 0.0 for obj in iter: - length = _length(obj, album) + length = _length(obj) if total_time + length <= secs: out.append(obj) total_time += length @@ -135,7 +146,11 @@ def _take_time(iter, secs, album): def random_objs( - objs, album, number=1, time=None, equal_chance=False, random_gen=None + objs: Sequence[Item | Album], + number=1, + time: float | None = None, + equal_chance: bool = False, + random_gen: random.Random | None = None, ): """Get a random subset of the provided `objs`. @@ -152,11 +167,11 @@ def random_objs( if equal_chance: perm = _equal_chance_permutation(objs) else: - perm = objs - rand.shuffle(perm) # N.B. This shuffles the original list. + perm = list(objs) + rand.shuffle(perm) # Select objects by time our count. if time: - return _take_time(perm, time * 60, album) + return _take_time(perm, time * 60) else: return _take(perm, number) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6126f70aa..6ccdd6060 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -146,6 +146,7 @@ Other changes: - Finally removed gmusic plugin and all related code/docs as the Google Play Music service was shut down in 2020. - Updated color documentation with ``bright_*`` and ``bg_bright_*`` entries. +- Moved `beets/random.py` into `beetsplug/random.py` to cleanup core module. 2.5.1 (October 14, 2025) ------------------------ diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index cb21edf47..5b71cd126 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -20,8 +20,8 @@ from random import Random import pytest -from beets import random from beets.test.helper import TestHelper +from beetsplug import random class RandomTest(TestHelper, unittest.TestCase): @@ -77,3 +77,74 @@ class RandomTest(TestHelper, unittest.TestCase): assert 0 == pytest.approx(median1, abs=1) assert len(self.items) // 2 == pytest.approx(median2, abs=1) assert stdev2 > stdev1 + + def test_equal_permutation_empty_input(self): + """Test _equal_chance_permutation with empty input.""" + result = list(random._equal_chance_permutation([], "artist")) + assert result == [] + + def test_equal_permutation_single_item(self): + """Test _equal_chance_permutation with single item.""" + result = list(random._equal_chance_permutation([self.item1], "artist")) + assert result == [self.item1] + + def test_equal_permutation_single_artist(self): + """Test _equal_chance_permutation with items from one artist.""" + items = [self.create_item(artist=self.artist1) for _ in range(5)] + result = list(random._equal_chance_permutation(items, "artist")) + assert set(result) == set(items) + assert len(result) == len(items) + + def test_random_objs_count(self): + """Test random_objs with count-based selection.""" + result = random.random_objs( + self.items, number=3, random_gen=self.random_gen + ) + assert len(result) == 3 + assert all(item in self.items for item in result) + + def test_random_objs_time(self): + """Test random_objs with time-based selection.""" + # Total length is 30 + 60 + 8*45 = 450 seconds + # Requesting 120 seconds should return 2-3 items + result = random.random_objs( + self.items, + time=2, + random_gen=self.random_gen, # 2 minutes = 120 sec + ) + total_time = sum(item.length for item in result) + assert total_time <= 120 + # Check we got at least some items + assert len(result) > 0 + + def test_random_objs_equal_chance(self): + """Test random_objs with equal_chance=True.""" + + # With equal_chance, artist1 should appear more often in results + def experiment(): + """Run the random_objs function multiple times and collect results.""" + results = [] + for _ in range(5000): + result = random.random_objs( + [self.item1, self.item2], + number=1, + equal_chance=True, + random_gen=self.random_gen, + ) + results.append(result[0].artist) + + # Return ratio + return results.count(self.artist1), results.count(self.artist2) + + count_artist1, count_artist2 = experiment() + assert 1 - count_artist1 / count_artist2 < 0.1 # 10% deviation + + def test_random_objs_empty_input(self): + """Test random_objs with empty input.""" + result = random.random_objs([], number=3) + assert result == [] + + def test_random_objs_zero_number(self): + """Test random_objs with number=0.""" + result = random.random_objs(self.items, number=0) + assert result == [] From bcb22e6c8533880b0fd37177c07cbd61d4abb81a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sun, 10 Aug 2025 22:20:28 +0200 Subject: [PATCH 246/274] Overall refactor of random plugin. Added length property to albums. --- beets/library/models.py | 5 ++ beetsplug/random.py | 145 +++++++++++++----------------- test/plugins/test_random.py | 174 +++++++++++++++++++++--------------- 3 files changed, 171 insertions(+), 153 deletions(-) diff --git a/beets/library/models.py b/beets/library/models.py index aee055134..e259f4e96 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -616,6 +616,11 @@ class Album(LibModel): for item in self.items(): item.try_sync(write, move) + @property + def length(self) -> float: + """Return the total length of all items in this album in seconds.""" + return sum(item.length for item in self.items()) + class Item(LibModel): """Represent a song or track.""" diff --git a/beetsplug/random.py b/beetsplug/random.py index fcd72c83e..933049d96 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -1,17 +1,3 @@ -# This file is part of beets. -# Copyright 2016, Philippe Mongeau. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - """Get a random song or album from the library.""" from __future__ import annotations @@ -19,26 +5,31 @@ from __future__ import annotations import random from itertools import groupby, islice from operator import attrgetter -from typing import Iterable, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, Iterable, Sequence, TypeVar -from beets.library import Album, Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ +if TYPE_CHECKING: + import optparse -def random_func(lib, opts, args): + from beets.library import LibModel, Library + + T = TypeVar("T", bound=LibModel) + + +def random_func(lib: Library, opts: optparse.Values, args: list[str]): """Select some random items or albums and print the results.""" # Fetch all the objects matching the query into a list. - if opts.album: - objs = list(lib.albums(args)) - else: - objs = list(lib.items(args)) + objs = lib.albums(args) if opts.album else lib.items(args) # Print a random subset. - objs = random_objs( - objs, opts.album, opts.number, opts.time, opts.equal_chance - ) - for obj in objs: + for obj in random_objs( + objs=list(objs), + number=opts.number, + time_minutes=opts.time, + equal_chance=opts.equal_chance, + ): print_(format(obj)) @@ -73,105 +64,93 @@ class Random(BeetsPlugin): return [random_cmd] -def _length(obj: Item | Album) -> float: - """Get the duration of an item or album.""" - if isinstance(obj, Album): - return sum(i.length for i in obj.items()) - else: - return obj.length +NOT_FOUND_SENTINEL = object() def _equal_chance_permutation( - objs: Sequence[Item | Album], + objs: Sequence[T], field: str = "albumartist", random_gen: random.Random | None = None, -) -> Iterable[Item | Album]: +) -> Iterable[T]: """Generate (lazily) a permutation of the objects where every group with equal values for `field` have an equal chance of appearing in any given position. """ - rand = random_gen or random + rand: random.Random = random_gen or random.Random() # Group the objects by artist so we can sample from them. key = attrgetter(field) - objs = sorted(objs, key=key) - objs_by_artists = {} - for artist, v in groupby(objs, key): - objs_by_artists[artist] = list(v) - # While we still have artists with music to choose from, pick one - # randomly and pick a track from that artist. - while objs_by_artists: - # Choose an artist and an object for that artist, removing - # this choice from the pool. - artist = rand.choice(list(objs_by_artists.keys())) - objs_from_artist = objs_by_artists[artist] - i = rand.randint(0, len(objs_from_artist) - 1) - yield objs_from_artist.pop(i) + def get_attr(obj: T) -> Any: + try: + return key(obj) + except AttributeError: + return NOT_FOUND_SENTINEL - # Remove the artist if we've used up all of its objects. - if not objs_from_artist: - del objs_by_artists[artist] + groups: dict[Any, list[T]] = { + NOT_FOUND_SENTINEL: [], + } + for k, values in groupby(objs, key=get_attr): + groups[k] = list(values) + # shuffle in category + rand.shuffle(groups[k]) - -T = TypeVar("T") - - -def _take( - iter: Iterable[T], - num: int, -) -> list[T]: - """Return a list containing the first `num` values in `iter` (or - fewer, if the iterable ends early). - """ - return list(islice(iter, num)) + # Remove items without the field value. + del groups[NOT_FOUND_SENTINEL] + while groups: + group = rand.choice(list(groups.keys())) + yield groups[group].pop() + if not groups[group]: + del groups[group] def _take_time( - iter: Iterable[Item | Album], + iter: Iterable[T], secs: float, -) -> list[Item | Album]: +) -> Iterable[T]: """Return a list containing the first values in `iter`, which should be Item or Album objects, that add up to the given amount of time in seconds. """ - out: list[Item | Album] = [] total_time = 0.0 for obj in iter: - length = _length(obj) + length = obj.length if total_time + length <= secs: - out.append(obj) + yield obj total_time += length - return out def random_objs( - objs: Sequence[Item | Album], - number=1, - time: float | None = None, + objs: Sequence[T], + number: int = 1, + time_minutes: float | None = None, equal_chance: bool = False, random_gen: random.Random | None = None, -): - """Get a random subset of the provided `objs`. +) -> Iterable[T]: + """Get a random subset of items, optionally constrained by time or count. - If `number` is provided, produce that many matches. Otherwise, if - `time` is provided, instead select a list whose total time is close - to that number of minutes. If `equal_chance` is true, give each - artist an equal chance of being included so that artists with more - songs are not represented disproportionately. + Args: + - objs: The sequence of objects to choose from. + - number: The number of objects to select. + - time_minutes: If specified, the total length of selected objects + should not exceed this many minutes. + - equal_chance: If True, each artist has the same chance of being + selected, regardless of how many tracks they have. + - random_gen: An optional random generator to use for shuffling. """ - rand = random_gen or random + rand: random.Random = random_gen or random.Random() # Permute the objects either in a straightforward way or an # artist-balanced way. + perm: Iterable[T] if equal_chance: - perm = _equal_chance_permutation(objs) + perm = _equal_chance_permutation(objs, random_gen=rand) else: perm = list(objs) rand.shuffle(perm) # Select objects by time our count. - if time: - return _take_time(perm, time * 60) + if time_minutes: + return _take_time(perm, time_minutes * 60) else: - return _take(perm, number) + return islice(perm, number) diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index 5b71cd126..a7e57280a 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -15,7 +15,6 @@ """Test the beets.random utilities associated with the random plugin.""" import math -import unittest from random import Random import pytest @@ -24,16 +23,30 @@ from beets.test.helper import TestHelper from beetsplug import random -class RandomTest(TestHelper, unittest.TestCase): - def setUp(self): - self.lib = None +@pytest.fixture(scope="class") +def helper(): + helper = TestHelper() + helper.setup_beets() + + yield helper + + helper.teardown_beets() + + +class TestEqualChancePermutation: + """Test the _equal_chance_permutation function.""" + + @pytest.fixture(autouse=True) + def setup(self, helper): + """Set up the test environment with items.""" + self.lib = helper.lib self.artist1 = "Artist 1" self.artist2 = "Artist 2" - self.item1 = self.create_item(artist=self.artist1) - self.item2 = self.create_item(artist=self.artist2) + self.item1 = helper.create_item(artist=self.artist1) + self.item2 = helper.create_item(artist=self.artist2) self.items = [self.item1, self.item2] for _ in range(8): - self.items.append(self.create_item(artist=self.artist2)) + self.items.append(helper.create_item(artist=self.artist2)) self.random_gen = Random() self.random_gen.seed(12345) @@ -78,73 +91,94 @@ class RandomTest(TestHelper, unittest.TestCase): assert len(self.items) // 2 == pytest.approx(median2, abs=1) assert stdev2 > stdev1 - def test_equal_permutation_empty_input(self): + @pytest.mark.parametrize( + "input_items, field, expected", + [ + ([], "artist", []), + ([{"artist": "Artist 1"}], "artist", [{"artist": "Artist 1"}]), + # Missing field should not raise an error, but return empty + ([{"artist": "Artist 1"}], "nonexistent", []), + # Multiple items with the same field value + ( + [{"artist": "Artist 1"}, {"artist": "Artist 1"}], + "artist", + [{"artist": "Artist 1"}, {"artist": "Artist 1"}], + ), + ], + ) + def test_equal_permutation_items( + self, input_items, field, expected, helper + ): """Test _equal_chance_permutation with empty input.""" - result = list(random._equal_chance_permutation([], "artist")) - assert result == [] - - def test_equal_permutation_single_item(self): - """Test _equal_chance_permutation with single item.""" - result = list(random._equal_chance_permutation([self.item1], "artist")) - assert result == [self.item1] - - def test_equal_permutation_single_artist(self): - """Test _equal_chance_permutation with items from one artist.""" - items = [self.create_item(artist=self.artist1) for _ in range(5)] - result = list(random._equal_chance_permutation(items, "artist")) - assert set(result) == set(items) - assert len(result) == len(items) - - def test_random_objs_count(self): - """Test random_objs with count-based selection.""" - result = random.random_objs( - self.items, number=3, random_gen=self.random_gen + result = list( + random._equal_chance_permutation( + [helper.create_item(**i) for i in input_items], field + ) ) - assert len(result) == 3 - assert all(item in self.items for item in result) - def test_random_objs_time(self): - """Test random_objs with time-based selection.""" - # Total length is 30 + 60 + 8*45 = 450 seconds - # Requesting 120 seconds should return 2-3 items - result = random.random_objs( - self.items, - time=2, - random_gen=self.random_gen, # 2 minutes = 120 sec + for item in expected: + for key, value in item.items(): + assert any(getattr(r, key) == value for r in result) + assert len(result) == len(expected) + + +class TestRandomObjs: + """Test the random_objs function.""" + + @pytest.fixture(autouse=True) + def setup(self, helper): + """Set up the test environment with items.""" + self.lib = helper.lib + self.artist1 = "Artist 1" + self.artist2 = "Artist 2" + self.items = [ + helper.create_item(artist=self.artist1, length=180), # 3 minutes + helper.create_item(artist=self.artist2, length=240), # 4 minutes + helper.create_item(artist=self.artist2, length=300), # 5 minutes + ] + self.random_gen = random.Random() + + def test_random_selection_by_count(self): + """Test selecting a specific number of items.""" + selected = list(random.random_objs(self.items, number=2)) + assert len(selected) == 2 + assert all(item in self.items for item in selected) + + def test_random_selection_by_time(self): + """Test selecting items constrained by total time (minutes).""" + selected = list( + random.random_objs(self.items, time_minutes=6) + ) # 6 minutes + total_time = ( + sum(item.length for item in selected) / 60 + ) # Convert to minutes + assert total_time <= 6 + + def test_equal_chance_permutation(self, helper): + """Test equal chance permutation ensures balanced artist selection.""" + # Add more items to make the test meaningful + for _ in range(5): + self.items.append( + helper.create_item(artist=self.artist1, length=180) + ) + + selected = list( + random.random_objs(self.items, number=10, equal_chance=True) ) - total_time = sum(item.length for item in result) - assert total_time <= 120 - # Check we got at least some items - assert len(result) > 0 + artist_counts = {} + for item in selected: + artist_counts[item.artist] = artist_counts.get(item.artist, 0) + 1 - def test_random_objs_equal_chance(self): - """Test random_objs with equal_chance=True.""" + # Ensure both artists are represented (not strictly equal due to randomness) + assert len(artist_counts) >= 2 - # With equal_chance, artist1 should appear more often in results - def experiment(): - """Run the random_objs function multiple times and collect results.""" - results = [] - for _ in range(5000): - result = random.random_objs( - [self.item1, self.item2], - number=1, - equal_chance=True, - random_gen=self.random_gen, - ) - results.append(result[0].artist) + def test_empty_input_list(self): + """Test behavior with an empty input list.""" + selected = list(random.random_objs([], number=1)) + assert len(selected) == 0 - # Return ratio - return results.count(self.artist1), results.count(self.artist2) - - count_artist1, count_artist2 = experiment() - assert 1 - count_artist1 / count_artist2 < 0.1 # 10% deviation - - def test_random_objs_empty_input(self): - """Test random_objs with empty input.""" - result = random.random_objs([], number=3) - assert result == [] - - def test_random_objs_zero_number(self): - """Test random_objs with number=0.""" - result = random.random_objs(self.items, number=0) - assert result == [] + def test_no_constraints_returns_all(self): + """Test that no constraints return all items in random order.""" + selected = list(random.random_objs(self.items, 3)) + assert len(selected) == len(self.items) + assert set(selected) == set(self.items) From 3dd6f5a25b82f6a13a83f582326f3bd9c365786a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 13 Aug 2025 12:27:12 +0200 Subject: [PATCH 247/274] Cached property for length & forgot sorted. --- beets/library/models.py | 2 +- beetsplug/random.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/beets/library/models.py b/beets/library/models.py index e259f4e96..2118fded6 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -616,7 +616,7 @@ class Album(LibModel): for item in self.items(): item.try_sync(write, move) - @property + @cached_property def length(self) -> float: """Return the total length of all items in this album in seconds.""" return sum(item.length for item in self.items()) diff --git a/beetsplug/random.py b/beetsplug/random.py index 933049d96..853e9b14a 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -5,7 +5,7 @@ from __future__ import annotations import random from itertools import groupby, islice from operator import attrgetter -from typing import TYPE_CHECKING, Any, Iterable, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, Iterable, Sequence, Union from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ @@ -13,9 +13,9 @@ from beets.ui import Subcommand, print_ if TYPE_CHECKING: import optparse - from beets.library import LibModel, Library + from beets.library import Album, Item, Library - T = TypeVar("T", bound=LibModel) + T = Union[Item, Album] def random_func(lib: Library, opts: optparse.Values, args: list[str]): @@ -87,7 +87,9 @@ def _equal_chance_permutation( except AttributeError: return NOT_FOUND_SENTINEL - groups: dict[Any, list[T]] = { + sorted(objs, key=get_attr) + + groups: dict[str | object, list[T]] = { NOT_FOUND_SENTINEL: [], } for k, values in groupby(objs, key=get_attr): From 2aa7575294c4924bed9c5ebeb7b31888b9e0f545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 19 Aug 2025 15:00:41 +0100 Subject: [PATCH 248/274] Replace random.Random with random module --- beetsplug/random.py | 14 ++++---------- test/plugins/test_random.py | 32 ++++++++++++++------------------ 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index 853e9b14a..b8778eb08 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -70,14 +70,11 @@ NOT_FOUND_SENTINEL = object() def _equal_chance_permutation( objs: Sequence[T], field: str = "albumartist", - random_gen: random.Random | None = None, ) -> Iterable[T]: """Generate (lazily) a permutation of the objects where every group with equal values for `field` have an equal chance of appearing in any given position. """ - rand: random.Random = random_gen or random.Random() - # Group the objects by artist so we can sample from them. key = attrgetter(field) @@ -95,12 +92,12 @@ def _equal_chance_permutation( for k, values in groupby(objs, key=get_attr): groups[k] = list(values) # shuffle in category - rand.shuffle(groups[k]) + random.shuffle(groups[k]) # Remove items without the field value. del groups[NOT_FOUND_SENTINEL] while groups: - group = rand.choice(list(groups.keys())) + group = random.choice(list(groups.keys())) yield groups[group].pop() if not groups[group]: del groups[group] @@ -127,7 +124,6 @@ def random_objs( number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, - random_gen: random.Random | None = None, ) -> Iterable[T]: """Get a random subset of items, optionally constrained by time or count. @@ -140,16 +136,14 @@ def random_objs( selected, regardless of how many tracks they have. - random_gen: An optional random generator to use for shuffling. """ - rand: random.Random = random_gen or random.Random() - # Permute the objects either in a straightforward way or an # artist-balanced way. perm: Iterable[T] if equal_chance: - perm = _equal_chance_permutation(objs, random_gen=rand) + perm = _equal_chance_permutation(objs) else: perm = list(objs) - rand.shuffle(perm) + random.shuffle(perm) # Select objects by time our count. if time_minutes: diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index a7e57280a..e061473d0 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -15,12 +15,12 @@ """Test the beets.random utilities associated with the random plugin.""" import math -from random import Random +import random import pytest from beets.test.helper import TestHelper -from beetsplug import random +from beetsplug.random import _equal_chance_permutation, random_objs @pytest.fixture(scope="class") @@ -33,6 +33,11 @@ def helper(): helper.teardown_beets() +@pytest.fixture(scope="module", autouse=True) +def seed_random(): + random.seed(12345) + + class TestEqualChancePermutation: """Test the _equal_chance_permutation function.""" @@ -47,8 +52,6 @@ class TestEqualChancePermutation: self.items = [self.item1, self.item2] for _ in range(8): self.items.append(helper.create_item(artist=self.artist2)) - self.random_gen = Random() - self.random_gen.seed(12345) def _stats(self, data): mean = sum(data) / len(data) @@ -74,9 +77,7 @@ class TestEqualChancePermutation: positions = [] for _ in range(500): shuffled = list( - random._equal_chance_permutation( - self.items, field=field, random_gen=self.random_gen - ) + _equal_chance_permutation(self.items, field=field) ) positions.append(shuffled.index(self.item1)) # Print a histogram (useful for debugging). @@ -111,7 +112,7 @@ class TestEqualChancePermutation: ): """Test _equal_chance_permutation with empty input.""" result = list( - random._equal_chance_permutation( + _equal_chance_permutation( [helper.create_item(**i) for i in input_items], field ) ) @@ -136,19 +137,16 @@ class TestRandomObjs: helper.create_item(artist=self.artist2, length=240), # 4 minutes helper.create_item(artist=self.artist2, length=300), # 5 minutes ] - self.random_gen = random.Random() def test_random_selection_by_count(self): """Test selecting a specific number of items.""" - selected = list(random.random_objs(self.items, number=2)) + selected = list(random_objs(self.items, number=2)) assert len(selected) == 2 assert all(item in self.items for item in selected) def test_random_selection_by_time(self): """Test selecting items constrained by total time (minutes).""" - selected = list( - random.random_objs(self.items, time_minutes=6) - ) # 6 minutes + selected = list(random_objs(self.items, time_minutes=6)) # 6 minutes total_time = ( sum(item.length for item in selected) / 60 ) # Convert to minutes @@ -162,9 +160,7 @@ class TestRandomObjs: helper.create_item(artist=self.artist1, length=180) ) - selected = list( - random.random_objs(self.items, number=10, equal_chance=True) - ) + selected = list(random_objs(self.items, number=10, equal_chance=True)) artist_counts = {} for item in selected: artist_counts[item.artist] = artist_counts.get(item.artist, 0) + 1 @@ -174,11 +170,11 @@ class TestRandomObjs: def test_empty_input_list(self): """Test behavior with an empty input list.""" - selected = list(random.random_objs([], number=1)) + selected = list(random_objs([], number=1)) assert len(selected) == 0 def test_no_constraints_returns_all(self): """Test that no constraints return all items in random order.""" - selected = list(random.random_objs(self.items, 3)) + selected = list(random_objs(self.items, 3)) assert len(selected) == len(self.items) assert set(selected) == set(self.items) From da9244d54d814c9eeda64ca8bb63746cd45964e5 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Fri, 22 Aug 2025 12:00:34 +0200 Subject: [PATCH 249/274] Added an option to define the field to use for equal chance sampling --- beetsplug/random.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index b8778eb08..42af2bb5d 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -29,6 +29,7 @@ def random_func(lib: Library, opts: optparse.Values, args: list[str]): number=opts.number, time_minutes=opts.time, equal_chance=opts.equal_chance, + equal_chance_field=opts.field, ): print_(format(obj)) @@ -55,6 +56,13 @@ random_cmd.parser.add_option( type="float", help="total length in minutes of objects to choose", ) +random_cmd.parser.add_option( + "-f", + "--field", + action="store", + type="string", + help="field to use for equal chance sampling (default: albumartist)", +) random_cmd.parser.add_all_common_options() random_cmd.func = random_func @@ -124,6 +132,7 @@ def random_objs( number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, + equal_chance_field: str = "albumartist", ) -> Iterable[T]: """Get a random subset of items, optionally constrained by time or count. @@ -140,7 +149,7 @@ def random_objs( # artist-balanced way. perm: Iterable[T] if equal_chance: - perm = _equal_chance_permutation(objs) + perm = _equal_chance_permutation(objs, field=equal_chance_field) else: perm = list(objs) random.shuffle(perm) From 5ed0a723106457073022aa48f6dc59beb56949de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 26 Aug 2025 12:24:33 +0100 Subject: [PATCH 250/274] Add annotation for LibModel.length property --- beets/library/models.py | 3 ++- beetsplug/random.py | 27 ++++++++++++--------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/beets/library/models.py b/beets/library/models.py index 2118fded6..f710d2b5d 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -40,6 +40,7 @@ class LibModel(dbcore.Model["Library"]): # Config key that specifies how an instance should be formatted. _format_config_key: str path: bytes + length: float @cached_classproperty def _types(cls) -> dict[str, types.Type]: @@ -617,7 +618,7 @@ class Album(LibModel): item.try_sync(write, move) @cached_property - def length(self) -> float: + def length(self) -> float: # type: ignore[override] # still writable since we override __setattr__ """Return the total length of all items in this album in seconds.""" return sum(item.length for item in self.items()) diff --git a/beetsplug/random.py b/beetsplug/random.py index 42af2bb5d..ba687f477 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -5,7 +5,7 @@ from __future__ import annotations import random from itertools import groupby, islice from operator import attrgetter -from typing import TYPE_CHECKING, Any, Iterable, Sequence, Union +from typing import TYPE_CHECKING, Any, Iterable from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ @@ -13,9 +13,7 @@ from beets.ui import Subcommand, print_ if TYPE_CHECKING: import optparse - from beets.library import Album, Item, Library - - T = Union[Item, Album] + from beets.library import LibModel, Library def random_func(lib: Library, opts: optparse.Values, args: list[str]): @@ -25,7 +23,7 @@ def random_func(lib: Library, opts: optparse.Values, args: list[str]): # Print a random subset. for obj in random_objs( - objs=list(objs), + objs=objs, number=opts.number, time_minutes=opts.time, equal_chance=opts.equal_chance, @@ -76,9 +74,8 @@ NOT_FOUND_SENTINEL = object() def _equal_chance_permutation( - objs: Sequence[T], - field: str = "albumartist", -) -> Iterable[T]: + objs: Iterable[LibModel], field: str = "albumartist" +) -> Iterable[LibModel]: """Generate (lazily) a permutation of the objects where every group with equal values for `field` have an equal chance of appearing in any given position. @@ -86,7 +83,7 @@ def _equal_chance_permutation( # Group the objects by artist so we can sample from them. key = attrgetter(field) - def get_attr(obj: T) -> Any: + def get_attr(obj: LibModel) -> Any: try: return key(obj) except AttributeError: @@ -94,7 +91,7 @@ def _equal_chance_permutation( sorted(objs, key=get_attr) - groups: dict[str | object, list[T]] = { + groups: dict[str | object, list[LibModel]] = { NOT_FOUND_SENTINEL: [], } for k, values in groupby(objs, key=get_attr): @@ -112,9 +109,9 @@ def _equal_chance_permutation( def _take_time( - iter: Iterable[T], + iter: Iterable[LibModel], secs: float, -) -> Iterable[T]: +) -> Iterable[LibModel]: """Return a list containing the first values in `iter`, which should be Item or Album objects, that add up to the given amount of time in seconds. @@ -128,12 +125,12 @@ def _take_time( def random_objs( - objs: Sequence[T], + objs: Iterable[LibModel], number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, equal_chance_field: str = "albumartist", -) -> Iterable[T]: +) -> Iterable[LibModel]: """Get a random subset of items, optionally constrained by time or count. Args: @@ -147,7 +144,7 @@ def random_objs( """ # Permute the objects either in a straightforward way or an # artist-balanced way. - perm: Iterable[T] + perm: Iterable[LibModel] if equal_chance: perm = _equal_chance_permutation(objs, field=equal_chance_field) else: From 6c52252672862abb7bc80b141560aecc858da388 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 7 Jan 2026 15:57:48 +0100 Subject: [PATCH 251/274] Readded licence. Removed last legacy occurrences of `artist` and replaced them with `field`. Removed unnecessary default parameters where applicable. --- beetsplug/random.py | 37 +++++++++++++++++++++++++------------ test/plugins/test_random.py | 14 +++++++++----- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index ba687f477..9714c8e53 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -1,17 +1,31 @@ -"""Get a random song or album from the library.""" +# This file is part of beets. +# Copyright 2016, Philippe Mongeau. +# Copyright 2025, Sebastian Mohr. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. from __future__ import annotations import random from itertools import groupby, islice from operator import attrgetter -from typing import TYPE_CHECKING, Any, Iterable +from typing import TYPE_CHECKING, Any from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ if TYPE_CHECKING: import optparse + from collections.abc import Iterable from beets.library import LibModel, Library @@ -24,10 +38,10 @@ def random_func(lib: Library, opts: optparse.Values, args: list[str]): # Print a random subset. for obj in random_objs( objs=objs, + equal_chance_field=opts.field, number=opts.number, time_minutes=opts.time, equal_chance=opts.equal_chance, - equal_chance_field=opts.field, ): print_(format(obj)) @@ -45,7 +59,7 @@ random_cmd.parser.add_option( "-e", "--equal-chance", action="store_true", - help="each artist has the same chance", + help="each field has the same chance", ) random_cmd.parser.add_option( "-t", @@ -55,10 +69,10 @@ random_cmd.parser.add_option( help="total length in minutes of objects to choose", ) random_cmd.parser.add_option( - "-f", "--field", action="store", type="string", + default="albumartist", help="field to use for equal chance sampling (default: albumartist)", ) random_cmd.parser.add_all_common_options() @@ -74,13 +88,13 @@ NOT_FOUND_SENTINEL = object() def _equal_chance_permutation( - objs: Iterable[LibModel], field: str = "albumartist" + objs: Iterable[LibModel], field: str ) -> Iterable[LibModel]: """Generate (lazily) a permutation of the objects where every group with equal values for `field` have an equal chance of appearing in any given position. """ - # Group the objects by artist so we can sample from them. + # Group the objects by field so we can sample from them. key = attrgetter(field) def get_attr(obj: LibModel) -> Any: @@ -126,10 +140,10 @@ def _take_time( def random_objs( objs: Iterable[LibModel], + equal_chance_field: str, number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, - equal_chance_field: str = "albumartist", ) -> Iterable[LibModel]: """Get a random subset of items, optionally constrained by time or count. @@ -138,15 +152,14 @@ def random_objs( - number: The number of objects to select. - time_minutes: If specified, the total length of selected objects should not exceed this many minutes. - - equal_chance: If True, each artist has the same chance of being + - equal_chance: If True, each field has the same chance of being selected, regardless of how many tracks they have. - random_gen: An optional random generator to use for shuffling. """ # Permute the objects either in a straightforward way or an - # artist-balanced way. - perm: Iterable[LibModel] + # field-balanced way. if equal_chance: - perm = _equal_chance_permutation(objs, field=equal_chance_field) + perm = _equal_chance_permutation(objs, equal_chance_field) else: perm = list(objs) random.shuffle(perm) diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index e061473d0..975bb8ffa 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -140,13 +140,15 @@ class TestRandomObjs: def test_random_selection_by_count(self): """Test selecting a specific number of items.""" - selected = list(random_objs(self.items, number=2)) + selected = list(random_objs(self.items, "artist", number=2)) assert len(selected) == 2 assert all(item in self.items for item in selected) def test_random_selection_by_time(self): """Test selecting items constrained by total time (minutes).""" - selected = list(random_objs(self.items, time_minutes=6)) # 6 minutes + selected = list( + random_objs(self.items, "artist", time_minutes=6) + ) # 6 minutes total_time = ( sum(item.length for item in selected) / 60 ) # Convert to minutes @@ -160,7 +162,9 @@ class TestRandomObjs: helper.create_item(artist=self.artist1, length=180) ) - selected = list(random_objs(self.items, number=10, equal_chance=True)) + selected = list( + random_objs(self.items, "artist", number=10, equal_chance=True) + ) artist_counts = {} for item in selected: artist_counts[item.artist] = artist_counts.get(item.artist, 0) + 1 @@ -170,11 +174,11 @@ class TestRandomObjs: def test_empty_input_list(self): """Test behavior with an empty input list.""" - selected = list(random_objs([], number=1)) + selected = list(random_objs([], "artist", number=1)) assert len(selected) == 0 def test_no_constraints_returns_all(self): """Test that no constraints return all items in random order.""" - selected = list(random_objs(self.items, 3)) + selected = list(random_objs(self.items, "artist", number=3)) assert len(selected) == len(self.items) assert set(selected) == set(self.items) From ee7dc3c4e7e2334ff89fe5f68fa818bcb6c6d8f8 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 7 Jan 2026 16:08:36 +0100 Subject: [PATCH 252/274] Enhanced documentation of random plugin. --- docs/changelog.rst | 2 + docs/plugins/random.rst | 123 ++++++++++++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6ccdd6060..aec3f143f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,6 +52,8 @@ New features: untouched the files without. - :doc:`plugins/fish`: Filenames are now completed in more places, like after ``beet import``. +- :doc:`plugins/random`: Added ``--field`` option to specify which field to use + for equal-chance sampling (default: ``albumartist``). Bug fixes: diff --git a/docs/plugins/random.rst b/docs/plugins/random.rst index ca227c4b8..b3f15da4c 100644 --- a/docs/plugins/random.rst +++ b/docs/plugins/random.rst @@ -8,24 +8,115 @@ listen to. First, enable the plugin named ``random`` (see :ref:`using-plugins`). You'll then be able to use the ``beet random`` command: -:: +.. code-block:: shell - $ beet random - Aesop Rock - None Shall Pass - The Harbor Is Yours + beet random + >> Aesop Rock - None Shall Pass - The Harbor Is Yours -The command has several options that resemble those for the ``beet list`` -command (see :doc:`/reference/cli`). To choose an album instead of a single -track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and -to use a custom format for printing, use ``-f FORMAT``. +Usage +----- -If the ``-e`` option is passed, the random choice will be even among artists -(the albumartist field). This makes sure that your anthology of Bob Dylan won't -make you listen to Bob Dylan 50% of the time. +The basic command selects and displays a single random track. Several options +allow you to customize the selection: -The ``-n NUMBER`` option controls the number of objects that are selected and -printed (default 1). To select 5 tracks from your library, type ``beet random --n5``. +.. code-block:: shell -As an alternative, you can use ``-t MINUTES`` to choose a set of music with a -given play time. To select tracks that total one hour, for example, type ``beet -random -t60``. + Usage: beet random [options] + + Options: + -h, --help show this help message and exit + -n NUMBER, --number=NUMBER + number of objects to choose + -e, --equal-chance each field has the same chance + -t TIME, --time=TIME total length in minutes of objects to choose + --field=FIELD field to use for equal chance sampling (default: + albumartist) + -a, --album match albums instead of tracks + -p PATH, --path=PATH print paths for matched items or albums + -f FORMAT, --format=FORMAT + print with custom format + +Detailed Options +---------------- + +``-n, --number=NUMBER`` + Select multiple items at once. The default is 1. + +``-e, --equal-chance`` + Give each distinct value of a field an equal chance of being selected. This + prevents artists with many albums/tracks from dominating the selection. + + **Implementation note:** When this option is used, the plugin: + + 1. Groups items by the specified field + 2. Shuffles items within each group + 3. Randomly selects groups, then items from those groups + 4. Continues until all groups are exhausted + + Items without the specified field (``--field``) value are excluded from the + selection. + +``--field=FIELD`` + Specify which field to use for equal chance sampling. Default is + ``albumartist``. + +``-t, --time=TIME`` + Select items whose total duration (in minutes) is approximately equal to + TIME. The plugin will continue adding items until the total exceeds the + requested time. + +``-a, --album`` + Operate on albums instead of tracks. + +``-p, --path`` + Output filesystem paths instead of formatted metadata. + +``-f, --format=FORMAT`` + Use a custom format string for output. See :doc:`/reference/query` for + format syntax. + +Examples +-------- + +Select multiple items: + +.. code-block:: shell + + # Select 5 random tracks + beet random -n 5 + + # Select 3 random albums + beet random -a -n 3 + +Control selection fairness: + +.. code-block:: shell + + # Ensure equal chance per artist (default field: albumartist) + beet random -e + + # Ensure equal chance per genre + beet random -e --field genre + +Select by total playtime: + +.. code-block:: shell + + # Select tracks totaling 60 minutes (1 hour) + beet random -t 60 + + # Select albums totaling 120 minutes (2 hours) + beet random -a -t 120 + +Custom output formats: + +.. code-block:: shell + + # Print only artist and title + beet random -f '$artist - $title' + + # Print file paths + beet random -p + + # Print album paths + beet random -a -p From 95cef2de2b97ec0949ebfc5b415b3d49d5b16112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 30 Jan 2026 00:18:02 +0000 Subject: [PATCH 253/274] Fix grouping for list fields and stabilize equal-chance order - Handle list-valued fields when grouping for --field/--equal-chance to avoid "TypeError: unhashable type: 'list'" (e.g., artists). - Sort items by the grouping key before building groups so equal-chance permutation preserves the same item set as `beet list`, only randomized. --- beetsplug/random.py | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index 9714c8e53..aa65dd5d5 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -17,8 +17,8 @@ from __future__ import annotations import random from itertools import groupby, islice -from operator import attrgetter -from typing import TYPE_CHECKING, Any +from operator import methodcaller +from typing import TYPE_CHECKING from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ @@ -84,9 +84,6 @@ class Random(BeetsPlugin): return [random_cmd] -NOT_FOUND_SENTINEL = object() - - def _equal_chance_permutation( objs: Iterable[LibModel], field: str ) -> Iterable[LibModel]: @@ -95,26 +92,16 @@ def _equal_chance_permutation( any given position. """ # Group the objects by field so we can sample from them. - key = attrgetter(field) + get_attr = methodcaller("get", field) - def get_attr(obj: LibModel) -> Any: - try: - return key(obj) - except AttributeError: - return NOT_FOUND_SENTINEL + groups = {} + for k, values in groupby(sorted(objs, key=get_attr), key=get_attr): + if k is not None: + vals = list(values) + # shuffle in category + random.shuffle(vals) + groups[str(k)] = vals - sorted(objs, key=get_attr) - - groups: dict[str | object, list[LibModel]] = { - NOT_FOUND_SENTINEL: [], - } - for k, values in groupby(objs, key=get_attr): - groups[k] = list(values) - # shuffle in category - random.shuffle(groups[k]) - - # Remove items without the field value. - del groups[NOT_FOUND_SENTINEL] while groups: group = random.choice(list(groups.keys())) yield groups[group].pop() From 78b6d537b69fe9a0e74751a7326ec25da2206606 Mon Sep 17 00:00:00 2001 From: "Kirill A. Korinsky" Date: Sun, 25 Jan 2026 20:41:16 +0100 Subject: [PATCH 254/274] Retries with 1, 2, 4, 8, 16, 32s backoff At least it allows me to more or less use MusicBrainz --- beetsplug/_utils/requests.py | 5 ++--- test/plugins/utils/test_request_handler.py | 25 ++++++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py index 92d52c9d6..61a9992be 100644 --- a/beetsplug/_utils/requests.py +++ b/beetsplug/_utils/requests.py @@ -76,9 +76,8 @@ class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta): self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/" retry = Retry( - connect=2, - total=2, - backoff_factor=1, + total=6, + backoff_factor=0.5, # Retry on server errors status_forcelist=[ HTTPStatus.INTERNAL_SERVER_ERROR, diff --git a/test/plugins/utils/test_request_handler.py b/test/plugins/utils/test_request_handler.py index 6887283dc..b36d6a8d9 100644 --- a/test/plugins/utils/test_request_handler.py +++ b/test/plugins/utils/test_request_handler.py @@ -14,15 +14,26 @@ from beetsplug._utils.requests import RequestHandler class TestRequestHandlerRetry: @pytest.fixture(autouse=True) def patch_connection(self, monkeypatch, last_response): + def make_response(): + if isinstance(last_response, HTTPResponse): + body = last_response.data + return HTTPResponse( + body=io.BytesIO(body), + status=last_response.status, + preload_content=False, + headers=last_response.headers, + ) + return last_response + + def responses(): + yield NewConnectionError(None, "Connection failed") + yield URLError("bad") + while True: + yield make_response() + monkeypatch.setattr( "urllib3.connectionpool.HTTPConnectionPool._make_request", - Mock( - side_effect=[ - NewConnectionError(None, "Connection failed"), - URLError("bad"), - last_response, - ] - ), + Mock(side_effect=responses()), ) @pytest.fixture From 47b16441107073bff583ccbab467c330ac42f182 Mon Sep 17 00:00:00 2001 From: "Kirill A. Korinsky" Date: Wed, 28 Jan 2026 02:42:25 +0100 Subject: [PATCH 255/274] Attemt to rework tests --- test/plugins/utils/test_request_handler.py | 71 +++++++++++++++++++++- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/test/plugins/utils/test_request_handler.py b/test/plugins/utils/test_request_handler.py index b36d6a8d9..b9180f2df 100644 --- a/test/plugins/utils/test_request_handler.py +++ b/test/plugins/utils/test_request_handler.py @@ -13,7 +13,13 @@ from beetsplug._utils.requests import RequestHandler class TestRequestHandlerRetry: @pytest.fixture(autouse=True) - def patch_connection(self, monkeypatch, last_response): + def patch_connection(self, monkeypatch, request): + callspec = getattr(request.node, "callspec", None) + if callspec is None or "last_response" not in callspec.params: + return + + last_response = callspec.params["last_response"] + def make_response(): if isinstance(last_response, HTTPResponse): body = last_response.data @@ -51,7 +57,7 @@ class TestRequestHandlerRetry: ], ids=["success"], ) - def test_retry_on_connection_error(self, request_handler): + def test_retry_on_connection_error(self, request_handler, last_response): """Verify that the handler retries on connection errors.""" response = request_handler.get("http://example.com/api") @@ -70,9 +76,68 @@ class TestRequestHandlerRetry: ], ids=["conn_error", "server_error"], ) - def test_retry_exhaustion(self, request_handler): + def test_retry_exhaustion(self, request_handler, last_response): """Verify that the handler raises an error after exhausting retries.""" with pytest.raises( requests.exceptions.RequestException, match="Max retries exceeded" ): request_handler.get("http://example.com/api") + + def test_retry_config(self, request_handler): + """Verify that the retry adapter is configured with expected settings.""" + adapter = request_handler.session.get_adapter("http://") + retry = adapter.max_retries + + assert retry.total == 6 + assert retry.backoff_factor == 0.5 + assert set(retry.status_forcelist) == { + HTTPStatus.INTERNAL_SERVER_ERROR, + HTTPStatus.BAD_GATEWAY, + HTTPStatus.SERVICE_UNAVAILABLE, + HTTPStatus.GATEWAY_TIMEOUT, + } + + def test_backoff_schedule_doubles(self, request_handler): + """Verify exponential backoff schedule for consecutive errors.""" + retry = request_handler.session.get_adapter("http://").max_retries + retry = retry.new(total=None) + + backoffs = [] + for _ in range(7): + retry = retry.increment( + error=NewConnectionError(None, "Connection failed") + ) + backoff = retry.get_backoff_time() + if backoff: + backoffs.append(backoff) + + assert backoffs == [2**i for i in range(6)] + + @pytest.mark.parametrize( + "last_response", + [ + HTTPResponse( + body=io.BytesIO(b"Server Error"), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + preload_content=False, + ), + ], + ids=["server_error"], + ) + def test_retry_backoff_sleep_calls( + self, request_handler, monkeypatch, last_response + ): + """Verify backoff sleep calls without real waiting.""" + sleep_calls = [] + + def fake_sleep(duration): + sleep_calls.append(duration) + + monkeypatch.setattr("urllib3.util.retry.time.sleep", fake_sleep) + + with pytest.raises( + requests.exceptions.RequestException, match="Max retries exceeded" + ): + request_handler.get("http://example.com/api") + + assert sleep_calls == [2**i for i in range(5)] From 48c954edf646a0b5b68f27d335e07702708fdcca Mon Sep 17 00:00:00 2001 From: "Kirill A. Korinsky" Date: Fri, 30 Jan 2026 00:50:36 +0100 Subject: [PATCH 256/274] Nuked tests --- test/plugins/utils/test_request_handler.py | 143 --------------------- 1 file changed, 143 deletions(-) delete mode 100644 test/plugins/utils/test_request_handler.py diff --git a/test/plugins/utils/test_request_handler.py b/test/plugins/utils/test_request_handler.py deleted file mode 100644 index b9180f2df..000000000 --- a/test/plugins/utils/test_request_handler.py +++ /dev/null @@ -1,143 +0,0 @@ -import io -from http import HTTPStatus -from unittest.mock import Mock -from urllib.error import URLError - -import pytest -import requests -from urllib3 import HTTPResponse -from urllib3.exceptions import NewConnectionError - -from beetsplug._utils.requests import RequestHandler - - -class TestRequestHandlerRetry: - @pytest.fixture(autouse=True) - def patch_connection(self, monkeypatch, request): - callspec = getattr(request.node, "callspec", None) - if callspec is None or "last_response" not in callspec.params: - return - - last_response = callspec.params["last_response"] - - def make_response(): - if isinstance(last_response, HTTPResponse): - body = last_response.data - return HTTPResponse( - body=io.BytesIO(body), - status=last_response.status, - preload_content=False, - headers=last_response.headers, - ) - return last_response - - def responses(): - yield NewConnectionError(None, "Connection failed") - yield URLError("bad") - while True: - yield make_response() - - monkeypatch.setattr( - "urllib3.connectionpool.HTTPConnectionPool._make_request", - Mock(side_effect=responses()), - ) - - @pytest.fixture - def request_handler(self): - return RequestHandler() - - @pytest.mark.parametrize( - "last_response", - [ - HTTPResponse( - body=io.BytesIO(b"success"), - status=HTTPStatus.OK, - preload_content=False, - ), - ], - ids=["success"], - ) - def test_retry_on_connection_error(self, request_handler, last_response): - """Verify that the handler retries on connection errors.""" - response = request_handler.get("http://example.com/api") - - assert response.text == "success" - assert response.status_code == HTTPStatus.OK - - @pytest.mark.parametrize( - "last_response", - [ - ConnectionResetError, - HTTPResponse( - body=io.BytesIO(b"Server Error"), - status=HTTPStatus.INTERNAL_SERVER_ERROR, - preload_content=False, - ), - ], - ids=["conn_error", "server_error"], - ) - def test_retry_exhaustion(self, request_handler, last_response): - """Verify that the handler raises an error after exhausting retries.""" - with pytest.raises( - requests.exceptions.RequestException, match="Max retries exceeded" - ): - request_handler.get("http://example.com/api") - - def test_retry_config(self, request_handler): - """Verify that the retry adapter is configured with expected settings.""" - adapter = request_handler.session.get_adapter("http://") - retry = adapter.max_retries - - assert retry.total == 6 - assert retry.backoff_factor == 0.5 - assert set(retry.status_forcelist) == { - HTTPStatus.INTERNAL_SERVER_ERROR, - HTTPStatus.BAD_GATEWAY, - HTTPStatus.SERVICE_UNAVAILABLE, - HTTPStatus.GATEWAY_TIMEOUT, - } - - def test_backoff_schedule_doubles(self, request_handler): - """Verify exponential backoff schedule for consecutive errors.""" - retry = request_handler.session.get_adapter("http://").max_retries - retry = retry.new(total=None) - - backoffs = [] - for _ in range(7): - retry = retry.increment( - error=NewConnectionError(None, "Connection failed") - ) - backoff = retry.get_backoff_time() - if backoff: - backoffs.append(backoff) - - assert backoffs == [2**i for i in range(6)] - - @pytest.mark.parametrize( - "last_response", - [ - HTTPResponse( - body=io.BytesIO(b"Server Error"), - status=HTTPStatus.INTERNAL_SERVER_ERROR, - preload_content=False, - ), - ], - ids=["server_error"], - ) - def test_retry_backoff_sleep_calls( - self, request_handler, monkeypatch, last_response - ): - """Verify backoff sleep calls without real waiting.""" - sleep_calls = [] - - def fake_sleep(duration): - sleep_calls.append(duration) - - monkeypatch.setattr("urllib3.util.retry.time.sleep", fake_sleep) - - with pytest.raises( - requests.exceptions.RequestException, match="Max retries exceeded" - ): - request_handler.get("http://example.com/api") - - assert sleep_calls == [2**i for i in range(5)] From e768f978b6d3d167605a7902ea2002ef1caa3d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 30 Jan 2026 00:55:13 +0000 Subject: [PATCH 257/274] Simplify creating indices --- beets/dbcore/db.py | 48 +++++------------------------ test/test_dbcore.py | 74 +++++---------------------------------------- 2 files changed, 16 insertions(+), 106 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 843bfeaff..0920065cc 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -1071,7 +1071,7 @@ class Database: for model_cls in self._models: self._make_table(model_cls._table, model_cls._fields) self._make_attribute_table(model_cls._flex_table) - self._migrate_indices(model_cls._table, model_cls._indices) + self._create_indices(model_cls._table, model_cls._indices) # Primitive access control: connections and transactions. @@ -1249,24 +1249,18 @@ class Database: ON {flex_table} (entity_id); """) - def _migrate_indices( + def _create_indices( self, table: str, indices: Sequence[Index], ): - """Create or replace indices for the given table. - - If the indices already exists and are up to date (i.e., the - index name and columns match), nothing is done. Otherwise, the - indices are created or replaced. - """ + """Create indices for the given table if they don't exist.""" with self.transaction() as tx: - current = { - Index.from_db(tx, r[1]) - for r in tx.query(f"PRAGMA index_list({table})") - } - for index in set(indices) - current: - index.recreate(tx, table) + for index in indices: + tx.script( + f"CREATE INDEX IF NOT EXISTS {index.name} " + f"ON {table} ({', '.join(index.columns)});" + ) # Querying. @@ -1346,29 +1340,3 @@ class Index(NamedTuple): name: str columns: tuple[str, ...] - - def __hash__(self) -> int: - """Unique hash for the index based on its name and columns.""" - return hash((self.name, *self.columns)) - - def recreate(self, tx: Transaction, table: str) -> None: - """Recreate the index in the database. - - This is useful when the index has been changed and needs to be - updated. - """ - tx.script(f""" - DROP INDEX IF EXISTS {self.name}; - CREATE INDEX {self.name} ON {table} ({", ".join(self.columns)}) - """) - - @classmethod - def from_db(cls, tx: Transaction, name: str) -> Index: - """Create an Index object from the database if it exists. - - The name has to exists in the database! Otherwise, an - Error will be raised. - """ - rows = tx.query(f"PRAGMA index_info({name})") - columns = tuple(row[2] for row in rows) - return cls(name, columns) diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 06aceaec0..88d94f914 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -240,6 +240,14 @@ class MigrationTest(unittest.TestCase): except sqlite3.OperationalError: self.fail("select failed") + def test_index_creation(self): + """Test that declared indices are created on database initialization.""" + db = DatabaseFixture1(":memory:") + with db.transaction() as tx: + rows = tx.query("PRAGMA index_info(field_one_index)") + assert len(rows) > 0 # Index exists + db._connection().close() + class TransactionTest(unittest.TestCase): def setUp(self): @@ -810,69 +818,3 @@ class TestException: with pytest.raises(DBCustomFunctionError): with db.transaction() as tx: tx.query("select * from test where plz_raise()") - - -class TestIndex: - @pytest.fixture(autouse=True) - def db(self): - """Set up an in-memory SQLite database.""" - db = DatabaseFixture1(":memory:") - yield db - db._connection().close() - - @pytest.fixture - def sample_index(self): - """Fixture for a sample Index object.""" - return Index(name="sample_index", columns=("field_one",)) - - def test_from_db(self, db, sample_index: Index): - """Test retrieving an index from the database.""" - with db.transaction() as tx: - sample_index.recreate(tx, "test") - retrieved = Index.from_db(tx, sample_index.name) - assert retrieved == sample_index - - @pytest.mark.parametrize( - "index1, index2, equality", - [ - ( - # Same - Index(name="sample_index", columns=("field_one",)), - Index(name="sample_index", columns=("field_one",)), - True, - ), - ( - # Multiple columns - Index(name="sample_index", columns=("f1", "f2")), - Index(name="sample_index", columns=("f1", "f2")), - True, - ), - ( - # Difference in name - Index(name="sample_indey", columns=("field_one",)), - Index(name="sample_index", columns=("field_one",)), - False, - ), - ( - # Difference in columns - Index(name="sample_indey", columns=("field_one",)), - Index(name="sample_index", columns=("field_two",)), - False, - ), - ( - # Difference in num columns - Index(name="sample_index", columns=("f1",)), - Index(name="sample_index", columns=("f1", "f2")), - False, - ), - ], - ) - def test_index_equality(self, index1: Index, index2: Index, equality: bool): - """Test the hashing and set behavior of the Index class.""" - - # Simple equality - assert (index1 == index2) == equality - - # Should be unique or not - index_set = {index1, index2} - assert len(index_set) == (1 if equality else 2) From a17857213b14347517a263f6b0c68ef88c064e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 30 Jan 2026 01:06:31 +0000 Subject: [PATCH 258/274] Fix lints --- beets/dbcore/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/dbcore/__init__.py b/beets/dbcore/__init__.py index 0b5e700cb..441d5f446 100644 --- a/beets/dbcore/__init__.py +++ b/beets/dbcore/__init__.py @@ -36,6 +36,7 @@ __all__ = [ "AndQuery", "Database", "FieldQuery", + "Index", "InvalidQueryError", "MatchQuery", "Model", @@ -43,7 +44,6 @@ __all__ = [ "Query", "Results", "Type", - "Index", "parse_sorted_query", "query_from_strings", "sort_from_strings", From cde73cc433f077a240e395d05b1d8e243cc186d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 30 Jan 2026 01:18:11 +0000 Subject: [PATCH 259/274] Add changelog note --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index aec3f143f..50b021bf8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -149,6 +149,8 @@ Other changes: Music service was shut down in 2020. - Updated color documentation with ``bright_*`` and ``bg_bright_*`` entries. - Moved `beets/random.py` into `beetsplug/random.py` to cleanup core module. +- dbcore: Allow models to declare SQL indices; add an ``items.album_id`` index + to speed up ``album.items()`` queries. :bug:`5809` 2.5.1 (October 14, 2025) ------------------------ From 4d7b9cb14bca39bd6166e2548336f0563fd72faf Mon Sep 17 00:00:00 2001 From: Arne Beer Date: Mon, 19 Jan 2026 02:12:38 +0100 Subject: [PATCH 260/274] fix(lastgenre): Canonicalize keep_existing fallback Fixes a bug where existing tags were set to None, if they weren't whitelisted, but an whitelisted canonicalized parent existed up the tree. In all other cases, the original genres are canonicalized and considered for the final genre, except in the keep_existing logic branch. This PR fixes the issue and results in the expected behavior for this combination of options. For the bug to trigger several conditions had to be met: - Canonicalization is enabled and a whitelist is specified. - `force` and `keep_existing` are set. Meaning, that Lastfm is queried for a genre, but the existing genres are still left around when none are found online. - A release with a non-whitelisted genre exists, but that genre has a whitelisted genre parent up the tree. - That very release has no genre on lastfm. This is rather convoluted, but stay with me :D What would happen is the following: - `keep_genres` is set to the existing genres, as `force` and `keep_existing` is set. - Genres for `track`/`album`/`artist` aren't found for this release, as they don't exist in lastfm. - Then the `keep_existing` logic is entered. - The old logic only checks if the existing genres have an **exact** match for the whitelist. In contrast to all other code branches, we don't do the `_try_resolve_stage` in case there's no direct match, resulting in no match. - We continue to the fallback logic, which returns the fallback (`None` in my case) This patch results in one last try to resolve the existing genres when `keep_existing` is set, which includes canonicalization (if enabled). --- beetsplug/lastgenre/__init__.py | 7 +++++++ docs/changelog.rst | 3 +++ test/plugins/test_lastgenre.py | 25 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 121d76596..1c91688a6 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -482,6 +482,13 @@ class LastGenrePlugin(plugins.BeetsPlugin): if obj.genre and self.config["keep_existing"]: if not self.whitelist or self._is_valid(obj.genre.lower()): return obj.genre, "original fallback" + else: + # If the original genre doesn't match a whitelisted genre, check + # if we can canonicalize it to find a matching, whitelisted genre! + if result := _try_resolve_stage( + "original fallback", keep_genres, [] + ): + return result # Return fallback string. if fallback := self.config["fallback"].get(): diff --git a/docs/changelog.rst b/docs/changelog.rst index 50b021bf8..df886700b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,6 +57,9 @@ New features: Bug fixes: +- :doc:`/plugins/lastgenre`: Canonicalize genres when ``force`` and + ``keep_existing`` are ``on``, yet no genre info on lastfm could be found. + :bug:`6303` - Handle potential OSError when unlinking temporary files in ArtResizer. :bug:`5615` - :doc:`/plugins/spotify`: Updated Spotify API credentials. :bug:`6270` diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 026001e38..3de43d197 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -541,6 +541,31 @@ class LastGenrePluginTest(PluginTestCase): "keep + album, whitelist", ), ), + # 16 - canonicalization transforms non-whitelisted original genres to canonical + # forms and deduplication works, **even** when no new genres are found online. + # + # "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the + # tree to "Disco" and "Electronic". + ( + { + "force": True, + "keep_existing": True, + "source": "album", + "whitelist": True, + "canonical": True, + "prefer_specific": False, + "count": 10, + }, + "Cosmic Disco", + { + "album": [], + "artist": [], + }, + ( + "Disco, Electronic", + "keep + original fallback, whitelist", + ), + ), ], ) def test_get_genre(config_values, item_genre, mock_genres, expected_result): From 5b48701ba196dd7b51a3bf081f43944b3d59a216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 22 Jan 2026 01:55:40 +0000 Subject: [PATCH 261/274] Add missing commits to git blame ignore revs --- .git-blame-ignore-revs | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 4137fe11e..bc6547536 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -27,6 +27,14 @@ af102c3e2f1c7a49e99839e2825906fe01780eec 910354a6c617ed5aa643cff666205b43e1557373 # pyupgrade beetsplug and tests 1ec87a3bdd737abe46c6e614051bf9e314db4619 +# Updates docstrings in library.py. +8c5ced3ee11a353546034189736c6001115135a4 +# Fixes inconsistencies in ending quote placements for single-line docstrings. +bbd32639b4c469fe3d6668f1e3bb17d8ba7a70ce +# Fixes linting errors by removing trailing whitespaces. +acf576c455e59e8197359d4517f8c0a5a9f362bb +# Alters docstrings in library.py to be imperative-style. +2f42c8b1c019a90448d33d940b609c18ba644cbc # 2022 # Reformat flake8 config comments @@ -37,12 +45,50 @@ abc3dfbf429b179fac25bd1dff72d577cd4d04c7 a6e5201ff3fad4c69bf24d17bace2ef744b9f51b # 2024 +# Replace assertTrue +0ecc345143cf89fabe74bb2e95eedfa1114857a3 +# Replace assertFalse +cb82917fe0d5476c74bb946f91ea0d9a9f019c9b +# Replace assertIsNone +5d4911e905d3a89793332eb851035e6529c0725e +# Replace assertIsNotNone +2616bcc950e592745713f28db0192293410ed3e3 +# Replace assertIn +11e948121cde969f9ea27caa545a6508145572fb +# Replace assertNotIn +6631b6aef6da3e09d3531de6df7995dd5396398f +# Replace assertEqual +9a05d27acfef3788d10dd0a8db72a6c8c15dfbe9 +# Replace assertNotEqual +f9359df0d15ea8ee8e3c80bc198e779f185160cb +# Replace assertIsInstance +eda0ef11d67f482fe50bbe581685b8b6a284afb9 +# Replace assertLess and assertLessEqual +6a3380bcb5e803e825bd9485fcc4b70d352947eb +# Replace assertGreater and assertGreaterEqual +46bdb84b464ffec3f0ce88d53467391be7b7046f +# Replace assertCountEqual +fdb8c28271e8b22d458330598a524067ca37026e +# Replace assertListEqual +fcc4d8481df295019945ac7973906f960c58c9fb +# Use f-string syntax +4b69b493d2630b723684f259ee9e7e07c480e8ee # Reformat the codebase 85a17ee5039628a6f3cdcb7a03d7d1bd530fbe89 # Fix lint issues f36bc497c8c8f89004f3f6879908d3f0b25123e1 # Remove some lint exclusions and fix the issues 5f78d1b82b2292d5ce0c99623ba0ec444b80d24c +# Use PEP585 lowercase collections typing annotations +51f9dd229e64f5106d69f87906a94e75604f346b +# Remove unnecessary quotes from types +fbfdfd54446fab6782ef0629da303f14f0a2ecdf +# Replace Union types by PEP604 pipe character +7ef1b61070ed4ed79c4720d019968baf38e38050 +# Update deprecated imports +161b0522bbf7f4984173fee4128416b05f6cc5f3 +# Move imports required for typing under the TYPE_CHECKING block +5c81f94cf7ced476673d0fa948cc7ecda00bae99 # 2025 # Fix formatting @@ -57,6 +103,20 @@ c490ac5810b70f3cf5fd8649669838e8fdb19f4d 1a045c91668c771686f4c871c84f1680af2e944b # Library restructure (split library.py into multiple modules) 0ad4e19d4f870db757373f44d12ff3be2441363a +# Split library file into different files inside library folder. +98377ab5f6fc1829d79211b376bfd8d82bafaf33 +# Use pathlib.Path in test_smartplaylist.py +d017270196dc8e0e2a4051afa5d05213946cbbbc +# Replace assertIsFile +ca4fa6ba10807f4a48a428d23e45c023c15dfa7d +# Replace assertIsDir +43b8cce063b1a1ef079266f362272307fb328d73 +# Replace assertFileTag and assertNoFileTag +c6b5b3bed31704f7fe8632a6aef1a2348028348f +# Replace assertAlbumImport +3c8179a762c4387f9c40a12e3b9e560ff1c194ec +# Replace assertCount +72caf0d2cdc8fcefe1c252bdb0ac9b11b90cc649 # Docs: fix linting issues 769dcdc88a1263638ae25944ba6b2be3e8933666 # Reformat all docs using docstrfmt From 8f514eb6ab961692b5fb104d5824b456b8247ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 17 Jan 2026 16:41:44 +0000 Subject: [PATCH 262/274] Replace/fix Release.type with Release.primary-type --- beetsplug/musicbrainz.py | 8 ++------ test/plugins/test_musicbrainz.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 4257e52ef..aac20e9ac 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -607,12 +607,8 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): if release.get("disambiguation"): info.albumdisambig = release.get("disambiguation") - # Get the "classic" Release type. This data comes from a legacy API - # feature before MusicBrainz supported multiple release types. - if "type" in release["release-group"]: - reltype = release["release-group"]["type"] - if reltype: - info.albumtype = reltype.lower() + if reltype := release["release-group"].get("primary-type"): + info.albumtype = reltype.lower() # Set the new-style "primary" and "secondary" release types. albumtypes = [] diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 09127d169..069f1fb99 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -52,7 +52,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): "asin": "ALBUM ASIN", "disambiguation": "R_DISAMBIGUATION", "release-group": { - "type": "Album", + "primary-type": "Album", "first-release-date": date_str, "id": "RELEASE GROUP ID", "disambiguation": "RG_DISAMBIGUATION", From 3388882c215574890a47c369c6d373704c302eb0 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 3 Nov 2025 15:52:34 +0100 Subject: [PATCH 263/274] Added a proxy to catch and handle exceptions in metadataplugins during the autotag process. --- beets/metadata_plugins.py | 151 ++++++++++++++++++++++++++++++++-- test/test_metadata_plugins.py | 97 ++++++++++++++++++++++ 2 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 test/test_metadata_plugins.py diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index f42e8f690..28374955c 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -8,14 +8,25 @@ implemented as plugins. from __future__ import annotations import abc +import inspect import re -from functools import cache, cached_property -from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar +from functools import cache, cached_property, wraps +from typing import ( + TYPE_CHECKING, + Callable, + ClassVar, + Generic, + Literal, + TypedDict, + TypeVar, + overload, +) import unidecode from confuse import NotFoundError -from typing_extensions import NotRequired +from typing_extensions import NotRequired, ParamSpec +from beets import config, logging from beets.util import cached_classproperty from beets.util.id_extractors import extract_release_id @@ -26,12 +37,18 @@ if TYPE_CHECKING: from .autotag.hooks import AlbumInfo, Item, TrackInfo + P = ParamSpec("P") + R = TypeVar("R") + +# Global logger. +log = logging.getLogger("beets") + @cache def find_metadata_source_plugins() -> list[MetadataSourcePlugin]: """Return a list of all loaded metadata source plugins.""" # TODO: Make this an isinstance(MetadataSourcePlugin, ...) check in v3.0.0 - return [p for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc] + return [SafeProxy(p) for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc,arg-type] @notify_info_yielded("albuminfo_received") @@ -43,7 +60,7 @@ def candidates(*args, **kwargs) -> Iterable[AlbumInfo]: @notify_info_yielded("trackinfo_received") def item_candidates(*args, **kwargs) -> Iterable[TrackInfo]: - """Return matching track candidates fromm all metadata source plugins.""" + """Return matching track candidates from all metadata source plugins.""" for plugin in find_metadata_source_plugins(): yield from plugin.item_candidates(*args, **kwargs) @@ -54,7 +71,7 @@ def album_for_id(_id: str) -> AlbumInfo | None: A single ID can yield just a single album, so we return the first match. """ for plugin in find_metadata_source_plugins(): - if info := plugin.album_for_id(album_id=_id): + if info := plugin.album_for_id(_id): send("albuminfo_received", info=info) return info @@ -259,11 +276,11 @@ class SearchFilter(TypedDict): album: NotRequired[str] -R = TypeVar("R", bound=IDResponse) +Res = TypeVar("Res", bound=IDResponse) class SearchApiMetadataSourcePlugin( - Generic[R], MetadataSourcePlugin, metaclass=abc.ABCMeta + Generic[Res], MetadataSourcePlugin, metaclass=abc.ABCMeta ): """Helper class to implement a metadata source plugin with an API. @@ -288,7 +305,7 @@ class SearchApiMetadataSourcePlugin( query_type: Literal["album", "track"], filters: SearchFilter, query_string: str = "", - ) -> Sequence[R]: + ) -> Sequence[Res]: """Perform a search on the API. :param query_type: The type of query to perform. @@ -357,3 +374,119 @@ class SearchApiMetadataSourcePlugin( query = unidecode.unidecode(query) return query + + +# To have proper typing for the proxy class below, we need to +# trick mypy into thinking that SafeProxy is a subclass of +# MetadataSourcePlugin. +# https://stackoverflow.com/questions/71365594/how-to-make-a-proxy-object-with-typing-as-underlying-object-in-python +Proxied = TypeVar("Proxied", bound=MetadataSourcePlugin) +if TYPE_CHECKING: + base = MetadataSourcePlugin +else: + base = object + + +class SafeProxy(base): + """A proxy class that forwards all attribute access to the wrapped + MetadataSourcePlugin instance. + + We use this to catch and log exceptions from metadata source plugins + without crashing beets. E.g. on long running autotag operations. + """ + + _plugin: MetadataSourcePlugin + _SAFE_METHODS: ClassVar[set[str]] = { + "candidates", + "item_candidates", + "album_for_id", + "track_for_id", + } + + def __init__(self, plugin: MetadataSourcePlugin): + self._plugin = plugin + + def __getattribute__(self, name): + if ( + name == "_plugin" + or name == "_handle_exception" + or name == "_SAFE_METHODS" + or name == "_safe_execute" + ): + return super().__getattribute__(name) + + attr = getattr(self._plugin, name) + + if callable(attr) and name in SafeProxy._SAFE_METHODS: + return self._safe_execute(attr) + return attr + + def __setattr__(self, name, value): + if name == "_plugin": + super().__setattr__(name, value) + else: + self._plugin.__setattr__(name, value) + + @overload + def _safe_execute( + self, + func: Callable[P, Iterable[R]], + ) -> Callable[P, Iterable[R]]: ... + @overload + def _safe_execute(self, func: Callable[P, R]) -> Callable[P, R | None]: ... + def _safe_execute( + self, func: Callable[P, R] + ) -> Callable[P, R | Iterable[R] | None]: + """Wrap any function (generator or regular) and safely execute it. + + Limitation: This does not work on properties! + """ + + @wraps(func) + def wrapper( + *args: P.args, **kwargs: P.kwargs + ) -> R | Iterable[R] | None: + try: + result = func(*args, **kwargs) + except Exception as e: + self._handle_exception(func, e) + + return None + + if inspect.isgenerator(result): + try: + yield from result + except Exception as e: + self._handle_exception(func, e) + return None + else: + return result + + return wrapper + + def _handle_exception(self, func: Callable[P, R], e: Exception) -> None: + """Helper function to log exceptions from metadata source plugins.""" + if config["raise_on_error"].get(bool): + raise e + log.error( + "Error in '{}.{}': {}", + self._plugin.data_source, + func.__name__, + e, + ) + log.debug("Exception details:", exc_info=True) + + # Implement abstract methods to satisfy the ABC + # this is only needed because of the typing hack above. + + def album_for_id(self, album_id: str): + raise NotImplementedError + + def track_for_id(self, track_id: str): + raise NotImplementedError + + def candidates(self, *args, **kwargs): + raise NotImplementedError + + def item_candidates(self, *args, **kwargs): + raise NotImplementedError diff --git a/test/test_metadata_plugins.py b/test/test_metadata_plugins.py new file mode 100644 index 000000000..b8cf5fb6c --- /dev/null +++ b/test/test_metadata_plugins.py @@ -0,0 +1,97 @@ +from typing import Iterable + +import pytest + +from beets import metadata_plugins +from beets.test.helper import PluginMixin + + +class ErrorMetadataMockPlugin(metadata_plugins.MetadataSourcePlugin): + """A metadata source plugin that raises errors in all its methods.""" + + data_source = "ErrorMetadataMockPlugin" + + def candidates(self, *args, **kwargs): + raise ValueError("Mocked error") + + def item_candidates(self, *args, **kwargs): + for i in range(3): + raise ValueError("Mocked error") + yield # This is just to make this a generator + + def album_for_id(self, *args, **kwargs): + raise ValueError("Mocked error") + + def track_for_id(self, *args, **kwargs): + raise ValueError("Mocked error") + + def track_distance(self, *args, **kwargs): + raise ValueError("Mocked error") + + def album_distance(self, *args, **kwargs): + raise ValueError("Mocked error") + + +class TestMetadataPluginsException(PluginMixin): + """Check that errors during the metadata plugins do not crash beets. + They should be logged as errors instead. + """ + + @pytest.fixture(autouse=True) + def setup(self): + self.register_plugin(ErrorMetadataMockPlugin) + yield + self.unload_plugins() + + @pytest.mark.parametrize( + "method_name,args", + [ + ("candidates", ()), + ("item_candidates", ()), + ("album_for_id", ("some_id",)), + ("track_for_id", ("some_id",)), + ], + ) + def test_logging( + self, + caplog, + method_name, + args, + ): + self.config["raise_on_error"] = False + with caplog.at_level("ERROR"): + # Call the method to trigger the error + ret = getattr(metadata_plugins, method_name)(*args) + if isinstance(ret, Iterable): + list(ret) + + # Check that an error was logged + assert len(caplog.records) >= 1 + logs = [record.getMessage() for record in caplog.records] + for msg in logs: + assert ( + msg + == f"Error in 'ErrorMetadataMockPlugin.{method_name}': Mocked error" + ) + + caplog.clear() + + @pytest.mark.parametrize( + "method_name,args", + [ + ("candidates", ()), + ("item_candidates", ()), + ("album_for_id", ("some_id",)), + ("track_for_id", ("some_id",)), + ], + ) + def test_raising( + self, + method_name, + args, + ): + self.config["raise_on_error"] = True + with pytest.raises(ValueError, match="Mocked error"): + getattr(metadata_plugins, method_name)(*args) if not isinstance( + args, Iterable + ) else list(getattr(metadata_plugins, method_name)(*args)) From 4511a376991cc6ab60bd3b4cfc2f3c9169edbd9e Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 3 Nov 2025 16:22:41 +0100 Subject: [PATCH 264/274] Added default config and simplified proxy class. --- beets/config_default.yaml | 2 + beets/metadata_plugins.py | 95 ++++++++++++--------------------------- 2 files changed, 30 insertions(+), 67 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index c0bab8056..dfc0378a1 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -10,6 +10,8 @@ plugins: [musicbrainz] pluginpath: [] +raise_on_error: no + # --------------- Import --------------- clutter: ["Thumbs.DB", ".DS_Store"] diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 28374955c..ed492b537 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -8,18 +8,15 @@ implemented as plugins. from __future__ import annotations import abc -import inspect import re -from functools import cache, cached_property, wraps +from functools import cache, cached_property from typing import ( TYPE_CHECKING, Callable, - ClassVar, Generic, Literal, TypedDict, TypeVar, - overload, ) import unidecode @@ -396,30 +393,22 @@ class SafeProxy(base): """ _plugin: MetadataSourcePlugin - _SAFE_METHODS: ClassVar[set[str]] = { - "candidates", - "item_candidates", - "album_for_id", - "track_for_id", - } def __init__(self, plugin: MetadataSourcePlugin): self._plugin = plugin def __getattribute__(self, name): - if ( - name == "_plugin" - or name == "_handle_exception" - or name == "_SAFE_METHODS" - or name == "_safe_execute" - ): + if name in { + "_plugin", + "_handle_exception", + "candidates", + "item_candidates", + "album_for_id", + "track_for_id", + }: return super().__getattribute__(name) - - attr = getattr(self._plugin, name) - - if callable(attr) and name in SafeProxy._SAFE_METHODS: - return self._safe_execute(attr) - return attr + else: + return getattr(self._plugin, name) def __setattr__(self, name, value): if name == "_plugin": @@ -427,43 +416,6 @@ class SafeProxy(base): else: self._plugin.__setattr__(name, value) - @overload - def _safe_execute( - self, - func: Callable[P, Iterable[R]], - ) -> Callable[P, Iterable[R]]: ... - @overload - def _safe_execute(self, func: Callable[P, R]) -> Callable[P, R | None]: ... - def _safe_execute( - self, func: Callable[P, R] - ) -> Callable[P, R | Iterable[R] | None]: - """Wrap any function (generator or regular) and safely execute it. - - Limitation: This does not work on properties! - """ - - @wraps(func) - def wrapper( - *args: P.args, **kwargs: P.kwargs - ) -> R | Iterable[R] | None: - try: - result = func(*args, **kwargs) - except Exception as e: - self._handle_exception(func, e) - - return None - - if inspect.isgenerator(result): - try: - yield from result - except Exception as e: - self._handle_exception(func, e) - return None - else: - return result - - return wrapper - def _handle_exception(self, func: Callable[P, R], e: Exception) -> None: """Helper function to log exceptions from metadata source plugins.""" if config["raise_on_error"].get(bool): @@ -476,17 +428,26 @@ class SafeProxy(base): ) log.debug("Exception details:", exc_info=True) - # Implement abstract methods to satisfy the ABC - # this is only needed because of the typing hack above. - - def album_for_id(self, album_id: str): - raise NotImplementedError + def album_for_id(self, *args, **kwargs): + try: + return self._plugin.album_for_id(*args, **kwargs) + except Exception as e: + return self._handle_exception(self._plugin.album_for_id, e) def track_for_id(self, track_id: str): - raise NotImplementedError + try: + return self._plugin.track_for_id(track_id) + except Exception as e: + return self._handle_exception(self._plugin.track_for_id, e) def candidates(self, *args, **kwargs): - raise NotImplementedError + try: + yield from self._plugin.candidates(*args, **kwargs) + except Exception as e: + return self._handle_exception(self._plugin.candidates, e) def item_candidates(self, *args, **kwargs): - raise NotImplementedError + try: + yield from self._plugin.item_candidates(*args, **kwargs) + except Exception as e: + return self._handle_exception(self._plugin.item_candidates, e) From cfba015998d48801c0b8963e6a59ac713359fd1c Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 3 Nov 2025 16:26:31 +0100 Subject: [PATCH 265/274] Fixed cache clear issue. --- test/test_metadata_plugins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_metadata_plugins.py b/test/test_metadata_plugins.py index b8cf5fb6c..edf66adcd 100644 --- a/test/test_metadata_plugins.py +++ b/test/test_metadata_plugins.py @@ -39,6 +39,7 @@ class TestMetadataPluginsException(PluginMixin): @pytest.fixture(autouse=True) def setup(self): + metadata_plugins.find_metadata_source_plugins.cache_clear() self.register_plugin(ErrorMetadataMockPlugin) yield self.unload_plugins() From 5cbdab40d2d3028694f7e9ca27dd7f75e4ca715a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 3 Nov 2025 16:45:32 +0100 Subject: [PATCH 266/274] Renamed variable to use protected names. --- beets/metadata_plugins.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index ed492b537..05e5c5d41 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -392,15 +392,15 @@ class SafeProxy(base): without crashing beets. E.g. on long running autotag operations. """ - _plugin: MetadataSourcePlugin + __plugin: MetadataSourcePlugin def __init__(self, plugin: MetadataSourcePlugin): - self._plugin = plugin + self.__plugin = plugin def __getattribute__(self, name): if name in { - "_plugin", - "_handle_exception", + "_SafeProxy__plugin", + "_SafeProxy__handle_exception", "candidates", "item_candidates", "album_for_id", @@ -408,21 +408,21 @@ class SafeProxy(base): }: return super().__getattribute__(name) else: - return getattr(self._plugin, name) + return getattr(self.__plugin, name) def __setattr__(self, name, value): - if name == "_plugin": + if name == "_SafeProxy__plugin": super().__setattr__(name, value) else: - self._plugin.__setattr__(name, value) + self.__plugin.__setattr__(name, value) - def _handle_exception(self, func: Callable[P, R], e: Exception) -> None: + def __handle_exception(self, func: Callable[P, R], e: Exception) -> None: """Helper function to log exceptions from metadata source plugins.""" if config["raise_on_error"].get(bool): raise e log.error( "Error in '{}.{}': {}", - self._plugin.data_source, + self.__plugin.data_source, func.__name__, e, ) @@ -430,24 +430,24 @@ class SafeProxy(base): def album_for_id(self, *args, **kwargs): try: - return self._plugin.album_for_id(*args, **kwargs) + return self.__plugin.album_for_id(*args, **kwargs) except Exception as e: - return self._handle_exception(self._plugin.album_for_id, e) + return self.__handle_exception(self.__plugin.album_for_id, e) def track_for_id(self, track_id: str): try: - return self._plugin.track_for_id(track_id) + return self.__plugin.track_for_id(track_id) except Exception as e: - return self._handle_exception(self._plugin.track_for_id, e) + return self.__handle_exception(self.__plugin.track_for_id, e) def candidates(self, *args, **kwargs): try: - yield from self._plugin.candidates(*args, **kwargs) + yield from self.__plugin.candidates(*args, **kwargs) except Exception as e: - return self._handle_exception(self._plugin.candidates, e) + return self.__handle_exception(self.__plugin.candidates, e) def item_candidates(self, *args, **kwargs): try: - yield from self._plugin.item_candidates(*args, **kwargs) + yield from self.__plugin.item_candidates(*args, **kwargs) except Exception as e: - return self._handle_exception(self._plugin.item_candidates, e) + return self.__handle_exception(self.__plugin.item_candidates, e) From 8e0b3f1323a1ab24d692e0727e70f912916aec8c Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 10 Nov 2025 18:33:52 +0100 Subject: [PATCH 267/274] Moved config check into find_metadata_source_plugins func. --- beets/metadata_plugins.py | 14 +++++++++----- docs/changelog.rst | 4 ++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 05e5c5d41..8c3b438b0 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -45,7 +45,13 @@ log = logging.getLogger("beets") def find_metadata_source_plugins() -> list[MetadataSourcePlugin]: """Return a list of all loaded metadata source plugins.""" # TODO: Make this an isinstance(MetadataSourcePlugin, ...) check in v3.0.0 - return [SafeProxy(p) for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc,arg-type] + # This should also allow us to remove the type: ignore comments below. + metadata_plugins = [p for p in find_plugins() if hasattr(p, "data_source")] + + if config["raise_on_error"].get(bool): + return metadata_plugins # type: ignore[return-value] + else: + return list(map(SafeProxy, metadata_plugins)) # type: ignore[arg-type] @notify_info_yielded("albuminfo_received") @@ -418,8 +424,6 @@ class SafeProxy(base): def __handle_exception(self, func: Callable[P, R], e: Exception) -> None: """Helper function to log exceptions from metadata source plugins.""" - if config["raise_on_error"].get(bool): - raise e log.error( "Error in '{}.{}': {}", self.__plugin.data_source, @@ -434,9 +438,9 @@ class SafeProxy(base): except Exception as e: return self.__handle_exception(self.__plugin.album_for_id, e) - def track_for_id(self, track_id: str): + def track_for_id(self, *args, **kwargs): try: - return self.__plugin.track_for_id(track_id) + return self.__plugin.track_for_id(*args, **kwargs) except Exception as e: return self.__handle_exception(self.__plugin.track_for_id, e) diff --git a/docs/changelog.rst b/docs/changelog.rst index df886700b..5529bf940 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -111,6 +111,10 @@ Bug fixes: avoiding extra lossy duplicates. - :doc:`plugins/discogs`: Fixed unexpected flex attr from the Discogs plugin. :bug:`6177` +- Errors in metadata plugins during autotage process will now be logged but + won't crash beets anymore. If you want to raise exceptions instead, set the + new configuration option ``raise_on_error`` to ``yes`` :bug:`5903`, + :bug:`4789`. For plugin developers: From cb6ad89ce65662a6daa9499aa9a8d0c6cf241836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 30 Jan 2026 22:28:52 +0000 Subject: [PATCH 268/274] Use a decorator-based approach --- beets/metadata_plugins.py | 187 +++++++++++----------------------- beets/plugins.py | 11 +- test/test_metadata_plugins.py | 17 ++-- 3 files changed, 76 insertions(+), 139 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 8c3b438b0..7c08d72a3 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -9,33 +9,26 @@ from __future__ import annotations import abc import re -from functools import cache, cached_property -from typing import ( - TYPE_CHECKING, - Callable, - Generic, - Literal, - TypedDict, - TypeVar, -) +from contextlib import contextmanager, nullcontext +from functools import cache, cached_property, wraps +from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar import unidecode from confuse import NotFoundError -from typing_extensions import NotRequired, ParamSpec +from typing_extensions import NotRequired from beets import config, logging from beets.util import cached_classproperty from beets.util.id_extractors import extract_release_id -from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send +from .plugins import BeetsPlugin, find_plugins, notify_info_yielded if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Callable, Iterable, Iterator, Sequence from .autotag.hooks import AlbumInfo, Item, TrackInfo - P = ParamSpec("P") - R = TypeVar("R") + Ret = TypeVar("Ret") # Global logger. log = logging.getLogger("beets") @@ -46,52 +39,68 @@ def find_metadata_source_plugins() -> list[MetadataSourcePlugin]: """Return a list of all loaded metadata source plugins.""" # TODO: Make this an isinstance(MetadataSourcePlugin, ...) check in v3.0.0 # This should also allow us to remove the type: ignore comments below. - metadata_plugins = [p for p in find_plugins() if hasattr(p, "data_source")] + return [p for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc] - if config["raise_on_error"].get(bool): - return metadata_plugins # type: ignore[return-value] - else: - return list(map(SafeProxy, metadata_plugins)) # type: ignore[arg-type] + +@contextmanager +def handle_plugin_error(plugin: MetadataSourcePlugin, method_name: str): + """Safely call a plugin method, catching and logging exceptions.""" + try: + yield + except Exception as e: + log.error("Error in '{}.{}': {}", plugin.data_source, method_name, e) + log.debug("Exception details:", exc_info=True) + + +def _yield_from_plugins( + func: Callable[..., Iterable[Ret]], +) -> Callable[..., Iterator[Ret]]: + method_name = func.__name__ + + @wraps(func) + def wrapper(*args, **kwargs) -> Iterator[Ret]: + for plugin in find_metadata_source_plugins(): + method = getattr(plugin, method_name) + with ( + nullcontext() + if config["raise_on_error"] + else handle_plugin_error(plugin, method_name) + ): + yield from filter(None, method(*args, **kwargs)) + + return wrapper @notify_info_yielded("albuminfo_received") -def candidates(*args, **kwargs) -> Iterable[AlbumInfo]: - """Return matching album candidates from all metadata source plugins.""" - for plugin in find_metadata_source_plugins(): - yield from plugin.candidates(*args, **kwargs) +@_yield_from_plugins +def candidates(*args, **kwargs) -> Iterator[AlbumInfo]: + yield from () @notify_info_yielded("trackinfo_received") -def item_candidates(*args, **kwargs) -> Iterable[TrackInfo]: - """Return matching track candidates from all metadata source plugins.""" - for plugin in find_metadata_source_plugins(): - yield from plugin.item_candidates(*args, **kwargs) +@_yield_from_plugins +def item_candidates(*args, **kwargs) -> Iterator[TrackInfo]: + yield from () + + +@notify_info_yielded("albuminfo_received") +@_yield_from_plugins +def albums_for_ids(*args, **kwargs) -> Iterator[AlbumInfo]: + yield from () + + +@notify_info_yielded("trackinfo_received") +@_yield_from_plugins +def tracks_for_ids(*args, **kwargs) -> Iterator[TrackInfo]: + yield from () def album_for_id(_id: str) -> AlbumInfo | None: - """Get AlbumInfo object for the given ID string. - - A single ID can yield just a single album, so we return the first match. - """ - for plugin in find_metadata_source_plugins(): - if info := plugin.album_for_id(_id): - send("albuminfo_received", info=info) - return info - - return None + return next(albums_for_ids([_id]), None) def track_for_id(_id: str) -> TrackInfo | None: - """Get TrackInfo object for the given ID string. - - A single ID can yield just a single track, so we return the first match. - """ - for plugin in find_metadata_source_plugins(): - if info := plugin.track_for_id(_id): - send("trackinfo_received", info=info) - return info - - return None + return next(tracks_for_ids([_id]), None) @cache @@ -279,11 +288,11 @@ class SearchFilter(TypedDict): album: NotRequired[str] -Res = TypeVar("Res", bound=IDResponse) +R = TypeVar("R", bound=IDResponse) class SearchApiMetadataSourcePlugin( - Generic[Res], MetadataSourcePlugin, metaclass=abc.ABCMeta + Generic[R], MetadataSourcePlugin, metaclass=abc.ABCMeta ): """Helper class to implement a metadata source plugin with an API. @@ -308,7 +317,7 @@ class SearchApiMetadataSourcePlugin( query_type: Literal["album", "track"], filters: SearchFilter, query_string: str = "", - ) -> Sequence[Res]: + ) -> Sequence[R]: """Perform a search on the API. :param query_type: The type of query to perform. @@ -377,81 +386,3 @@ class SearchApiMetadataSourcePlugin( query = unidecode.unidecode(query) return query - - -# To have proper typing for the proxy class below, we need to -# trick mypy into thinking that SafeProxy is a subclass of -# MetadataSourcePlugin. -# https://stackoverflow.com/questions/71365594/how-to-make-a-proxy-object-with-typing-as-underlying-object-in-python -Proxied = TypeVar("Proxied", bound=MetadataSourcePlugin) -if TYPE_CHECKING: - base = MetadataSourcePlugin -else: - base = object - - -class SafeProxy(base): - """A proxy class that forwards all attribute access to the wrapped - MetadataSourcePlugin instance. - - We use this to catch and log exceptions from metadata source plugins - without crashing beets. E.g. on long running autotag operations. - """ - - __plugin: MetadataSourcePlugin - - def __init__(self, plugin: MetadataSourcePlugin): - self.__plugin = plugin - - def __getattribute__(self, name): - if name in { - "_SafeProxy__plugin", - "_SafeProxy__handle_exception", - "candidates", - "item_candidates", - "album_for_id", - "track_for_id", - }: - return super().__getattribute__(name) - else: - return getattr(self.__plugin, name) - - def __setattr__(self, name, value): - if name == "_SafeProxy__plugin": - super().__setattr__(name, value) - else: - self.__plugin.__setattr__(name, value) - - def __handle_exception(self, func: Callable[P, R], e: Exception) -> None: - """Helper function to log exceptions from metadata source plugins.""" - log.error( - "Error in '{}.{}': {}", - self.__plugin.data_source, - func.__name__, - e, - ) - log.debug("Exception details:", exc_info=True) - - def album_for_id(self, *args, **kwargs): - try: - return self.__plugin.album_for_id(*args, **kwargs) - except Exception as e: - return self.__handle_exception(self.__plugin.album_for_id, e) - - def track_for_id(self, *args, **kwargs): - try: - return self.__plugin.track_for_id(*args, **kwargs) - except Exception as e: - return self.__handle_exception(self.__plugin.track_for_id, e) - - def candidates(self, *args, **kwargs): - try: - yield from self.__plugin.candidates(*args, **kwargs) - except Exception as e: - return self.__handle_exception(self.__plugin.candidates, e) - - def item_candidates(self, *args, **kwargs): - try: - yield from self.__plugin.item_candidates(*args, **kwargs) - except Exception as e: - return self.__handle_exception(self.__plugin.item_candidates, e) diff --git a/beets/plugins.py b/beets/plugins.py index ec3f999c4..01d9d3327 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -35,7 +35,7 @@ from beets.util import unique_list from beets.util.deprecation import deprecate_for_maintainers, deprecate_for_user if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Sequence + from collections.abc import Callable, Iterable, Iterator, Sequence from confuse import ConfigView @@ -58,7 +58,6 @@ if TYPE_CHECKING: P = ParamSpec("P") Ret = TypeVar("Ret", bound=Any) Listener = Callable[..., Any] - IterF = Callable[P, Iterable[Ret]] PLUGIN_NAMESPACE = "beetsplug" @@ -548,7 +547,7 @@ def named_queries(model_cls: type[AnyModel]) -> dict[str, FieldQueryType]: def notify_info_yielded( event: EventType, -) -> Callable[[IterF[P, Ret]], IterF[P, Ret]]: +) -> Callable[[Callable[P, Iterable[Ret]]], Callable[P, Iterator[Ret]]]: """Makes a generator send the event 'event' every time it yields. This decorator is supposed to decorate a generator, but any function returning an iterable should work. @@ -556,9 +555,11 @@ def notify_info_yielded( 'send'. """ - def decorator(func: IterF[P, Ret]) -> IterF[P, Ret]: + def decorator( + func: Callable[P, Iterable[Ret]], + ) -> Callable[P, Iterator[Ret]]: @wraps(func) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> Iterable[Ret]: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Iterator[Ret]: for v in func(*args, **kwargs): send(event, info=v) yield v diff --git a/test/test_metadata_plugins.py b/test/test_metadata_plugins.py index edf66adcd..d34185330 100644 --- a/test/test_metadata_plugins.py +++ b/test/test_metadata_plugins.py @@ -45,18 +45,23 @@ class TestMetadataPluginsException(PluginMixin): self.unload_plugins() @pytest.mark.parametrize( - "method_name,args", + "method_name,error_method_name,args", [ - ("candidates", ()), - ("item_candidates", ()), - ("album_for_id", ("some_id",)), - ("track_for_id", ("some_id",)), + ("candidates", "candidates", ()), + ("item_candidates", "item_candidates", ()), + ("albums_for_ids", "albums_for_ids", (["some_id"],)), + ("tracks_for_ids", "tracks_for_ids", (["some_id"],)), + # Currently, singular methods call plural ones internally and log + # errors from there + ("album_for_id", "albums_for_ids", ("some_id",)), + ("track_for_id", "tracks_for_ids", ("some_id",)), ], ) def test_logging( self, caplog, method_name, + error_method_name, args, ): self.config["raise_on_error"] = False @@ -72,7 +77,7 @@ class TestMetadataPluginsException(PluginMixin): for msg in logs: assert ( msg - == f"Error in 'ErrorMetadataMockPlugin.{method_name}': Mocked error" + == f"Error in 'ErrorMetadataMockPlugin.{error_method_name}': Mocked error" # noqa: E501 ) caplog.clear() From 2196bd89de364d57b8f473fbf6e7694129c5cbac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 30 Jan 2026 22:36:54 +0000 Subject: [PATCH 269/274] Simplify tests --- test/test_metadata_plugins.py | 56 +++++++++++------------------------ 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/test/test_metadata_plugins.py b/test/test_metadata_plugins.py index d34185330..684784191 100644 --- a/test/test_metadata_plugins.py +++ b/test/test_metadata_plugins.py @@ -1,4 +1,4 @@ -from typing import Iterable +from collections.abc import Iterable import pytest @@ -9,8 +9,6 @@ from beets.test.helper import PluginMixin class ErrorMetadataMockPlugin(metadata_plugins.MetadataSourcePlugin): """A metadata source plugin that raises errors in all its methods.""" - data_source = "ErrorMetadataMockPlugin" - def candidates(self, *args, **kwargs): raise ValueError("Mocked error") @@ -25,12 +23,6 @@ class ErrorMetadataMockPlugin(metadata_plugins.MetadataSourcePlugin): def track_for_id(self, *args, **kwargs): raise ValueError("Mocked error") - def track_distance(self, *args, **kwargs): - raise ValueError("Mocked error") - - def album_distance(self, *args, **kwargs): - raise ValueError("Mocked error") - class TestMetadataPluginsException(PluginMixin): """Check that errors during the metadata plugins do not crash beets. @@ -44,6 +36,14 @@ class TestMetadataPluginsException(PluginMixin): yield self.unload_plugins() + @pytest.fixture + def call_method(self, method_name, args): + def _call(): + result = getattr(metadata_plugins, method_name)(*args) + return list(result) if isinstance(result, Iterable) else result + + return _call + @pytest.mark.parametrize( "method_name,error_method_name,args", [ @@ -57,30 +57,15 @@ class TestMetadataPluginsException(PluginMixin): ("track_for_id", "tracks_for_ids", ("some_id",)), ], ) - def test_logging( - self, - caplog, - method_name, - error_method_name, - args, - ): + def test_logging(self, caplog, call_method, error_method_name): self.config["raise_on_error"] = False - with caplog.at_level("ERROR"): - # Call the method to trigger the error - ret = getattr(metadata_plugins, method_name)(*args) - if isinstance(ret, Iterable): - list(ret) - # Check that an error was logged - assert len(caplog.records) >= 1 - logs = [record.getMessage() for record in caplog.records] - for msg in logs: - assert ( - msg - == f"Error in 'ErrorMetadataMockPlugin.{error_method_name}': Mocked error" # noqa: E501 - ) + call_method() - caplog.clear() + assert ( + f"Error in 'ErrorMetadataMock.{error_method_name}': Mocked error" + in caplog.text + ) @pytest.mark.parametrize( "method_name,args", @@ -91,13 +76,8 @@ class TestMetadataPluginsException(PluginMixin): ("track_for_id", ("some_id",)), ], ) - def test_raising( - self, - method_name, - args, - ): + def test_raising(self, call_method): self.config["raise_on_error"] = True + with pytest.raises(ValueError, match="Mocked error"): - getattr(metadata_plugins, method_name)(*args) if not isinstance( - args, Iterable - ) else list(getattr(metadata_plugins, method_name)(*args)) + call_method() From d2600c354cda54d0d7657ff169c6b9a02ab0053f Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 17 Jan 2026 10:03:01 +0100 Subject: [PATCH 270/274] Fix crash in task.imported_items --- beets/importer/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 1c1d0e61e..e56157ed0 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -253,7 +253,7 @@ class ImportTask(BaseImportTask): ): return self.match.items else: - assert False + return [] def apply_metadata(self): """Copy metadata from match info to the items.""" From 680473b9e5ab3c2ee6e4083467dd7dc082688799 Mon Sep 17 00:00:00 2001 From: snejus Date: Sun, 1 Feb 2026 14:42:50 +0000 Subject: [PATCH 271/274] Increment version to 2.6.0 --- beets/__init__.py | 2 +- docs/changelog.rst | 11 +++++++++++ docs/conf.py | 4 ++-- pyproject.toml | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index 2c6069b29..036b197ef 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -19,7 +19,7 @@ import confuse from .util.deprecation import deprecate_imports -__version__ = "2.5.1" +__version__ = "2.6.0" __author__ = "Adrian Sampson " diff --git a/docs/changelog.rst b/docs/changelog.rst index 5529bf940..cd2c2b4c3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,17 @@ below! Unreleased ---------- +New features: + +Bug fixes: + +For packagers: + +Other changes: + +2.6.0 (February 01, 2026) +------------------------- + Beets now requires Python 3.10 or later since support for EOL Python 3.9 has been dropped. diff --git a/docs/conf.py b/docs/conf.py index c04e034ab..0bd4ad308 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,8 +18,8 @@ copyright = "2016, Adrian Sampson" master_doc = "index" language = "en" -version = "2.5" -release = "2.5.1" +version = "2.6" +release = "2.6.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index b14f442ff..aa3c9d5c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.5.1" +version = "2.6.0" description = "music tagger and library organizer" authors = ["Adrian Sampson "] maintainers = ["Serene-Arc"] From b6230a84fc4da22185028702f3c47f4af385a33e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 2 Feb 2026 02:16:36 +0000 Subject: [PATCH 272/274] Make packaging a required dependency --- docs/changelog.rst | 6 +----- poetry.lock | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cd2c2b4c3..24a9f5973 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,13 +7,9 @@ below! Unreleased ---------- -New features: - Bug fixes: -For packagers: - -Other changes: +- Make ``packaging`` a required dependency. :bug:`6332` 2.6.0 (February 01, 2026) ------------------------- diff --git a/poetry.lock b/poetry.lock index 8eb7c74ac..285f519d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4583,4 +4583,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "f8ce55ae74c5e3c5d1d330582f83dae30ef963a0b8dd8c8b79f16c3bcfdb525a" +content-hash = "eefe427d3b3b9b871ca6bcd8405e3578a16d660afd7925c14793514f03c96ac6" diff --git a/pyproject.toml b/pyproject.toml index aa3c9d5c7..ed1e98f75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ numpy = [ { python = "<3.13", version = ">=2.0.2" }, { python = ">=3.13", version = ">=2.3.4" }, ] +packaging = ">=24.0" platformdirs = ">=3.5.0" pyyaml = "*" requests = ">=2.32.5" @@ -132,7 +133,6 @@ types-urllib3 = "*" [tool.poetry.group.release.dependencies] click = ">=8.1.7" -packaging = ">=24.0" tomli = ">=2.0.1" [tool.poetry.extras] From 9d7d3ae7b005e788fffbe04153a8c425ad734f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 2 Feb 2026 02:18:51 +0000 Subject: [PATCH 273/274] Update vulnerable dependencies --- poetry.lock | 251 +++++++++++++++++++++++----------------------------- 1 file changed, 113 insertions(+), 138 deletions(-) diff --git a/poetry.lock b/poetry.lock index 285f519d3..1a0515819 100644 --- a/poetry.lock +++ b/poetry.lock @@ -284,136 +284,111 @@ files = [ [[package]] name = "brotli" -version = "1.1.0" +version = "1.2.0" description = "Python bindings for the Brotli compression library" optional = false python-versions = "*" files = [ - {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752"}, - {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9"}, - {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3"}, - {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d"}, - {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e"}, - {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, - {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, - {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, - {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, - {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6"}, - {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd"}, - {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf"}, - {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61"}, - {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, - {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, - {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, - {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, - {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91"}, - {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408"}, - {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, - {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, - {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, - {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, - {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, - {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, - {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, - {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"}, - {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"}, - {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"}, - {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112"}, - {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"}, - {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"}, - {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"}, - {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"}, - {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985"}, - {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60"}, - {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a"}, - {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"}, - {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"}, - {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"}, - {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"}, - {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208"}, - {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7"}, - {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751"}, - {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48"}, - {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"}, - {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"}, - {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"}, - {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, - {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f"}, - {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9"}, - {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf"}, - {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac"}, - {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, - {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, - {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, - {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, + {file = "brotli-1.2.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8"}, + {file = "brotli-1.2.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a"}, + {file = "brotli-1.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92"}, + {file = "brotli-1.2.0-cp27-cp27m-win32.whl", hash = "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb"}, + {file = "brotli-1.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f"}, + {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f"}, + {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46"}, + {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e"}, + {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1"}, + {file = "brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997"}, + {file = "brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196"}, + {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744"}, + {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae"}, + {file = "brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03"}, + {file = "brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24"}, + {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84"}, + {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036"}, + {file = "brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161"}, + {file = "brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44"}, + {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab"}, + {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5"}, + {file = "brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a"}, + {file = "brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8"}, + {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21"}, + {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888"}, + {file = "brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d"}, + {file = "brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3"}, + {file = "brotli-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533"}, + {file = "brotli-1.2.0-cp36-cp36m-win32.whl", hash = "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96"}, + {file = "brotli-1.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13"}, + {file = "brotli-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a"}, + {file = "brotli-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982"}, + {file = "brotli-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16"}, + {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8"}, + {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7"}, + {file = "brotli-1.2.0-cp38-cp38-win32.whl", hash = "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c"}, + {file = "brotli-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470"}, + {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1"}, + {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4"}, + {file = "brotli-1.2.0-cp39-cp39-win32.whl", hash = "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49"}, + {file = "brotli-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937"}, + {file = "brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a"}, ] [[package]] @@ -1002,13 +977,13 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.20.2" +version = "3.20.3" description = "A platform independent file lock." optional = true python-versions = ">=3.10" files = [ - {file = "filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8"}, - {file = "filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64"}, + {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, + {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, ] [[package]] @@ -4495,20 +4470,20 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0)"] [[package]] name = "webencodings" @@ -4523,17 +4498,17 @@ files = [ [[package]] name = "werkzeug" -version = "3.1.3" +version = "3.1.5" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" files = [ - {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, - {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, + {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, + {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, ] [package.dependencies] -MarkupSafe = ">=2.1.1" +markupsafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] From cdfb8139102220986c0cd1c470b2444d406878a1 Mon Sep 17 00:00:00 2001 From: snejus Date: Mon, 2 Feb 2026 02:29:04 +0000 Subject: [PATCH 274/274] Increment version to 2.6.1 --- beets/__init__.py | 2 +- docs/changelog.rst | 11 +++++++++++ docs/conf.py | 2 +- pyproject.toml | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index 036b197ef..4bde53504 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -19,7 +19,7 @@ import confuse from .util.deprecation import deprecate_imports -__version__ = "2.6.0" +__version__ = "2.6.1" __author__ = "Adrian Sampson " diff --git a/docs/changelog.rst b/docs/changelog.rst index 24a9f5973..25a0c1365 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,17 @@ below! Unreleased ---------- +New features: + +Bug fixes: + +For packagers: + +Other changes: + +2.6.1 (February 02, 2026) +------------------------- + Bug fixes: - Make ``packaging`` a required dependency. :bug:`6332` diff --git a/docs/conf.py b/docs/conf.py index 0bd4ad308..15ba699cd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ copyright = "2016, Adrian Sampson" master_doc = "index" language = "en" version = "2.6" -release = "2.6.0" +release = "2.6.1" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index ed1e98f75..24505d73e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.6.0" +version = "2.6.1" description = "music tagger and library organizer" authors = ["Adrian Sampson "] maintainers = ["Serene-Arc"]