├── .coveragerc ├── .flake8 ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .python-version ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── beetsplug └── check.py ├── pyproject.toml ├── test ├── __init__.py ├── cli_test.py ├── fixtures │ ├── crc.flac │ ├── md5.flac │ ├── ok.flac │ ├── ok.mp3 │ ├── ok.ogg │ ├── truncated.flac │ ├── truncated.mp3 │ └── truncated.ogg ├── helper.py └── integration_test.py └── uv.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */pyshared/* 4 | */python?.?/* 5 | */site-packages/nose/* 6 | */test/* 7 | beetsplug/__init__.py 8 | exclude_lines = 9 | raise NotImplementedError 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203,E501 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Check and test 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | FORCE_COLOR: "1" 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | python-version: 13 | - "3.9" # minimum required 14 | - "3.12" # latest 15 | - "3.13-dev" # next 16 | 17 | runs-on: ubuntu-latest 18 | continue-on-error: ${{ matrix.python-version == '3.13-dev' }} 19 | 20 | steps: 21 | - run: sudo apt-get install flac mp3val oggz-tools 22 | - uses: actions/checkout@v4 23 | - uses: astral-sh/setup-uv@v2 24 | with: 25 | enable-cache: true 26 | - uses: actions/setup-python@v5 27 | id: setup-python 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - run: echo "UV_PYTHON=${{ steps.setup-python.outputs.python-path }}" >> $GITHUB_ENV 31 | - run: uv lock --locked 32 | - run: uv sync --all-extras --dev 33 | - run: uv run ruff check . 34 | - run: uv run pytest 35 | 36 | build-beets-versions: 37 | strategy: 38 | matrix: 39 | beets: 40 | - "beets@git+https://github.com/beetbox/beets#master" 41 | - "beets==1.6.1" 42 | 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - run: sudo apt-get install flac mp3val oggz-tools 47 | - uses: actions/checkout@v4 48 | - uses: astral-sh/setup-uv@v2 49 | with: 50 | enable-cache: true 51 | - uses: actions/setup-python@v5 52 | id: setup-python 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | - run: echo "UV_PYTHON=${{ steps.setup-python.outputs.python-path }}" >> $GITHUB_ENV 56 | - run: uv lock -P ${{ matrix.beets }} 57 | - run: uv sync --all-extras --dev 58 | - run: uv run pytest 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | beets_check.egg-info/ 3 | build/ 4 | .coverage 5 | coverage/ 6 | *.pyc 7 | .tox/ 8 | htmlcov 9 | .noseids 10 | /venv 11 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.15.0 2024-09-20 4 | 5 | - Don’t run custom external programs with `-v` (e.g. `ffmpeg -v`) to determine 6 | whether they are available. (Fixes #43) 7 | - Require Python >=3.9 8 | 9 | ## v0.14.1 2024-07-11 10 | 11 | - Require beets >=1.6.1 and support beets v2.x 12 | 13 | ## v0.14.0 2024-02-12 14 | 15 | - Require Python ^3.8 16 | - Require beets ^1.6 17 | 18 | ## v0.13.0 2020-06-27 19 | 20 | - Drop support for Python2.7 21 | - Require `beets>=1.4.7` 22 | - Fix a crash in `beet check --add` when a music file is not found on disk. (@ssssam) 23 | 24 | ## v0.12.1 2020-04-19 25 | 26 | - Fix crash when running `beet import` with threading enabled ([#22](https://github.com/geigerzaehler/beets-check/issues/22)) ([@alebianco](https://github.com/alebianco)) 27 | 28 | ## v0.12.0 2019-08-12 29 | 30 | - Add support for Python 3 31 | - Drop support for `beets<=1.3.10` 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Thomas Scholtes. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune test 2 | include LICENSE README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # beets-check 2 | 3 | [![Build Status](https://travis-ci.org/geigerzaehler/beets-check.svg?branch=master)](https://travis-ci.org/geigerzaehler/beets-check) 4 | [![Coverage Status](https://coveralls.io/repos/geigerzaehler/beets-check/badge.png?branch=master)](https://coveralls.io/r/geigerzaehler/beets-check?branch=master) 5 | 6 | _The [beets][] plugin for paranoid obsessive-compulsive music geeks._ 7 | 8 | _beets-check_ lets you verify the integrity of your audio files. It computes 9 | and validates file checksums and uses third party tools to run custom 10 | tests on files. 11 | 12 | This plugin requires at least version 1.6.1 of beets and at least Python 3.8 13 | 14 | ``` 15 | pip install --upgrade beets>=1.6.1 16 | pip install git+git://github.com/geigerzaehler/beets-check.git@main 17 | ``` 18 | 19 | Then add `check` to the list of plugins in your beet configuration. 20 | (Running `beet config --edit` might be the quickest way.) 21 | 22 | If you want to use third-party tools to test your audio files you have 23 | to manually install them on your system. Run `beet check --list-tools` 24 | to see a list of programs the plugin can use or [add your 25 | own](#third-party-tests). 26 | 27 | ## Usage 28 | 29 | Let’s get started and add some checksums to your library. 30 | 31 | ``` 32 | $ beet check -a 33 | WARNING integrity error: /music/Abbey Road/01 Come Together.mp3 34 | Adding unknown checksums: 1032/8337 [12%] 35 | ``` 36 | 37 | The `check` command looks for all files that don’t have a checksum yet. 38 | It computes the checksum for each of these files and stores it in the 39 | database. The command also prints a warning if one of the third-party 40 | tools finds an error. (More on those [later](#third-party-tests).) 41 | 42 | After some time (or maybe a system crash) you’ll probably want to go back to 43 | your library and verify that none of the files have changed. To do this run 44 | 45 | ``` 46 | $ beet check 47 | FAILED: /music/Sgt. Pepper/13 A Day in the Life.mp3 48 | Verifying checksums: 5102/8337 [53%] 49 | ``` 50 | 51 | For later inspection you might want to keep a log. To do that just 52 | redirect the error output with `beet check 2>check.log`. All `WARNING` 53 | and `ERROR` lines are sent to stderr, so you will still see the 54 | progressbar. 55 | 56 | When you change your files through beets, using the `modfiy` command 57 | for example, the plugin will [update the checksums 58 | automatically](#automatic-update). However, if you change files 59 | manually, you also need to update the checksums manually. 60 | 61 | ``` 62 | $ beet check -u 'album:Sgt. Pepper' 63 | Updating checksums: 2/13 [15%] 64 | ``` 65 | 66 | ### Third-party Tests 67 | 68 | The plugin allows you to add custom file checks through external tools. 69 | The plugin supports `flac --test`, `oggz-validate`, and `mp3val` out of 70 | the box, but you can also [configure your own](#third-party-tools). 71 | 72 | Custom tests are run when on the following occasions. 73 | 74 | - Before importing a file (see below) 75 | - Before adding checksums with the `-a` flag 76 | - When running `beet check --external` 77 | 78 | The file checks are not run when updating files. The rationale is that 79 | if the checksum of a file is correct, the file is assumed to be clean 80 | and pass all the custom tests. 81 | 82 | If some file fails a test the line 83 | 84 | ``` 85 | WARNING error description: /path/to/file 86 | ``` 87 | 88 | is printed. 89 | 90 | ### Usage with `import` 91 | 92 | Since it would be tedious to run `check -a` every time you import new music 93 | into beets, _beets-check_ will add checksum automatically. Before file 94 | is imported the plugin will also check the file with the provided 95 | third-party tools. If the check fails beets will ask you to confirm the 96 | import. 97 | 98 | ``` 99 | $ beet import 'Abbey Road' 100 | Tagging: 101 | The Beatles - Abbey Road 102 | URL: 103 | http://musicbrainz.org/release/eca8996a-a637-3259-ba07-d2573c601a1b 104 | (Similarity: 100.0%) (Vinyl, 1969, DE, Apple Records) 105 | Warning: failed to verify integrity 106 | Abbey Road/01 Come Together.mp3: MPEG stream error 107 | Do you want to skip this album? (Y/n) 108 | ``` 109 | 110 | After a track has been added to the database and all modifications to the tags 111 | have been written, beets-check adds the checksums. This is virtually the same as 112 | running `beets check -a ` after the import. 113 | 114 | If you run `import` with the `--quiet` flag the importer will skip 115 | files that do not pass third-party tests automatically and log an 116 | error. 117 | 118 | ### Automatic Update 119 | 120 | The [`write`][write] and [`modify`][modify] commands as well as some plugins will 121 | change a file’s content and thus invalidate its checksum. To relieve you from 122 | updating the checksum manually, _beets-check_ will recalculate the checksums of 123 | all the files that were changed. 124 | 125 | ``` 126 | $ beet check -e 'title:A Day in the Life' 127 | ded5...363f */music/life.mp3 128 | 129 | $ beet modify 'artist=The Beatles' title:A Day in the Life' 130 | 131 | $ beet check -e 'title:A Day in the Life' 132 | d942...5a82 */music/life.mp3 133 | ``` 134 | 135 | This is basically equivalent to running `beets check -u QUERY` after a modifying 136 | command. 137 | 138 | To make sure that a file hasn’t changed before beets changes it, the 139 | plugin will verify the checksum before the file is written. If the 140 | check fails, beets will not write the file and issue a warning. 141 | 142 | ``` 143 | $ beet modify 'artist=The Beatles' 'title:A Day in the Life' 144 | could not write /music/life.mp3: checksum did not match value in library 145 | ``` 146 | 147 | ### Usage with `convert` 148 | 149 | The [`convert`][convert] plugin can replace an audio file with a 150 | transcoded version using the `--keep-new` flag. This will invalidate you 151 | checksum, but _beets-check_ knows about this and will update the 152 | checksum automatically. You can disable this behaviour in the plugin 153 | configuration. Note that, at the moment we do not verify the checksum 154 | prior to the conversion, so a corrupted file might go undetected. This 155 | feature is also only available with the master branch of beets 156 | 157 | [beets]: http://beets.readthedocs.org/en/latest 158 | [write]: http://beets.readthedocs.org/en/latest/reference/cli.html#write 159 | [modify]: http://beets.readthedocs.org/en/latest/reference/cli.html#modify 160 | [convert]: http://beets.readthedocs.org/en/latest/plugins/convert.html 161 | 162 | ## CLI Reference 163 | 164 | ``` 165 | beet check [--quiet] 166 | [ --external 167 | | --add 168 | | --update [--force] 169 | | --export 170 | | --fix [--force] 171 | ] [QUERY...] 172 | beet check --list-tools 173 | ``` 174 | 175 | The plugin has subcommands for checking files, running integrity checks, 176 | adding, updating and exporting checksums and listing third-party tools. All but 177 | the last accepty a `QUERY` paramter that will restrict the operation to files 178 | matching the query. Remember, if a query contains a slash beets will 179 | [interpret it as a path][path query] and match all files that are contained in 180 | a subdirectory of that path. 181 | 182 | The default `check` command, as well as the `--add`, `--update`, and 183 | `--external` commands provide structured output to `stderr` to be easily parseable 184 | by other tools. If a file’s checksum cannot be verified the line 185 | `FAILED: /path/to/file` is printed to stdout. If an external test 186 | fails, the line `WARNING error description: /path/to/file` is printed. 187 | 188 | In addition, the commands print a progress indicator to `stdout` if 189 | `stdout` is connected to a terminal. This can be disabled with the 190 | **`-q, --quiet`** flag. 191 | 192 | - **`beet check [-q] [QUERY...]`** The default command verifies all 193 | file checksums against the database. The output is described above. 194 | Exits with status code `15` if at least one file does not pass a 195 | test. 196 | 197 | - **`-e, --external`** Run third-party tools for the given file. The 198 | output is described above. Exits with status code `15` if at least 199 | one file does not pass a test. 200 | 201 | - **`-a, --add`** Look for files in the database that don’t have a 202 | checksum, compute it from the file and add it to the database. This will also 203 | print warnings for failed integrity checks. 204 | 205 | - **`-u, --update`** Calculate checksums for all files matching the 206 | query and write the them to the database. If no query is given this will 207 | overwrite all checksums already in the database. Since that is almost 208 | certainly not what you want, beets will ask you for confirmation in that 209 | case unless the `--force` flag is set. 210 | 211 | - **`--export`** Outputs a list of filenames with corresponding 212 | checksums in the format used by the `sha256sum` command. You can then use 213 | that command to check your files externally. For example 214 | `beet check -e | sha256sum -c`. 215 | 216 | - **`-x, --fix [--force | -f]`** Since `v0.9.2`. Fix files with 217 | third-party tools. Since this changes files it will ask for you to 218 | confirm the fixes. This can be disabled with the `--force` flag. 219 | 220 | - **`-l, --list-tools`** Outputs a list of third party programs that 221 | _beets-check_ uses to verify file integrity and shows whether they are 222 | installed. The plugin comes with support for the 223 | [`oggz-validate`][oggz-validate], [`mp3val`][mp3val] and [`flac`][flac] commands. 224 | 225 | [path query]: http://beets.readthedocs.org/en/latest/reference/query.html#path-queries 226 | [flac]: https://xiph.org/flac/documentation_tools_flac.html 227 | [mp3val]: http://mp3val.sourceforge.net/ 228 | [oggz-validate]: https://www.xiph.org/oggz/ 229 | 230 | ## Configuration 231 | 232 | By default _beets-check_ uses the following configuration. 233 | 234 | ```yaml 235 | check: 236 | import: yes 237 | write-check: yes 238 | write-update: yes 239 | convert-update: yes 240 | threads: num_of_cpus 241 | ``` 242 | 243 | These option control at which point _beets-check_ will be used automatically by 244 | other beets commands. You can disable each option by setting its value to `no`. 245 | 246 | - `import: no` Don’t add checksums for new files during the import process. 247 | This also disables integrity checks on import and will not ask you to skip 248 | the import of corrupted files. 249 | - `write-check: no` Don’t verify checksums before writing files with 250 | `beet write` or `beet modify`. 251 | - `write-update: no` Don’t update checksums after writing files with 252 | `beet write` or `beet modify`. 253 | - `convert-update: no` Don’t updated the checksum if a file has been 254 | converted with the `--keep-new` flag. 255 | - `threads: 4` Use four threads to compute checksums. 256 | 257 | ### Third-party Tools 258 | 259 | _beets-check_ allows you to configure custom tests for your files. 260 | 261 | Custom tests are shell commands that are run on an audio file and 262 | may produce an error. 263 | 264 | ```yaml 265 | check: 266 | tools: 267 | mp3val: 268 | cmd: "mp3val {}" 269 | formats: MP3 270 | error: '^WARNING: .* \(offset 0x[0-9a-f]+\): (.*)$' 271 | fix: "mp3val -f -nb {}" 272 | ``` 273 | 274 | Each tool is a dictionary entry under `check.tools`, where the key is 275 | the tools name and the value is a configuration dictionary with the 276 | following keys. 277 | 278 | - **`cmd`** The shell command that tests the file. The string is 279 | formatted with python’s [`str.format()`][python-format] to replace 280 | '{}' with the quoted path of the file to check. 281 | 282 | - **`formats`** A space separated list of audio formats the tool can 283 | check. Valid formats include 'MP' 284 | 285 | - **`error`** Python regular expression to match against the tools 286 | output. If a match is found, an error is assumed to have occured 287 | and the error description is the first match group. 288 | 289 | - **`fix`** Shell command to run when fixing files. The command is 290 | formtted similar to `cmd`. 291 | 292 | A test run with a given tool is assumed to have failed in one of the 293 | following two cases. 294 | 295 | - The combined output of `stdout` and `stderr` matches the `error` 296 | Regular Expression. 297 | 298 | - The shell command exits with a non-zero status code. 299 | 300 | [python-format]: https://docs.python.org/2/library/string.html#format-string-syntax 301 | 302 | ## License 303 | 304 | Copyright (c) 2014 Thomas Scholtes 305 | 306 | Permission is hereby granted, free of charge, to any person obtaining a 307 | copy of this software and associated documentation files (the "Software"), to 308 | deal in the Software without restriction, including without limitation the 309 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 310 | sell copies of the Software, and to permit persons to whom the Software is 311 | furnished to do so, subject to the following conditions: 312 | 313 | The above copyright notice and this permission notice shall be included in 314 | all copies or substantial portions of the Software. 315 | 316 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 317 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 318 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 319 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 320 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 321 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 322 | SOFTWARE. 323 | -------------------------------------------------------------------------------- /beetsplug/check.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Thomas Scholtes 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), to 5 | # deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | 14 | import os 15 | import re 16 | import shutil 17 | import sys 18 | from concurrent import futures 19 | from hashlib import sha256 20 | from optparse import OptionParser 21 | from subprocess import PIPE, STDOUT, Popen, check_call 22 | 23 | import beets 24 | from beets import config, importer, logging 25 | from beets.library import ReadError 26 | from beets.plugins import BeetsPlugin 27 | from beets.ui import Subcommand, UserError, colorize, decargs, input_yn 28 | from beets.util import displayable_path, syspath 29 | 30 | log = logging.getLogger("beets.check") 31 | 32 | 33 | def set_checksum(item): 34 | item["checksum"] = compute_checksum(item) 35 | item.store() 36 | 37 | 38 | def compute_checksum(item): 39 | hash = sha256() 40 | with open(syspath(item.path), "rb") as file: # noqa: FURB101 41 | hash.update(file.read()) 42 | return hash.hexdigest() 43 | 44 | 45 | def verify_checksum(item): 46 | if item["checksum"] != compute_checksum(item): 47 | raise ChecksumError(item.path, "checksum did not match value in library.") 48 | 49 | 50 | def verify_integrity(item): 51 | for checker in IntegrityChecker.allAvailable(): 52 | checker.check(item) 53 | 54 | 55 | class ChecksumError(ReadError): 56 | def __str__(self): 57 | return f"error reading {displayable_path(self.path)}: {self.reason}" 58 | 59 | 60 | class CheckPlugin(BeetsPlugin): 61 | def __init__(self): 62 | super().__init__() 63 | self.config.add({ 64 | "import": True, 65 | "write-check": True, 66 | "write-update": True, 67 | "integrity": True, 68 | "convert-update": True, 69 | "threads": os.cpu_count(), 70 | "external": { 71 | "mp3val": { 72 | "cmdline": "mp3val {0}", 73 | "formats": "MP3", 74 | "error": r"^WARNING: .* \(offset 0x[0-9a-f]+\): (.*)$", 75 | "fix": "mp3val -nb -f {0}", 76 | }, 77 | "flac": { 78 | "cmdline": "flac --test --silent {0}", 79 | "formats": "FLAC", 80 | "error": "^.*: ERROR,? (.*)$", 81 | }, 82 | "oggz-validate": {"cmdline": "oggz-validate {0}", "formats": "OGG"}, 83 | }, 84 | }) 85 | 86 | if self.config["import"]: 87 | self.register_listener("item_imported", self.item_imported) 88 | self.import_stages = [self.copy_original_checksum] 89 | self.register_listener("album_imported", self.album_imported) 90 | if self.config["write-check"]: 91 | self.register_listener("write", self.item_before_write) 92 | if self.config["write-update"]: 93 | self.register_listener("after_write", self.item_after_write) 94 | if self.config["convert-update"]: 95 | self.register_listener("after_convert", self.after_convert) 96 | if self.config["integrity"]: 97 | self.register_listener("import_task_choice", self.verify_import_integrity) 98 | 99 | def commands(self): 100 | return [CheckCommand(self.config)] 101 | 102 | def album_imported(self, lib, album): 103 | for item in album.items(): 104 | if not item.get("checksum", None): 105 | set_checksum(item) 106 | 107 | def item_imported(self, lib, item): 108 | if not item.get("checksum", None): 109 | set_checksum(item) 110 | 111 | def item_before_write(self, item, path, **kwargs): 112 | if path != item.path: 113 | return 114 | if item.get("checksum", None): 115 | verify_checksum(item) 116 | 117 | def item_after_write(self, item, path, **kwargs): 118 | if path != item.path: 119 | return 120 | set_checksum(item) 121 | 122 | def after_convert(self, item, dest, keepnew): 123 | if keepnew: 124 | set_checksum(item) 125 | 126 | def copy_original_checksum(self, config, task): 127 | for item in task.imported_items(): 128 | checksum = None 129 | for replaced in task.replaced_items[item]: 130 | try: 131 | checksum = replaced["checksum"] 132 | except KeyError: 133 | continue 134 | if checksum: 135 | break 136 | if checksum: 137 | item["checksum"] = checksum 138 | item.store() 139 | 140 | def verify_import_integrity(self, session, task): 141 | integrity_errors = [] 142 | if not task.items: 143 | return 144 | for item in task.items: 145 | try: 146 | verify_integrity(item) 147 | except IntegrityError as ex: 148 | integrity_errors.append(ex) 149 | 150 | if integrity_errors: 151 | log.warning("Warning: failed to verify integrity") 152 | for error in integrity_errors: 153 | log.warning(f" {displayable_path(item.path)}: {error}") 154 | if beets.config["import"]["quiet"] or input_yn( 155 | "Do you want to skip this album (Y/n)" 156 | ): 157 | log.info("Skipping.") 158 | task.choice_flag = importer.action.SKIP 159 | 160 | 161 | class CheckCommand(Subcommand): 162 | def __init__(self, config): 163 | self.threads = config["threads"].get(int) 164 | self.check_integrity = config["integrity"].get(bool) 165 | 166 | parser = OptionParser(usage="%prog [options] [QUERY...]") 167 | parser.add_option( 168 | "-e", 169 | "--external", 170 | action="store_true", 171 | dest="external", 172 | default=False, 173 | help="run external tools", 174 | ) 175 | parser.add_option( 176 | "-a", 177 | "--add", 178 | action="store_true", 179 | dest="add", 180 | default=False, 181 | help="add checksum for all files that do not already have one", 182 | ) 183 | parser.add_option( 184 | "-u", 185 | "--update", 186 | action="store_true", 187 | dest="update", 188 | default=False, 189 | help="compute new checksums and add the to the database", 190 | ) 191 | parser.add_option( 192 | "-f", 193 | "--force", 194 | action="store_true", 195 | dest="force", 196 | default=False, 197 | help="force updating the whole library or fixing all files", 198 | ) 199 | parser.add_option( 200 | "--export", 201 | action="store_true", 202 | dest="export", 203 | default=False, 204 | help="print paths and corresponding checksum", 205 | ) 206 | parser.add_option( 207 | "-x", 208 | "--fix", 209 | action="store_true", 210 | dest="fix", 211 | default=False, 212 | help="fix errors with external tools", 213 | ) 214 | parser.add_option( 215 | "-l", 216 | "--list-tools", 217 | action="store_true", 218 | dest="list_tools", 219 | default=False, 220 | help="list available third-party used to check integrity", 221 | ) 222 | parser.add_option( 223 | "-q", 224 | "--quiet", 225 | action="store_true", 226 | dest="quiet", 227 | default=False, 228 | help="only show errors", 229 | ) 230 | super().__init__( 231 | parser=parser, name="check", help="compute and verify checksums" 232 | ) 233 | 234 | def func(self, lib, options, arguments): 235 | self.quiet = options.quiet 236 | self.lib = lib 237 | arguments = decargs(arguments) 238 | self.query = arguments 239 | self.force_update = options.force 240 | if options.add: 241 | self.add() 242 | elif options.update: 243 | self.update() 244 | elif options.export: 245 | self.export() 246 | elif options.fix: 247 | self.fix(ask=not options.force) 248 | elif options.list_tools: 249 | self.list_tools() 250 | else: 251 | self.check(options.external) 252 | 253 | def add(self): 254 | self.log("Looking for files without checksums...") 255 | items = [i for i in self.lib.items(self.query) if not i.get("checksum", None)] 256 | 257 | def add(item): 258 | log.debug(f"adding checksum for {displayable_path(item.path)}") 259 | try: 260 | set_checksum(item) 261 | except FileNotFoundError: 262 | log.warning( 263 | "{} {}: {}".format( 264 | colorize("text_warning", "WARNING"), 265 | "No such file", 266 | displayable_path(item.path), 267 | ) 268 | ) 269 | return 270 | if self.check_integrity: 271 | try: 272 | verify_integrity(item) 273 | except IntegrityError as ex: 274 | log.warning( 275 | "{} {}: {}".format( 276 | colorize("text_warning", "WARNING"), 277 | ex.reason, 278 | displayable_path(item.path), 279 | ) 280 | ) 281 | 282 | self.execute_with_progress(add, items, msg="Adding missing checksums") 283 | 284 | def check(self, external): 285 | if external and not IntegrityChecker.allAvailable(): 286 | no_checkers_warning = ( 287 | "No integrity checkers found. " "Run 'beet check --list-tools'" 288 | ) 289 | raise UserError(no_checkers_warning) 290 | 291 | if external: 292 | progs = [c.name for c in IntegrityChecker.allAvailable()] 293 | plural = "s" if len(progs) > 1 else "" 294 | self.log("Using integrity checker{} {}".format(plural, ", ".join(progs))) 295 | 296 | items = list(self.lib.items(self.query)) 297 | failures = [0] 298 | 299 | def check(item): 300 | try: 301 | if external: 302 | verify_integrity(item) 303 | elif item.get("checksum", None): 304 | verify_checksum(item) 305 | log.debug( 306 | "{}: {}".format( 307 | colorize("text_success", "OK"), displayable_path(item.path) 308 | ) 309 | ) 310 | except ChecksumError: 311 | log.error( 312 | "{}: {}".format( 313 | colorize("text_error", "FAILED"), displayable_path(item.path) 314 | ) 315 | ) 316 | failures[0] += 1 317 | except IntegrityError as ex: 318 | log.warning( 319 | "{} {}: {}".format( 320 | colorize("text_warning", "WARNING"), 321 | ex.reason, 322 | displayable_path(item.path), 323 | ) 324 | ) 325 | failures[0] += 1 326 | except OSError as exc: 327 | log.error("{} {}".format(colorize("text_error", "ERROR"), exc)) 328 | failures[0] += 1 329 | 330 | if external: 331 | msg = "Running external tests" 332 | else: 333 | msg = "Verifying checksums" 334 | self.execute_with_progress(check, items, msg) 335 | 336 | failures = failures[0] 337 | if external: 338 | if failures: 339 | self.log(f"Found {failures} integrity error(s)") 340 | sys.exit(15) 341 | else: 342 | self.log("Integrity successfully verified") 343 | else: 344 | if failures: 345 | self.log(f"Failed to verify checksum of {failures} file(s)") 346 | sys.exit(15) 347 | else: 348 | self.log("All checksums successfully verified") 349 | 350 | def update(self): 351 | if ( 352 | not self.query 353 | and not self.force_update 354 | and not input_yn( 355 | "Do you want to overwrite all " "checksums in your database? (y/n)", 356 | require=True, 357 | ) 358 | ): 359 | return 360 | 361 | items = self.lib.items(self.query) 362 | 363 | def update(item): 364 | log.debug(f"updating checksum: {displayable_path(item.path)}") 365 | try: 366 | set_checksum(item) 367 | except OSError as exc: 368 | log.error("{} {}".format(colorize("text_error", "ERROR"), exc)) 369 | 370 | self.execute_with_progress(update, items, msg="Updating checksums") 371 | 372 | def export(self): 373 | for item in self.lib.items(self.query): 374 | if item.get("checksum", None): 375 | print(f"{item.checksum} *{displayable_path(item.path)}") # noqa: T201 376 | 377 | def fix(self, ask=True): 378 | items = list(self.lib.items(self.query)) 379 | failed = [] 380 | 381 | def check(item): 382 | try: 383 | if "checksum" in item: 384 | verify_checksum(item) 385 | fixer = IntegrityChecker.fixer(item) 386 | if fixer: 387 | fixer.check(item) 388 | log.debug( 389 | "{}: {}".format( 390 | colorize("text_success", "OK"), displayable_path(item.path) 391 | ) 392 | ) 393 | except IntegrityError: 394 | failed.append(item) 395 | except ChecksumError: 396 | log.error( 397 | "{}: {}".format( 398 | colorize("text_error", "FAILED checksum"), 399 | displayable_path(item.path), 400 | ) 401 | ) 402 | except OSError as exc: 403 | log.error("{} {}".format(colorize("text_error", "ERROR"), exc)) 404 | 405 | self.execute_with_progress(check, items, msg="Verifying integrity") 406 | 407 | if not failed: 408 | self.log("No MP3 files to fix") 409 | return 410 | 411 | for item in failed: 412 | log.info(displayable_path(item.path)) 413 | 414 | if ask and not input_yn( 415 | "Do you want to fix these files? {} (y/n)", require=True 416 | ): 417 | return 418 | 419 | def fix(item): 420 | fixer = IntegrityChecker.fixer(item) 421 | if fixer: 422 | fixer.fix(item) 423 | log.debug( 424 | "{}: {}".format( 425 | colorize("text_success", "FIXED"), displayable_path(item.path) 426 | ) 427 | ) 428 | set_checksum(item) 429 | 430 | self.execute_with_progress(fix, failed, msg="Fixing files") 431 | 432 | def list_tools(self): 433 | checkers = [ 434 | (checker.name, checker.available()) for checker in IntegrityChecker.all() 435 | ] 436 | prog_length = max(len(c[0]) for c in checkers) + 3 437 | for name, available in checkers: 438 | msg = name + (prog_length - len(name)) * " " 439 | if available: 440 | msg += colorize("text_success", "found") 441 | else: 442 | msg += colorize("text_error", "not found") 443 | print(msg) # noqa: T201 444 | 445 | def log(self, msg): 446 | if not self.quiet: 447 | print(msg) # noqa: T201 448 | 449 | def log_progress(self, msg, index, total): 450 | if self.quiet or not sys.stdout.isatty(): 451 | return 452 | msg = f"{msg}: {index}/{total} [{index * 100 / total}%]" 453 | sys.stdout.write(msg + "\r") 454 | sys.stdout.flush() 455 | if index == total: 456 | sys.stdout.write("\n") 457 | else: 458 | sys.stdout.write(len(msg) * " " + "\r") 459 | 460 | def execute_with_progress(self, func, args, msg=None): 461 | """Run `func` for each value in the iterator `args` in a thread pool. 462 | 463 | When the function has finished it logs the progress and the `msg`. 464 | """ 465 | total = len(args) 466 | finished = 0 467 | with futures.ThreadPoolExecutor(max_workers=self.threads) as e: 468 | for _ in e.map(func, args): 469 | finished += 1 470 | self.log_progress(msg, finished, total) 471 | 472 | 473 | class IntegrityError(ReadError): 474 | def __str__(self): 475 | return f"error reading {displayable_path(self.path)}: {self.reason}" 476 | 477 | 478 | class IntegrityChecker: 479 | @classmethod 480 | def all(cls): 481 | if hasattr(cls, "_all"): 482 | return cls._all 483 | 484 | cls._all = [] 485 | for name, tool in config["check"]["external"].items(): 486 | cls._all.append(cls(name, tool)) 487 | return cls._all 488 | 489 | @classmethod 490 | def allAvailable(cls): 491 | if not hasattr(cls, "_all_available"): 492 | cls._all_available = [c for c in cls.all() if c.available()] 493 | return cls._all_available 494 | 495 | def __init__(self, name, config): 496 | self.name = name 497 | self.cmdline = config["cmdline"].get(str) 498 | 499 | if config["formats"].exists(): 500 | self.formats = config["formats"].as_str_seq() 501 | else: 502 | self.formats = True 503 | 504 | if config["error"].exists(): 505 | self.error_match = re.compile(config["error"].get(str), re.MULTILINE) 506 | else: 507 | self.error_match = False 508 | 509 | if config["fix"].exists(): 510 | self.fixcmd = config["fix"].get(str) 511 | else: 512 | self.fixcmd = False 513 | 514 | def available(self) -> bool: 515 | return shutil.which(self.cmdline.split(" ")[0]) is not None 516 | 517 | @classmethod 518 | def fixer(cls, item): 519 | """Return an `IntegrityChecker` instance that can fix this item.""" 520 | for checker in cls.allAvailable(): 521 | if checker.can_fix(item): 522 | return checker 523 | 524 | def can_check(self, item): 525 | return self.formats is True or item.format in self.formats 526 | 527 | def check(self, item): 528 | if not self.can_check(item): 529 | return 530 | process = Popen( 531 | self.cmdline.format(self.shellquote(item.path.decode("utf-8"))), 532 | shell=True, 533 | stdin=PIPE, 534 | stdout=PIPE, 535 | stderr=STDOUT, 536 | ) 537 | stdout = process.communicate()[0] 538 | if self.error_match: 539 | match = self.error_match.search(stdout.decode("utf-8")) 540 | else: 541 | match = False 542 | if match: 543 | raise IntegrityError(item.path, match.group(1)) 544 | elif process.returncode: 545 | raise IntegrityError(item.path, f"non-zero exit code for {self.name}") 546 | 547 | def can_fix(self, item): 548 | return self.can_check(item) and self.fixcmd 549 | 550 | def fix(self, item): 551 | assert isinstance(self.fixcmd, str) 552 | check_call( 553 | self.fixcmd.format(self.shellquote(item.path.decode("utf-8"))), 554 | shell=True, 555 | stdin=PIPE, 556 | stdout=PIPE, 557 | stderr=STDOUT, 558 | ) 559 | 560 | def shellquote(self, s): 561 | return "'" + s.replace("'", r"'\''") + "'" 562 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "beets-check" 3 | version = "0.15.0" 4 | description = "beets plugin verifying file integrity with checksums" 5 | authors = [{ name = "Thomas Scholtes", email = "geigerzaehler@axiom.fm" }] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "http://www.github.com/geigerzaehler/beets-check" 9 | classifiers = [ 10 | "Topic :: Multimedia :: Sound/Audio", 11 | "Topic :: Multimedia :: Sound/Audio :: Players :: MP3", 12 | "License :: OSI Approved :: MIT License", 13 | "Environment :: Console", 14 | "Environment :: Web Environment", 15 | "Programming Language :: Python :: 3", 16 | ] 17 | packages = [{ include = "beetsplug" }] 18 | dependencies = ["beets >=1.6.1, <3", "mediafile ~=0.12.0"] 19 | requires-python = ">=3.9" 20 | 21 | [tool.uv] 22 | dev-dependencies = ["pytest ~=8.0.0", "ruff >=0.5.1, <6"] 23 | 24 | [tool.ruff] 25 | target-version = "py39" 26 | unsafe-fixes = true 27 | preview = true 28 | 29 | [tool.ruff.lint] 30 | extend-select = [ 31 | "I", # Sort imports 32 | "C", # Pyflakes conventions 33 | # "PTH", # Use pathlib instead of os 34 | "PIE", # Misc. lints 35 | "UP", # Enforce modern Python syntax 36 | "FURB", # Also enforce more modern Python syntax 37 | "PT", # Pytest style 38 | "B", # Bugbear, avoid common sources of bugs 39 | "SIM", # Simplify 40 | "T20", # Warn about `print()` 41 | "RUF", 42 | "C4", # List comprehension 43 | ] 44 | ignore = [ 45 | # Pyright checks for unused imports and does it better. 46 | "F401", 47 | # ternary can be less readable 48 | "SIM108", 49 | # Ignore complexity 50 | "C901", 51 | ] 52 | 53 | [build-system] 54 | requires = ["hatchling"] 55 | build-backend = "hatchling.build" 56 | 57 | [tool.hatch.build.targets.sdist] 58 | include = ["beetsplug/*.py"] 59 | 60 | [tool.hatch.build.targets.wheel] 61 | include = ["beetsplug/*.py"] 62 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/__init__.py -------------------------------------------------------------------------------- /test/cli_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | from unittest import TestCase 5 | 6 | import beets.library 7 | import beets.ui 8 | import pytest 9 | from beets.library import Item 10 | from beets.ui import UserError 11 | 12 | from beetsplug.check import set_checksum, verify_checksum 13 | from test.helper import MockChecker, TestHelper, captureLog, captureStdout, controlStdin 14 | 15 | 16 | class TestBase(TestHelper, TestCase): 17 | def setUp(self): 18 | super().setUp() 19 | self.setupBeets() 20 | 21 | def tearDown(self): 22 | super().tearDown() 23 | 24 | 25 | class CheckAddTest(TestBase, TestCase): 26 | """beet check --add""" 27 | 28 | def test_add_checksums(self): 29 | self.setupFixtureLibrary() 30 | item = self.lib.items().get() 31 | del item["checksum"] 32 | item.store() 33 | 34 | beets.ui._raw_main(["check", "-a"]) 35 | 36 | item = self.lib.items().get() 37 | assert "checksum" in item 38 | 39 | def test_dont_add_existing_checksums(self): 40 | self.setupFixtureLibrary() 41 | item = self.lib.items().get() 42 | set_checksum(item) 43 | orig_checksum = item["checksum"] 44 | 45 | self.modifyFile(item.path) 46 | beets.ui._raw_main(["check", "-a"]) 47 | 48 | item["checksum"] = "" 49 | item.load() 50 | assert item["checksum"] == orig_checksum 51 | 52 | def test_dont_fail_missing_file(self): 53 | self.setupFixtureLibrary() 54 | item = self.lib.items().get() 55 | del item["checksum"] 56 | item.path = "/doesnotexist" 57 | item.store() 58 | 59 | with captureLog() as logs: 60 | beets.ui._raw_main(["check", "-a"]) 61 | 62 | assert "WARNING No such file: /doesnotexist" in "\n".join(logs) 63 | 64 | def test_add_shows_integrity_warning(self): 65 | MockChecker.install() 66 | item = self.addIntegrityFailFixture(checksum=False) 67 | 68 | with captureLog() as logs: 69 | beets.ui._raw_main(["check", "-a"]) 70 | 71 | assert "WARNING file is corrupt: {}".format( 72 | item.path.decode("utf-8") 73 | ) in "\n".join(logs) 74 | 75 | 76 | class CheckTest(TestBase, TestCase): 77 | """beet check""" 78 | 79 | def test_check_success(self): 80 | self.setupFixtureLibrary() 81 | with captureStdout() as stdout: 82 | beets.ui._raw_main(["check"]) 83 | assert ( 84 | stdout.getvalue().split("\n")[-2] == "All checksums successfully verified" 85 | ) 86 | 87 | def test_check_failed_error_log(self): 88 | self.setupFixtureLibrary() 89 | item = self.lib.items().get() 90 | self.modifyFile(item.path) 91 | 92 | try: 93 | with captureLog("beets.check") as logs: 94 | beets.ui._raw_main(["check"]) 95 | assert "FAILED: {}".format(item.path.decode("utf-8")) in "\n".join(logs) 96 | except SystemExit: 97 | pass 98 | 99 | def test_not_found_error_log(self): 100 | self.setupFixtureLibrary() 101 | item = self.lib.items().get() 102 | item.path = "/doesnotexist" 103 | item.store() 104 | 105 | try: 106 | with captureLog("beets.check") as logs: 107 | beets.ui._raw_main(["check"]) 108 | assert "OK:" in "\n".join(logs) 109 | assert "ERROR [Errno 2] No such file or directory" in "\n".join(logs) 110 | except SystemExit: 111 | pass 112 | 113 | def test_check_failed_exit_code(self): 114 | self.setupFixtureLibrary() 115 | item = self.lib.items().get() 116 | self.modifyFile(item.path) 117 | 118 | with pytest.raises(SystemExit) as exc_info: 119 | beets.ui._raw_main(["check"]) 120 | assert exc_info.value.code == 15 121 | 122 | 123 | class CheckIntegrityTest(TestBase, TestCase): 124 | # TODO beet check --external=mp3val,other 125 | """beet check --external""" 126 | 127 | def test_integrity_warning(self): 128 | MockChecker.install() 129 | self.addIntegrityFailFixture() 130 | 131 | with pytest.raises(SystemExit), captureLog() as logs: 132 | beets.ui._raw_main(["check", "--external"]) 133 | 134 | assert "WARNING file is corrupt" in "\n".join(logs) 135 | 136 | def test_check_failed_exit_code(self): 137 | MockChecker.install() 138 | self.addIntegrityFailFixture() 139 | 140 | with pytest.raises(SystemExit) as exc_info: 141 | beets.ui._raw_main(["check", "--external"]) 142 | assert exc_info.value.code == 15 143 | 144 | def test_no_integrity_checkers_warning(self): 145 | MockChecker.installNone() 146 | self.addIntegrityFailFixture() 147 | 148 | with pytest.raises(UserError) as exc_info: 149 | beets.ui._raw_main(["check", "--external"]) 150 | 151 | assert "No integrity checkers found." in exc_info.value.args[0] 152 | 153 | def test_print_integrity_checkers(self): 154 | MockChecker.install() 155 | self.addIntegrityFailFixture() 156 | 157 | with pytest.raises(SystemExit), captureStdout() as stdout: 158 | beets.ui._raw_main(["check", "--external"]) 159 | 160 | assert "Using integrity checker mock" in stdout.getvalue() 161 | 162 | 163 | class CheckUpdateTest(TestBase, TestCase): 164 | """beet check --update""" 165 | 166 | def test_force_all_update(self): 167 | self.setupFixtureLibrary() 168 | item = self.lib.items().get() 169 | orig_checksum = item["checksum"] 170 | self.modifyFile(item.path) 171 | 172 | beets.ui._raw_main(["check", "--force", "--update"]) 173 | 174 | item = self.lib.items().get() 175 | assert item["checksum"] != orig_checksum 176 | verify_checksum(item) 177 | 178 | def test_update_all_confirmation(self): 179 | self.setupFixtureLibrary() 180 | item = self.lib.items().get() 181 | orig_checksum = item["checksum"] 182 | self.modifyFile(item.path) 183 | 184 | with captureStdout() as stdout, controlStdin("y"): 185 | beets.ui._raw_main(["check", "--update"]) 186 | 187 | assert "Do you want to overwrite all checksums" in stdout.getvalue() 188 | 189 | item = self.lib.items().get() 190 | assert item["checksum"] != orig_checksum 191 | verify_checksum(item) 192 | 193 | def test_update_all_confirmation_no(self): 194 | self.setupFixtureLibrary() 195 | item = self.lib.items().get() 196 | orig_checksum = item["checksum"] 197 | self.modifyFile(item.path) 198 | 199 | with controlStdin("n"): 200 | beets.ui._raw_main(["check", "--update"]) 201 | 202 | item = self.lib.items().get() 203 | assert item["checksum"] == orig_checksum 204 | 205 | def test_update_nonexistent(self): 206 | item = Item(path="/doesnotexist") 207 | self.lib.add(item) 208 | 209 | with captureLog() as logs: 210 | beets.ui._raw_main(["check", "--update", "--force"]) 211 | 212 | assert "ERROR [Errno 2] No such file or directory" in "\n".join(logs) 213 | 214 | 215 | class CheckExportTest(TestBase, TestCase): 216 | """beet check --export""" 217 | 218 | def test_export(self): 219 | self.setupFixtureLibrary() 220 | with captureStdout() as stdout: 221 | beets.ui._raw_main(["check", "--export"]) 222 | 223 | item = self.lib.items().get() 224 | assert ( 225 | "{} *{}\n".format(item.checksum, item.path.decode("utf-8")) 226 | in stdout.getvalue() 227 | ) 228 | 229 | 230 | class IntegrityCheckTest(TestHelper, TestCase): 231 | """beet check --external 232 | 233 | For integrated third-party tools""" 234 | 235 | def setUp(self): 236 | super().setUp() 237 | self.setupBeets() 238 | self.setupFixtureLibrary() 239 | self.enableIntegrityCheckers() 240 | 241 | def tearDown(self): 242 | super().tearDown() 243 | 244 | def test_mp3_integrity(self): 245 | item = self.lib.items(["path::truncated.mp3"]).get() 246 | 247 | with pytest.raises(SystemExit), captureLog() as logs: 248 | beets.ui._raw_main(["check", "--external"]) 249 | assert ( 250 | "check: WARNING It seems that file is " 251 | "truncated or there is garbage at the " 252 | "end of the file: {}".format(item.path.decode("utf-8")) 253 | in logs 254 | ) 255 | 256 | def test_flac_integrity(self): 257 | item = self.lib.items("truncated.flac").get() 258 | 259 | with pytest.raises(SystemExit), captureLog() as logs: 260 | beets.ui._raw_main(["check", "--external"]) 261 | logs = "\n".join(logs) 262 | assert re.search( 263 | f"check: WARNING (while|during) decoding( data)?: {item.path.decode('utf-8')}", 264 | logs, 265 | ) 266 | 267 | def test_ogg_vorbis_integrity(self): 268 | item = self.lib.items("truncated.ogg").get() 269 | 270 | with captureLog() as logs, pytest.raises(SystemExit): 271 | beets.ui._raw_main(["check", "--external"]) 272 | assert ( 273 | f"check: WARNING non-zero exit code for oggz-validate: {str(item.path, 'utf-8')}" 274 | in logs 275 | ) 276 | 277 | def test_shellquote(self): 278 | item = self.lib.items(["ok.flac"]).get() 279 | item["title"] = "ok's" 280 | item.move() 281 | 282 | with captureLog() as logs: 283 | beets.ui._raw_main(["check", "--external", item.title]) 284 | assert "WARNING" not in "\n".join(logs) 285 | 286 | 287 | class FixIntegrityTest(TestHelper, TestCase): 288 | """beet check -x""" 289 | 290 | def setUp(self): 291 | super().setUp() 292 | self.setupBeets() 293 | self.enableIntegrityCheckers() 294 | 295 | def tearDown(self): 296 | super().tearDown() 297 | 298 | def test_fix_integrity(self): 299 | item = self.addIntegrityFailFixture() 300 | 301 | with pytest.raises(SystemExit), captureLog() as logs: 302 | beets.ui._raw_main(["check", "-e"]) 303 | assert "WARNING It seems that file is truncated" in "\n".join(logs) 304 | 305 | with controlStdin("y"), captureLog() as logs: 306 | beets.ui._raw_main(["check", "--fix"]) 307 | assert item.path.decode("utf-8") in "\n".join(logs) 308 | assert "FIXED: {}".format(item.path.decode("utf-8")) in "\n".join(logs) 309 | 310 | with captureLog() as logs: 311 | beets.ui._raw_main(["check", "-e"]) 312 | assert "WARNING It seems that file is truncated" not in "\n".join(logs) 313 | 314 | def test_fix_without_confirmation(self): 315 | item = self.addIntegrityFailFixture() 316 | 317 | with pytest.raises(SystemExit), captureLog() as logs: 318 | beets.ui._raw_main(["check", "-e"]) 319 | assert "WARNING It seems that file is truncated" in "\n".join(logs) 320 | 321 | with captureLog() as logs: 322 | beets.ui._raw_main(["check", "--fix", "--force"]) 323 | assert item.path.decode("utf-8") in "\n".join(logs) 324 | 325 | with captureLog() as logs: 326 | beets.ui._raw_main(["check", "-e"]) 327 | assert "WARNING It seems that file is truncated" not in "\n".join(logs) 328 | 329 | def test_update_checksum(self): 330 | item = self.addIntegrityFailFixture() 331 | old_checksum = item["checksum"] 332 | beets.ui._raw_main(["check", "--fix", "--force"]) 333 | 334 | item["checksum"] = "" 335 | item.load() 336 | verify_checksum(item) 337 | assert old_checksum != item["checksum"] 338 | 339 | def test_dont_fix_with_wrong_checksum(self): 340 | item = self.addIntegrityFailFixture() 341 | item["checksum"] = "this is wrong" 342 | item.store() 343 | 344 | with captureLog() as logs: 345 | beets.ui._raw_main(["check", "--fix", "--force"]) 346 | assert "FAILED checksum" in "\n".join(logs) 347 | 348 | item["checksum"] = "" 349 | item.load() 350 | assert item["checksum"] == "this is wrong" 351 | 352 | def test_nothing_to_fix(self): 353 | self.addItemFixture("ok.ogg") 354 | with captureStdout() as stdout: 355 | beets.ui._raw_main(["check", "--fix", "--force"]) 356 | assert "No MP3 files to fix" in stdout.getvalue() 357 | 358 | def test_do_not_fix(self): 359 | item = self.addIntegrityFailFixture() 360 | with controlStdin("n"): 361 | beets.ui._raw_main(["check", "--fix"]) 362 | verify_checksum(item) 363 | 364 | 365 | class ToolListTest(TestHelper, TestCase): 366 | def setUp(self): 367 | super().setUp() 368 | self.enableIntegrityCheckers() 369 | self.setupBeets() 370 | self.orig_path = os.environ["PATH"] 371 | os.environ["PATH"] = self.temp_dir 372 | 373 | def tearDown(self): 374 | super().tearDown() 375 | os.environ["PATH"] = self.orig_path 376 | 377 | def test_list(self): 378 | with captureStdout() as stdout: 379 | beets.ui._raw_main(["check", "--list-tools"]) 380 | assert "mp3val" in stdout.getvalue() 381 | assert "flac" in stdout.getvalue() 382 | assert "oggz-validate" in stdout.getvalue() 383 | 384 | def test_found_mp3val(self): 385 | shutil.copy("/bin/echo", os.path.join(self.temp_dir, "mp3val")) 386 | with captureStdout() as stdout: 387 | beets.ui._raw_main(["check", "--list-tools"]) 388 | assert re.search("mp3val *found", stdout.getvalue()) 389 | 390 | def test_oggz_validate_not_found(self): 391 | with captureStdout() as stdout: 392 | beets.ui._raw_main(["check", "--list-tools"]) 393 | assert re.search("oggz-validate *not found", stdout.getvalue()) 394 | -------------------------------------------------------------------------------- /test/fixtures/crc.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/fixtures/crc.flac -------------------------------------------------------------------------------- /test/fixtures/md5.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/fixtures/md5.flac -------------------------------------------------------------------------------- /test/fixtures/ok.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/fixtures/ok.flac -------------------------------------------------------------------------------- /test/fixtures/ok.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/fixtures/ok.mp3 -------------------------------------------------------------------------------- /test/fixtures/ok.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/fixtures/ok.ogg -------------------------------------------------------------------------------- /test/fixtures/truncated.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/fixtures/truncated.flac -------------------------------------------------------------------------------- /test/fixtures/truncated.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/fixtures/truncated.mp3 -------------------------------------------------------------------------------- /test/fixtures/truncated.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geigerzaehler/beets-check/d54f4211e8bc63c3bde08cb784d0413fa7093692/test/fixtures/truncated.ogg -------------------------------------------------------------------------------- /test/helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import sys 5 | import tempfile 6 | from contextlib import contextmanager 7 | 8 | try: 9 | from StringIO import StringIO 10 | except ImportError: 11 | from io import StringIO 12 | 13 | import beets 14 | from beets import autotag, plugins 15 | from beets.autotag import ( 16 | AlbumInfo, 17 | AlbumMatch, 18 | Proposal, 19 | Recommendation, 20 | TrackInfo, 21 | TrackMatch, 22 | ) 23 | from beets.autotag.hooks import Distance 24 | from beets.library import Item 25 | from mediafile import MediaFile 26 | 27 | from beetsplug import check 28 | 29 | logging.getLogger("beets").propagate = True 30 | 31 | 32 | class LogCapture(logging.Handler): 33 | 34 | def __init__(self): 35 | super().__init__() 36 | self.messages = [] 37 | 38 | def emit(self, record): 39 | self.messages.append(str(record.msg)) 40 | 41 | 42 | @contextmanager 43 | def captureLog(logger="beets"): 44 | capture = LogCapture() 45 | log = logging.getLogger(logger) 46 | log.addHandler(capture) 47 | try: 48 | yield capture.messages 49 | finally: 50 | log.removeHandler(capture) 51 | 52 | 53 | @contextmanager 54 | def captureStdout(): 55 | org = sys.stdout 56 | sys.stdout = StringIO() 57 | try: 58 | yield sys.stdout 59 | finally: 60 | sys.stdout = org 61 | 62 | 63 | @contextmanager 64 | def controlStdin(input=None): 65 | org = sys.stdin 66 | sys.stdin = StringIO(input) 67 | try: 68 | yield sys.stdin 69 | finally: 70 | sys.stdin = org 71 | 72 | 73 | class TestHelper: 74 | 75 | def setUp(self): 76 | self.temp_dir = tempfile.mkdtemp() 77 | plugins._classes = {check.CheckPlugin} 78 | self.disableIntegrityCheckers() 79 | 80 | def tearDown(self): 81 | self.unloadPlugins() 82 | if hasattr(self, "temp_dir"): 83 | shutil.rmtree(self.temp_dir) 84 | MockChecker.restore() 85 | 86 | def setupBeets(self): 87 | os.environ["BEETSDIR"] = self.temp_dir 88 | 89 | self.config = beets.config 90 | self.config.clear() 91 | self.config.read() 92 | 93 | self.config["plugins"] = [] 94 | self.config["verbose"] = True 95 | self.config["ui"]["color"] = False 96 | self.config["threaded"] = False 97 | self.config["import"]["copy"] = False 98 | 99 | self.libdir = os.path.join(self.temp_dir, "libdir") 100 | os.mkdir(self.libdir) 101 | self.config["directory"] = self.libdir 102 | 103 | self.lib = beets.library.Library( 104 | self.config["library"].as_filename(), self.libdir 105 | ) 106 | 107 | self.fixture_dir = os.path.join(os.path.dirname(__file__), "fixtures") 108 | 109 | def setupImportDir(self, files): 110 | self.import_dir = os.path.join(self.temp_dir, "import") 111 | if not os.path.isdir(self.import_dir): 112 | os.mkdir(self.import_dir) 113 | for file in files: 114 | src = os.path.join(self.fixture_dir, file) 115 | shutil.copy(src, self.import_dir) 116 | 117 | def setupFixtureLibrary(self): 118 | for basename in os.listdir(self.fixture_dir): 119 | item = self.addItemFixture(basename) 120 | check.set_checksum(item) 121 | 122 | def addIntegrityFailFixture(self, checksum=True): 123 | """Add item with integrity errors to the library and return it. 124 | 125 | The `MockChecker` will raise an integrity error when run on this item. 126 | """ 127 | item = self.addItemFixture("truncated.mp3") 128 | if checksum: 129 | check.set_checksum(item) 130 | return item 131 | 132 | def addCorruptedFixture(self): 133 | """Add item with a wrong checksum to the library and return it.""" 134 | item = self.addItemFixture("ok.ogg") 135 | item["checksum"] = "this is a wrong checksum" 136 | item.store() 137 | return item 138 | 139 | def addItemFixture(self, basename): 140 | src = os.path.join(self.fixture_dir, basename) 141 | dst = os.path.join(self.libdir, basename) 142 | shutil.copy(src, dst) 143 | item = Item.from_path(dst) 144 | item.add(self.lib) 145 | return item 146 | 147 | def disableIntegrityCheckers(self): 148 | check.IntegrityChecker._all = [] 149 | check.IntegrityChecker._all_available = [] 150 | 151 | def enableIntegrityCheckers(self): 152 | if hasattr(check.IntegrityChecker, "_all"): 153 | delattr(check.IntegrityChecker, "_all") 154 | if hasattr(check.IntegrityChecker, "_all_available"): 155 | delattr(check.IntegrityChecker, "_all_available") 156 | 157 | def modifyFile(self, path, title="a different title"): 158 | mediafile = MediaFile(path) 159 | mediafile.title = title 160 | mediafile.save() 161 | 162 | @contextmanager 163 | def mockAutotag(self): 164 | mock = AutotagMock() 165 | mock.install() 166 | try: 167 | yield 168 | finally: 169 | mock.restore() 170 | 171 | def unloadPlugins(self): 172 | for plugin in plugins._classes: 173 | plugin.listeners = None 174 | plugins._classes = set() 175 | plugins._instances = {} 176 | 177 | 178 | class AutotagMock: 179 | 180 | def __init__(self): 181 | self.id = 0 182 | 183 | def nextid(self): 184 | self.id += 1 185 | return self.id 186 | 187 | def install(self): 188 | self._orig_tag_album = autotag.tag_album 189 | self._orig_tag_item = autotag.tag_item 190 | autotag.tag_album = self.tag_album 191 | autotag.tag_item = self.tag_item 192 | 193 | def restore(self): 194 | autotag.tag_album = self._orig_tag_album 195 | autotag.tag_item = self._orig_tag_item 196 | 197 | def tag_album(self, items, **kwargs): 198 | artist = (items[0].artist or "") + " tag" 199 | album = (items[0].album or "") + " tag" 200 | mapping = {} 201 | dist = Distance() 202 | dist.tracks = {} 203 | for item in items: 204 | title = (item.title or "") + " tag" 205 | track_info = TrackInfo(title=title, track_id=self.nextid(), index=1) 206 | mapping[item] = track_info 207 | dist.tracks[track_info] = Distance() 208 | 209 | album_info = AlbumInfo( 210 | album="album", 211 | album_id=self.nextid(), 212 | artist="artist", 213 | artist_id=self.nextid(), 214 | tracks=mapping.values(), 215 | ) 216 | match = AlbumMatch( 217 | distance=dist, 218 | info=album_info, 219 | mapping=mapping, 220 | extra_items=[], 221 | extra_tracks=[], 222 | ) 223 | return artist, album, Proposal([match], Recommendation.strong) 224 | 225 | def tag_item(self, item, **kwargs): 226 | title = (item.title or "") + " tag" 227 | track_info = TrackInfo(title=title, track_id=self.nextid()) 228 | match = TrackMatch(distance=Distance(), info=track_info) 229 | return Proposal([match], Recommendation.strong) 230 | 231 | 232 | class MockChecker: 233 | name = "mock" 234 | 235 | @classmethod 236 | def install(cls): 237 | check.IntegrityChecker._all_available = [cls()] 238 | 239 | @classmethod 240 | def restore(cls): 241 | if hasattr(check.IntegrityChecker, "_all_available"): 242 | delattr(check.IntegrityChecker, "_all_available") 243 | 244 | @classmethod 245 | def installNone(cls): 246 | check.IntegrityChecker._all_available = [] 247 | 248 | def check(self, item): 249 | if b"truncated" in item.path: 250 | raise check.IntegrityError(item.path, "file is corrupt") 251 | -------------------------------------------------------------------------------- /test/integration_test.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import re 3 | from unittest import TestCase 4 | 5 | import beets 6 | import beets.library 7 | import beets.plugins 8 | import beets.ui 9 | from mediafile import MediaFile 10 | 11 | from beetsplug.check import IntegrityChecker, verify_checksum 12 | from test.helper import MockChecker, TestHelper, captureLog, captureStdout, controlStdin 13 | 14 | 15 | class ImportTest(TestHelper, TestCase): 16 | def setUp(self): 17 | super().setUp() 18 | self.setupBeets() 19 | self.setupImportDir(["ok.mp3"]) 20 | IntegrityChecker._all_available = [] 21 | 22 | def tearDown(self): 23 | super().tearDown() 24 | MockChecker.restore() 25 | 26 | def test_add_album_checksum(self): 27 | with self.mockAutotag(): 28 | beets.ui._raw_main(["import", self.import_dir]) 29 | item = self.lib.items().get() 30 | assert "checksum" in item 31 | assert item.title == "ok tag" 32 | verify_checksum(item) 33 | 34 | def test_add_singleton_checksum(self): 35 | with self.mockAutotag(): 36 | beets.ui._raw_main(["import", "--singletons", self.import_dir]) 37 | item = self.lib.items().get() 38 | assert "checksum" in item 39 | verify_checksum(item) 40 | 41 | def test_add_album_checksum_without_autotag(self): 42 | with self.mockAutotag(): 43 | beets.ui._raw_main(["import", "--noautotag", self.import_dir]) 44 | item = self.lib.items().get() 45 | assert "checksum" in item 46 | assert item.title == "ok" 47 | verify_checksum(item) 48 | 49 | def test_add_singleton_checksum_without_autotag(self): 50 | with self.mockAutotag(): 51 | beets.ui._raw_main([ 52 | "import", 53 | "--singletons", 54 | "--noautotag", 55 | self.import_dir, 56 | ]) 57 | item = self.lib.items().get() 58 | assert "checksum" in item 59 | verify_checksum(item) 60 | 61 | def test_reimport_does_not_overwrite_checksum(self): 62 | self.setupFixtureLibrary() 63 | 64 | item = self.lib.items().get() 65 | orig_checksum = item["checksum"] 66 | verify_checksum(item) 67 | self.modifyFile(item.path, "changed") 68 | 69 | with self.mockAutotag(): 70 | beets.ui._raw_main(["import", self.libdir]) 71 | 72 | item = self.lib.items([item.path.decode("utf-8")]).get() 73 | assert item["checksum"] == orig_checksum 74 | 75 | def test_skip_corrupt_files(self): 76 | MockChecker.install() 77 | self.setupImportDir(["ok.mp3", "truncated.mp3"]) 78 | 79 | with self.mockAutotag(), controlStdin( 80 | " " 81 | ), captureStdout() as stdout, captureLog() as logs: 82 | beets.ui._raw_main(["import", self.import_dir]) 83 | 84 | assert "check: Warning: failed to verify integrity" in logs 85 | assert "truncated.mp3: file is corrupt" in "\n".join(logs) 86 | assert "Do you want to skip this album" in stdout.getvalue() 87 | assert len(self.lib.items()) == 0 88 | 89 | def test_quiet_skip_corrupt_files(self): 90 | MockChecker.install() 91 | self.setupImportDir(["ok.mp3", "truncated.mp3"]) 92 | 93 | with self.mockAutotag(), captureLog() as logs: 94 | beets.ui._raw_main(["import", "-q", self.import_dir]) 95 | 96 | assert "check: Warning: failed to verify integrity" in logs 97 | assert "truncated.mp3: file is corrupt\ncheck: Skipping." in "\n".join(logs) 98 | assert len(self.lib.items()) == 0 99 | 100 | def test_add_corrupt_files(self): 101 | MockChecker.install() 102 | self.setupImportDir(["ok.mp3", "truncated.mp3"]) 103 | 104 | with self.mockAutotag(), controlStdin("n"): 105 | beets.ui._raw_main(["import", self.import_dir]) 106 | 107 | assert len(self.lib.items()) == 2 108 | item = self.lib.items("truncated").get() 109 | mediafile = MediaFile(item.path) 110 | assert mediafile.title == "truncated tag" 111 | 112 | 113 | class WriteTest(TestHelper, TestCase): 114 | def setUp(self): 115 | super().setUp() 116 | self.setupBeets() 117 | self.setupFixtureLibrary() 118 | 119 | def test_log_error_for_invalid_checksum(self): 120 | item = self.lib.items("ok").get() 121 | verify_checksum(item) 122 | self.modifyFile(item.path) 123 | 124 | with captureLog() as logs: 125 | beets.ui._raw_main(["write", item.title]) 126 | assert re.search( 127 | "error reading .*: checksum did not match value in library", "\n".join(logs) 128 | ) 129 | 130 | def test_abort_write_when_invalid_checksum(self): 131 | item = self.lib.items("ok").get() 132 | verify_checksum(item) 133 | self.modifyFile(item.path, title="other title") 134 | 135 | item["title"] = "newtitle" 136 | item.store() 137 | beets.ui._raw_main(["write", item.title]) 138 | 139 | mediafile = MediaFile(item.path) 140 | assert mediafile.title != "newtitle" 141 | 142 | def test_write_on_integrity_error(self): 143 | MockChecker.install() 144 | 145 | item = self.lib.items("truncated").get() 146 | 147 | item["title"] = "newtitle" 148 | item.store() 149 | beets.ui._raw_main(["write", item.title]) 150 | 151 | item["checksum"] = "" 152 | item.load() 153 | verify_checksum(item) 154 | mediafile = MediaFile(item.path) 155 | assert mediafile.title == "newtitle" 156 | 157 | def test_update_checksum(self): 158 | item = self.lib.items("ok").get() 159 | orig_checksum = item["checksum"] 160 | verify_checksum(item) 161 | 162 | item["title"] = "newtitle" 163 | item.store() 164 | beets.ui._raw_main(["write", item.title]) 165 | 166 | item["checksum"] = "" 167 | item.load() 168 | assert item["checksum"] != orig_checksum 169 | verify_checksum(item) 170 | 171 | mediafile = MediaFile(item.path) 172 | assert mediafile.title == "newtitle" 173 | 174 | 175 | class ConvertTest(TestHelper, TestCase): 176 | def setUp(self): 177 | super().setUp() 178 | self.setupBeets() 179 | beets.config["plugins"] = ["convert"] 180 | beets.plugins._instances.clear() 181 | beets.plugins.load_plugins(("convert", "check")) 182 | 183 | beets.config["convert"] = { 184 | "dest": os.path.join(self.temp_dir, "convert"), 185 | # Truncated copy to break checksum 186 | "command": "dd bs=1024 count=6 if=$source of=$dest", 187 | } 188 | self.setupFixtureLibrary() 189 | 190 | def test_convert_command(self): 191 | with controlStdin("y"): 192 | beets.ui._raw_main(["convert", "ok.ogg"]) 193 | 194 | def test_update_after_keep_new_convert(self): 195 | item = self.lib.items("ok.ogg").get() 196 | verify_checksum(item) 197 | 198 | with controlStdin("y"): 199 | beets.ui._raw_main(["convert", "--keep-new", "ok.ogg"]) 200 | 201 | converted = self.lib.items("ok.ogg").get() 202 | assert converted.path != item.path 203 | assert converted.checksum != item.checksum 204 | verify_checksum(converted) 205 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.9" 3 | 4 | [[package]] 5 | name = "beets" 6 | version = "2.0.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | dependencies = [ 9 | { name = "confuse" }, 10 | { name = "jellyfish" }, 11 | { name = "mediafile" }, 12 | { name = "munkres" }, 13 | { name = "musicbrainzngs" }, 14 | { name = "pyyaml" }, 15 | { name = "typing-extensions" }, 16 | { name = "unidecode" }, 17 | ] 18 | sdist = { url = "https://files.pythonhosted.org/packages/8f/7d/a6e7fc23af347ce939a1dbca465dc29c6ff91e11b31da0fbae54455d158c/beets-2.0.0.tar.gz", hash = "sha256:3b1172b5bc3729e33a6ea4689f7d0236682bf828c67196b6a260f0389cb1f8cf", size = 2194709 } 19 | wheels = [ 20 | { url = "https://files.pythonhosted.org/packages/a9/60/9d38d3f3706c36ec20146bf8add05213e46138c710add8c68dc5a5dea3ec/beets-2.0.0-py3-none-any.whl", hash = "sha256:6fe596f578ce50652fc634d399af67bc0450b325c349989af781805599fcedb3", size = 553307 }, 21 | ] 22 | 23 | [[package]] 24 | name = "beets-check" 25 | version = "0.15.0" 26 | source = { editable = "." } 27 | dependencies = [ 28 | { name = "beets" }, 29 | { name = "mediafile" }, 30 | ] 31 | 32 | [package.dev-dependencies] 33 | dev = [ 34 | { name = "pytest" }, 35 | { name = "ruff" }, 36 | ] 37 | 38 | [package.metadata] 39 | requires-dist = [ 40 | { name = "beets", specifier = ">=1.6.1,<3" }, 41 | { name = "mediafile", specifier = "~=0.12.0" }, 42 | ] 43 | 44 | [package.metadata.requires-dev] 45 | dev = [ 46 | { name = "pytest", specifier = "~=8.0.0" }, 47 | { name = "ruff", specifier = ">=0.5.1,<6" }, 48 | ] 49 | 50 | [[package]] 51 | name = "colorama" 52 | version = "0.4.6" 53 | source = { registry = "https://pypi.org/simple" } 54 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 55 | wheels = [ 56 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 57 | ] 58 | 59 | [[package]] 60 | name = "confuse" 61 | version = "2.0.1" 62 | source = { registry = "https://pypi.org/simple" } 63 | dependencies = [ 64 | { name = "pyyaml" }, 65 | ] 66 | sdist = { url = "https://files.pythonhosted.org/packages/a7/77/05e2284baff5f2106f74b528b9930caf764d6c400733eb42e617c4234a7d/confuse-2.0.1.tar.gz", hash = "sha256:7379a2ad49aaa862b79600cc070260c1b7974d349f4fa5e01f9afa6c4dd0611f", size = 50872 } 67 | wheels = [ 68 | { url = "https://files.pythonhosted.org/packages/32/1f/cf496479814d41fc252004482deeb90b740b4a6a391a3355c0b11d7e0abf/confuse-2.0.1-py3-none-any.whl", hash = "sha256:9b9e5bbc70e2cb9b318bcab14d917ec88e21bf1b724365e3815eb16e37aabd2a", size = 24750 }, 69 | ] 70 | 71 | [[package]] 72 | name = "exceptiongroup" 73 | version = "1.2.2" 74 | source = { registry = "https://pypi.org/simple" } 75 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 76 | wheels = [ 77 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 78 | ] 79 | 80 | [[package]] 81 | name = "iniconfig" 82 | version = "2.0.0" 83 | source = { registry = "https://pypi.org/simple" } 84 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 85 | wheels = [ 86 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 87 | ] 88 | 89 | [[package]] 90 | name = "jellyfish" 91 | version = "1.1.0" 92 | source = { registry = "https://pypi.org/simple" } 93 | sdist = { url = "https://files.pythonhosted.org/packages/c9/2f/cda51a742a873ae4b0b52620cd282a885195612edaa2a7ec4b68cf968b2d/jellyfish-1.1.0.tar.gz", hash = "sha256:2a2eec494c81dc1eb23dfef543110dad1873538eccaffabea8520bdac8aecbc1", size = 364391 } 94 | wheels = [ 95 | { url = "https://files.pythonhosted.org/packages/ce/60/364265ea5ac6fab5a6f6645c2ad3a0d5c41b1b0ce2b0e5f98ce2cfc67ac2/jellyfish-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:feb1fa5838f2bb6dbc9f6d07dabf4b9d91e130b289d72bd70dc33b651667688f", size = 306835 }, 96 | { url = "https://files.pythonhosted.org/packages/47/16/fafbbb45fca910df4df2bceea6f99265f59667348230b8775cfdbf1d0415/jellyfish-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:623fa58cca9b8e594a46e7b9cf3af629588a202439d97580a153d6af24736a1b", size = 303195 }, 97 | { url = "https://files.pythonhosted.org/packages/f3/bf/ba3807e42dd3622523c719cd7fc03576e905a5dc9937c4c7efb04a809724/jellyfish-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a87e4a17006f7cdd7027a053aeeaacfb0b3366955e242cd5b74bbf882bafe022", size = 346571 }, 98 | { url = "https://files.pythonhosted.org/packages/e3/5f/b85a71bcca1bced0751e9a132604792b73e33b2d51c7b5506b74db1720c5/jellyfish-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f10fa36491840bda29f2164cc49e61244ea27c5db5a66aaa437724f5626f5610", size = 343856 }, 99 | { url = "https://files.pythonhosted.org/packages/81/00/861186a78604c03bcd7846e4c3f1eed1af9072769f4a60997adccb846e00/jellyfish-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24f91daaa515284cdb691b1e01b0f91f9c9e51e685420725a1ded4f54d5376ff", size = 335971 }, 100 | { url = "https://files.pythonhosted.org/packages/06/02/8a21858097971c0779952cdf1a5ea1e65585d123c4d00977b6bebc4305d0/jellyfish-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:65e58350618ebb1488246998a7356a8c9a7c839ec3ecfe936df55be6776fc173", size = 525166 }, 101 | { url = "https://files.pythonhosted.org/packages/c4/11/ca9fd25ba78e29c3610e01b2e4ac7218aa41de64f6d3d205dba9b1c530d1/jellyfish-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5c5ed62b23093b11de130c3fe1b381a2d3bfaf086757fa21341ac6f30a353e92", size = 529630 }, 102 | { url = "https://files.pythonhosted.org/packages/6f/cb/978a74aa3ae100c63baaaaab32ffc2ef7328c8af024ccc938f3225a65bf0/jellyfish-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c42aa02e791d3e5a8fc6a96bec9f64ebbb2afef27b01eca201b56132e3d0c64e", size = 506763 }, 103 | { url = "https://files.pythonhosted.org/packages/ae/23/dd2eba6f8a5724bc651db0bd47ac6f69fd996fa77afbb5e7d6babaaf64fc/jellyfish-1.1.0-cp310-none-win32.whl", hash = "sha256:84680353261161c627cbdd622ea4243e3d3da75894bfacc2f3fcbbe56e8e59d4", size = 201398 }, 104 | { url = "https://files.pythonhosted.org/packages/7e/10/1b41958cfef740dfda441e04e725381c7c234a37c1b7da0e768f0d896969/jellyfish-1.1.0-cp310-none-win_amd64.whl", hash = "sha256:017c794b89d827d0306cb056fc5fbd040ff558a90ff0e68a6b60d6e6ba661fe3", size = 207275 }, 105 | { url = "https://files.pythonhosted.org/packages/89/e1/d80963467a1b4edd79b46f305bbc060dcffbcb7b138e308ecba86d99ffd1/jellyfish-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:fed2e4ecf9b4995d2aa771453d0a0fdf47a5e1b13dbd74b98a30cb0070ede30c", size = 306851 }, 106 | { url = "https://files.pythonhosted.org/packages/3b/0c/3df520c610785a83411237eeefbb9ba283eabbee80d2b87b1b93abe5d158/jellyfish-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61a382ba8a3d3cd0bd50029062d54d3a0726679be248789fef6a3901eee47a60", size = 303132 }, 107 | { url = "https://files.pythonhosted.org/packages/a2/38/89f737cfa3c9716c583a5aac018b136d69322706cb34d3fca2c741e90533/jellyfish-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a4b526ed2080b97431454075c46c19baddc944e95cc605248e32a2a07be231e", size = 346485 }, 108 | { url = "https://files.pythonhosted.org/packages/82/fe/ff9dcf224c4fbb12cf7b6c6ec6f4fc2975f1dcf49a605ef4edd334e170c8/jellyfish-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0fa7450c3217724b73099cb18ee594926fcbc1cc4d9964350f31a4c1dc267b35", size = 343871 }, 109 | { url = "https://files.pythonhosted.org/packages/6e/d2/2055b8b6d1f4e432e40cfa44ae3e315e36e4eddb989eed4db7a53bff51e3/jellyfish-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33ebb6e9647d5d52f4d461a163449f6d1c73f1a80ccbe98bb17efac0062a6423", size = 335967 }, 110 | { url = "https://files.pythonhosted.org/packages/ae/49/a94610285aa756f80844c926ccd51cf0d1b83a7ea00cb56efeb3b96f0606/jellyfish-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:759172602343115f910d7c63b39239051e32425115bc31ab4dafdaf6177f880c", size = 525034 }, 111 | { url = "https://files.pythonhosted.org/packages/e7/08/f4472b5f7c2063b63904b95ccc57b5213ca1cfeab4ec7ec7dd3f556a0c7f/jellyfish-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:273fdc362ccdb09259eec9bc4abdc2467d9a54bd94d05ae22e71423dd1357255", size = 529613 }, 112 | { url = "https://files.pythonhosted.org/packages/7e/2a/3dd192bb8b731c7618797a1abb24f485f341f4d5d13441a01ddd028d2c33/jellyfish-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bd5c335f8d762447691dc0572f4eaf0cfdfbfffb6dce740341425ab1b32134ff", size = 506597 }, 113 | { url = "https://files.pythonhosted.org/packages/dd/9b/556bb5aff966e003441b15b66fe3638a85d70ff66b0be777852d6b71613f/jellyfish-1.1.0-cp311-none-win32.whl", hash = "sha256:cc16a60a42f1541ad9c13c72c797107388227f01189aa3c0ec7ee9b939e57ea8", size = 201447 }, 114 | { url = "https://files.pythonhosted.org/packages/cd/76/a077eee8a50c522121ba74b6a6dff6987f1a7333992219251987fe0db850/jellyfish-1.1.0-cp311-none-win_amd64.whl", hash = "sha256:95dfe61eabf360a92e6d76d1c4dbafa29bcb3f70e2ad7354de2661141fcce038", size = 207305 }, 115 | { url = "https://files.pythonhosted.org/packages/db/6b/5d66bf5be9ed7b6cb030d111cc4bc4d6d61f19ea6ca4d90c4c6e5bc65382/jellyfish-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:828a7000d369cbd4d812b88510c01fdab20b73dc54c63cdbe03bdff67ab362d0", size = 306412 }, 116 | { url = "https://files.pythonhosted.org/packages/cc/b0/cb8bfd5e1a19ff73826971a7656ca59b88e6ae2e5b1d07b7e458a3290c88/jellyfish-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e250dc1074d730a03c96ac9dfce44716cf45e0e2825cbddaf32a015cdf9cf594", size = 302950 }, 117 | { url = "https://files.pythonhosted.org/packages/57/51/33fcb48d9a6415838305e2fd787935e086c5bca732987f79c94ba8c696ce/jellyfish-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87dc2a82c45b773a579fb695a5956a54106c1187f27c9ccee8508726d2e59cfc", size = 346287 }, 118 | { url = "https://files.pythonhosted.org/packages/4e/f3/ae1d6e474e2ab6a04abf244450424b62c196eb3597885cb7b90d858b3b06/jellyfish-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e41677ec860454da5977c698fc64fed73b4054a92c5c62ba7d1af535f8082ac7", size = 343892 }, 119 | { url = "https://files.pythonhosted.org/packages/1c/41/91ff9bbf83389fba009de3896ae5f44292242990e53bdbece106eceb33ad/jellyfish-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9d4002d01252f18eb26f28b66f6c9ce0696221804d8769553c5912b2f221a18", size = 335763 }, 120 | { url = "https://files.pythonhosted.org/packages/33/dd/6a0e7023e0c0aaa1b5a55665062f35bc204cf8dba33816787f383416d23d/jellyfish-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:936df26c10ca6cd6b4f0fb97753087354c568e2129c197cbb4e0f0172db7511f", size = 525046 }, 121 | { url = "https://files.pythonhosted.org/packages/96/5d/e37ff36554871d02c9248d3f7837479b0c8f6e92cdeee4e6b017f10e39de/jellyfish-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:684c2093fa0d68a91146e15a1e9ca859259b19d3bc36ec4d60948d86751f744e", size = 529581 }, 122 | { url = "https://files.pythonhosted.org/packages/70/25/3a3a39df1c3b28d1e6c0f1693047dec7450d5406ec2c516ac93e35883af5/jellyfish-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fcaefebe9d67f282d89d3a66646b77184a42b3eca2771636789b2dc1288c003", size = 506686 }, 123 | { url = "https://files.pythonhosted.org/packages/4e/9c/37c1701ac56c978043239430b69845900632b485573259eac6de015a2ea7/jellyfish-1.1.0-cp312-none-win32.whl", hash = "sha256:e512c99941a257541ffd9f75c7a5c4689de0206841b72f1eb015599d17fed2c3", size = 201172 }, 124 | { url = "https://files.pythonhosted.org/packages/de/a5/07ee2c08dcf970284e6412c9b8cb7f79222fc9e2aaf9b3c45837d8e2173b/jellyfish-1.1.0-cp312-none-win_amd64.whl", hash = "sha256:2b928bad2887c662783a4d9b5828ed1fa0e943f680589f7fc002c456fc02e184", size = 206918 }, 125 | { url = "https://files.pythonhosted.org/packages/10/72/fc8d6c159d7fad7f8563bdd2a4ba5c24839940f5d6480ae3f1416d40b69b/jellyfish-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e965241e54f9cb9be6fe8f7a1376b6cc61ff831de017bde9150156771820f669", size = 307135 }, 126 | { url = "https://files.pythonhosted.org/packages/d2/bb/dd01a1a005b2d71a82d4c709a9a335410e9f91d67ed4e3bdbca4b8ee9cef/jellyfish-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e59a4c3bf0847dfff44195a4c250bc9e281b1c403f6212534ee36fc7c913dc1", size = 303435 }, 127 | { url = "https://files.pythonhosted.org/packages/9a/b4/41cb968baaf411386e18280ff87059c1c0ea11defa4e51a05dc343968f44/jellyfish-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84fa4e72b7754060d352604e07ea89af98403b0436caad443276ae46135b7fd7", size = 346527 }, 128 | { url = "https://files.pythonhosted.org/packages/73/e5/b137559228ba3d83f1af14e60f8348dab8a7e01df759708288cd16070394/jellyfish-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:125e9bfd1cc2c053eae3afa04fa142bbc8b3c1290a40a3416271b221f7e6bc87", size = 344091 }, 129 | { url = "https://files.pythonhosted.org/packages/13/7a/90d4a938a0a914c4d0a84fa9e0d60dc909c375ea6a3b879817b483099d8e/jellyfish-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4a8fff36462bf1bdaa339d58fadd7e79a63690902e6d7ddd65a84efc3a4cc6d", size = 336373 }, 130 | { url = "https://files.pythonhosted.org/packages/35/53/a7a1a2f9bd82ca451d15875ea350e4fa232b7215c9350aa139b1c55038d5/jellyfish-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6b438b3d7f970cfd8f77b30b05694537a54c08f3775b35debae45ff5a469f1a5", size = 525508 }, 131 | { url = "https://files.pythonhosted.org/packages/85/10/8c4899b695f246c549079b19855d644bc78648b0a16d9f7e87684571c36e/jellyfish-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:cf8d26c3735b5c2764cc53482dec14bb9b794ba829db3cd4c9a29d194a61cada", size = 529746 }, 132 | { url = "https://files.pythonhosted.org/packages/76/73/a975850734327f0a0caeaf079f0b26d16ca65f72302615a7dd25453d4767/jellyfish-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f341d0582ecac0aa73f380056dc8d25d8a60104f94debe8bf3f924a32a11588d", size = 507328 }, 133 | { url = "https://files.pythonhosted.org/packages/38/07/8f6cedf852907500baf6dee4eb51497bf0ef1dcb3612852417e03052cce8/jellyfish-1.1.0-cp39-none-win32.whl", hash = "sha256:49f2be59573b22d0adb615585ff66ca050198ec1f9f6784eec168bcd8137caf5", size = 201525 }, 134 | { url = "https://files.pythonhosted.org/packages/ef/68/3c3c1d3ab7e7277fb6316e69009477396a0db574feb82b12e6640b14243e/jellyfish-1.1.0-cp39-none-win_amd64.whl", hash = "sha256:c58988138666b1cd860004c1afc7a09bb402e71e16e1f324be5c5d2b85fdfa3e", size = 207278 }, 135 | { url = "https://files.pythonhosted.org/packages/2e/e4/4d87b377af913633c45a3b65ea2a2113d0acf4194748f8d16c20a4989fb1/jellyfish-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54effec80c7a5013bea8e2ea6cd87fdd35a2c5b35f86ccf69ec33f4212245f25", size = 348149 }, 136 | { url = "https://files.pythonhosted.org/packages/67/db/d166a55444bfea3460bc477af96defcdf84b2b045a225c90fa379bcdf0e9/jellyfish-1.1.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12ae67e9016c9a173453023fd7b400ec002bbc106c12722d914c53951acfa190", size = 345503 }, 137 | { url = "https://files.pythonhosted.org/packages/c1/66/a9fce02be03110e6259ee07acd4f6b89b6914c9904089c7f2c3d524bf455/jellyfish-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efd342f9d4fb0ead8a3c30fe26e442308fb665ca37f4aa97baf448d814469bf1", size = 337608 }, 138 | { url = "https://files.pythonhosted.org/packages/da/6b/f392fbd61db0649bb67b66edc3b3fc5defcb944cd1c18f0e7bb784cb5223/jellyfish-1.1.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b0dc9f1bb335b6caa412c3d27028e25d315ef2bc993d425db93e451d7bc28056", size = 527251 }, 139 | { url = "https://files.pythonhosted.org/packages/d9/e5/c0662901217afca989b6f6263a6b728a46d213eadbae756d050e0beef7ba/jellyfish-1.1.0-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:3f12cb59b3266e37ec47bd7c2c37faadc74ae8ccdc0190444daeafda3bd93da2", size = 531325 }, 140 | { url = "https://files.pythonhosted.org/packages/9e/10/93ab864ba8c59b4436d5a2260058995b5da22a864d969860b1ade0a7743f/jellyfish-1.1.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c7ea99734b7767243b5b98eca953f0d719b48b0d630af3965638699728ef7523", size = 508647 }, 141 | { url = "https://files.pythonhosted.org/packages/08/b6/7347ddc9d1d54a24bf28279b19bfa601f2ae04bdf3fffd1342b443c4c52e/jellyfish-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ca252e6088c6afe5f8138ce9f557157ad0329f0610914ba50729c641d57cd662", size = 308958 }, 142 | { url = "https://files.pythonhosted.org/packages/a9/bf/0d610dbc026f0899899022087d698b0e156df349d5ae239de97b37788499/jellyfish-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b2512ab6a1625a168796faaa159e1d1b8847cb3d0cc2b1b09ae77ff0623e7d10", size = 305858 }, 143 | { url = "https://files.pythonhosted.org/packages/29/cd/447370d4ae1f7c5c80729a7f1e25dcac6a9a30eec0ac1ec7a6e2085391bf/jellyfish-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b868da3186306efb48fbd8a8dee0a742a5c8bc9c4c74aa5003914a8600435ba8", size = 349194 }, 144 | { url = "https://files.pythonhosted.org/packages/ea/63/5baf9b46e212659ddba605cdcb61adb6a1934701e0f7805344a844122fc6/jellyfish-1.1.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc2cb1f007ddfad2f9175a8c1f934a8a0a6cc73187e2339fe1a4b3fd90b263e", size = 346517 }, 145 | { url = "https://files.pythonhosted.org/packages/46/47/c211ff62b8bcd28059403867b6c7280d1cbf8e949f455ad05cd08870196a/jellyfish-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e17885647f3a0faf1518cf6b319865b2e84439cfc16a3ea14468513c0fba227", size = 339035 }, 146 | { url = "https://files.pythonhosted.org/packages/f5/92/4a946bbb94da71a141daf9c15080a4e5227ec7040820f516f91c5d5dc387/jellyfish-1.1.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:84ea543d05e6b7a7a704d45ebd9c753e2425da01fc5000ddc149031be541c4d5", size = 528001 }, 147 | { url = "https://files.pythonhosted.org/packages/f4/01/627ccddd0695bf539ddd559fb78d40551482f5c8b9cc952571313c864812/jellyfish-1.1.0-pp39-pypy39_pp73-musllinux_1_1_i686.whl", hash = "sha256:065a59ab0d02969d45e5ab4b0315ed6f5977a4eb8eaef24f2589e25b85822d18", size = 532444 }, 148 | { url = "https://files.pythonhosted.org/packages/58/fb/27100ab65aa426cdf5af04ca129f884ee36f8051c61c0909c1b06516c8f4/jellyfish-1.1.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f747f34071e1558151b342a2bf96b813e04b5384024ba7c50f3c907fbaab484f", size = 509631 }, 149 | ] 150 | 151 | [[package]] 152 | name = "mediafile" 153 | version = "0.12.0" 154 | source = { registry = "https://pypi.org/simple" } 155 | dependencies = [ 156 | { name = "mutagen" }, 157 | { name = "six" }, 158 | ] 159 | sdist = { url = "https://files.pythonhosted.org/packages/27/6d/b30759aa51fed37da31d8628a36dc7c95f85329538dd5c5c8540db4a4af0/mediafile-0.12.0.tar.gz", hash = "sha256:d75d805a06ed56150dbcea76505e700f9809abd9e98f98117ae46f5df2ccf1d7", size = 563534 } 160 | wheels = [ 161 | { url = "https://files.pythonhosted.org/packages/f7/bd/cc250fbe08ec4ac7631bc991f91703945e089c2c73429954f5a51e62a402/mediafile-0.12.0-py3-none-any.whl", hash = "sha256:6b6fdb61bb151cd9d6a8a8821ce28adee604ede8a9a992f0d9dd3e835ef4899b", size = 21988 }, 162 | ] 163 | 164 | [[package]] 165 | name = "munkres" 166 | version = "1.1.4" 167 | source = { registry = "https://pypi.org/simple" } 168 | sdist = { url = "https://files.pythonhosted.org/packages/fd/41/6a3d0ef908f47d07c31e5d1c2504388c27c39b10b8cf610175b5a789a5c1/munkres-1.1.4.tar.gz", hash = "sha256:fc44bf3c3979dada4b6b633ddeeb8ffbe8388ee9409e4d4e8310c2da1792db03", size = 14047 } 169 | wheels = [ 170 | { url = "https://files.pythonhosted.org/packages/90/ab/0301c945a704218bc9435f0e3c88884f6b19ef234d8899fb47ce1ccfd0c9/munkres-1.1.4-py2.py3-none-any.whl", hash = "sha256:6b01867d4a8480d865aea2326e4b8f7c46431e9e55b4a2e32d989307d7bced2a", size = 7015 }, 171 | ] 172 | 173 | [[package]] 174 | name = "musicbrainzngs" 175 | version = "0.7.1" 176 | source = { registry = "https://pypi.org/simple" } 177 | sdist = { url = "https://files.pythonhosted.org/packages/0a/67/3e74ae93d90ceeba72ed1a266dd3ca9abd625f315f0afd35f9b034acedd1/musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627", size = 117469 } 178 | wheels = [ 179 | { url = "https://files.pythonhosted.org/packages/6d/fd/cef7b2580436910ccd2f8d3deec0f3c81743e15c0eb5b97dde3fbf33c0c8/musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10", size = 25289 }, 180 | ] 181 | 182 | [[package]] 183 | name = "mutagen" 184 | version = "1.47.0" 185 | source = { registry = "https://pypi.org/simple" } 186 | sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186 } 187 | wheels = [ 188 | { url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391 }, 189 | ] 190 | 191 | [[package]] 192 | name = "packaging" 193 | version = "24.1" 194 | source = { registry = "https://pypi.org/simple" } 195 | sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } 196 | wheels = [ 197 | { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, 198 | ] 199 | 200 | [[package]] 201 | name = "pluggy" 202 | version = "1.5.0" 203 | source = { registry = "https://pypi.org/simple" } 204 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 205 | wheels = [ 206 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 207 | ] 208 | 209 | [[package]] 210 | name = "pytest" 211 | version = "8.0.2" 212 | source = { registry = "https://pypi.org/simple" } 213 | dependencies = [ 214 | { name = "colorama", marker = "sys_platform == 'win32'" }, 215 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 216 | { name = "iniconfig" }, 217 | { name = "packaging" }, 218 | { name = "pluggy" }, 219 | { name = "tomli", marker = "python_full_version < '3.11'" }, 220 | ] 221 | sdist = { url = "https://files.pythonhosted.org/packages/3f/c0/238f25cb27495fdbaa5c48cef9886162e9df1f3d0e957fc8326d9c24fa2f/pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd", size = 1396924 } 222 | wheels = [ 223 | { url = "https://files.pythonhosted.org/packages/a7/ea/d0ab9595a0d4b2320483e634123171deaf50885e29d442180efcbf2ed0b2/pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096", size = 333984 }, 224 | ] 225 | 226 | [[package]] 227 | name = "pyyaml" 228 | version = "6.0.2" 229 | source = { registry = "https://pypi.org/simple" } 230 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 231 | wheels = [ 232 | { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, 233 | { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, 234 | { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, 235 | { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, 236 | { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, 237 | { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, 238 | { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, 239 | { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, 240 | { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, 241 | { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, 242 | { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, 243 | { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, 244 | { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, 245 | { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, 246 | { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, 247 | { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, 248 | { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, 249 | { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, 250 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, 251 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, 252 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, 253 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, 254 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, 255 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, 256 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, 257 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, 258 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, 259 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, 260 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, 261 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, 262 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, 263 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, 264 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, 265 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, 266 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, 267 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, 268 | { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, 269 | { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, 270 | { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, 271 | { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, 272 | { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, 273 | { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, 274 | { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, 275 | { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, 276 | { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, 277 | ] 278 | 279 | [[package]] 280 | name = "ruff" 281 | version = "0.6.3" 282 | source = { registry = "https://pypi.org/simple" } 283 | sdist = { url = "https://files.pythonhosted.org/packages/5d/f9/0b32e5d1c6f957df49398cd882a011e9488fcbca0d6acfeeea50ccd37a4d/ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983", size = 2463514 } 284 | wheels = [ 285 | { url = "https://files.pythonhosted.org/packages/72/68/1da6a1e39a03a229ea57c511691d6225072759cc7764206c3f0989521194/ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3", size = 9696928 }, 286 | { url = "https://files.pythonhosted.org/packages/6e/59/3b8b1d3a4271c6eb6ceecd3cef19a6d881639a0f18ad651563d6f619aaae/ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc", size = 9448462 }, 287 | { url = "https://files.pythonhosted.org/packages/35/4f/b942ecb8bbebe53aa9b33e9b96df88acd50b70adaaed3070f1d92131a1cb/ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1", size = 9176190 }, 288 | { url = "https://files.pythonhosted.org/packages/a0/20/b0bcb29d4ee437f3567b73b6905c034e2e94d29b9b826c66daecc1cf6388/ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1", size = 10108892 }, 289 | { url = "https://files.pythonhosted.org/packages/9c/e3/211bc759f424e8823a9937e0f678695ca02113c621dfde1fa756f9f26f6d/ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672", size = 9476471 }, 290 | { url = "https://files.pythonhosted.org/packages/b2/a3/2ec35a2d7a554364864206f0e46812b92a074ad8a014b923d821ead532aa/ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1", size = 10294802 }, 291 | { url = "https://files.pythonhosted.org/packages/03/8b/56ef687b3489c88886dea48c78fb4969b6b65f18007d0ac450070edd1f58/ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384", size = 11022372 }, 292 | { url = "https://files.pythonhosted.org/packages/a5/21/327d147feb442adb88975e81e2263102789eba9ad2afa102c661912a482f/ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a", size = 10596596 }, 293 | { url = "https://files.pythonhosted.org/packages/6c/86/ff386de63729da3e08c8099c57f577a00ec9f3eea711b23ac07cf3588dc5/ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500", size = 11572830 }, 294 | { url = "https://files.pythonhosted.org/packages/38/5d/b33284c108e3f315ddd09b70296fd76bd28ecf8965a520bc93f3bbd8ac40/ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470", size = 10262577 }, 295 | { url = "https://files.pythonhosted.org/packages/29/99/9cdfad0d7f460e66567236eddc691473791afd9aff93a0dfcdef0462a6c7/ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f", size = 10098751 }, 296 | { url = "https://files.pythonhosted.org/packages/a8/9f/f801a1619f5549e552f1f722f1db57eb39e7e1d83d482133142781d450de/ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5", size = 9563859 }, 297 | { url = "https://files.pythonhosted.org/packages/0b/4d/fb2424faf04ffdb960ae2b3a1d991c5183dd981003de727d2d5cc38abc98/ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351", size = 9914291 }, 298 | { url = "https://files.pythonhosted.org/packages/2e/dd/94fddf002a8f6152e8ebfbb51d3f93febc415c1fe694345623c31ce8b33b/ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8", size = 10331549 }, 299 | { url = "https://files.pythonhosted.org/packages/b4/73/ca9c2f9237a430ca423b6dca83b77e9a428afeb7aec80596e86c369123fe/ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521", size = 7962163 }, 300 | { url = "https://files.pythonhosted.org/packages/55/ce/061c605b1dfb52748d59bc0c7a8507546c178801156415773d18febfd71d/ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb", size = 8800901 }, 301 | { url = "https://files.pythonhosted.org/packages/63/28/ae4ffe7d3b6134ca6d31ebef07447ef70097c4a9e8fbbc519b374c5c1559/ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82", size = 8229171 }, 302 | ] 303 | 304 | [[package]] 305 | name = "six" 306 | version = "1.16.0" 307 | source = { registry = "https://pypi.org/simple" } 308 | sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } 309 | wheels = [ 310 | { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, 311 | ] 312 | 313 | [[package]] 314 | name = "tomli" 315 | version = "2.0.1" 316 | source = { registry = "https://pypi.org/simple" } 317 | sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } 318 | wheels = [ 319 | { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, 320 | ] 321 | 322 | [[package]] 323 | name = "typing-extensions" 324 | version = "4.12.2" 325 | source = { registry = "https://pypi.org/simple" } 326 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 327 | wheels = [ 328 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 329 | ] 330 | 331 | [[package]] 332 | name = "unidecode" 333 | version = "1.3.8" 334 | source = { registry = "https://pypi.org/simple" } 335 | sdist = { url = "https://files.pythonhosted.org/packages/f7/89/19151076a006b9ac0dd37b1354e031f5297891ee507eb624755e58e10d3e/Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4", size = 192701 } 336 | wheels = [ 337 | { url = "https://files.pythonhosted.org/packages/84/b7/6ec57841fb67c98f52fc8e4a2d96df60059637cba077edc569a302a8ffc7/Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39", size = 235494 }, 338 | ] 339 | --------------------------------------------------------------------------------