Compare commits

..
2 Commits
Author SHA1 Message Date
julle 4519d722bc add program files 2025-11-16 10:00:56 +01:00
julle 4a019c37c2 update gitignore 2025-11-16 10:00:23 +01:00
24 changed files with 1209 additions and 0 deletions
+4
View File
@@ -174,3 +174,7 @@ cython_debug/
# PyPI configuration file
.pypirc
#testdata folder
testdata/
output/
sorted_library.json
+25
View File
@@ -0,0 +1,25 @@
# Repository Guidelines
## Project Structure & Module Organization
Source lives in `src/audiobooksorter/`: ingestion (`ingest.py`), sorting rules (`sorter/series_rules.py`), exporting (`export.py`), and the CLI (`cli.py`). Config defaults sit in `config/`, constants in `constants.py`, and shared logging in `logging.py`. Sample inputs for quick runs live in `samples/`. Test suites mirror the package under `tests/`, with fixtures in `tests/conftest.py`. Assets that do not execute (icons, mock exports) belong in `assets/`. Local library fixtures for manual checks live in `testdata/`.
## Build, Test, and Development Commands
Use Python 3.11+. Set up a venv and install dev tooling:
```
python -m venv .venv && source .venv/bin/activate
python -m pip install --upgrade pip
pip install -e .[dev]
```
Preview ordering without writing files via `python -m audiobooksorter.cli --library ./testdata --dry-run`. Run against a JSON export with `python -m audiobooksorter.cli --library ./samples/library.json --output ./sorted_library.json`. Format and lint with `ruff check --fix` (plus `ruff format` if desired). Execute the suite using `pytest -q` (coverage gates are pre-configured).
## Coding Style & Naming Conventions
Follow PEP 8 with 4-space indentation and type hints on public surfaces. Prefer descriptive module names (`series_rules.py`, `ingest.py`) and uppercase constants. Use dataclasses for structured audiobook records and the shared logger from `logging.py` to centralize verbosity control.
## Testing Guidelines
Tests target `pytest` with coverage enforced at 90% (`pytest --cov=audiobooksorter --cov-report=term-missing`). Name files `test_<unit>.py` and tests `test_<behavior>`. Add regression cases whenever sorting order changes—especially around edge series, mixed narrators, or missing metadata—in both `tests/` and `testdata/` when a folder-based scenario is required.
## Commit & Pull Request Guidelines
Commit messages follow the `type(scope): summary` pattern (e.g., `feat(cli): add dry-run preview`). Reference issues in bodies (`Fixes #ID`). Pull requests should include a clear description of user-facing changes, testing evidence (command output is fine), and any migration notes for library paths or naming rules. Request review for sorting rule tweaks to avoid regressions in existing shelves.
## Security & Configuration Tips
Do not commit personal Audiobookshelf exports; anonymize anything checked into `samples/` or `testdata/`. Keep secrets (.env, tokens) out of the repo and load them at runtime. Validate file paths before writing outputs to avoid clobbering a users library.
+88
View File
@@ -1,2 +1,90 @@
# audibooksorter
An audiobook sorter for Audiobookshelf libraries. Give the root path of your Audiobookshelf library and the tool will materialize a sorted structure arranged by:
-Author
--Series
----Title
# Title Folder Naming
In addition to the book title, the title folder can include the publish year, series sequence, the subtitle, and the narrator.
Here are a bunch of ways the same book could be named:
Wizards First Rule
Wizards First Rule {Sam Tsoutsouvas}
1994 - Wizards First Rule
Wizards First Rule - A Really Good Subtitle
1994 - Book 1 - Wizards First Rule
1994 - Volume 1. Wizards First Rule {Sam Tsoutsouvas}
Vol 1 - 1994 - Wizards First Rule
1994 - Wizards First Rule - Volume 1
Vol. 1 - 1994 - Wizards First Rule - A Really Good Subtitle {Sam Tsoutsouvas}
(1994) - Wizards First Rule - A Really Good Subtitle
1 - Wizards First Rule
1. Wizards First Rule
Subtitle: Parsing out subtitles into a separate field is optional and must be enabled in settings. Subtitle must be separated by " - ".
Series Sequence: Case insensitive & decimals supported.
The sequence can be placed anywhere in the folder name.
It must be followed by " - " or ". "
If it is not at the beginning of the folder name, it must be preceded by " - " and "Vol" "Vol." "Volume" or "Book"
Publish Year: The publish year must be the first part of the name OR directly after a series sequence, and separated by " - " on both sides.
Narrator: Must be wrapped in curly braces. e.g. {Sam Tsoutsouvas}.
Disc Numbers: The title folder can contain subfolders for discs as long as they are named "Disc", "CD", or "Disk" with a number following (case insensitive). Example: "Disc 1", "CD 2", "Disk 3", "Disc 004", or no space between like "Disc1".
# Author Folder Naming
Supports "Last, First" author naming as well as multiple authors separated by ",", ";", "&" or "and".
Valid author folder names:
Ichiro Kishimi
Kishimi, Ichiro
Ichiro Kishimi, Fumitake Koga
Kishimi, Ichiro, Koga, Fumitake
Ichiro Kishimi & Fumitake Koga
Kishimi, Ichiro & Koga, Fumitake
Terry Goodkind, Ichiro Kishimi and Fumitake Koga
## Development setup
This repository now uses [PDM](https://pdm.fming.dev) for packaging so tooling, dependencies, and the src layout stay in sync.
1. Install PDM (`pipx install pdm` or `pip install --user pdm`).
2. Create a virtual environment via `pdm venv create --with python3.11` if your system Python does not match the required version.
3. Install the application and dev requirements with `pdm install --dev`.
PDM writes metadata to `pdm.lock`; regenerate it with `pdm lock --refresh` whenever dependencies change.
## Common commands
- Run the CLI: `pdm run python -m audiobooksorter.cli --library /path/to/library --output ./sorted_library` (pass a JSON export from `samples/` only when running tests)
- Format and lint: `pdm run ruff check --fix && pdm run ruff format`
- Test with coverage: `pdm run pytest -q`
## Library ingestion
The sorter walks the supplied library directory looking for `metadata.json` files (one per audiobook folder). Those metadata files must at least contain `title` and `author`, and may optionally declare `series` and `seriesIndex`. For integration tests and CI you can still point `--library` at a JSON export; the CLI will treat JSON inputs as fixtures and skip filesystem moves.
## Project layout
Source code lives under `src/audiobooksorter/` with ingestion, sorter, and export modules split into subpackages so the command-line interface stays thin. Static assets are kept in `assets/`, while anonymized example exports live in `samples/`. Pytest suites mirror the package structure inside `tests/`.
View File
Generated
+314
View File
@@ -0,0 +1,314 @@
# This file is @generated by PDM.
# It is not intended for manual editing.
[metadata]
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:a9e0e59614457c6779aec164dec9d7f5b4c4da75ff9d7b7f0382f6c244b438dd"
[[metadata.targets]]
requires_python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text."
groups = ["dev"]
marker = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.11.3"
requires_python = ">=3.10"
summary = "Code coverage measurement for Python"
groups = ["dev"]
files = [
{file = "coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1"},
{file = "coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06"},
{file = "coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80"},
{file = "coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa"},
{file = "coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297"},
{file = "coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060"},
{file = "coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7"},
{file = "coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55"},
{file = "coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc"},
{file = "coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f"},
{file = "coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e"},
{file = "coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a"},
{file = "coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1"},
{file = "coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd"},
{file = "coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405"},
{file = "coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e"},
{file = "coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055"},
{file = "coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f"},
{file = "coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36"},
{file = "coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e"},
{file = "coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2"},
{file = "coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63"},
{file = "coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3"},
{file = "coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094"},
{file = "coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c"},
{file = "coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2"},
{file = "coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944"},
{file = "coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428"},
{file = "coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76"},
{file = "coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c"},
{file = "coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac"},
{file = "coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc"},
{file = "coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c"},
{file = "coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203"},
{file = "coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240"},
{file = "coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83"},
{file = "coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902"},
{file = "coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131"},
{file = "coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a"},
{file = "coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86"},
{file = "coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e"},
{file = "coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df"},
{file = "coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820"},
{file = "coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237"},
{file = "coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9"},
{file = "coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd"},
{file = "coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe"},
{file = "coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b"},
]
[[package]]
name = "coverage"
version = "7.11.3"
extras = ["toml"]
requires_python = ">=3.10"
summary = "Code coverage measurement for Python"
groups = ["dev"]
dependencies = [
"coverage==7.11.3",
"tomli; python_full_version <= \"3.11.0a6\"",
]
files = [
{file = "coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1"},
{file = "coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06"},
{file = "coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80"},
{file = "coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa"},
{file = "coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297"},
{file = "coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060"},
{file = "coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7"},
{file = "coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55"},
{file = "coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc"},
{file = "coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f"},
{file = "coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e"},
{file = "coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a"},
{file = "coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1"},
{file = "coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd"},
{file = "coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405"},
{file = "coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e"},
{file = "coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055"},
{file = "coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f"},
{file = "coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36"},
{file = "coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e"},
{file = "coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2"},
{file = "coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63"},
{file = "coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3"},
{file = "coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094"},
{file = "coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c"},
{file = "coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2"},
{file = "coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944"},
{file = "coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428"},
{file = "coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76"},
{file = "coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c"},
{file = "coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac"},
{file = "coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc"},
{file = "coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c"},
{file = "coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203"},
{file = "coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240"},
{file = "coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83"},
{file = "coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902"},
{file = "coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131"},
{file = "coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a"},
{file = "coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86"},
{file = "coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e"},
{file = "coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df"},
{file = "coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820"},
{file = "coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237"},
{file = "coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9"},
{file = "coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd"},
{file = "coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe"},
{file = "coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b"},
]
[[package]]
name = "iniconfig"
version = "2.3.0"
requires_python = ">=3.10"
summary = "brain-dead simple config-ini parsing"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "packaging"
version = "25.0"
requires_python = ">=3.8"
summary = "Core utilities for Python packages"
groups = ["dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
requires_python = ">=3.9"
summary = "plugin and hook calling mechanisms for python"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[[package]]
name = "pygments"
version = "2.19.2"
requires_python = ">=3.8"
summary = "Pygments is a syntax highlighting package written in Python."
groups = ["dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[[package]]
name = "pytest"
version = "9.0.1"
requires_python = ">=3.10"
summary = "pytest: simple powerful testing with Python"
groups = ["dev"]
dependencies = [
"colorama>=0.4; sys_platform == \"win32\"",
"exceptiongroup>=1; python_version < \"3.11\"",
"iniconfig>=1.0.1",
"packaging>=22",
"pluggy<2,>=1.5",
"pygments>=2.7.2",
"tomli>=1; python_version < \"3.11\"",
]
files = [
{file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"},
{file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"},
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
requires_python = ">=3.9"
summary = "Pytest plugin for measuring coverage."
groups = ["dev"]
dependencies = [
"coverage[toml]>=7.10.6",
"pluggy>=1.2",
"pytest>=7",
]
files = [
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
]
[[package]]
name = "ruff"
version = "0.14.5"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["dev"]
files = [
{file = "ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594"},
{file = "ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72"},
{file = "ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a"},
{file = "ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f"},
{file = "ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68"},
{file = "ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7"},
{file = "ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78"},
{file = "ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb"},
{file = "ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2"},
{file = "ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19"},
{file = "ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4"},
{file = "ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1"},
{file = "ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151"},
{file = "ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465"},
{file = "ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367"},
{file = "ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b"},
{file = "ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621"},
{file = "ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4"},
{file = "ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1"},
]
+39
View File
@@ -0,0 +1,39 @@
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[project]
name = "audiobooksorter"
version = "0.1.0"
description = "Command-line tool for organizing Audiobookshelf exports"
readme = "README.md"
requires-python = ">=3.11"
authors = [{ name = "Project Contributors" }]
dependencies = []
[tool.pdm]
distribution = true
[tool.pdm.build]
package-dir = "src"
includes = ["src/audiobooksorter"]
[tool.pdm.dev-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=4.1",
"ruff>=0.5",
]
[tool.ruff]
target-version = "py311"
line-length = 100
extend-select = ["B", "I", "UP", "PT"]
[tool.ruff.lint.isort]
known-first-party = ["audiobooksorter"]
[tool.pytest.ini_options]
addopts = "--cov=audiobooksorter --cov-report=term-missing --cov-fail-under=90"
testpaths = ["tests"]
pythonpath = ["src"]
+7
View File
@@ -0,0 +1,7 @@
[
{"title": "The Final Empire", "series": "Mistborn", "seriesIndex": 1, "author": "Brandon Sanderson"},
{"title": "The Well of Ascension", "series": "Mistborn", "seriesIndex": 2, "author": "Brandon Sanderson"},
{"title": "Wayfarers", "series": null, "seriesIndex": null, "author": "Becky Chambers"},
{"title": "Words of Radiance", "series": "Stormlight Archive", "seriesIndex": 2, "author": "Brandon Sanderson"},
{"title": "The Way of Kings", "series": "Stormlight Archive", "seriesIndex": 1, "author": "Brandon Sanderson"}
]
+7
View File
@@ -0,0 +1,7 @@
"""Audiobook sorter package."""
from __future__ import annotations
__all__ = ["__version__"]
__version__ = "0.1.0"
+97
View File
@@ -0,0 +1,97 @@
"""Command line entry point."""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Sequence
from audiobooksorter import __version__
from audiobooksorter.config import DEFAULT_OUTPUT, SorterConfig
from audiobooksorter.export import write_sorted
from audiobooksorter.ingest import AudiobookRecord, IngestError, load_library
from audiobooksorter.logging import configure_logging
from audiobooksorter.sorter import sort_by_series
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Sort an Audiobookshelf library into Author/Series/Title folders"
)
parser.add_argument(
"--library",
required=True,
type=Path,
help="Path to the Audiobookshelf library folder (or JSON export for tests)",
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
help="Path to write the sorted JSON export (ignored with --dry-run)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview the final order instead of writing files",
)
parser.add_argument("--version", action="version", version=f"audiobooksorter {__version__}")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
logger = configure_logging()
config = SorterConfig(library_path=args.library, output_path=args.output)
try:
records = load_library(config.library_path)
except IngestError as exc:
logger.error(str(exc))
return 1
sorted_records = sort_by_series(records)
if args.dry_run:
print(_format_dry_run(sorted_records))
return 0
write_sorted(sorted_records, config.output_path)
logger.info("Sorted library written to %s", config.output_path)
return 0
def _format_dry_run(records: Sequence[AudiobookRecord]) -> str:
from audiobooksorter.export import _format_title, _sanitize_component
lines = ["Dry run (no files written):"]
# Track which author/series combinations we've already shown
seen_series: set[tuple[str, str]] = set()
for record in records:
author = _sanitize_component(record.author or "Unknown Author")
series = _sanitize_component(record.series) if record.series else None
# Show author/series header if this is the first book in the series
if series:
key = (author, series)
if key not in seen_series:
lines.append(f"{author} / {series}")
seen_series.add(key)
# Show the book within the series
title = _format_title(record)
lines.append(f"{author} / {series} / {title}")
else:
# Standalone book - show author and title
title = _format_title(record)
lines.append(f"{author} / {title}")
return "\n".join(lines)
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+14
View File
@@ -0,0 +1,14 @@
"""Configuration defaults for audiobooksorter."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_OUTPUT = Path("sorted_library")
@dataclass(slots=True)
class SorterConfig:
library_path: Path
output_path: Path = field(default_factory=lambda: DEFAULT_OUTPUT)
+6
View File
@@ -0,0 +1,6 @@
"""Global constants for audiobooksorter."""
from __future__ import annotations
PACKAGE_NAME = "audiobooksorter"
SUPPORTED_VERSION = "1"
+72
View File
@@ -0,0 +1,72 @@
"""Write sorted libraries to disk."""
from __future__ import annotations
import json
import re
import shutil
from pathlib import Path
from typing import Iterable
from audiobooksorter.ingest import AudiobookRecord
def write_sorted(records: Iterable[AudiobookRecord], destination: Path) -> None:
"""Materialize the sorted library into author/series/title folders."""
destination.mkdir(parents=True, exist_ok=True)
for record in records:
author_dir = destination / _sanitize_component(record.author or "Unknown Author")
if record.series:
series_dir = author_dir / _sanitize_component(record.series)
else:
series_dir = author_dir
title_dir = series_dir / _format_title(record)
_copy_record(record, title_dir)
def _copy_record(record: AudiobookRecord, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if record.source_path and record.source_path.exists():
if record.source_path.is_file():
# Copy individual audio file
target.mkdir(parents=True, exist_ok=True)
dest_file = target / record.source_path.name
shutil.copy2(record.source_path, dest_file)
else:
# Copy entire directory (for metadata.json based libraries)
if target.exists():
shutil.rmtree(target)
shutil.copytree(record.source_path, target)
else:
target.mkdir(exist_ok=True)
metadata = {
"title": record.title,
"series": record.series,
"seriesIndex": record.series_index,
"author": record.author,
}
(target / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
_INVALID_CHARS = str.maketrans({char: "_" for char in '<>:"/\\|?*'})
_SPACE_COLLAPSE = re.compile(r"\s+")
def _sanitize_component(value: str) -> str:
normalized = _SPACE_COLLAPSE.sub(" ", value or "").strip()
sanitized = normalized.translate(_INVALID_CHARS)
return sanitized or "Unknown"
def _format_title(record: AudiobookRecord) -> str:
parts: list[str] = []
if record.series_index is not None:
parts.append(_format_sequence(record.series_index))
parts.append(_sanitize_component(record.title or "Untitled"))
return " - ".join(parts)
def _format_sequence(index: float) -> str:
if index.is_integer():
return f"Book {int(index)}"
return f"Book {index:g}"
+147
View File
@@ -0,0 +1,147 @@
"""Read Audiobookshelf libraries."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List
@dataclass(slots=True)
class AudiobookRecord:
"""Simplified audiobook representation used for sorting."""
title: str
series: str | None
series_index: float | None
author: str
source_path: Path | None = None
class IngestError(RuntimeError):
"""Raised when loading the library fails."""
def load_library(path: Path) -> List[AudiobookRecord]:
"""Load audiobook records from a JSON export file or a library directory."""
if path.is_file():
return _load_from_json(path)
if path.is_dir():
return _load_from_directory(path)
raise IngestError(f"Library path not found: {path}")
def _load_from_json(path: Path) -> List[AudiobookRecord]:
"""Load records from a JSON export (used primarily for tests)."""
try:
with path.open("r", encoding="utf-8") as handle:
raw_items: Iterable[dict[str, object]] = json.load(handle)
except FileNotFoundError as exc:
raise IngestError(f"Library file not found: {path}") from exc
records: List[AudiobookRecord] = []
for item in raw_items:
records.append(
AudiobookRecord(
title=str(item.get("title", "")),
series=_normalize_series(item.get("series")),
series_index=_parse_index(item.get("seriesIndex")),
author=str(item.get("author", "")),
source_path=None,
)
)
return records
def _load_from_directory(root: Path) -> List[AudiobookRecord]:
"""Walk a folder structure and read each book's metadata.json."""
metadata_files = sorted(root.rglob("metadata.json"))
if metadata_files:
return _load_from_metadata_files(metadata_files)
series_dirs = sorted(entry for entry in root.iterdir() if entry.is_dir())
if not series_dirs:
raise IngestError(f"No metadata.json files or audiobook folders found in {root}")
records: List[AudiobookRecord] = []
for series_dir in series_dirs:
series_name, author = _parse_series_dir(series_dir.name)
for audio_file in sorted(series_dir.glob("*.m4b")):
title, index = _parse_audio_filename(audio_file.name, series_name)
records.append(
AudiobookRecord(
title=title,
series=series_name,
series_index=index,
author=author,
source_path=audio_file,
)
)
if not records:
raise IngestError(f"No audiobooks found in {root}")
return records
def _load_from_metadata_files(metadata_files: Iterable[Path]) -> List[AudiobookRecord]:
records: List[AudiobookRecord] = []
for metadata_file in metadata_files:
try:
raw_item = json.loads(metadata_file.read_text(encoding="utf-8"))
except OSError as exc:
raise IngestError(f"Failed to read {metadata_file}") from exc
except json.JSONDecodeError as exc:
raise IngestError(f"Invalid metadata in {metadata_file}") from exc
records.append(
AudiobookRecord(
title=str(raw_item.get("title") or metadata_file.parent.name),
series=_normalize_series(raw_item.get("series")),
series_index=_parse_index(raw_item.get("seriesIndex")),
author=str(raw_item.get("author") or "Unknown Author"),
source_path=metadata_file.parent,
)
)
return records
def _normalize_series(raw_series: object | None) -> str | None:
series = (raw_series or "").strip()
return series or None
def _parse_index(raw_index: object | None) -> float | None:
if raw_index is None:
return None
try:
return float(raw_index)
except (TypeError, ValueError):
return None
def _parse_audio_filename(filename: str, fallback_series: str) -> tuple[str, float | None]:
"""Extract title and index from an audiobook filename."""
parts = [segment.strip() for segment in Path(filename).stem.split("-") if segment.strip()]
book_index: float | None = None
title_parts: list[str] = []
for part in parts:
match = re.fullmatch(r"(?i)book\s*(\d+(?:\.\d+)?)", part)
if match:
book_index = float(match.group(1))
continue
title_parts.append(part)
title = " - ".join(title_parts) if title_parts else fallback_series
return title, book_index
def _parse_series_dir(dirname: str) -> tuple[str, str]:
"""Infer series and author from a directory name."""
parts = [section.strip() for section in dirname.split("-") if section.strip()]
series = parts[0] if parts else dirname
author = parts[1] if len(parts) > 1 else "Unknown Author"
return series, author
+24
View File
@@ -0,0 +1,24 @@
"""Shared logging helpers."""
from __future__ import annotations
import logging
from typing import Final
_LOG_LEVEL: Final = "AUDIOBOOKSORTER_LOG_LEVEL"
def configure_logging(name: str = "audiobooksorter") -> logging.Logger:
"""Return a configured logger using env override when provided."""
logger = logging.getLogger(name)
if logger.handlers:
return logger
handler = logging.StreamHandler()
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
level = logging.getLevelName(logging.getLogger().level)
logger.setLevel(level)
return logger
+7
View File
@@ -0,0 +1,7 @@
"""Sorting utilities."""
from __future__ import annotations
from .series_rules import sort_by_series
__all__ = ["sort_by_series"]
@@ -0,0 +1,76 @@
"""Series-based sorting rules."""
from __future__ import annotations
import re
from typing import Iterable, List, Sequence
from audiobooksorter.ingest import AudiobookRecord
_AUTHOR_MULTI_SEPARATOR = re.compile(r"\s*(?:&|;| and )\s*", re.IGNORECASE)
_SPACE_COLLAPSE = re.compile(r"\s+")
def sort_by_series(records: Iterable[AudiobookRecord]) -> List[AudiobookRecord]:
"""Return records ordered by author, series, series index, and title."""
def sort_key(record: AudiobookRecord) -> tuple:
return (
_author_sort_key(record.author),
_series_sort_key(record.series),
_series_index_value(record.series_index),
_title_key(record.title),
)
return sorted(records, key=sort_key)
def _author_sort_key(author: str) -> Sequence[str]:
names = _split_authors(author)
if not names:
return ("~unknown",)
return tuple(_normalize_name(name) for name in names)
def _series_sort_key(series: str | None) -> tuple[int, str]:
if series:
return (0, _normalize_name(series))
return (1, "")
def _series_index_value(index: float | None) -> float:
return index if index is not None else float("inf")
def _title_key(title: str) -> str:
return _normalize_name(title)
def _split_authors(author_field: str) -> list[str]:
chunks = _AUTHOR_MULTI_SEPARATOR.split(author_field or "")
names: list[str] = []
for chunk in chunks:
normalized_chunk = chunk.strip()
if not normalized_chunk:
continue
names.extend(_split_last_first_chunk(normalized_chunk))
return names
def _split_last_first_chunk(chunk: str) -> list[str]:
pieces = [piece.strip() for piece in chunk.split(",") if piece.strip()]
if len(pieces) <= 1:
return pieces
if len(pieces) % 2 == 0 and all(" " not in pieces[i] for i in range(0, len(pieces), 2)):
paired: list[str] = []
for i in range(0, len(pieces), 2):
last = pieces[i]
first = pieces[i + 1]
paired.append(f"{first} {last}".strip())
return paired
return pieces
def _normalize_name(value: str) -> str:
collapsed = _SPACE_COLLAPSE.sub(" ", value or "").strip()
return collapsed.casefold()
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from pathlib import Path
import pytest
@pytest.fixture(scope="session")
def samples_dir() -> Path:
return Path(__file__).parent.parent / "samples"
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from pathlib import Path
from audiobooksorter.export import write_sorted
from audiobooksorter.ingest import AudiobookRecord
def test_write_sorted_copies_source_directories(tmp_path: Path) -> None:
source = tmp_path / "library"
book_dir = source / "Brandon" / "Mistborn1"
book_dir.mkdir(parents=True)
(book_dir / "chapter1.mp3").write_text("audio-data", encoding="utf-8")
destination = tmp_path / "sorted"
record = AudiobookRecord(
title="The Final Empire",
series="Mistborn",
series_index=1,
author="Brandon Sanderson",
source_path=book_dir,
)
write_sorted([record], destination)
expected = destination / "Brandon Sanderson" / "Mistborn" / "Book 1 - The Final Empire"
assert (expected / "chapter1.mp3").read_text(encoding="utf-8") == "audio-data"
def test_write_sorted_creates_metadata_when_missing_source(tmp_path: Path) -> None:
destination = tmp_path / "sorted"
record = AudiobookRecord(
title="",
series=None,
series_index=None,
author="",
source_path=None,
)
write_sorted([record], destination)
expected = destination / "Unknown Author" / "Untitled"
metadata = expected / "metadata.json"
assert metadata.exists()
assert '"title": ""' in metadata.read_text(encoding="utf-8")
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from audiobooksorter.ingest import IngestError, load_library
def test_load_library_from_json(samples_dir: Path) -> None:
records = load_library(samples_dir / "library.json")
assert len(records) == 5
assert records[0].title == "The Final Empire"
assert records[0].source_path is None
def test_load_library_from_directory(tmp_path: Path) -> None:
library = tmp_path / "library"
book_one = library / "Mistborn1"
book_two = library / "Wayfarers"
book_one.mkdir(parents=True)
book_two.mkdir(parents=True)
(book_one / "metadata.json").write_text(
json.dumps(
{"title": "The Final Empire", "series": "Mistborn", "seriesIndex": 1, "author": "Brandon"}
),
encoding="utf-8",
)
(book_two / "metadata.json").write_text(
json.dumps({"title": "Wayfarers", "author": "Becky Chambers"}),
encoding="utf-8",
)
records = load_library(library)
sources = {record.title: record.source_path for record in records}
assert sources["The Final Empire"] == book_one
assert sources["Wayfarers"] == book_two
def test_missing_library_raises(tmp_path: Path) -> None:
with pytest.raises(IngestError):
load_library(tmp_path / "missing")
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import json
from pathlib import Path
from audiobooksorter.ingest import AudiobookRecord
from audiobooksorter.sorter import sort_by_series
def _load_sample(path: Path) -> list[AudiobookRecord]:
data = json.loads(path.read_text(encoding="utf-8"))
return [
AudiobookRecord(
title=item["title"],
series=item.get("series"),
series_index=item.get("seriesIndex"),
author=item["author"],
)
for item in data
]
def test_sorting_orders_by_author_then_series(samples_dir: Path) -> None:
records = _load_sample(samples_dir / "library.json")
sorted_records = sort_by_series(records)
titles = [record.title for record in sorted_records]
assert titles == [
"Wayfarers", # Becky Chambers
"The Final Empire",
"The Well of Ascension",
"The Way of Kings",
"Words of Radiance",
]
def test_author_parsing_handles_last_first_pairs() -> None:
records = [
AudiobookRecord(
title="A Solo Standard",
series=None,
series_index=None,
author="Ichiro Kishimi",
),
AudiobookRecord(
title="B Solo LastFirst",
series=None,
series_index=None,
author="Kishimi, Ichiro",
),
AudiobookRecord(
title="C Ampersand",
series=None,
series_index=None,
author="Ichiro Kishimi & Fumitake Koga",
),
AudiobookRecord(
title="D Comma List",
series=None,
series_index=None,
author="Kishimi, Ichiro, Koga, Fumitake",
),
]
sorted_records = sort_by_series(records)
titles = [record.title for record in sorted_records]
assert titles == [
"A Solo Standard",
"B Solo LastFirst",
"C Ampersand",
"D Comma List",
]
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import json
from pathlib import Path
from audiobooksorter.cli import main
def _make_book(folder: Path, title: str, author: str, series: str | None, index: int | None) -> None:
folder.mkdir(parents=True)
(folder / "metadata.json").write_text(
json.dumps(
{"title": title, "author": author, "series": series, "seriesIndex": index},
),
encoding="utf-8",
)
(folder / f"{title}.txt").write_text(title, encoding="utf-8")
def test_cli_sorts_library(tmp_path: Path) -> None:
library = tmp_path / "input"
_make_book(library / "BookA", "Wayfarers", "Becky Chambers", None, None)
_make_book(library / "BookB", "The Final Empire", "Brandon Sanderson", "Mistborn", 1)
output = tmp_path / "output"
exit_code = main(["--library", str(library), "--output", str(output)])
assert exit_code == 0
assert (output / "Becky Chambers" / "Wayfarers" / "Wayfarers.txt").read_text(encoding="utf-8") == "Wayfarers"
assert (
output / "Brandon Sanderson" / "Mistborn" / "Book 1 - The Final Empire" / "The Final Empire.txt"
).read_text(encoding="utf-8") == "The Final Empire"
def test_cli_dry_run_shows_directory_structure(tmp_path: Path, capsys: object) -> None:
library = tmp_path / "input"
_make_book(library / "BookA", "All the Skills", "Honour Rae", "All the Skills", 1)
_make_book(library / "BookB", "All the Skills", "Honour Rae", "All the Skills", 2)
_make_book(library / "BookC", "The Wandering Inn", "PirateAba", "The Wandering Inn", 1)
_make_book(library / "BookD", "Fae and Fare", "PirateAba", "The Wandering Inn", 2)
_make_book(library / "BookE", "Domestication", "Seth Ring", "Battle Mage Farmer", 1)
exit_code = main(["--library", str(library), "--dry-run"])
assert exit_code == 0
captured = capsys.readouterr()
assert "Honour Rae / All the Skills" in captured.out
assert "Honour Rae / All the Skills / Book 1 - All the Skills" in captured.out
assert "Honour Rae / All the Skills / Book 2 - All the Skills" in captured.out
assert "PirateAba / The Wandering Inn" in captured.out
assert "PirateAba / The Wandering Inn / Book 1 - The Wandering Inn" in captured.out
assert "PirateAba / The Wandering Inn / Book 2 - Fae and Fare" in captured.out
assert "Seth Ring / Battle Mage Farmer" in captured.out
assert "Seth Ring / Battle Mage Farmer / Book 1 - Domestication" in captured.out
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
from audiobooksorter import constants
def test_constants_exposed() -> None:
assert constants.PACKAGE_NAME == "audiobooksorter"
assert constants.SUPPORTED_VERSION == "1"
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from pathlib import Path
from audiobooksorter.cli import _format_dry_run
from audiobooksorter.ingest import load_library
from audiobooksorter.sorter import sort_by_series
def test_loads_directory_without_metadata_files() -> None:
records = load_library(Path("testdata"))
assert len(records) == 14
series_names = {record.series for record in records}
assert {"All the Skills", "Battle Mage Farmer", "The Wandering Inn"} <= series_names
battle_indexes = sorted(
record.series_index for record in records if record.series == "Battle Mage Farmer"
)
assert battle_indexes == [1.0, 2.0, 3.0, 4.0]
def test_dry_run_formats_preview() -> None:
records = sort_by_series(load_library(Path("testdata")))
preview = _format_dry_run(records)
assert "Dry run (no files written)" in preview
assert "Seth Ring / Battle Mage Farmer" in preview
assert "Seth Ring / Battle Mage Farmer / Book 1" in preview
assert "PirateAba / The Wandering Inn" in preview
assert "PirateAba / The Wandering Inn / Book 1" in preview
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from audiobooksorter.logging import configure_logging
def test_configure_logging_reuses_logger() -> None:
logger = configure_logging("audiobooksorter-tests")
logger.info("hello")
# calling again should not attach additional handlers
logger_again = configure_logging("audiobooksorter-tests")
assert logger is logger_again
assert len(logger.handlers) == 1