From 4519d722bc373d37a853e017d4723a4fede468fe Mon Sep 17 00:00:00 2001 From: julle Date: Sun, 16 Nov 2025 10:00:56 +0100 Subject: [PATCH] add program files --- AGENTS.md | 25 ++ README.md | 88 ++++++ assets/.gitkeep | 0 pdm.lock | 314 +++++++++++++++++++++ pyproject.toml | 39 +++ samples/library.json | 7 + src/audiobooksorter/__init__.py | 7 + src/audiobooksorter/cli.py | 97 +++++++ src/audiobooksorter/config/__init__.py | 14 + src/audiobooksorter/constants.py | 6 + src/audiobooksorter/export.py | 72 +++++ src/audiobooksorter/ingest.py | 147 ++++++++++ src/audiobooksorter/logging.py | 24 ++ src/audiobooksorter/sorter/__init__.py | 7 + src/audiobooksorter/sorter/series_rules.py | 76 +++++ tests/conftest.py | 10 + tests/export/test_write_sorted.py | 45 +++ tests/ingest/test_load_library.py | 46 +++ tests/sorter/test_series_order.py | 74 +++++ tests/test_cli.py | 55 ++++ tests/test_constants.py | 8 + tests/test_ingest_directory.py | 32 +++ tests/test_logging.py | 12 + 23 files changed, 1205 insertions(+) create mode 100644 AGENTS.md create mode 100644 assets/.gitkeep create mode 100644 pdm.lock create mode 100644 pyproject.toml create mode 100644 samples/library.json create mode 100644 src/audiobooksorter/__init__.py create mode 100644 src/audiobooksorter/cli.py create mode 100644 src/audiobooksorter/config/__init__.py create mode 100644 src/audiobooksorter/constants.py create mode 100644 src/audiobooksorter/export.py create mode 100644 src/audiobooksorter/ingest.py create mode 100644 src/audiobooksorter/logging.py create mode 100644 src/audiobooksorter/sorter/__init__.py create mode 100644 src/audiobooksorter/sorter/series_rules.py create mode 100644 tests/conftest.py create mode 100644 tests/export/test_write_sorted.py create mode 100644 tests/ingest/test_load_library.py create mode 100644 tests/sorter/test_series_order.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_constants.py create mode 100644 tests/test_ingest_directory.py create mode 100644 tests/test_logging.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..28f68fe --- /dev/null +++ b/AGENTS.md @@ -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_.py` and tests `test_`. 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 user’s library. diff --git a/README.md b/README.md index ac744ab..2b79aab 100644 --- a/README.md +++ b/README.md @@ -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/`. diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 0000000..2e8383d --- /dev/null +++ b/pdm.lock @@ -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"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6b02602 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/samples/library.json b/samples/library.json new file mode 100644 index 0000000..7ccc169 --- /dev/null +++ b/samples/library.json @@ -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"} +] diff --git a/src/audiobooksorter/__init__.py b/src/audiobooksorter/__init__.py new file mode 100644 index 0000000..6e53bf8 --- /dev/null +++ b/src/audiobooksorter/__init__.py @@ -0,0 +1,7 @@ +"""Audiobook sorter package.""" + +from __future__ import annotations + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/audiobooksorter/cli.py b/src/audiobooksorter/cli.py new file mode 100644 index 0000000..27964ef --- /dev/null +++ b/src/audiobooksorter/cli.py @@ -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()) diff --git a/src/audiobooksorter/config/__init__.py b/src/audiobooksorter/config/__init__.py new file mode 100644 index 0000000..d71dfb3 --- /dev/null +++ b/src/audiobooksorter/config/__init__.py @@ -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) diff --git a/src/audiobooksorter/constants.py b/src/audiobooksorter/constants.py new file mode 100644 index 0000000..810d0d2 --- /dev/null +++ b/src/audiobooksorter/constants.py @@ -0,0 +1,6 @@ +"""Global constants for audiobooksorter.""" + +from __future__ import annotations + +PACKAGE_NAME = "audiobooksorter" +SUPPORTED_VERSION = "1" diff --git a/src/audiobooksorter/export.py b/src/audiobooksorter/export.py new file mode 100644 index 0000000..22d6a93 --- /dev/null +++ b/src/audiobooksorter/export.py @@ -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}" diff --git a/src/audiobooksorter/ingest.py b/src/audiobooksorter/ingest.py new file mode 100644 index 0000000..0ee8221 --- /dev/null +++ b/src/audiobooksorter/ingest.py @@ -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 diff --git a/src/audiobooksorter/logging.py b/src/audiobooksorter/logging.py new file mode 100644 index 0000000..56efccc --- /dev/null +++ b/src/audiobooksorter/logging.py @@ -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 diff --git a/src/audiobooksorter/sorter/__init__.py b/src/audiobooksorter/sorter/__init__.py new file mode 100644 index 0000000..6bd42fb --- /dev/null +++ b/src/audiobooksorter/sorter/__init__.py @@ -0,0 +1,7 @@ +"""Sorting utilities.""" + +from __future__ import annotations + +from .series_rules import sort_by_series + +__all__ = ["sort_by_series"] diff --git a/src/audiobooksorter/sorter/series_rules.py b/src/audiobooksorter/sorter/series_rules.py new file mode 100644 index 0000000..2b6185d --- /dev/null +++ b/src/audiobooksorter/sorter/series_rules.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0b8ec7d --- /dev/null +++ b/tests/conftest.py @@ -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" diff --git a/tests/export/test_write_sorted.py b/tests/export/test_write_sorted.py new file mode 100644 index 0000000..6ad1a76 --- /dev/null +++ b/tests/export/test_write_sorted.py @@ -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") diff --git a/tests/ingest/test_load_library.py b/tests/ingest/test_load_library.py new file mode 100644 index 0000000..f525aff --- /dev/null +++ b/tests/ingest/test_load_library.py @@ -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") diff --git a/tests/sorter/test_series_order.py b/tests/sorter/test_series_order.py new file mode 100644 index 0000000..34616f6 --- /dev/null +++ b/tests/sorter/test_series_order.py @@ -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", + ] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4118188 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..0ed293c --- /dev/null +++ b/tests/test_constants.py @@ -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" diff --git a/tests/test_ingest_directory.py b/tests/test_ingest_directory.py new file mode 100644 index 0000000..211388d --- /dev/null +++ b/tests/test_ingest_directory.py @@ -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 diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..08aed8e --- /dev/null +++ b/tests/test_logging.py @@ -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