diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..c6ca25d4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,40 @@ +name: Tests + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +# A newer push to the same branch makes the running check obsolete +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: pytest on Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Install the project + # --locked fails when uv.lock no longer matches pyproject.toml, which + # doubles as the lockfile check the pull request workflows lack + run: uv sync --locked + + - name: Run tests + run: uv run --no-sync pytest -v diff --git a/pyproject.toml b/pyproject.toml index 2a63a32f..117d0aa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,8 @@ audible-quickstart = "audible_cli:quickstart" [dependency-groups] dev = [ - "pyinstaller" + "pyinstaller", + "pytest>=8.4.2" ] [tool.hatch.build.targets.sdist] @@ -150,4 +151,4 @@ lines-after-imports = 2 "tests/*" = ["S101"] [tool.pytest.ini_options] -testpaths = ["tests", "src/audible_cli"] +testpaths = ["tests"] diff --git a/test.py b/test.py deleted file mode 100644 index 572b451c..00000000 --- a/test.py +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- - -print("fake test") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..3c01da04 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import pytest +from helpers import FakeClient + + +@pytest.fixture +def client(): + return FakeClient() diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..23a90d4e --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,51 @@ +"""Shared stand-ins for the tests. + +Kept out of ``conftest.py`` on purpose: pytest discourages importing from a +conftest module, so anything the tests import directly lives here instead. +""" + +import json + +import httpx + + +class FakeClient: + """Stands in for ``audible.AsyncClient`` in the library code paths. + + Only the two calls the models make are implemented. ``last_params`` + records what a request was sent with, so tests can assert on the + serialized query. + """ + + def __init__(self, items=(), license_response=None): + self.items = list(items) + self.license_response = license_response + self.last_params = None + + async def get(self, path, response_callback=None, **params): + self.last_params = params + body = json.dumps({"items": self.items}).encode() + return httpx.Response( + 200, + content=body, + headers={ + "content-type": "application/json", + "total-count": str(len(self.items)), + }, + request=httpx.Request("GET", f"https://example.invalid/{path}"), + ) + + async def post(self, path, body=None, headers=None): + return self.license_response + + +def library_item(asin, **fields): + """A library item with only the fields the code under test reads.""" + item = { + "asin": asin, + "title": f"Title {asin}", + "content_delivery_type": "SinglePartBook", + "has_children": False, + } + item.update(fields) + return item diff --git a/tests/test_download_cli.py b/tests/test_download_cli.py new file mode 100644 index 00000000..ecc12626 --- /dev/null +++ b/tests/test_download_cli.py @@ -0,0 +1,109 @@ +"""The download command must not report success after a failed job. + +Covers the wiring for #256: `drain_queue()` collecting failures is only half +of it, the command also has to raise on them. Without this test, deleting the +`run.raise_for_errors()` call would leave every other test green. +""" + +import pytest +from click.testing import CliRunner + +from audible_cli.cmds import cmd_download +from audible_cli.config import Session +from audible_cli.exceptions import AudibleCliException +from audible_cli.models import Library + + +class FakeHTTPSession: + """Stands in for the httpx session `pass_client` opens.""" + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + +class FakeApiClient: + def __init__(self): + self.session = FakeHTTPSession() + + +@pytest.fixture +def session(monkeypatch): + """A session handing out a client that never reaches the network.""" + monkeypatch.setattr(Session, "get_client", lambda self, **kw: FakeApiClient()) + return Session() + + +@pytest.fixture +def one_item_library(monkeypatch): + """Work on a single known item instead of fetching a real library.""" + + async def fake_from_api_full_sync(cls, api_client, **request_params): + return Library( + { + "items": [ + { + "asin": "ASIN0001", + "title": "A Title", + "content_delivery_type": "SinglePartBook", + "has_children": False, + "purchase_date": "2020-01-01T00:00:00.000Z", + } + ] + }, + api_client=api_client, + ) + + monkeypatch.setattr( + Library, "from_api_full_sync", classmethod(fake_from_api_full_sync) + ) + + +def download(session, tmp_path, *extra): + # --chapter-type and --filename-mode are passed explicitly so the command + # never falls back to reading the user's config file + return CliRunner().invoke( + cmd_download.cli, + [ + "--asin", "ASIN0001", + "--cover", + "--output-dir", str(tmp_path), + "--filename-mode", "ascii", + "--chapter-type", "flat", + *extra, + ], + obj=session, + ) + + +@pytest.mark.parametrize("extra", [(), ("--ignore-errors",)]) +def test_raises_when_a_job_failed( + monkeypatch, session, one_item_library, tmp_path, extra +): + async def failing_cover(**kwargs): + raise RuntimeError("cover download failed") + + monkeypatch.setattr(cmd_download, "download_cover", failing_cover) + + result = download(session, tmp_path, *extra) + + # cli.main() maps this to exit code 2; here the exception itself is what + # proves the command did not swallow the failure + assert isinstance(result.exception, AudibleCliException), result.output + assert "job(s) failed" in str(result.exception) + + +def test_succeeds_when_every_job_worked( + monkeypatch, session, one_item_library, tmp_path +): + async def working_cover(**kwargs): + return None + + monkeypatch.setattr(cmd_download, "download_cover", working_cover) + + result = download(session, tmp_path) + + assert result.exception is None, result.output + assert result.exit_code == 0 diff --git a/tests/test_download_queue.py b/tests/test_download_queue.py new file mode 100644 index 00000000..d6bd6740 --- /dev/null +++ b/tests/test_download_queue.py @@ -0,0 +1,182 @@ +"""The download queue's behaviour on failing jobs. + +Covers the deadlock from #235/#239, where a failing job killed its consumer +and QUEUE.join() then waited forever, and the exit code from #256. +""" + +import asyncio + +import pytest + +from audible_cli.cmds import cmd_download +from audible_cli.cmds.cmd_download import DownloadRun, consume, drain_queue +from audible_cli.exceptions import AudibleCliException + + +@pytest.fixture(autouse=True) +def isolate_queue(monkeypatch): + """Restore the module-level QUEUE after every test. + + monkeypatch records the original here, so the direct assignments the + tests make below are undone at teardown regardless. + """ + monkeypatch.setattr(cmd_download, "QUEUE", None) + + +async def good(n): + await asyncio.sleep(0) + + +async def bad(n): + raise RuntimeError(f"job {n} failed") + + +async def slow(n, finished): + await asyncio.sleep(0.1) + finished.append(n) + + +def run_queue(jobs, sim_jobs, ignore_errors, timeout=5.0): + """Work a list of jobs off the real queue with the real consumers.""" + + async def main(): + cmd_download.QUEUE = asyncio.Queue() + run = DownloadRun(ignore_errors) + for i, job in enumerate(jobs): + cmd_download.QUEUE.put_nowait((job, {"n": i})) + + consumers = [ + asyncio.create_task(consume(run)) for _ in range(sim_jobs) + ] + try: + await asyncio.wait_for(cmd_download.QUEUE.join(), timeout=timeout) + finally: + for consumer in consumers: + consumer.cancel() + await asyncio.gather(*consumers, return_exceptions=True) + + return run + + return asyncio.run(main()) + + +@pytest.mark.parametrize( + "jobs, sim_jobs", + [ + # Enough failures to kill every consumer, which used to strand the + # queue and hang QUEUE.join() forever + ([bad] * 3 + [good] * 5, 3), + ([bad, good, good], 1), + ([bad] * 10, 3), + ], +) +def test_failures_never_strand_the_queue(jobs, sim_jobs): + run = run_queue(jobs, sim_jobs, ignore_errors=False) + + assert cmd_download.QUEUE.qsize() == 0 + assert run.errors + + +def test_first_failure_stops_the_queued_jobs(): + run = run_queue([bad] + [good] * 20, sim_jobs=1, ignore_errors=False) + + assert len(run.errors) == 1 + assert run.skipped == 20 + + +def test_ignore_errors_runs_everything(): + run = run_queue([bad] * 3 + [good] * 5, sim_jobs=3, ignore_errors=True) + + assert len(run.errors) == 3 + assert run.skipped == 0 + + +def test_a_clean_run_reports_nothing(): + run = run_queue([good] * 10, sim_jobs=3, ignore_errors=False) + + assert (run.errors, run.skipped) == ([], 0) + run.raise_for_errors() # must not raise + + +def test_running_downloads_are_not_cut_short(): + finished = [] + + async def main(): + cmd_download.QUEUE = asyncio.Queue() + run = DownloadRun(ignore_errors=False) + # Two slow jobs occupy consumers while the third one fails + cmd_download.QUEUE.put_nowait((slow, {"n": 0, "finished": finished})) + cmd_download.QUEUE.put_nowait((slow, {"n": 1, "finished": finished})) + cmd_download.QUEUE.put_nowait((bad, {"n": 2})) + for i in range(3, 8): + cmd_download.QUEUE.put_nowait((good, {"n": i})) + + consumers = [asyncio.create_task(consume(run)) for _ in range(3)] + await asyncio.wait_for(cmd_download.QUEUE.join(), timeout=5.0) + for consumer in consumers: + consumer.cancel() + await asyncio.gather(*consumers, return_exceptions=True) + return run + + run = asyncio.run(main()) + + assert sorted(finished) == [0, 1] + assert run.skipped == 5 + + +def test_raise_for_errors_reports_failed_and_skipped(): + run = run_queue([bad] + [good] * 4, sim_jobs=1, ignore_errors=False) + + with pytest.raises(AudibleCliException) as excinfo: + run.raise_for_errors() + + message = str(excinfo.value) + assert "1 job(s) failed" in message + assert "4 skipped" in message + assert "--ignore-errors" in message + + +def test_raise_for_errors_also_fires_with_ignore_errors(): + # #256: a run that saw failures must not report success + run = run_queue([bad] * 2 + [good] * 3, sim_jobs=3, ignore_errors=True) + + with pytest.raises(AudibleCliException, match="2 job\\(s\\) failed"): + run.raise_for_errors() + + +def test_drain_queue_reports_a_consumer_that_ends_early(): + async def dying_consumer(run): + await asyncio.sleep(0) + raise asyncio.CancelledError() + + async def main(): + cmd_download.QUEUE = asyncio.Queue() + for i in range(20): + cmd_download.QUEUE.put_nowait((good, {"n": i})) + + original = cmd_download.consume + cmd_download.consume = dying_consumer + try: + await asyncio.wait_for( + drain_queue(DownloadRun(ignore_errors=False), 1), timeout=5.0 + ) + finally: + cmd_download.consume = original + + # Waiting on QUEUE.join() alone would hang here instead + with pytest.raises(AudibleCliException, match="stopped unexpectedly"): + asyncio.run(main()) + + +def test_drain_queue_completes_a_normal_run(): + async def main(): + cmd_download.QUEUE = asyncio.Queue() + for i in range(20): + cmd_download.QUEUE.put_nowait((good, {"n": i})) + run = DownloadRun(ignore_errors=False) + await asyncio.wait_for(drain_queue(run, 3), timeout=5.0) + return run + + run = asyncio.run(main()) + + assert (run.errors, run.skipped) == ([], 0) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000..acbc93e5 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,29 @@ +"""`ItemNotPublished` must never fail while building its message. + +Covers #268, where a missing publication date reached the parser and turned +a "not published yet" notice into a stack trace. +""" + +import pytest + +from audible_cli.exceptions import ItemNotPublished + + +def test_reports_the_countdown_for_a_real_date(): + message = str(ItemNotPublished("ASIN123", "2999-01-01T00:00:00.000Z")) + + assert "ASIN123" in message + assert "will be available in" in message + + +@pytest.mark.parametrize( + "pub_date", + [ + None, + "", + "garbage", + "9999-12-31T23:59:59-23:59", # overflows when converted to UTC + ], +) +def test_falls_back_to_naming_the_item(pub_date): + assert str(ItemNotPublished("ASIN123", pub_date)) == "ASIN123 is not published." diff --git a/tests/test_models_dates.py b/tests/test_models_dates.py new file mode 100644 index 00000000..4cb10f9b --- /dev/null +++ b/tests/test_models_dates.py @@ -0,0 +1,161 @@ +"""Date filtering and publication checks on library items. + +Covers the crash from #264, the naive/aware comparison risk from #266 and +the missing-field handling from #268. +""" + +import asyncio +from datetime import UTC, datetime, timedelta, timezone + +import pytest +from helpers import FakeClient, library_item + +from audible_cli.models import Library, LibraryItem + + +def sync(coro): + return asyncio.run(coro) + + +def filtered(items, **bounds): + client = FakeClient(items) + library = sync(Library.from_api(client, **bounds)) + return sorted(i.asin for i in library), client + + +ITEMS = [ + library_item("WITHFRAC", purchase_date="2019-11-29T11:40:49.000Z"), + # The shape that used to crash: an episode without fractional seconds + library_item("NOFRAC__", purchase_date="2019-11-29T11:40:49Z"), + library_item("TOOOLD__", purchase_date="2007-01-01T09:00:00Z"), + library_item( + "FALLBACK", + purchase_date=None, + library_status={"date_added": "2020-05-05T10:00:00Z"}, + ), + library_item("NODATE__", purchase_date=None, library_status=None), +] + + +def test_start_date_keeps_both_timestamp_shapes(): + kept, _ = filtered(ITEMS, start_date=datetime(2008, 1, 1)) + + # TOOOLD__ is the only item before the bound; the undatable one is kept + assert kept == ["FALLBACK", "NODATE__", "NOFRAC__", "WITHFRAC"] + + +def test_naive_bounds_do_not_clash_with_aware_timestamps(): + # A programmatic caller may still pass naive datetimes + kept, client = filtered(ITEMS, start_date=datetime(2008, 1, 1)) + + assert kept + assert client.last_params["purchased_after"] == "2008-01-01T00:00:00.000000Z" + + +def test_aware_bounds_are_converted_before_serialization(): + berlin = timezone(timedelta(hours=2)) + + kept, client = filtered(ITEMS, start_date=datetime(2008, 1, 1, 2, tzinfo=berlin)) + + assert kept == ["FALLBACK", "NODATE__", "NOFRAC__", "WITHFRAC"] + assert client.last_params["purchased_after"] == "2008-01-01T00:00:00.000000Z" + + +def test_end_date_drops_later_items(): + kept, _ = filtered( + ITEMS, start_date=datetime(2008, 1, 1), end_date=datetime(2020, 1, 1) + ) + + assert "FALLBACK" not in kept # added 2020-05, past the bound + + +@pytest.mark.parametrize( + "fields", + [ + {"purchase_date": None}, # library_status missing entirely + {"purchase_date": None, "library_status": None}, + {"purchase_date": None, "library_status": {"date_added": None}}, + ], +) +def test_items_without_any_date_are_kept_not_crashed_on(fields): + kept, _ = filtered([library_item("A", **fields)], start_date=datetime(2008, 1, 1)) + + assert kept == ["A"] + + +def test_date_added_branch_still_filters(): + items = [ + library_item( + "KEEP", purchase_date=None, library_status={"date_added": "2020-05-05T10:00:00Z"} + ), + library_item( + "DROP", purchase_date=None, library_status={"date_added": "2001-01-01T10:00:00Z"} + ), + ] + + kept, _ = filtered(items, start_date=datetime(2008, 1, 1)) + + assert kept == ["KEEP"] + + +@pytest.mark.parametrize( + "publication_datetime, expected", + [ + ("2019-11-29T11:40:49Z", True), + ("2019-11-29T11:40:49.000Z", True), + ("2999-01-01T00:00:00Z", False), + ("2999-01-01T00:00:00.000Z", False), + (None, True), # unknown is not the same as unpublished + ], +) +def test_is_published(client, publication_datetime, expected): + item = LibraryItem( + library_item("X", publication_datetime=publication_datetime), + api_client=client, + ) + + assert item.is_published() is expected + + +@pytest.mark.parametrize( + "parent_date, child_date, expected", + [ + # The parent date wins while it is there + ("2999-01-01T00:00:00Z", "2019-01-01T00:00:00Z", False), + ("2019-01-01T00:00:00Z", "2999-01-01T00:00:00Z", True), + # Without one, the part's own date still counts + (None, "2999-01-01T00:00:00Z", False), + (None, "2019-01-01T00:00:00Z", True), + # Only when neither is known is the part assumed published + (None, None, True), + ], +) +def test_audiopart_falls_back_to_its_own_publication_date( + client, parent_date, child_date, expected +): + parent = LibraryItem( + library_item( + "P", publication_datetime=parent_date, content_delivery_type="MultiPartBook" + ), + api_client=client, + ) + child = LibraryItem( + library_item( + "C", publication_datetime=child_date, content_delivery_type="AudioPart" + ), + api_client=client, + ) + child._parent = parent + + assert child.is_published() is expected + + +def test_publication_check_compares_against_utc_now(client): + # Guards against a naive/aware TypeError in the comparison + soon = datetime.now(UTC) + timedelta(days=1) + item = LibraryItem( + library_item("X", publication_datetime=soon.strftime("%Y-%m-%dT%H:%M:%SZ")), + api_client=client, + ) + + assert item.is_published() is False diff --git a/tests/test_utils_datetime.py b/tests/test_utils_datetime.py new file mode 100644 index 00000000..64e0ee4c --- /dev/null +++ b/tests/test_utils_datetime.py @@ -0,0 +1,90 @@ +"""Parsing of API timestamps and the UTC handling around it. + +Covers the crash from #264, where the API returns the same field with and +without fractional seconds, and the timezone-aware conversion added in #266. +""" + +from datetime import UTC, datetime, timedelta, timezone + +import pytest + +from audible_cli.utils import datetime_type, parse_api_datetime, to_utc_datetime + + +@pytest.mark.parametrize( + "value, expected", + [ + # The two shapes the API actually returns. Top-level library items + # usually carry the fraction, podcast episodes usually do not. + ("2019-11-29T11:40:49.000Z", datetime(2019, 11, 29, 11, 40, 49, tzinfo=UTC)), + ("2019-11-29T11:40:49Z", datetime(2019, 11, 29, 11, 40, 49, tzinfo=UTC)), + # More precision than datetime keeps is truncated, not rejected + ( + "2019-11-29T11:40:49.1234567Z", + datetime(2019, 11, 29, 11, 40, 49, 123456, tzinfo=UTC), + ), + # An offset other than Z denotes the same instant + ("2019-11-29T13:40:49+02:00", datetime(2019, 11, 29, 11, 40, 49, tzinfo=UTC)), + ], +) +def test_parse_api_datetime_accepts_and_normalizes(value, expected): + parsed = parse_api_datetime(value) + + assert parsed == expected + assert parsed.utcoffset() == timedelta(0) + + +@pytest.mark.parametrize( + "value", + [ + "2019-11-29T11:40:49", # no timezone, so the instant is ambiguous + "2019-11-29", + "garbage", + "", + None, + ], +) +def test_parse_api_datetime_rejects_unusable_values(value): + # ValueError rather than TypeError even for None, so callers only have to + # handle one kind of failure + with pytest.raises(ValueError): + parse_api_datetime(value) + + +def test_to_utc_datetime_reads_naive_as_utc(): + assert to_utc_datetime(datetime(2020, 1, 1, 12)) == datetime( + 2020, 1, 1, 12, tzinfo=UTC + ) + + +def test_to_utc_datetime_converts_other_offsets(): + berlin = timezone(timedelta(hours=2)) + + assert to_utc_datetime(datetime(2020, 1, 1, 14, tzinfo=berlin)) == datetime( + 2020, 1, 1, 12, tzinfo=UTC + ) + + +@pytest.mark.parametrize( + "value", + [ + "2019-11-29", + "2019-11-29T11:40:49", + "2019-11-29 11:40:49", + "2019-11-29T11:40:49.000Z", + "2019-11-29T11:40:49Z", + ], +) +def test_datetime_type_always_yields_utc(value): + # Every accepted format either ends in a literal Z or carries no timezone + # at all, and both mean UTC for the --start-date/--end-date options + converted = datetime_type.convert(value, None, None) + + assert converted.utcoffset() == timedelta(0) + + +def test_datetime_type_normalizes_an_already_parsed_datetime(): + # click hands a datetime straight back, so the conversion has to catch it + converted = datetime_type.convert(datetime(2020, 1, 1, 12), None, None) + + assert converted == datetime(2020, 1, 1, 12, tzinfo=UTC) diff --git a/uv.lock b/uv.lock index 5db23134..68ad335d 100644 --- a/uv.lock +++ b/uv.lock @@ -81,6 +81,7 @@ cryptography = [ [package.dev-dependencies] dev = [ { name = "pyinstaller" }, + { name = "pytest" }, ] [package.metadata] @@ -101,7 +102,10 @@ requires-dist = [ provides-extras = ["cryptography"] [package.metadata.requires-dev] -dev = [{ name = "pyinstaller" }] +dev = [ + { name = "pyinstaller" }, + { name = "pytest", specifier = ">=8.4.2" }, +] [[package]] name = "beautifulsoup4" @@ -322,6 +326,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "macholib" version = "1.16.4" @@ -422,6 +435,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -458,6 +480,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyinstaller" version = "6.21.0" @@ -498,6 +529,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3"