diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 14b50859f..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,22 +45,78 @@ 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 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) 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 @@ -73,3 +137,21 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2 33f1a5d0bef8ca08be79ee7a0d02a018d502680d # Moved art.py utility module from beets into beetsplug 28aee0fde463f1e18dfdba1994e2bdb80833722f +# Refactor `ui/commands.py` into multiple modules +59c93e70139f70e9fd1c6f3c1bceb005945bec33 +# Moved ui.commands._utils into ui.commands.utils +25ae330044abf04045e3f378f72bbaed739fb30d +# 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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bb888d520..fe4ce3378 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,4 +2,6 @@ * @beetbox/maintainers # Specific ownerships: -/beets/metadata_plugins.py @semohr \ No newline at end of file +/beets/metadata_plugins.py @semohr +/beetsplug/titlecase.py @henry-oberholtzer +/beetsplug/mbpseudo.py @asardaes diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fa6e9a7be..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"] + 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 @@ -39,7 +39,15 @@ 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 \ + pandoc \ + imagemagick - name: Get changed lyrics files id: lyrics-update 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 ee963ab46..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 @@ -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/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 설치 ------- diff --git a/beets/__init__.py b/beets/__init__.py index d448d8c49..4bde53504 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -17,22 +17,18 @@ from sys import stderr import confuse -from .util import deprecate_imports +from .util.deprecation import deprecate_imports -__version__ = "2.5.1" +__version__ = "2.6.1" __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 319f7f522..feeefbf28 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -16,39 +16,33 @@ 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 # Parts of external interface. from beets.util import unique_list +from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports -from ..util import deprecate_imports 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 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 ) @@ -117,8 +111,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.""" @@ -210,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..82e685b7a 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -17,10 +17,14 @@ from __future__ import annotations from copy import deepcopy -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar +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 @@ -54,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, @@ -95,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], @@ -167,6 +179,10 @@ class TrackInfo(Info): stand alone for singleton matching. """ + @cached_property + def name(self) -> str: + return self.title or "" + def __init__( self, *, @@ -214,16 +230,32 @@ class TrackInfo(Info): # Structures that compose all the information for a candidate match. - - -class AlbumMatch(NamedTuple): +@dataclass +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): info: AlbumInfo mapping: dict[Item, TrackInfo] extra_items: list[Item] extra_tracks: list[TrackInfo] + @property + def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]: + return list(self.mapping.items()) -class TrackMatch(NamedTuple): - distance: Distance + @property + def items(self) -> list[Item]: + return [i for i, _ in self.item_info_pairs] + + +@dataclass +class TrackMatch(Match): info: TrackInfo diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 8fec844a6..374ea3c13 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -24,8 +24,8 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar import lap import numpy as np -from beets import config, logging, metadata_plugins -from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks +from beets import config, logging, metadata_plugins, plugins +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. @@ -69,7 +70,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 +96,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 +218,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,14 +235,14 @@ 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 ) 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 @@ -274,12 +277,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"]: @@ -295,10 +303,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 = ( @@ -310,9 +318,11 @@ 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): + plugins.send("album_matched", match=opt_candidate) log.debug("Evaluating {} candidates.", len(candidates)) # Sort and get the recommendation. @@ -324,7 +334,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 @@ -366,12 +376,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) 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/dbcore/__init__.py b/beets/dbcore/__init__.py index fa20eb00d..441d5f446 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, @@ -36,6 +36,7 @@ __all__ = [ "AndQuery", "Database", "FieldQuery", + "Index", "InvalidQueryError", "MatchQuery", "Model", diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index afae6e906..8640a5678 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -26,31 +26,42 @@ 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 functools import cached_property from sqlite3 import Connection, sqlite_version_info -from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic +from typing import TYPE_CHECKING, Any, AnyStr, ClassVar, Generic, NamedTuple -from typing_extensions import TypeVar # default value support +from typing_extensions import ( + Self, + TypeVar, # default value support +) from unidecode import unidecode 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) @@ -76,6 +87,10 @@ class DBCustomFunctionError(Exception): ) +class NotFoundError(LookupError): + pass + + class FormattedMapping(Mapping[str, str]): """A `dict`-like formatted view of a model. @@ -90,6 +105,8 @@ class FormattedMapping(Mapping[str, str]): are replaced. """ + model: Model + ALL_KEYS = "*" def __init__( @@ -289,7 +306,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. """ @@ -299,12 +316,17 @@ 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.""" 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`. """ @@ -353,6 +375,22 @@ 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() + + 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.""" @@ -592,7 +630,6 @@ class Model(ABC, Generic[D]): """ if fields is None: fields = self._fields - db = self._check_db() # Build assignments for query. assignments = [] @@ -604,7 +641,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=?" @@ -638,21 +675,16 @@ 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) - 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): """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,) @@ -668,7 +700,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") @@ -689,7 +721,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. """ @@ -733,9 +765,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. @@ -1059,6 +1091,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._create_indices(model_cls._table, model_cls._indices) # Primitive access control: connections and transactions. @@ -1097,6 +1130,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: @@ -1236,6 +1279,19 @@ class Database: ON {flex_table} (entity_id); """) + def _create_indices( + self, + table: str, + indices: Sequence[Index], + ): + """Create indices for the given table if they don't exist.""" + with self.transaction() as tx: + for index in indices: + tx.script( + f"CREATE INDEX IF NOT EXISTS {index.name} " + f"ON {table} ({', '.join(index.columns)});" + ) + # Querying. def _fetch( @@ -1296,12 +1352,15 @@ 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() + + +class Index(NamedTuple): + """A helper class to represent the index + information in the database schema. + """ + + name: str + columns: tuple[str, ...] diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index dfeb42707..f486df672 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -20,17 +20,19 @@ 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_ from re import Pattern -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar 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) @@ -122,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"] @@ -689,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/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/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/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/session.py b/beets/importer/session.py index 46277837e..123cc7248 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -15,9 +15,9 @@ 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 import config, logging, plugins, util from beets.importer.tasks import Action from beets.util import displayable_path, normpath, pipeline, syspath @@ -25,6 +25,9 @@ from . import stages as stagefuncs 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/stages.py b/beets/importer/stages.py index d99b742a2..0f8cf922b 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 @@ -386,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 710f4da50..e56157ed0 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 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 @@ -32,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 @@ -231,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() @@ -244,21 +247,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 + return [] 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 = [] @@ -677,6 +680,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): @@ -889,7 +894,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 b38381438..0f3d7d155 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 @@ -13,17 +13,17 @@ 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__ = [ - "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/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 cbee2a411..e26f2ced3 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 @@ -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]: @@ -229,7 +230,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 +282,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", @@ -616,13 +617,20 @@ class Album(LibModel): for item in self.items(): item.try_sync(write, move) + @cached_property + 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()) + class Item(LibModel): """Represent a song or track.""" + album_id: int | None + _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, @@ -716,6 +724,7 @@ class Item(LibModel): "mtime": types.DATE, "added": types.DATE, } + _indices = (dbcore.Index("idx_item_album_id", ("album_id",)),) _search_fields = ( "artist", @@ -742,7 +751,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]: @@ -1143,7 +1154,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 +1193,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 +1233,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 diff --git a/beets/logging.py b/beets/logging.py index 3ed5e5a84..0fc3a13e7 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 ( @@ -34,10 +35,25 @@ from logging import ( Handler, Logger, NullHandler, - RootLogger, StreamHandler, ) -from typing import TYPE_CHECKING, Any, Mapping, 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,17 +69,13 @@ __all__ = [ "getLogger", ] -if TYPE_CHECKING: - 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) +# - 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: @@ -80,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/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/metadata_plugins.py b/beets/metadata_plugins.py index b865167e4..7c08d72a3 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -9,69 +9,98 @@ 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 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 +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 + from collections.abc import Callable, Iterable, Iterator, Sequence from .autotag.hooks import AlbumInfo, Item, TrackInfo + Ret = TypeVar("Ret") + +# 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 + # This should also allow us to remove the type: ignore comments below. return [p for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc] +@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 fromm 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(album_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 diff --git a/beets/plugins.py b/beets/plugins.py index e10dcf80c..01d9d3327 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -20,12 +20,10 @@ 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 from pathlib import Path -from types import GenericAlias from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar import mediafile @@ -34,9 +32,10 @@ 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, 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 @@ -59,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" @@ -72,6 +70,7 @@ EventType = Literal[ "album_imported", "album_removed", "albuminfo_received", + "album_matched", "before_choose_candidate", "before_item_moved", "cli_exit", @@ -141,7 +140,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 +156,10 @@ 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: TFuncMap[str] + template_fields: TFuncMap[Item] + album_template_fields: TFuncMap[Album] name: str config: ConfigView @@ -161,7 +167,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: @@ -184,11 +190,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, ) @@ -219,14 +226,10 @@ 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 = {} - 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 = [] @@ -256,16 +259,19 @@ 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 - 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 @@ -368,8 +374,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 +388,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 @@ -414,16 +416,22 @@ def get_plugin_names() -> list[str]: # *contain* a `beetsplug` package. sys.path += paths plugins = unique_list(beets.config["plugins"].as_str_seq()) - # TODO: Remove in v3.0.0 - if ( - "musicbrainz" not in plugins - and "musicbrainz" in beets.config - and beets.config["musicbrainz"].get().get("enabled") - ): - plugins.append("musicbrainz") - beets.config.add({"disabled_plugins": []}) disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq()) + # TODO: Remove in v3.0.0 + mb_enabled = beets.config["musicbrainz"].flatten().get("enabled") + if mb_enabled: + deprecate_for_user( + log, + "'musicbrainz.enabled' configuration option", + "'plugins' configuration to explicitly add 'musicbrainz'", + ) + if "musicbrainz" not in plugins: + plugins.append("musicbrainz") + elif mb_enabled is False: + deprecate_for_user(log, "'musicbrainz.enabled' configuration option") + disabled_plugins.add("musicbrainz") + return [p for p in plugins if p not in disabled_plugins] @@ -449,9 +457,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) @@ -542,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. @@ -550,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 @@ -568,8 +575,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 @@ -595,21 +601,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]: 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/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..6eba85b1b 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, @@ -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( @@ -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)) @@ -524,7 +526,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 +882,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/beets/ui/__init__.py b/beets/ui/__init__.py index 60e201448..8db4dd79f 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -23,16 +23,15 @@ import errno import optparse import os.path import re +import shutil import sqlite3 -import struct import sys import textwrap import traceback -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 @@ -40,8 +39,15 @@ 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: + 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": try: @@ -111,11 +117,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 @@ -470,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, @@ -486,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, @@ -495,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 @@ -699,27 +717,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): @@ -1045,42 +1047,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. @@ -1090,7 +1097,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 @@ -1098,105 +1105,28 @@ 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) -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. @@ -1682,9 +1612,9 @@ 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() + return config_edit(options) test_lib = bool(lib) subcommands, lib = _setup(options, lib) 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..e1d0389a3 --- /dev/null +++ b/beets/ui/commands/__init__.py @@ -0,0 +1,67 @@ +# 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.util.deprecation import deprecate_imports + +from .completion import completion_cmd +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 + + +def __getattr__(name: str): + """Handle deprecated imports.""" + return deprecate_imports( + __name__, + { + "TerminalImportSession": "beets.ui.commands.import_.session", + "PromptChoice": "beets.util", + }, + name, + ) + + +# 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, + completion_cmd, +] + + +__all__ = ["default_commands"] diff --git a/beets/ui/commands/completion.py b/beets/ui/commands/completion.py new file mode 100644 index 000000000..776c389b4 --- /dev/null +++ b/beets/ui/commands/completion.py @@ -0,0 +1,117 @@ +"""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 print_completion(*args): + from beets.ui.commands import default_commands + + 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 + + +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/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/beets/ui/commands/config.py b/beets/ui/commands/config.py new file mode 100644 index 000000000..15d571324 --- /dev/null +++ b/beets/ui/commands/config.py @@ -0,0 +1,93 @@ +"""The 'config' command: show and edit user configuration.""" + +import os + +from beets import config, ui +from beets.util import displayable_path, editor_command, interactive_open + + +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_(displayable_path(filename)) + + # Open in editor. + elif opts.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: + 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(cli_options): + """Open a program to edit the user configuration. + An empty config file is created if no existing config file exists. + """ + path = cli_options.config or config.user_config_path() + editor = editor_command() + try: + if not os.path.isfile(path): + open(path, "w+").close() + 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..b2991f183 --- /dev/null +++ b/beets/ui/commands/import_/__init__.py @@ -0,0 +1,341 @@ +"""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 .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. + """ + # 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 byte_paths: + raise ui.UserError("none of the paths are importable") + + 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") +) +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 diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py new file mode 100644 index 000000000..bdc44d51f --- /dev/null +++ b/beets/ui/commands/import_/display.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from functools import cached_property +from typing import TYPE_CHECKING, TypedDict + +from typing_extensions import NotRequired + +from beets import 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 import autotag + from beets.autotag.distance import Distance + 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 + tags are changed according to `match`, which must be an AlbumMatch or + 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") + + @cached_property + def _indentation_config(self) -> confuse.Subview: + return config["ui"]["import"]["indentation"] + + @cached_property + def indent_header(self) -> str: + return ui.indent(self._indentation_config["match_header"].as_number()) + + @cached_property + def indent_detail(self) -> str: + return ui.indent(self._indentation_config["match_details"].as_number()) + + @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: 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() + 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) -> 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. + """ + # 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)}):" + ) + + artist_name_str = f"{self.match.info.artist} - {self.match.info.name}" + ui.print_( + self.indent_header + + dist_colorize(artist_name_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) -> None: + """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 = "", "" + left: Side + right: Side + 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_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} {type_}: ", + "contents": name_l, + "suffix": "", + } + right = {"prefix": "", "contents": name_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + else: + ui.print_(f"{self.indent_detail}*", f"{type_}:", name_r) + + 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. + 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: hooks.TrackInfo | Item) -> str: + """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: 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): + 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: Item, track_info: hooks.TrackInfo + ) -> tuple[str, str, bool]: + """Format colored track titles.""" + 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)) + 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: 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 + 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: Item, track_info: hooks.TrackInfo + ) -> tuple[Side, Side]: + """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: Side = { + "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", + "contents": lhs_title, + "suffix": f" {lhs_length}", + } + rhs: Side = {"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: list[tuple[Side, Side]]) -> None: + """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: Side) -> int: + """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): + match: autotag.hooks.AlbumMatch + + def show_match_tracks(self) -> None: + """Print out the tracks of the match, summarizing changes the match + suggests for them. + """ + pairs = sorted( + 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: 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 + # 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.""" + + match: autotag.hooks.TrackMatch + + +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_album, 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: 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(item.artist, item.title, match) + # Print the match header. + change.show_match_header() + # Print the match details. + change.show_match_details() + + +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. + """ + 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: str, dist: Distance) -> str: + """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: Distance) -> str: + """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: Distance, limit: int | None = None) -> str: + """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) + + return "" diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py new file mode 100644 index 000000000..42a809634 --- /dev/null +++ b/beets/ui/commands/import_/session.py @@ -0,0 +1,545 @@ +from collections import Counter +from itertools import chain + +from beets import autotag, config, importer, logging, plugins, ui +from beets.autotag import Recommendation +from beets.util import PromptChoice, displayable_path +from beets.util.units import human_bytes, human_seconds_short + +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 + + +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: + ui.print_(f"No matching release found for {itemcount} tracks.") + ui.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} - {match.info.name}" + if i == 0: + metadata = dist_colorize(metadata, match.distance) + else: + metadata = ui.colorize("text_highlight_minor", metadata) + line1 = [index, distance, metadata] + ui.print_(f" {' '.join(line1)}") + + # Penalties. + penalties = penalty_string(match.distance, 3) + if penalties: + ui.print_(f"{' ' * 13}{penalties}") + + # Disambiguation + disambig = disambig_string(match.info) + if disambig: + ui.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..186bfb6dd --- /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..206c24dcf --- /dev/null +++ b/beets/ui/commands/move.py @@ -0,0 +1,200 @@ +"""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, displayable_path, normpath, syspath + +from .utils import do_query + +if TYPE_CHECKING: + from beets.util import PathLike + +# 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: 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 diff --git a/beets/ui/commands/remove.py b/beets/ui/commands/remove.py new file mode 100644 index 000000000..997a4b48c --- /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..9286bf12b --- /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/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/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..87fba8236 --- /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, _ = 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 diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 8a1446adb..0b4698beb 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -28,7 +28,6 @@ import sys import tempfile import threading import traceback -import warnings import weakref from collections import Counter from collections.abc import Sequence @@ -43,12 +42,10 @@ from typing import ( TYPE_CHECKING, Any, AnyStr, - Callable, ClassVar, Generic, NamedTuple, TypeVar, - Union, cast, ) @@ -58,7 +55,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 @@ -69,8 +66,8 @@ WINDOWS_MAGIC_PREFIX = "\\\\?\\" T = TypeVar("T") T_co = TypeVar("T_co", covariant=True) _R_co = TypeVar("_R_co", covariant=True) -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 @@ -173,6 +170,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. @@ -582,10 +585,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: - os.link(syspath(path), syspath(dest)) + dest_path.hardlink_to(origin_path) except NotImplementedError: raise FilesystemError( "OS does not support hard links.link", @@ -1205,26 +1212,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/artresizer.py b/beets/util/artresizer.py index 5ecde5140..ae1476101 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -24,9 +24,10 @@ import platform import re import subprocess from abc import ABC, abstractmethod +from contextlib import suppress 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 +38,9 @@ from beets.util import ( syspath, ) +if TYPE_CHECKING: + from collections.abc import Mapping + PROXY_URL = "https://images.weserv.nl/" log = logging.getLogger("beets") @@ -265,7 +269,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>", @@ -295,7 +300,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), @@ -333,7 +339,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", @@ -348,7 +355,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", @@ -365,7 +372,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), @@ -397,14 +405,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", @@ -484,7 +494,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) @@ -825,7 +835,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 @@ -837,7 +847,8 @@ class ArtResizer: ) finally: if result_path != path_in: - os.unlink(path_in) + with suppress(OSError): + os.unlink(path_in) return result_path @property diff --git a/beets/util/deprecation.py b/beets/util/deprecation.py new file mode 100644 index 000000000..b9ffeae82 --- /dev/null +++ b/beets/util/deprecation.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import warnings +from importlib import import_module +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" + 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_user( + logger: Logger, old: str, new: str | None = None +) -> None: + logger.warning(_format_message(old, new)) + + +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 +) -> 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): + 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}'") 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/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..2c1e72e53 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__" @@ -189,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 @@ -213,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/_utils/art.py b/beetsplug/_utils/art.py index 656c303ce..fce650c5b 100644 --- a/beetsplug/_utils/art.py +++ b/beetsplug/_utils/art.py @@ -206,9 +206,14 @@ 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}) + + 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}) + clear_item(item, log) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py new file mode 100644 index 000000000..2fc821df9 --- /dev/null +++ b/beetsplug/_utils/musicbrainz.py @@ -0,0 +1,290 @@ +"""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 +from dataclasses import dataclass, field +from functools import cached_property, singledispatchmethod, wraps +from itertools import groupby +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 + +log = logging.getLogger(__name__) + + +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 + type for easier downstream consumption. + + Documentation: https://musicbrainz.org/doc/MusicBrainz_API + """ + + 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() + ) + + @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_resource( + self, resource: str, includes: list[str] | None = None, **kwargs + ) -> JSONDict: + """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 + target type so that callers receive a consistently structured mapping. + """ + if includes: + kwargs["inc"] = "+".join(includes) + + return self._group_relations( + self.get_json(f"{self.api_root}/{resource}", params=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. + + * 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_resource(entity, **kwargs)[f"{entity}s"] + + 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: Unpack[LookupKwargs] + ) -> JSONDict: + """Retrieve a recording by its MusicBrainz ID.""" + return self._lookup("recording", 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) + + @require_one_of("artist", "collection", "release", "work") + def browse_recordings( + self, **kwargs: Unpack[BrowseRecordingsKwargs] + ) -> list[JSONDict]: + """Browse recordings related to the given entities. + + 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 + 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: + """Mixin that provides a cached MusicBrainzAPI helper instance.""" + + @cached_property + def mb_api(self) -> MusicBrainzAPI: + return MusicBrainzAPI() diff --git a/beetsplug/_utils/requests.py b/beetsplug/_utils/requests.py new file mode 100644 index 000000000..61a9992be --- /dev/null +++ b/beetsplug/_utils/requests.py @@ -0,0 +1,195 @@ +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 requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from beets import __version__ + +if TYPE_CHECKING: + from collections.abc import Iterator + + +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 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 TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta): + """HTTP session with sensible defaults. + + * default beets User-Agent header + * default request timeout + * automatic retries on transient connection or server 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( + total=6, + backoff_factor=0.5, + # 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) + + def request(self, *args, **kwargs): + """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) + 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. + + Usage: + 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 + + 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 + ] + + 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) -> TimeoutAndRetrySession: + return self.create_session() + + 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 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/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/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/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..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 @@ -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.""" @@ -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: @@ -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/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index fa23f2b0e..e4f38af88 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -27,8 +27,17 @@ import gi from beets import ui -gi.require_version("Gst", "1.0") -from gi.repository import GLib, Gst # noqa: E402 +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 Gst.init(None) @@ -106,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 @@ -196,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 9ae6d47d5..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 @@ -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/chroma.py b/beetsplug/chroma.py index 192310fb8..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 functools import cached_property, partial -from typing import Iterable +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/convert.py b/beetsplug/convert.py index e72f8c75a..af1279299 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] @@ -258,10 +274,15 @@ class ConvertPlugin(BeetsPlugin): pretend, hardlink, link, - playlist, + _, + force, ) = 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, @@ -272,6 +293,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, threads, items, + force, ) # Utilities converted from functions to methods on logging overhaul @@ -347,6 +369,7 @@ class ConvertPlugin(BeetsPlugin): pretend=False, link=False, hardlink=False, + force=False, ): """A pipeline thread that converts `Item` objects from a library. @@ -372,11 +395,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 +429,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 +600,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, link, playlist, + force, ) = self._get_opts_and_config(opts) if opts.album: @@ -613,6 +637,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, threads, items, + force, ) if playlist: @@ -735,7 +760,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 +770,7 @@ class ConvertPlugin(BeetsPlugin): hardlink, link, playlist, + force, ) def _parallel_convert( @@ -758,13 +784,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/beetsplug/deezer.py b/beetsplug/deezer.py index 3eaca1e05..61b028361 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -18,27 +18,26 @@ from __future__ import annotations import collections import time -from typing import TYPE_CHECKING, Literal, Sequence +from typing import TYPE_CHECKING, ClassVar, Literal 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 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/discogs.py b/beetsplug/discogs/__init__.py similarity index 69% rename from beetsplug/discogs.py rename to beetsplug/discogs/__init__.py index be1cf97fa..dc88e0f14 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs/__init__.py @@ -27,13 +27,12 @@ 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 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 @@ -42,15 +41,20 @@ from beets.autotag.distance import string_dist from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin +from .states import DISAMBIGUATION_RE, ArtistState, TracklistState + if TYPE_CHECKING: - from collections.abc import Callable, Iterable + 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, @@ -60,7 +64,6 @@ CONNECTION_ERRORS = ( DiscogsAPIError, ) - TRACK_INDEX_RE = re.compile( r""" (.*?) # medium: everything before medium_index. @@ -76,50 +79,6 @@ TRACK_INDEX_RE = re.compile( 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]] - - -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) - class DiscogsPlugin(MetadataSourcePlugin): def __init__(self): @@ -277,7 +236,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]: @@ -343,25 +301,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 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 @@ -391,11 +330,10 @@ 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 + # Information for the album artist + albumartist = ArtistState.from_config( + self.config, artist_data, for_album_artist=True ) - artist_credit = album_artist_anv album = re.sub(r" +", " ", result.title) album_id = result.data["id"] @@ -405,19 +343,13 @@ class DiscogsPlugin(MetadataSourcePlugin): # 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), + ArtistState.from_config(self.config, 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.artist == 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")) @@ -447,11 +379,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 @@ -474,9 +402,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.info, # Unpacks values to satisfy the keyword arguments tracks=tracks, albumtype=albumtype, va=va, @@ -494,7 +420,7 @@ class DiscogsPlugin(MetadataSourcePlugin): data_url=data_url, discogs_albumid=discogs_albumid, discogs_labelid=labelid, - discogs_artistid=album_artist_id, + discogs_artistid=albumartist.artist_id, cover_art_url=cover_art_url, ) @@ -516,63 +442,22 @@ class DiscogsPlugin(MetadataSourcePlugin): else: return None - def _process_clean_tracklist( - self, - clean_tracklist: list[Track], - album_artist_data: tuple[str, str, str | None], - ) -> tuple[list[TrackInfo], dict[int, str], int, list[str], list[str]]: - # Distinct works and intra-work divisions, as defined by index tracks. - tracks: list[TrackInfo] = [] - index_tracks = {} - index = 0 - divisions: list[str] = [] - next_divisions: list[str] = [] - for track in clean_tracklist: - # Only real tracks have `position`. Otherwise, it's an index track. - if track["position"]: - index += 1 - if next_divisions: - # End of a block of index tracks: update the current - # divisions. - divisions += next_divisions - del next_divisions[:] - track_info = self.get_track_info( - track, index, divisions, album_artist_data - ) - track_info.track_alt = track["position"] - tracks.append(track_info) - else: - next_divisions.append(track["title"]) - # We expect new levels of division at the beginning of the - # tracklist (and possibly elsewhere). - try: - divisions.pop() - except IndexError: - pass - index_tracks[index + 1] = track["title"] - return tracks, index_tracks, index, divisions, next_divisions - def get_tracks( self, tracklist: list[Track], - album_artist_data: tuple[str, str, str | None], + albumartistinfo: ArtistState, ) -> 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 # 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 - processed = self._process_clean_tracklist( - clean_tracklist, album_artist_data - ) - tracks, index_tracks, index, divisions, next_divisions = processed + 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 @@ -581,32 +466,36 @@ 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 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 track in 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_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 +506,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 @@ -625,69 +514,25 @@ 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 cast(list[TrackInfo], tracks) + return t.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 +544,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 +553,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. @@ -737,17 +625,10 @@ class DiscogsPlugin(MetadataSourcePlugin): track: Track, index: int, divisions: list[str], - album_artist_data: tuple[str, str, str | None], - ) -> IntermediateTrackInfo: + albumartistinfo: ArtistState, + ) -> 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 - title = track["title"] if self.config["index_tracks"]: prefix = ", ".join(divisions) @@ -756,44 +637,26 @@ 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", []): - 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"] - ) length = self.get_track_length(track["duration"]) + # If artists are found on the track, we will use those instead + artistinfo = ArtistState.from_config( + self.config, + [ + *(track.get("artists") or albumartistinfo.raw_artists), + *track.get("extraartists", []), + ], + ) - # 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}" - ) - 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, + **artistinfo.info, + length=length, + index=index, + ), + medium, + medium_index, ) @staticmethod diff --git a/beetsplug/discogs/states.py b/beetsplug/discogs/states.py new file mode 100644 index 000000000..2a8173ba5 --- /dev/null +++ b/beetsplug/discogs/states.py @@ -0,0 +1,237 @@ +# 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 + +import re +from dataclasses import asdict, dataclass, field +from functools import cached_property +from typing import TYPE_CHECKING, NamedTuple + +from beets import config + +from .types import ArtistInfo + +if TYPE_CHECKING: + from confuse import ConfigView + + from beets.autotag.hooks import TrackInfo + + from . import DiscogsPlugin + from .types import Artist, Track, TracklistInfo + +DISAMBIGUATION_RE = re.compile(r" \(\d+\)") + + +@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_config( + cls, + config: ConfigView, + artists: list[Artist], + for_album_artist: bool = False, + ) -> ArtistState: + return cls( + artists, + 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), + ) + + +@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/edit.py b/beetsplug/edit.py index f6fadefd0..46e756122 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.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. @@ -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.") @@ -274,23 +275,18 @@ 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. + 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() + self.apply_data(objs, new_data, old_data) continue # Remove the temporary file before returning. @@ -379,9 +375,7 @@ 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() + return None def importer_edit_candidate(self, session, task): """Callback for invoking the functionality during an interactive 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/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 37e7426f6..ef311cbbd 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 @@ -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}" @@ -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( @@ -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()), ) @@ -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( @@ -1118,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)), @@ -1293,7 +1303,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, @@ -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( @@ -1574,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 @@ -1587,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/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/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/ftintitle.py b/beetsplug/ftintitle.py index dd681a972..fde7ff92a 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -17,13 +17,38 @@ from __future__ import annotations import re +from functools import cached_property, lru_cache 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 + + +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", +) def split_on_feat( @@ -36,11 +61,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: @@ -98,7 +135,46 @@ 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): + @cached_property + 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 true and we match any bracket content. + kw = rf"\b(?={kw_inner})\b" if kw_inner else "" + return re.compile( + rf""" + (?: # 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, + ) + def __init__(self) -> None: super().__init__() @@ -110,6 +186,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], + "bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS), } ) @@ -129,6 +206,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) @@ -207,8 +288,10 @@ 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) - new_title = f"{item.title} {new_format}" + formatted = feat_format.format(feat_part) + new_title = self.insert_ft_into_title( + item.title, formatted, self.bracket_keywords + ) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -253,3 +336,28 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item, feat_part, drop_feat, keep_in_artist_field, custom_words ) return True + + @staticmethod + def find_bracket_position( + title: str, keywords: list[str] | None = None + ) -> int | None: + 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 + + @classmethod + def insert_ft_into_title( + 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. + """ + 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/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/beetsplug/importsource.py b/beetsplug/importsource.py new file mode 100644 index 000000000..e42be3f1f --- /dev/null +++ b/beetsplug/importsource.py @@ -0,0 +1,169 @@ +"""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().__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): + if task.skip: + return + 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/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/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 902cef9ef..1c91688a6 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, Any import pylast import yaml @@ -38,6 +38,9 @@ from beets.library import Album, Item from beets.util import plurality, unique_list if TYPE_CHECKING: + import optparse + from collections.abc import Callable + from beets.library import LibModel LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) @@ -52,7 +55,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). """ @@ -61,15 +68,15 @@ def flatten_tree(elem, path, branches): 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, 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 +96,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( @@ -106,18 +113,17 @@ class LastGenrePlugin(plugins.BeetsPlugin): "separator": ", ", "prefer_specific": False, "title_case": True, - "extended_debug": False, "pretend": False, } ) 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() @@ -162,6 +168,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize + 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) + @property def sources(self) -> tuple[str, ...]: """A tuple of allowed genre sources. May contain 'track', @@ -178,7 +189,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): @@ -187,7 +198,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. """ @@ -255,9 +266,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) @@ -274,8 +287,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. @@ -289,32 +304,27 @@ 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] - 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): - """Return raw album genres from Last.fm for this Item or Album.""" + 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, 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: str) -> list[str]: + """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: str, tracktitle: str) -> list[str]: + """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. @@ -349,7 +359,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 @@ -369,7 +379,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 @@ -402,14 +414,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 ): @@ -418,20 +430,36 @@ 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" + 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 = [] + assert isinstance(obj, Album) # Type narrowing for mypy 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: @@ -454,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(): @@ -497,7 +532,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", @@ -554,16 +589,11 @@ 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): + 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 @@ -573,10 +603,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 @@ -588,11 +624,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 [] @@ -607,6 +644,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 diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py index 2aa4e7ad6..fa73bd6b8 100644 --- a/beetsplug/listenbrainz.py +++ b/beetsplug/listenbrainz.py @@ -2,15 +2,16 @@ import datetime -import musicbrainzngs import requests from beets import config, ui from beets.plugins import BeetsPlugin from beetsplug.lastimport import process_tracks +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/" @@ -129,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( + "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.""" @@ -207,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/lyrics.py b/beetsplug/lyrics.py index d245d6a14..7995daefc 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,10 +24,9 @@ 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, Iterable, Iterator, NamedTuple +from typing import TYPE_CHECKING, ClassVar, NamedTuple from urllib.parse import quote, quote_plus, urlencode, urlparse import langdetect @@ -36,12 +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 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 @@ -54,41 +57,12 @@ 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() + def __init__(self, *args, **kwargs) -> None: + super().__init__("Captcha is required", *args, **kwargs) # Utilities. @@ -184,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) @@ -206,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. @@ -216,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]: @@ -249,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 @@ -354,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(NotFoundError): - yield [self.fetch_json(self.GET_URL, params=get_params)] + with suppress(HTTPNotFoundError): + yield [self.get_json(self.GET_URL, params=get_params)] @classmethod def pick_best_match(cls, lyrics: Iterable[LRCLyrics]) -> LRCLyrics | None: @@ -382,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", @@ -405,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 @@ -530,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 @@ -560,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, @@ -589,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) @@ -615,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", @@ -645,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: @@ -655,12 +643,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 @@ -716,7 +704,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", []): @@ -741,11 +729,13 @@ 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 = " | " - remove_translations = partial(re.compile(r" / [^\n]+").sub, "") + remove_translations = staticmethod( + partial(re.compile(r" / [^\n]+").sub, "") + ) _log: Logger api_key: str @@ -949,14 +939,14 @@ class RestFiles: ui.print_(textwrap.dedent(text)) -class LyricsPlugin(RequestHandler, plugins.BeetsPlugin): - BACKEND_BY_NAME = { +class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin): + BACKEND_BY_NAME: ClassVar[dict[str, type[Backend]]] = { b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch] } @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/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 2f9ef709e..f89670dd3 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -13,48 +13,151 @@ # 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, ClassVar -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 -SUBMISSION_CHUNK_SIZE = 200 -FETCH_CHUNK_SIZE = 100 -UUID_REGEX = r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$" +from ._utils.musicbrainz import MusicBrainzAPI + +if TYPE_CHECKING: + 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 + +UUID_PAT = re.compile(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): + """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. -def submit_albums(collection_id, release_ids): - """Add all of the release IDs to the indicated collection. Multiple - requests are made if there are many release IDs to submit. + 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. """ - 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) + auth: HTTPDigestAuth = field(init=False) -class MusicBrainzCollectionPlugin(BeetsPlugin): - def __init__(self): - super().__init__() + def __post_init__(self) -> None: + super().__post_init__() config["musicbrainz"]["pass"].redact = True - musicbrainzngs.auth( + self.auth = HTTPDigestAuth( config["musicbrainz"]["user"].as_str(), config["musicbrainz"]["pass"].as_str(), ) + + 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 browse_collections(self) -> list[JSONDict]: + """Get all collections for the authenticated user.""" + return self._browse("collection") + + +@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 + + data: JSONDict + mb_api: MusicBrainzUserAPI + + @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}, + )["releases"] + + @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)}") + + +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. + """ + collection.add_releases(release_ids) + + +class MusicBrainzCollectionPlugin(BeetsPlugin): + def __init__(self) -> None: + super().__init__() self.config.add( { "auto": False, @@ -65,47 +168,32 @@ 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() + + @cached_property + def collection(self) -> MBCollection: + if not (collections := self.mb_api.browse_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] - - 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(0, release_count, FETCH_CHUNK_SIZE): - albums_in_collection += _fetch(offset)[0] - offset += FETCH_CHUNK_SIZE - - return albums_in_collection + return MBCollection(collection, self.mb_api) def commands(self): mbupdate = Subcommand("mbupdate", help="Update MusicBrainz collection") @@ -120,45 +208,33 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): mbupdate.func = self.update_collection return [mbupdate] - def remove_missing(self, collection_id, 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, - ) - - 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_id = 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_id, album_ids) + self._log.info("Updating MusicBrainz collection {}...", collection.id) + collection.add_releases(album_ids) if remove_missing: - self.remove_missing(collection_id, 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/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py new file mode 100644 index 000000000..d084d1531 --- /dev/null +++ b/beetsplug/mbpseudo.py @@ -0,0 +1,350 @@ +# 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.""" + +from __future__ import annotations + +import itertools +from copy import deepcopy +from typing import TYPE_CHECKING, Any + +import mediafile +from typing_extensions import override + +from beets import config +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 +from beets.util.id_extractors import extract_release_id +from beetsplug.musicbrainz import ( + MusicBrainzPlugin, + _merge_pseudo_and_actual_album, + _preferred_alias, +) + +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 + +_STATUS_PSEUDO = "Pseudo-Release" + + +class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): + def __init__(self) -> None: + super().__init__() + + 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) + + # noinspection PyMethodMayBeStatic + def _on_plugins_loaded(self): + 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" + ) + + @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): + 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 # 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: + official_release = super().album_info(release) + + if release.get("status") == _STATUS_PSEUDO: + return official_release + + if (ids := self._intercept_mb_release(release)) and ( + album_id := self._extract_id(ids[0]) + ): + 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): + 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 + + 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): + return [] + + return [ + pr_id + for rel in data.get("release-relations", []) + if (pr_id := self._wanted_pseudo_release_id(album_id, rel)) + is not 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, + album_id: str, + 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): + self._log.debug( + "Adding pseudo-release {0} for main release {1}", + release["id"], + album_id, + ) + return release["id"] + 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("aliases", []) + 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("name"): + 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, + 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): + self._log.debug( + "Switching {0} to pseudo-release source for final proposal", + album_info.album_id, + ) + album_info.use_pseudo_as_ref() + 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" + + @override + def _extract_id(self, url: str) -> str | None: + return extract_release_id("MusicBrainz", url) + + +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 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) + + 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, 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) -> 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: + 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 diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index 93e88dc9e..7136f4c29 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 @@ -70,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 3f7daec6c..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 @@ -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/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 cbdda4599..d2aae14e9 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -15,18 +15,26 @@ """List missing tracks.""" -from collections import defaultdict -from collections.abc import Iterator +from __future__ import annotations -import musicbrainzngs -from musicbrainzngs.musicbrainz import MusicBrainzError +from collections import defaultdict +from typing import TYPE_CHECKING, ClassVar + +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}$" @@ -85,10 +93,10 @@ def _item(track_info, album_info, album_id): ) -class MissingPlugin(BeetsPlugin): +class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): """List missing tracks""" - album_types = { + album_types: ClassVar[dict[str, types.Type]] = { "missing": types.INTEGER, } @@ -189,19 +197,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/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/musicbrainz.py b/beetsplug/musicbrainz.py index 8e259e94b..aac20e9ac 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -16,24 +16,27 @@ from __future__ import annotations -import traceback 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 from confuse.exceptions import NotFoundError 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 +from ._utils.musicbrainz import MusicBrainzAPIMixin +from ._utils.requests import HTTPNotFoundError + if TYPE_CHECKING: + from collections.abc import Iterable, Sequence from typing import Literal from beets.library import Item @@ -55,55 +58,33 @@ FIELDS_TO_MB_KEYS = { "year": "date", } -musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/") +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", +] -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 = 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", - "tags", - } - & set(musicbrainzngs.VALID_INCLUDES["release"]) -) - -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", @@ -112,19 +93,18 @@ 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 -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 - alias is found. +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 """ 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,15 +114,18 @@ 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 = [] for alias in valid_aliases: if ( alias["locale"] == locale - and "primary" in alias - and alias.get("type", "").lower() not in ignored_alias_types + and alias.get("primary") + and (alias.get("type") or "").lower() not in ignored_alias_types ): matches.append(alias) @@ -152,6 +135,8 @@ def _preferred_alias(aliases: list[JSONDict]): return matches[0] + return None + def _multi_artist_credit( credit: list[JSONDict], include_join_phrase: bool @@ -164,36 +149,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, @@ -263,10 +245,11 @@ 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"]: - return country, event["date"] + if area := event.get("area"): + if country in area["iso-3166-1-codes"]: + return country, event["date"] except KeyError: pass @@ -297,33 +280,9 @@ 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 | None: +) -> beets.autotag.hooks.AlbumInfo: """ Merges a pseudo release with its actual release. @@ -361,7 +320,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" + def __init__(self): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. @@ -369,11 +332,8 @@ 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": { "discogs": False, "bandcamp": False, @@ -389,20 +349,11 @@ 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) - # 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, @@ -449,9 +400,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"): @@ -459,13 +410,13 @@ 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 = [] 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"] @@ -474,7 +425,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"] @@ -492,7 +443,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": @@ -524,9 +475,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: @@ -535,35 +486,32 @@ 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.mb_api.browse_recordings( + release=release["id"], offset=i + ) ) 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: @@ -578,7 +526,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 @@ -632,7 +580,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, @@ -659,12 +607,8 @@ class MusicBrainzPlugin(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 = [] @@ -672,9 +616,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 @@ -690,8 +634,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]": @@ -705,18 +649,18 @@ 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" 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: @@ -732,11 +676,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", @@ -774,17 +718,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 @@ -801,20 +748,9 @@ 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()) - } - self._log.debug( - "Searching for MusicBrainz {}s with: {!r}", query_type, filters + return self.mb_api.search( + query_type, filters, limit=self.config["search_limit"].get() ) - try: - method = getattr(musicbrainzngs, f"search_{query_type}s") - res = method(limit=self.config["search_limit"].get(), **filters) - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, f"{query_type} search", filters, traceback.format_exc() - ) - return res[f"{query_type}-list"] def candidates( self, @@ -826,7 +762,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 @@ -849,29 +788,35 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug("Invalid MBID ({}).", album_id) return None + # A 404 error here is fine. e.g. re-importing a release that has + # been deleted on MusicBrainz. try: - res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES) - - # resolve linked release relations - actual_res = None - - if res["release"].get("status") == "Pseudo-Release": - actual_res = _find_actual_release_from_pseudo_release(res) - - except musicbrainzngs.ResponseError: - self._log.debug("Album ID match failed.") + res = self.mb_api.get_release(albumid, includes=RELEASE_INCLUDES) + except HTTPNotFoundError: + self._log.debug("Release {} not found on MusicBrainz.", albumid) return None - except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError( - exc, "get release by ID", albumid, traceback.format_exc() - ) + + # 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.mb_api.get_release( + rel["release"]["id"], includes=RELEASE_INCLUDES + ) # 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 @@ -886,13 +831,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin): self._log.debug("Invalid MBID ({}).", track_id) return None - try: - res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES) - except 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() + with suppress(HTTPNotFoundError): + return self.track_info( + self.mb_api.get_recording(trackid, includes=TRACK_INCLUDES) ) - return self.track_info(res["recording"]) + + return None diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py index eb2fd8f11..15fcdefa8 100644 --- a/beetsplug/parentwork.py +++ b/beetsplug/parentwork.py @@ -16,59 +16,19 @@ and work composition date """ -import musicbrainzngs +from __future__ import annotations + +from typing import Any + +import requests from beets import 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 +from ._utils.musicbrainz import MusicBrainzAPIMixin -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): - """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 - - -class ParentWorkPlugin(BeetsPlugin): +class ParentWorkPlugin(MusicBrainzAPIMixin, BeetsPlugin): def __init__(self): super().__init__() @@ -130,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( @@ -149,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 @@ -190,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 @@ -233,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/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/beetsplug/playlist.py b/beetsplug/playlist.py index 07c12e0e0..a1f9fff39 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -10,17 +10,22 @@ # # 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, ClassVar 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 + + from beets.dbcore.query import FieldQueryType + def is_m3u_file(path: str) -> bool: return Path(path).suffix.lower() in {".m3u", ".m3u8"} @@ -82,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/random.py b/beetsplug/random.py index c791af414..aa65dd5d5 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -1,5 +1,6 @@ # 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 @@ -12,26 +13,36 @@ # 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 + +import random +from itertools import groupby, islice +from operator import methodcaller +from typing import TYPE_CHECKING from beets.plugins import BeetsPlugin -from beets.random import random_objs from beets.ui import Subcommand, print_ +if TYPE_CHECKING: + import optparse + from collections.abc import Iterable -def random_func(lib, opts, args): + from beets.library import LibModel, Library + + +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=objs, + equal_chance_field=opts.field, + number=opts.number, + time_minutes=opts.time, + equal_chance=opts.equal_chance, + ): print_(format(obj)) @@ -48,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", @@ -57,6 +68,13 @@ random_cmd.parser.add_option( type="float", help="total length in minutes of objects to choose", ) +random_cmd.parser.add_option( + "--field", + action="store", + type="string", + default="albumartist", + help="field to use for equal chance sampling (default: albumartist)", +) random_cmd.parser.add_all_common_options() random_cmd.func = random_func @@ -64,3 +82,77 @@ random_cmd.func = random_func class Random(BeetsPlugin): def commands(self): return [random_cmd] + + +def _equal_chance_permutation( + 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 field so we can sample from them. + get_attr = methodcaller("get", field) + + 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 + + while groups: + group = random.choice(list(groups.keys())) + yield groups[group].pop() + if not groups[group]: + del groups[group] + + +def _take_time( + iter: Iterable[LibModel], + secs: float, +) -> 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. + """ + total_time = 0.0 + for obj in iter: + length = obj.length + if total_time + length <= secs: + yield obj + total_time += length + + +def random_objs( + objs: Iterable[LibModel], + equal_chance_field: str, + number: int = 1, + time_minutes: float | None = None, + equal_chance: bool = False, +) -> Iterable[LibModel]: + """Get a random subset of items, optionally constrained by time or count. + + 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 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 + # field-balanced way. + if equal_chance: + perm = _equal_chance_permutation(objs, equal_chance_field) + else: + perm = list(objs) + random.shuffle(perm) + + # Select objects by time our count. + if time_minutes: + return _take_time(perm, time_minutes * 60) + else: + return islice(perm, number) 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/replaygain.py b/beetsplug/replaygain.py index 3e777d977..e83345059 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,8 +28,9 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass from multiprocessing.pool import ThreadPool +from pathlib import Path from threading import Event, Thread -from typing import TYPE_CHECKING, Any, Callable, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar from beets import ui from beets.plugins import BeetsPlugin @@ -36,7 +38,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 @@ -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,25 +567,21 @@ 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): - raise FatalReplayGainError( - f"replaygain command does not exist: {self.command}" - ) - else: - # Check whether the program is in $PATH. - for cmd in ("mp3gain", "aacgain"): - try: - call([cmd, "-v"], self._log) - self.command = cmd - except OSError: - pass - if not self.command: + if (cmd_name := cmd_path.name) not in supported_tools: raise FatalReplayGainError( - "no replaygain command found: install mp3gain or aacgain" + 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: + raise FatalReplayGainError( + f"replaygain command not found: {cmd_path}" ) self.noclip = config["noclip"].get(bool) @@ -608,11 +616,7 @@ 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 - return True + return item.format in self.SUPPORTED_FORMATS_BY_TOOL[self.cmd_name] def compute_gain( self, @@ -642,11 +646,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 +1109,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/smartplaylist.py b/beetsplug/smartplaylist.py index 8203ce4ef..e22a65787 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -14,13 +14,15 @@ """Generates smart playlists based on beets queries.""" +from __future__ import annotations + import os +from typing import TYPE_CHECKING, Any, TypeAlias 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, Query, Sort from beets.library import Album, Item, parse_query_string from beets.plugins import BeetsPlugin from beets.plugins import send as send_event @@ -34,9 +36,20 @@ 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[ + str, + tuple[PlaylistQuery, Sort | None], + tuple[PlaylistQuery, Sort | None], +] + class SmartPlaylistPlugin(BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( { @@ -55,13 +68,13 @@ class SmartPlaylistPlugin(BeetsPlugin): ) self.config["prefix"].redact = True # May contain username/password. - self._matched_playlists = None - self._unmatched_playlists = None + self._matched_playlists: set[PlaylistMatch] = set() + self._unmatched_playlists: set[PlaylistMatch] = set() 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 " @@ -124,18 +137,18 @@ class SmartPlaylistPlugin(BeetsPlugin): spl_update.func = self.update_cmd return [spl_update] - def update_cmd(self, lib, opts, args): + def update_cmd(self, lib: Library, 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] @@ -151,16 +164,32 @@ 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 _parse_one_query( + self, playlist: dict[str, Any], key: str, model_cls: type + ) -> tuple[PlaylistQuery, Sort | None]: + 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[QueryAndSort, ...] = 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. - 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)). @@ -169,7 +198,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() @@ -179,55 +208,37 @@ 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) - 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 - queries, sorts = zip( - *(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 - - 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(self, model, query, album_query): - if album_query and isinstance(model, Album): - return album_query.match(model) - if query and isinstance(model, Item): - return query.match(model) + def _matches_query(self, model: Item | Album, query: PlaylistQuery) -> 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: PlaylistQuery, + album_query: PlaylistQuery, + ) -> 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, model): + def db_change(self, lib: Library, model: Item | Album) -> None: if self._unmatched_playlists is None: self.build_queries() @@ -240,7 +251,7 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists -= self._matched_playlists - def update_playlists(self, lib, pretend=False): + def update_playlists(self, lib: Library, pretend: bool = False) -> None: if pretend: self._log.info( "Showing query results for {} smart playlists...", @@ -260,7 +271,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 @@ -270,9 +281,28 @@ class SmartPlaylistPlugin(BeetsPlugin): self._log.info("Creating playlist {}", name) items = [] - if query: + # 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: + 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 album_query: + + if isinstance(album_query, (list, tuple)): + for q, sort in album_query: + for album in lib.albums(q, sort): + 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()) @@ -292,7 +322,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]: @@ -336,7 +368,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( @@ -348,6 +380,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 diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 7cb9e330d..9b26b1e49 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -13,8 +13,9 @@ # 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 @@ -23,9 +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 +from typing import TYPE_CHECKING, Any, ClassVar, Literal import confuse import requests @@ -34,14 +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 @@ -50,13 +51,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 @@ -77,12 +79,16 @@ class APIError(Exception): pass +class AudioFeaturesUnavailableError(Exception): + """Raised when audio features API returns 403 (deprecated).""" + + pass + + class SpotifyPlugin( - SearchApiMetadataSourcePlugin[ - Union[SearchResponseAlbums, SearchResponseTracks] - ] + 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, @@ -108,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", @@ -133,13 +139,19 @@ class SpotifyPlugin( "region_filter": None, "regex": [], "client_id": "4e414367a1d14c75a5c5129a627fcab8", - "client_secret": "f82bdc09b2254f1a8286815d02fd46dc", + "client_secret": "4a9b5b7848e54e118a7523b1c7c3e1e5", "tokenfile": "spotify_token.json", } ) 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.setup() def setup(self): @@ -158,9 +170,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() @@ -201,9 +211,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 + :param dict params: (optional) list of tuples or bytes to send in the query string for the :class:`Request`. - :type params: dict + """ if retry_count > max_retries: @@ -246,6 +256,17 @@ 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 url.startswith(self.audio_features_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 @@ -268,21 +289,37 @@ 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: 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 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: + 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: """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 @@ -293,7 +330,10 @@ 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("-") @@ -336,8 +376,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] 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, albumtype=album_data["album_type"], va=len(album_data["artists"]) == 1 @@ -356,9 +398,14 @@ 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"]) + artists_names, artists_ids = self._multi_artist_credit( + track_data["artists"] + ) + artist = ", ".join(artists_names) # Get album information for spotify tracks try: @@ -371,8 +418,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] 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, index=track_data["track_number"], medium=track_data["disc_number"], @@ -385,6 +434,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)): @@ -425,10 +475,11 @@ class SpotifyPlugin( """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 @@ -523,13 +574,16 @@ 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. - :return: 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 = [] failures = [] @@ -640,12 +694,14 @@ 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) - :param 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] @@ -691,13 +747,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: @@ -721,11 +782,34 @@ 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: + # 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) return None diff --git a/beetsplug/the.py b/beetsplug/the.py index 664d4c01e..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__() @@ -58,9 +59,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/beetsplug/titlecase.py b/beetsplug/titlecase.py new file mode 100644 index 000000000..d722d4d16 --- /dev/null +++ b/beetsplug/titlecase.py @@ -0,0 +1,258 @@ +# 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.""" + +from __future__ import annotations + +import re +from functools import cached_property +from typing import TYPE_CHECKING, TypedDict + +from titlecase import titlecase + +from beets import ui +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" + + +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": [], + "separators": [], + "force_lowercase": False, + "small_first_last": True, + "the_artist": True, + "all_caps": False, + "all_lowercase": False, + "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 + 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. 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 + + # 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 separators(self) -> re.Pattern[str] | None: + if separators := "".join( + dict.fromkeys(self.config["separators"].as_str_seq()) + ): + 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") + + 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.debug( + 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.debug(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.separators: + if len(splits := self.separators.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 + ) + # 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: + 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/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 7b13cf016..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. @@ -232,7 +244,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/_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 cce30a284..25a0c1365 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,18 +9,174 @@ Unreleased New features: -- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. -- :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. - Bug fixes: For packagers: Other changes: +2.6.1 (February 02, 2026) +------------------------- + +Bug fixes: + +- Make ``packaging`` a required dependency. :bug:`6332` + +2.6.0 (February 01, 2026) +------------------------- + +Beets now requires Python 3.10 or later since support for EOL Python 3.9 has +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 + 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 + 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. +- 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/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 + (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. +- :doc:`plugins/discogs`: Added support for multi value fields. :bug:`6068` +- :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. +- :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: + +- :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` +- :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` +- :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` +- :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. +- Running `beet --config config -e` now edits `` rather than + 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. +- :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. +- Sanitize log messages by removing control characters preventing terminal + rendering issues. +- 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. +- :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. +- :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` +- :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` +- 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: + +- 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: + +- The minimum supported Python version is now 3.10. +- An unused dependency on ``mock`` has been removed. + +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. +- :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. +- 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) ------------------------ @@ -1303,9 +1459,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` @@ -1356,7 +1512,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`. @@ -1795,7 +1951,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/conf.py b/docs/conf.py index 8d2bae130..15ba699cd 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.1" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -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/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..a593580f6 --- /dev/null +++ b/docs/dev/paths.rst @@ -0,0 +1,64 @@ +Handling Paths +============== + +``pathlib`` provides a clean, cross-platform API for working with filesystem +paths. + +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")) 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/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/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/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`` ------------------------- 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/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index abbe2460d..0140ceff8 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. 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/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 1d2ec5c20..7daea5582 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -32,6 +32,26 @@ 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", "remaster", + "remastered", "remix", "rmx", "unabridged", "unreleased", + "version", "vip"] + +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/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/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..1583ac5ab 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -84,10 +84,10 @@ databases. They share the following configuration options: fromfilename ftintitle fuzzy - gmusic hook ihate importadded + importsource importfeeds info inline @@ -101,6 +101,7 @@ databases. They share the following configuration options: loadext lyrics mbcollection + mbpseudo mbsubmit mbsync metasync @@ -126,6 +127,7 @@ databases. They share the following configuration options: substitute the thumbnails + titlecase types unimported web @@ -152,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. @@ -617,7 +622,7 @@ beets-youtube_ .. _beets-setlister: https://github.com/tomjaspers/beets-setlister -.. _beets-usertag: https://github.com/igordertigor/beets-usertag +.. _beets-usertag: https://github.com/edgars-supe/beets-usertag .. _beets-webm3u: https://github.com/mgoltzsche/beets-webm3u 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. diff --git a/docs/plugins/listenbrainz.rst b/docs/plugins/listenbrainz.rst index 21629ab54..ceff0e800 100644 --- a/docs/plugins/listenbrainz.rst +++ b/docs/plugins/listenbrainz.rst @@ -6,8 +6,8 @@ ListenBrainz Plugin The ListenBrainz plugin for beets allows you to interact with the ListenBrainz service. -Installation ------------- +Configuration +------------- To enable the ListenBrainz plugin, add the following to your beets configuration file (config.yaml_): diff --git a/docs/plugins/mbpseudo.rst b/docs/plugins/mbpseudo.rst new file mode 100644 index 000000000..56658db26 --- /dev/null +++ b/docs/plugins/mbpseudo.rst @@ -0,0 +1,103 @@ +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, 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. 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 + +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` if desired (see also +:ref:`metadata-source-plugin-configuration`). An example with multiple data +sources may look like this: + +.. code-block:: yaml + + plugins: mbpseudo deezer + + import: + languages: en + + mbpseudo: + data_source_mismatch_penalty: 0 + scripts: + - Latn + + 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/docs/plugins/missing.rst b/docs/plugins/missing.rst index 10842933c..d286e43cc 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -8,9 +8,8 @@ call to album data source. 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/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 00c553d8b..60c3bc4a2 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 @@ -68,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 @@ -136,6 +136,12 @@ Default ``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports existing data will be overwritten. +.. conf:: genres_tag + :default: genre + + 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 .. _building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst index 50c2c1ff0..21b774120 100644 --- a/docs/plugins/parentwork.rst +++ b/docs/plugins/parentwork.rst @@ -38,9 +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. -To use the ``parentwork`` plugin, enable it in your configuration (see -:ref:`using-plugins`). - Configuration ------------- 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 diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index c7e51d25d..2973dd959 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,59 @@ configuration file: The GStreamer backend does not support parallel analysis. -mp3gain and aacgain -~~~~~~~~~~~~~~~~~~~ +Supported ``command`` backends +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -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: +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``. - 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_. +- On Windows, download and install mp3gain_. + +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 @@ -68,21 +111,7 @@ tool or the aacgain_ fork thereof. Here are some hints: .. _mp3gain: http://mp3gain.sourceforge.net/download.php -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 +.. _mp3rgain: https://github.com/M-Igashi/mp3rgain Python Audio Tools ~~~~~~~~~~~~~~~~~~ @@ -144,10 +173,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: - ``/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``. diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst new file mode 100644 index 000000000..e2861f0ac --- /dev/null +++ b/docs/plugins/titlecase.rst @@ -0,0 +1,215 @@ +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: [] + separators: [] + force_lowercase: no + small_first_last: yes + the_artist: yes + all_lowercase: no + all_caps: no + 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:: separators + :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:: all_caps + :default: no + + 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 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 + :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/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 ~~~~~~~~~~~~~~ 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/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/poetry.lock b/poetry.lock index 615598d67..1a0515819 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,24 +20,24 @@ 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]] 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,43 @@ 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 = "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" @@ -63,18 +97,96 @@ 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 = "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" @@ -92,13 +204,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] @@ -114,33 +226,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] @@ -149,6 +261,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\""} @@ -171,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]] @@ -342,206 +430,265 @@ 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.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]] 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] @@ -575,13 +722,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] @@ -589,78 +736,103 @@ pyyaml = "*" [[package]] name = "coverage" -version = "7.9.2" +version = "7.11.0" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" 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.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] @@ -669,6 +841,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" @@ -694,6 +884,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" @@ -725,15 +930,34 @@ 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]] +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" @@ -751,6 +975,17 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "filelock" +version = "3.20.3" +description = "A platform independent file lock." +optional = true +python-versions = ">=3.10" +files = [ + {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, + {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, +] + [[package]] name = "filetype" version = "1.2.0" @@ -764,19 +999,18 @@ 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] 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" @@ -812,6 +1046,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" @@ -859,13 +1114,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] @@ -893,29 +1148,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" @@ -973,13 +1205,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]] @@ -995,86 +1227,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]] @@ -1096,13 +1338,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]] @@ -1206,241 +1448,295 @@ 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\""} -typing-extensions = {version = "*", markers = "python_version < \"3.10\""} +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\""} [[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]] name = "llvmlite" -version = "0.43.0" +version = "0.45.1" description = "lightweight wrapper around basic LLVM functionality" optional = true -python-versions = ">=3.9" +python-versions = ">=3.10" 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"}, + {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" +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] @@ -1451,72 +1747,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]] @@ -1538,87 +1862,85 @@ mutagen = ">=1.46" test = ["tox"] [[package]] -name = "mock" -version = "5.2.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = true +python-versions = ">=3.9" files = [ - {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, - {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, + {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.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -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]] @@ -1637,17 +1959,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 = false -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" @@ -1661,43 +1972,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] @@ -1724,92 +2041,200 @@ 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.60.0" +version = "0.62.1" description = "compiling Python code using LLVM" optional = true -python-versions = ">=3.9" +python-versions = ">=3.10" 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"}, + {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.43.*" -numpy = ">=1.22,<2.1" +llvmlite = "==0.45.*" +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]] +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]] @@ -1852,143 +2277,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.3.8" +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.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, - {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, + {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" @@ -2039,26 +2448,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" @@ -2182,6 +2600,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"}, @@ -2190,13 +2610,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]] @@ -2292,12 +2712,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] @@ -2305,17 +2725,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"] @@ -2392,15 +2812,30 @@ 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.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] @@ -2417,22 +2852,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" @@ -2500,6 +2935,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" @@ -2513,64 +2962,84 @@ 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-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"}, + {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]] @@ -2601,105 +3070,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] @@ -2733,109 +3202,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] @@ -2883,6 +3380,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" @@ -2905,13 +3420,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] @@ -2933,128 +3448,317 @@ 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.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]] 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" +version = "1.16.3" +description = "Fundamental algorithms for scientific computing in Python" +optional = true +python-versions = ">=3.11" +files = [ + {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] +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" @@ -3091,13 +3795,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] @@ -3134,43 +3838,48 @@ 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]] 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] @@ -3182,40 +3891,57 @@ 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]] +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" @@ -3259,15 +3985,31 @@ 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.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] @@ -3277,6 +4019,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" @@ -3371,6 +4187,57 @@ 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-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" +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" @@ -3407,6 +4274,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" @@ -3420,43 +4300,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]] @@ -3486,13 +4376,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] @@ -3500,24 +4390,13 @@ 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"}, -] - -[[package]] -name = "types-mock" -version = "5.2.0.20250516" -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_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]] @@ -3533,24 +4412,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] @@ -3569,13 +4448,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]] @@ -3591,67 +4470,62 @@ 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" +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" +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)"] [[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" -files = [ - {file = "xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac"}, - {file = "xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553"}, -] - -[[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"}, + {file = "xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d"}, + {file = "xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649"}, ] [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"] +test = ["pytest", "pytest-cov"] [extras] absubmit = ["requests"] @@ -3661,7 +4535,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"] @@ -3678,9 +4552,10 @@ replaygain = ["PyGObject"] scrub = ["mutagen"] sonosupdate = ["soco"] thumbnails = ["Pillow", "pyxdg"] +titlecase = ["titlecase"] web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" -python-versions = ">=3.9,<4" -content-hash = "aedfeb1ac78ae0120855c6a7d6f35963c63cc50a8750142c95dd07ffd213683f" +python-versions = ">=3.10,<4" +content-hash = "eefe427d3b3b9b871ca6bcd8405e3578a16d660afd7925c14793514f03c96ac6" diff --git a/pyproject.toml b/pyproject.toml index b546b4dc2..24505d73e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.5.1" +version = "2.6.1" description = "music tagger and library organizer" authors = ["Adrian Sampson "] maintainers = ["Serene-Arc"] @@ -17,10 +17,10 @@ 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", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ @@ -41,17 +41,22 @@ 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 = ">=1.5.0" +confuse = ">=2.1.0" jellyfish = "*" lap = ">=0.5.12" mediafile = ">=0.12.0" -musicbrainzngs = ">=0.4" -numpy = ">=1.24.4" +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" +requests-ratelimiter = ">=0.7.0" typing_extensions = "*" unidecode = ">=1.3.6" @@ -60,7 +65,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.13", version = ">=1.13.1", optional = true }, + { python = ">=3.13", version = ">=1.16.1", optional = true }, +] +numba = [ # for librosa + { 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 } py7zr = { version = "*", optional = true } @@ -72,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 } @@ -82,13 +94,14 @@ 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] beautifulsoup4 = "*" codecov = ">=2.1.13" flask = "*" langdetect = "*" -mock = "*" pylast = "*" pytest = "*" pytest-cov = "*" @@ -101,17 +114,17 @@ 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" -ruff = ">=0.6.4" +ruff = ">=0.13.0" sphinx-lint = ">=1.0.0" [tool.poetry.group.typing.dependencies] mypy = "*" types-beautifulsoup4 = "*" types-docutils = ">=0.22.2.20251006" -types-mock = "*" types-Flask-Cors = "*" types-Pillow = "*" types-PyYAML = "*" @@ -120,7 +133,6 @@ types-urllib3 = "*" [tool.poetry.group.release.dependencies] click = ">=8.1.7" -packaging = ">=24.0" tomli = ">=2.0.1" [tool.poetry.extras] @@ -140,6 +152,7 @@ docs = [ "sphinx-lint", "sphinx-design", "sphinx-copybutton", + "sphinx-toolbox", ] discogs = ["python3-discogs-client"] embedart = ["Pillow"] # ImageMagick @@ -161,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"] @@ -212,15 +226,15 @@ 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" -cmd = "docstrfmt" +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" @@ -274,13 +288,13 @@ extend-exclude = [ "docs/api/**/*", "README_kr.rst", ] -files = ["docs", "*.rst"] [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 80 [tool.ruff.lint] +future-annotations = true select = [ # "ARG", # flake8-unused-arguments # "C4", # flake8-comprehensions @@ -292,11 +306,9 @@ select = [ "ISC", # flake8-implicit-str-concat "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 - "TCH", # flake8-type-checking + "RUF", # ruff + "UP", # pyupgrade + "TC", # flake8-type-checking "W", # pycodestyle ] ignore = [ @@ -305,7 +317,11 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "beets/**" = ["PT"] +"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 213d32956..ac0864564 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 @@ -182,7 +180,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 @@ -339,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/conftest.py b/test/conftest.py index 7e37eb664..f9fb0cc42 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() -> None: cached_classproperty.clear_cache() + + +@pytest.fixture(scope="module") +def config(): + """Provide a fresh beets configuration for a module, when requested.""" + return ConfigMixin().config 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/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_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_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])) diff --git a/test/plugins/test_aura.py b/test/plugins/test_aura.py index f4535c738..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, Optional +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(): @@ -58,9 +62,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_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_convert.py b/test/plugins/test_convert.py index 1452686a7..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 @@ -236,6 +239,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 +273,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 +329,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.""" diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index eb65bc588..15d47db6c 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -21,7 +21,19 @@ 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 + + +def _artist(name: str, **kwargs): + return { + "id": 1, + "name": name, + "join": "", + "role": "", + "anv": "", + "tracks": "", + "resource_url": "", + } | kwargs @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) @@ -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": [ @@ -409,7 +417,12 @@ 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): @@ -424,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": [ @@ -448,35 +459,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", "PERFORMER"]), + (True, "ART Feat. PERF", ["ART", "PERF"]), + ], +) +@pytest.mark.parametrize( + "album_artist_anv,album_artist,album_artists", + [ + (False, "DRUMMER, ARTIST & SOLOIST", ["DRUMMER", "ARTIST", "SOLOIST"]), + (True, "DRUM, ARTY & SOLO", ["DRUM", "ARTY", "SOLO"]), + ], +) +@pytest.mark.parametrize( + ( + "artist_credit_anv,track_artist_credit," + "track_artists_credit,album_artist_credit,album_artists_credit" + ), + [ + ( + False, + "ARTIST Feat. PERFORMER", + ["ARTIST", "PERFORMER"], + "DRUMMER, ARTIST & SOLOIST", + ["DRUMMER", "ARTIST", "SOLOIST"], + ), + ( + True, + "ART Feat. PERF", + ["ART", "PERF"], + "DRUM, ARTY & SOLO", + ["DRUM", "ARTY", "SOLO"], + ), ], ) @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 = { @@ -488,27 +526,21 @@ def test_anv( "position": "A", "type_": "track", "duration": "5:44", - "artists": [ - { - "name": "ARTIST", - "tracks": "", - "anv": "VARIATION", - "id": 11146, - } - ], + "artists": [_artist("ARTIST", id=11146, anv="ART")], "extraartists": [ - { - "name": "PERFORMER", - "role": "Featuring", - "anv": "VARIATION", - "id": 787, - } + _artist( + "PERFORMER", + id=787, + role="Featuring", + anv="PERF", + ) ], } ], "artists": [ - {"name": "ARTIST (4)", "anv": "VARIATION", "id": 321, "join": "&"}, - {"name": "SOLOIST", "anv": "VARIATION", "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", } @@ -522,9 +554,53 @@ def test_anv( config["discogs"]["anv"]["artist_credit"] = artist_credit_anv r = DiscogsPlugin().get_album_info(release) assert r.artist == album_artist + assert r.artists == album_artists assert r.artist_credit == album_artist_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 + 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": [_artist("PERFORMER", id=1)], + } + ], + "artists": [_artist("ARTIST", 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.artists == ["ARTIST"] + assert r.artist_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" + assert r.tracks[0].artists_credit == ["PERFORMER"] @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) @@ -543,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( @@ -558,13 +632,18 @@ 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.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"] 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", [ ( { @@ -573,45 +652,32 @@ 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"], ), ], ) @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.""" + plugin = DiscogsPlugin() + 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 @pytest.mark.parametrize( @@ -637,6 +703,32 @@ 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", + [ + ( + [_artist("Various")], + { + "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 ( + ArtistState.from_config(DiscogsPlugin().config, given_artists).info + == expected_info + ) + + @pytest.mark.parametrize( "position, medium, index, subindex", [ diff --git a/test/plugins/test_edit.py b/test/plugins/test_edit.py index 4e6c97ab2..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 @@ -176,6 +177,36 @@ 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.""" + self.run_mocked_command( + {"replacements": {"t\u00eftle": "modified t\u00eftle"}}, + # keep Editing, then Apply + ["e", "a"], + ) + + assert mock_write.call_count == self.TRACK_COUNT + 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. @@ -289,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() @@ -320,8 +351,8 @@ class EditDuringImporterNonSingletonTest(EditDuringImporterTestCase): self.lib.items(), self.items_orig, ["title"], - self.IGNORED - + [ + [ + *self.IGNORED, "albumartist", "mb_albumartistid", "mb_albumartistids", @@ -348,7 +379,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()) @@ -460,6 +491,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_embedart.py b/test/plugins/test_embedart.py index d40025374..a7038b152 100644 --- a/test/plugins/test_embedart.py +++ b/test/plugins/test_embedart.py @@ -28,6 +28,7 @@ from beets.test import _common from beets.test.helper import ( BeetsTestCase, FetchImageHelper, + ImportHelper, IOMixin, PluginMixin, ) @@ -75,7 +76,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") @@ -225,10 +228,20 @@ class EmbedartCliTest(IOMixin, PluginMixin, FetchImageHelper, BeetsTestCase): item = album.items()[0] self.io.addinput("y") self.run_command("embedart", "-f", self.small_artpath) + 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 = os.path.getmtime(syspath(item.path)) + assert clear_time > embedded_time + + # A run on a file without an image should not be modified + self.io.addinput("y") + self.run_command("clearart") + no_clear_time = os.path.getmtime(syspath(item.path)) + assert no_clear_time == clear_time def test_clear_art_with_no_input(self): self._setup_data() @@ -273,6 +286,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 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 diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 56c82b9d2..aff4dda18 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -14,14 +14,23 @@ """Tests for the 'ftintitle' plugin.""" -from typing import Dict, Generator, Optional, Tuple, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeAlias import pytest -from beets.library.models import 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] + class FtInTitlePluginFunctional(PluginTestCase): plugin = "ftintitle" @@ -39,7 +48,7 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]: def set_config( env: FtInTitlePluginFunctional, - cfg: Optional[Dict[str, Union[str, bool, list[str]]]], + cfg: dict[str, ConfigValue] | None, ) -> None: cfg = {} if cfg is None else cfg defaults = { @@ -57,7 +66,7 @@ def add_item( path: str, artist: str, title: str, - albumartist: Optional[str], + albumartist: str | None, ) -> Item: return env.add_item( path=path, @@ -246,14 +255,29 @@ 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. {}", "bracket_keywords": ["mix"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), + ("Alice", "Song 1 ft. Bob (Club Mix)"), + id="ft-inserted-before-matching-bracket-keyword", + ), + pytest.param( + {"format": "ft. {}", "bracket_keywords": ["nomatch"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), + ("Alice", "Song 1 (Club Remix) ft. Bob"), + id="ft-inserted-at-end-no-bracket-keyword-match", + ), ], ) 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 +311,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 @@ -303,15 +327,79 @@ 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( given: str, - expected: Tuple[str, Optional[str]], + expected: tuple[str, str | None], ) -> None: assert ftintitle.split_on_feat(given) == expected +@pytest.mark.parametrize( + "given,keywords,expected", + [ + ## default keywords + # different braces and keywords + ("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, "Song ft. Bob (Remix) (Live)"), + # brace insensitivity + ("Song (Live) [Remix]", None, "Song ft. Bob (Live) [Remix]"), + ("Song [Edit] (Remastered)", None, "Song ft. Bob [Edit] (Remastered)"), + # negative cases + ("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, "Song ft. Bob (Live) (Arbitrary)"), + ("Song (Arbitrary) (Remix)", None, "Song (Arbitrary) ft. Bob (Remix)"), + # nested brackets - same type + ("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, "Song ft. Bob (Remix [Extended])"), + # nested - returns outer start position despite inner keyword + ("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"], "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"], "Song ft. Bob (Club Mix)"), # Positive: matches multi-word + ("Song (Club Remix)", ["club mix"], "Song (Club Remix) ft. Bob"), # Negative: no match + ], +) # fmt: skip +def test_insert_ft_into_title( + given: str, + keywords: list[str] | None, + expected: str, +) -> None: + assert ( + ftintitle.FtInTitlePlugin.insert_ft_into_title( + given, "ft. Bob", keywords + ) + == expected + ) + + @pytest.mark.parametrize( "given,expected", [ @@ -359,8 +447,17 @@ 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 = [] 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" diff --git a/test/plugins/test_hook.py b/test/plugins/test_hook.py index 3392d6881..d47162666 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, ClassVar 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): @@ -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_importfeeds.py b/test/plugins/test_importfeeds.py index d525bd801..3f51eca76 100644 --- a/test/plugins/test_importfeeds.py +++ b/test/plugins/test_importfeeds.py @@ -1,22 +1,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 +30,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 +41,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) diff --git a/test/plugins/test_importsource.py b/test/plugins/test_importsource.py new file mode 100644 index 000000000..a4f498181 --- /dev/null +++ b/test/plugins/test_importsource.py @@ -0,0 +1,146 @@ +# 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, plugins +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}" + ) + + 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) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py new file mode 100644 index 000000000..79118bd06 --- /dev/null +++ b/test/plugins/test_inline.py @@ -0,0 +1,62 @@ +# 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 beetsplug.inline import InlinePlugin + + +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" + + 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())) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 12ff30f8e..3de43d197 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -541,18 +541,43 @@ 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): """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 diff --git a/test/plugins/test_listenbrainz.py b/test/plugins/test_listenbrainz.py new file mode 100644 index 000000000..b94cff219 --- /dev/null +++ b/test/plugins/test_listenbrainz.py @@ -0,0 +1,47 @@ +import pytest + +from beets.test.helper import ConfigMixin +from beetsplug.listenbrainz import ListenBrainzPlugin + + +class TestListenBrainzPlugin(ConfigMixin): + @pytest.fixture(scope="class") + 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", + [([{"id": "id1"}], "id1"), ([], None)], + ids=["found", "not_found"], + ) + def test_get_mb_recording_id( + self, plugin, requests_mock, search_response, expected_id + ): + 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, 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"}], + }, + ) + + assert plugin.get_track_info([{"identifier": "id1"}]) == [ + { + "identifier": "id1", + "title": "T", + "artist": None, + "album": "Al", + "year": "2023", + } + ] diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index 945a7158c..376f0b9f2 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", @@ -424,7 +431,7 @@ class TestTekstowoLyrics(LyricsBackendTest): [ ("tekstowopl/piosenka24kgoldncityofangels1", True), ( - "tekstowopl/piosenkabeethovenbeethovenpianosonata17tempestthe3rdmovement", # noqa: E501 + "tekstowopl/piosenkabeethovenbeethovenpianosonata17tempestthe3rdmovement", False, ), ], @@ -607,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_mbcollection.py b/test/plugins/test_mbcollection.py new file mode 100644 index 000000000..adfadc103 --- /dev/null +++ b/test/plugins/test_mbcollection.py @@ -0,0 +1,142 @@ +import re +import uuid +from contextlib import nullcontext as does_not_raise + +import pytest + +from beets.library import Album +from beets.test.helper import PluginMixin, TestHelper +from beets.ui import UserError +from beetsplug import mbcollection + + +class TestMbCollectionPlugin(PluginMixin, TestHelper): + """Tests for the MusicBrainzCollectionPlugin class methods.""" + + plugin = "mbcollection" + + COLLECTION_ID = str(uuid.uuid4()) + + @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 + + @pytest.fixture(autouse=True) + def helper(self): + self.setup_beets() + + yield self + + self.teardown_beets() + + @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(), + ), + ], + ids=["no collections", "no release collections", "invalid ID", "valid"], + ) + def test_get_collection_validation( + self, requests_mock, user_collections, expectation + ): + requests_mock.get( + "/ws/2/collection", json={"collections": user_collections} + ) + + with expectation: + mbcollection.MusicBrainzCollectionPlugin().collection + + 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( + "/ws/2/collection", + json={ + "collections": [ + { + "id": self.COLLECTION_ID, + "entity-type": "release", + "release-count": 3, + } + ] + }, + ) + + collection_releases = f"/ws/2/collection/{self.COLLECTION_ID}/releases" + # Force small fetch chunk to require multiple paged requests. + monkeypatch.setattr( + "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"}]}, + ) + + # 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") + ) + + helper.run_command("mbupdate", "--remove") + + assert requests_mock.call_count == 6 diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py new file mode 100644 index 000000000..2fb6321b3 --- /dev/null +++ b/test/plugins/test_mbpseudo.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from typing import TYPE_CHECKING + +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 +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): + 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="百花繚乱")], + album_id="official", + album="百花繚乱", + ) + + +@pytest.fixture +def pseudo_release_info() -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="In Bloom")], + album_id="pseudo", + album="In Bloom", + ) + + +@pytest.mark.usefixtures("config") +class TestPseudoAlbumInfo: + def test_album_id_always_from_pseudo( + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo + ): + 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_info: AlbumInfo, pseudo_release_info: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) + assert info.album == "In Bloom" + + def test_get_attr_from_official( + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo + ): + 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_info: AlbumInfo, pseudo_release_info: AlbumInfo + ): + info = PseudoAlbumInfo( + pseudo_release_info, official_release_info, data_source="test" + ) + item = Item(title="百花繚乱") + + assert info.determine_best_ref([item]) == "official" + + info.use_pseudo_as_ref() + assert info.data_source == "test" + + +class TestMBPseudoMixin(PluginMixin): + plugin = "mbpseudo" + + @pytest.fixture(autouse=True) + def patch_get_release(self, monkeypatch, pseudo_release: JSONDict): + monkeypatch.setattr( + "beetsplug._utils.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 + def mbpseudo_plugin(self, plugin_config) -> MusicBrainzPseudoReleasePlugin: + self.config[self.plugin].set(plugin_config) + return MusicBrainzPseudoReleasePlugin() + + +class TestMBPseudoPlugin(TestMBPseudoMixin): + 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) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + 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-relations"][0][json_key] + + album_info = mbpseudo_plugin.album_info(official_release) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + + def test_interception_skip_when_script_doesnt_match( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + ): + official_release["release-relations"][0]["release"][ + "text-representation" + ]["script"] = "Null" + + album_info = mbpseudo_plugin.album_info(official_release) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + + def test_interception( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + ): + album_info = mbpseudo_plugin.album_info(official_release) + assert isinstance(album_info, PseudoAlbumInfo) + 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" + + +class TestMBPseudoPluginCustomTagsOnly(TestMBPseudoMixin): + @pytest.fixture(scope="class") + def plugin_config(self): + return {"scripts": ["Latn", "Dummy"], "custom_tags_only": True} + + def test_custom_tags( + self, + config, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + ): + 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" + 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, + config, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + ): + 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" + 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" diff --git a/test/plugins/test_missing.py b/test/plugins/test_missing.py new file mode 100644 index 000000000..d12f2b4cf --- /dev/null +++ b/test/plugins/test_missing.py @@ -0,0 +1,61 @@ +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 must not be reported as missing." + " Needs fixing." + ) + ), + id="not missing", + ), + ], + ) + def test_missing_artist_albums( + self, requests_mock, helper, release_from_mb, expected_output + ): + helper.lib.add(self.album_in_lib) + 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({}): + assert ( + helper.run_with_output("missing", "--album") == expected_output + ) 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 844b2ad4e..069f1fb99 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -15,9 +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 @@ -29,6 +32,7 @@ class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() self.mb = musicbrainz.MusicBrainzPlugin() + self.config["match"]["preferred"]["countries"] = ["US"] class MBAlbumInfoTest(MusicBrainzTestCase): @@ -48,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", @@ -64,8 +68,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ], "date": "3001", - "medium-list": [], - "label-info-list": [ + "media": [], + "genres": [{"count": 1, "name": "GENRE"}], + "tags": [{"count": 1, "name": "TAG"}], + "label-info": [ { "catalog-number": "CATALOG NUMBER", "label": {"name": "LABEL NAME"}, @@ -78,10 +84,11 @@ class MBAlbumInfoTest(MusicBrainzTestCase): "country": "COUNTRY", "status": "STATUS", "barcode": "BARCODE", + "release-events": [{"area": None, "date": "2021-03-26"}], } if multi_artist_credit: - release["artist-credit"].append(" & ") # add join phase + release["artist-credit"][0]["joinphrase"] = " & " release["artist-credit"].append( { "artist": { @@ -122,7 +129,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": { @@ -146,11 +153,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", } @@ -186,7 +193,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": { @@ -198,11 +205,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ) if remixer: - track["artist-relation-list"] = [ + track["artist-relations"] = [ { "type": "remixer", "type-id": "RELATION TYPE ID", - "target": "RECORDING REMIXER ARTIST ID", "direction": "RECORDING RELATION DIRECTION", "artist": { "id": "RECORDING REMIXER ARTIST ID", @@ -213,7 +219,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ] if video: - track["video"] = "true" + track["video"] = True if disambiguation: track["disambiguation"] = disambiguation return track @@ -299,10 +305,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): "number": "A1", } ] - release["medium-list"].append( + release["media"].append( { "position": "2", - "track-list": second_track_list, + "tracks": second_track_list, } ) @@ -515,6 +521,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 = [ @@ -678,15 +704,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()] @@ -703,7 +729,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" @@ -761,86 +790,86 @@ 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", + "direction": "backward", + "release": { + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01" + }, + } + ], }, { - "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._utils.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" @@ -848,44 +877,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._utils.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 @@ -893,43 +921,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._utils.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 @@ -937,50 +965,52 @@ 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", + "direction": "backward", + "release": { + "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01" + }, + } + ], + } ] - with mock.patch("musicbrainzngs.get_release_by_id") as gp: + with mock.patch( + "beetsplug._utils.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 @@ -990,7 +1020,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): @@ -1025,15 +1059,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._utils.musicbrainz.MusicBrainzAPI.get_json", + lambda *_, **__: {"recordings": [self.RECORDING]}, ) candidates = list(mb.item_candidates(Item(), "hello", "there")) @@ -1043,34 +1075,32 @@ class TestMusicBrainzPlugin(PluginMixin): def test_candidates(self, monkeypatch, mb): monkeypatch.setattr( - "musicbrainzngs.search_releases", - lambda *_, **__: {"release-list": [{"id": self.mbid}]}, + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_json", + lambda *_, **__: {"releases": [{"id": self.mbid}]}, ) monkeypatch.setattr( - "musicbrainzngs.get_release_by_id", + "beetsplug._utils.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)) @@ -1078,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 diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py index 1abe25709..2218e9fd6 100644 --- a/test/plugins/test_parentwork.py +++ b/test/plugins/test_parentwork.py @@ -14,74 +14,10 @@ """Tests for the 'parentwork' plugin.""" -from unittest.mock import 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,35 +70,56 @@ 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] - ) - 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 + @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", + }, + } + ], + }, ) - 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") @@ -204,7 +161,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] diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index 9bcf8e59b..975bb8ffa 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -15,27 +15,43 @@ """Test the beets.random utilities associated with the random plugin.""" import math -import unittest -from random import Random +import random import pytest -from beets import random from beets.test.helper import TestHelper +from beetsplug.random import _equal_chance_permutation, random_objs -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() + + +@pytest.fixture(scope="module", autouse=True) +def seed_random(): + random.seed(12345) + + +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.random_gen = Random() - self.random_gen.seed(12345) + self.items.append(helper.create_item(artist=self.artist2)) def _stats(self, data): mean = sum(data) / len(data) @@ -61,9 +77,7 @@ class RandomTest(TestHelper, unittest.TestCase): 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). @@ -72,8 +86,99 @@ 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 + + @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( + _equal_chance_permutation( + [helper.create_item(**i) for i in input_items], field + ) + ) + + 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 + ] + + def test_random_selection_by_count(self): + """Test selecting a specific number of items.""" + 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, "artist", 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_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 + + # Ensure both artists are represented (not strictly equal due to randomness) + assert len(artist_counts) >= 2 + + def test_empty_input_list(self): + """Test behavior with an empty input list.""" + 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, "artist", number=3)) + assert len(selected) == len(self.items) + assert set(selected) == set(self.items) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index d3569d836..8ec2c74ce 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 @@ -34,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() @@ -54,16 +53,17 @@ 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 +86,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 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"] == [ + sort("year"), + NullSort(), + MultipleSort([sort("genre"), sort("id", False)]), + ] def test_matches(self): spl = SmartPlaylistPlugin() @@ -122,6 +131,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() @@ -164,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 @@ -206,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" @@ -256,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" @@ -301,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" @@ -327,6 +345,118 @@ 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", id=1) + i1.evaluate_template.return_value = "ordered.m3u" + i2 = Mock(path=b"/item2.mp3", id=2) + i2.evaluate_template.return_value = "ordered.m3u" + i3 = Mock(path=b"/item3.mp3", id=3) + 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" + + 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" diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index bc55485c6..6e322ca0b 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -249,3 +249,61 @@ class SpotifyPluginTest(PluginTestCase): query = params["q"][0] assert query.isascii() + + @responses.activate + 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"multiartist_album.json" + ) + with open(json_file, "rb") as f: + album_response_body = f.read() + + responses.add( + responses.GET, + f"{spotify.SpotifyPlugin.album_url}0yhKyyjyKXWUieJ4w1IAEa", + 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", + ) + + 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"] + 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/plugins/test_titlecase.py b/test/plugins/test_titlecase.py new file mode 100644 index 000000000..c25661bbf --- /dev/null +++ b/test/plugins/test_titlecase.py @@ -0,0 +1,426 @@ +# 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_separators(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: + 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): + 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() 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")) diff --git a/test/plugins/utils/__init__.py b/test/plugins/utils/__init__.py new file mode 100644 index 000000000..e69de29bb 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", + }, + }, + ], + } diff --git a/test/rsrc/mbpseudo/official_release.json b/test/rsrc/mbpseudo/official_release.json new file mode 100644 index 000000000..cd6bb3ba9 --- /dev/null +++ b/test/rsrc/mbpseudo/official_release.json @@ -0,0 +1,1878 @@ +{ + "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": [ + { + "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", + "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": "幾田りら" + } + ], + "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", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": "Person", + "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" + }, + "joinphrase": "", + "name": "幾田りら" + } + ], + "artist-relations": [ + { + "artist": { + "country": "JP", + "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": [ + { + "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" + }, + { + "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/" + } + } + ] + } + } + ] + }, + "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", + "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": "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" + }, + "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" + } + }, + { + "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 new file mode 100644 index 000000000..ae4bf7b6b --- /dev/null +++ b/test/rsrc/mbpseudo/pseudo_release.json @@ -0,0 +1,515 @@ +{ + "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": [ + { + "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" + } + ], + "id": "2018b012-a184-49a2-a464-fb4628a89588", + "length": 179239, + "number": "1", + "position": 1, + "recording": { + "aliases": [], + "artist-credit": [ + { + "artist": { + "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": "幾田りら" + } + ], + "artist-relations": [ + { + "artist": { + "country": "JP", + "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": [ + { + "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" + }, + { + "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/" + } + } + ] + } + } + ] + }, + "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 + }, + "joinphrase": "", + "name": "幾田りら" + } + ], + "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" +} diff --git a/test/rsrc/spotify/multiartist_album.json b/test/rsrc/spotify/multiartist_album.json new file mode 100644 index 000000000..9aef25f10 --- /dev/null +++ b/test/rsrc/spotify/multiartist_album.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": "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_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 +} 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" +} 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_autotag.py b/test/test_autotag.py index 8d467e5ed..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) @@ -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/test_dbcore.py b/test/test_dbcore.py index 653adf298..9930956d9 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -19,11 +19,12 @@ import shutil import sqlite3 import unittest from tempfile import mkstemp +from typing import ClassVar 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 @@ -57,15 +58,16 @@ 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, } + _indices = (Index("field_one_index", ("field_one",)),) @cached_classproperty def _types(cls): @@ -92,7 +94,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 +106,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 +119,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 +135,15 @@ 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, } + _indices = (Index("another_foo_index", ("foo",)),) 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, @@ -238,6 +241,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): @@ -411,7 +422,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_files.py b/test/test_files.py index 8b08a3fab..d0d93987c 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -36,7 +36,8 @@ class MoveTest(BeetsTestCase): super().setUp() # make a temporary file - self.path = self.temp_dir_path / "temp.mp3" + self.temp_music_file_name = "temp.mp3" + self.path = self.temp_dir_path / self.temp_music_file_name shutil.copy(self.resource_path, self.path) # add it to a temporary library @@ -197,6 +198,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("./", 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(unittest.TestCase): def test_ancestry_works_on_file(self): @@ -563,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() diff --git a/test/test_importer.py b/test/test_importer.py index c1768df3e..6ae7d562b 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -258,6 +258,17 @@ class ImportSingletonTest(AutotagImportTestCase): assert self.lib.items().get().title == "Applied Track 1" assert (self.lib_path / "singletons" / "Applied Track 1.mp3").exists() + 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_track(self): self.importer.add_choice(importer.Action.SKIP) self.importer.run() 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_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): diff --git a/test/test_metadata_plugins.py b/test/test_metadata_plugins.py new file mode 100644 index 000000000..684784191 --- /dev/null +++ b/test/test_metadata_plugins.py @@ -0,0 +1,83 @@ +from collections.abc 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.""" + + 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") + + +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): + metadata_plugins.find_metadata_source_plugins.cache_clear() + self.register_plugin(ErrorMetadataMockPlugin) + 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", + [ + ("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, call_method, error_method_name): + self.config["raise_on_error"] = False + + call_method() + + assert ( + f"Error in 'ErrorMetadataMock.{error_method_name}': Mocked error" + in caplog.text + ) + + @pytest.mark.parametrize( + "method_name,args", + [ + ("candidates", ()), + ("item_candidates", ()), + ("album_for_id", ("some_id",)), + ("track_for_id", ("some_id",)), + ], + ) + def test_raising(self, call_method): + self.config["raise_on_error"] = True + + with pytest.raises(ValueError, match="Mocked error"): + call_method() diff --git a/test/test_plugins.py b/test/test_plugins.py index 07bbf0966..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 @@ -41,12 +42,12 @@ 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): 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( @@ -292,8 +295,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) @@ -308,7 +311,9 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "Enter search", "enter Id", "aBort", - ) + ("Foo", "baR") + "Foo", + "baR", + ) self.importer.add_choice(Action.SKIP) self.importer.run() @@ -328,8 +333,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) @@ -342,7 +347,9 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "Enter search", "enter Id", "aBort", - ) + ("Foo", "baR") + "Foo", + "baR", + ) config["import"]["singletons"] = True self.importer.add_choice(Action.SKIP) @@ -363,10 +370,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) @@ -381,7 +388,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( @@ -399,7 +407,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 @@ -416,7 +424,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: @@ -441,7 +450,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 @@ -458,7 +467,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): "Enter search", "enter Id", "aBort", - ) + ("Foo",) + "Foo", + ) # DummyPlugin.foo() should be called once with helper.control_stdin("f\n"): @@ -543,3 +553,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 + ) 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 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/test_ui_commands.py b/test/test_ui_commands.py deleted file mode 100644 index 412ddc2b7..000000000 --- a/test/test_ui_commands.py +++ /dev/null @@ -1,96 +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. - -"""Test module for file ui/commands.py""" - -import os -import shutil - -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.util import syspath - - -class QueryTest(BeetsTestCase): - def add_item(self, filename=b"srcfile", templatefile=b"full.mp3"): - itempath = os.path.join(self.libdir, filename) - shutil.copy( - syspath(os.path.join(_common.RSRC, templatefile)), - syspath(itempath), - ) - item = library.Item.from_path(itempath) - self.lib.add(item) - return item, itempath - - def add_album(self, items): - album = self.lib.add_album(items) - return album - - 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) - 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) - - def test_query_empty_album(self): - with pytest.raises(ui.UserError): - commands._do_query(self.lib, (), True) - - def test_query_item(self): - self.add_item() - self.check_do_query(1, 0, album=False) - self.add_item() - self.check_do_query(2, 0, album=False) - - def test_query_album(self): - item, itempath = 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() - 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/__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..ee2881a0e --- /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, _ = 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 93% rename from test/test_config_command.py rename to test/ui/commands/test_config.py index b68c4f042..c1215ef43 100644 --- a/test/test_config_command.py +++ b/test/ui/commands/test_config.py @@ -128,3 +128,11 @@ 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): + os.environ["EDITOR"] = "myeditor" + with patch("os.execlp") as execlp: + self.run_command("--config", self.cli_config_path, "config", "-e") + execlp.assert_called_once_with( + "myeditor", "myeditor", self.cli_config_path + ) 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..6e96c3bf3 --- /dev/null +++ b/test/ui/commands/test_import.py @@ -0,0 +1,258 @@ +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 + item_info_pairs = list(zip(items, info.tracks)) + config["ui"]["color"] = color + config["import"]["detail"] = True + 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, dict(item_info_pairs), 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..77d378032 --- /dev/null +++ b/test/ui/commands/test_modify.py @@ -0,0 +1,212 @@ +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, _ = modify_parse_args(["title:oldTitle", "title=newTitle"]) + assert query == ["title:oldTitle"] + assert mods == {"title": "newTitle"} + + def test_arg_parsing_delete(self): + 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, _ = 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, _ = 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/ui/commands/test_utils.py b/test/ui/commands/test_utils.py new file mode 100644 index 000000000..075f522a7 --- /dev/null +++ b/test/ui/commands/test_utils.py @@ -0,0 +1,59 @@ +import os +import shutil + +import pytest + +from beets import library, ui +from beets.test import _common +from beets.test.helper import BeetsTestCase +from beets.ui.commands.utils import do_query +from beets.util import syspath + + +class QueryTest(BeetsTestCase): + def add_item(self, filename=b"srcfile", templatefile=b"full.mp3"): + itempath = os.path.join(self.libdir, filename) + shutil.copy( + syspath(os.path.join(_common.RSRC, templatefile)), + syspath(itempath), + ) + item = library.Item.from_path(itempath) + self.lib.add(item) + return item + + def add_album(self, items): + album = self.lib.add_album(items) + return album + + def check_do_query( + self, num_items, num_albums, q=(), album=False, also_items=True + ): + 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): + do_query(self.lib, (), False) + + def test_query_empty_album(self): + with pytest.raises(ui.UserError): + do_query(self.lib, (), True) + + def test_query_item(self): + self.add_item() + self.check_do_query(1, 0, album=False) + self.add_item() + self.check_do_query(2, 0, album=False) + + def test_query_album(self): + 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 = 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/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_field_diff.py b/test/ui/test_field_diff.py new file mode 100644 index 000000000..35f3c6ca7 --- /dev/null +++ b/test/ui/test_field_diff.py @@ -0,0 +1,59 @@ +import pytest + +from beets.library import Item +from beets.ui import _field_diff + +p = pytest.param + + +class TestFieldDiff: + @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" diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py new file mode 100644 index 000000000..a0bf2e598 --- /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" + _, 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 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):