From 7654e91ebc95e8cdb12da4aec67c7afbc3756c19 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Fri, 22 May 2026 13:52:35 -0600 Subject: [PATCH] feat(dl): gate s_lang/a_lang miss behind --best-available Missing requested subtitle and audio languages now warn and continue when --best-available is set instead of hard-exiting. Without the flag, missing languages still produce an error and exit, matching the prior strict behavior. Audio missing-lang detection is now symmetric with subtitles. - add find_missing_langs helper in core/utilities for reuse between s_lang and a_lang paths (skips all/best/orig sentinels) - refactor dl.py s_lang/a_lang checks to share the helper - add tests/lang_selection covering match primitives, helper output, and tricky langcodes corners (zh-Hans/zh-Hant/zh-CN/zh-TW/zh-HK, cmn/yue, fil/tl/tgl) - clean up unused-var ruff F841 in tests/remote/unit/ --- tests/lang_selection/__init__.py | 0 .../lang_selection/test_find_missing_langs.py | 103 ++++++++++++++++ tests/lang_selection/test_match_primitives.py | 78 ++++++++++++ tests/remote/unit/test_compression.py | 1 - tests/remote/unit/test_errors.py | 9 +- tests/remote/unit/test_handlers_serialize.py | 13 +- .../unit/test_remote_service_helpers.py | 12 +- tests/remote/unit/test_session_store.py | 2 +- unshackle/commands/dl.py | 66 +++++++++-- unshackle/core/utilities.py | 12 ++ uv.lock | 111 ++++++++++++++++++ 11 files changed, 365 insertions(+), 42 deletions(-) create mode 100644 tests/lang_selection/__init__.py create mode 100644 tests/lang_selection/test_find_missing_langs.py create mode 100644 tests/lang_selection/test_match_primitives.py diff --git a/tests/lang_selection/__init__.py b/tests/lang_selection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lang_selection/test_find_missing_langs.py b/tests/lang_selection/test_find_missing_langs.py new file mode 100644 index 0000000..e31807a --- /dev/null +++ b/tests/lang_selection/test_find_missing_langs.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import pytest + +from unshackle.core.utilities import find_missing_langs + + +@pytest.mark.parametrize( + "requested,available,expected", + [ + (["en"], ["en"], []), + (["en", "ja"], ["en", "ja", "fr"], []), + (["en", "fil"], ["en"], ["fil"]), + (["en", "ko", "ja"], ["en"], ["ko", "ja"]), + (["fil"], ["en", "ja"], ["fil"]), + (["fil", "ko"], ["en"], ["fil", "ko"]), + (["es"], ["es-419"], []), + (["es-419"], ["es"], []), + (["en"], ["en-US"], []), + (["all"], [], []), + (["best"], [], []), + (["orig"], [], []), + (["all", "en"], ["en"], []), + (["best", "fil"], ["en"], ["fil"]), + ([], ["en"], []), + (["en"], [], ["en"]), + (["en"], [None, "en"], []), + (["en"], [None], ["en"]), + (["zh"], ["zh-Hans"], []), + (["zh-CN"], ["zh-Hans"], []), + (["zh-Hans"], ["zh-CN"], []), + (["zh-TW"], ["zh-Hant"], []), + (["zh-Hant"], ["zh-TW"], []), + (["zh"], ["zh-Hant"], ["zh"]), + (["zh-Hans"], ["zh-Hant"], ["zh-Hans"]), + (["zh-CN"], ["zh-TW"], ["zh-CN"]), + (["zh-HK"], ["zh-Hant"], []), + (["zh"], ["cmn"], []), + (["cmn"], ["zh"], []), + (["zh"], ["yue"], ["zh"]), + (["yue"], ["zh-HK"], ["yue"]), + (["fil"], ["tl"], []), + (["tl"], ["fil"], []), + (["fil"], ["tgl"], []), + (["tgl"], ["fil"], []), + (["fil"], ["fil-PH"], []), + (["tl"], ["fil-PH"], []), + ], +) +def test_close_match(requested, available, expected): + assert find_missing_langs(requested, available, exact=False) == expected + + +@pytest.mark.parametrize( + "requested,available,expected", + [ + (["es"], ["es-419"], ["es"]), + (["es-419"], ["es"], ["es-419"]), + (["en"], ["en-US"], []), + (["en-US"], ["en"], []), + (["en"], ["en-GB"], ["en"]), + (["en-US"], ["en-GB"], ["en-US"]), + (["en-US"], ["en-US"], []), + (["en-US", "en-GB"], ["en-US"], ["en-GB"]), + (["en"], ["en"], []), + (["all", "es-419"], ["es"], ["es-419"]), + (["zh"], ["zh-Hans"], []), + (["zh-CN"], ["zh-Hans"], []), + (["zh-TW"], ["zh-Hant"], []), + (["zh"], ["cmn"], []), + (["zh-HK"], ["zh-Hant"], ["zh-HK"]), + (["zh-Hans"], ["zh-Hant"], ["zh-Hans"]), + (["zh-CN"], ["zh-TW"], ["zh-CN"]), + (["fil"], ["tl"], []), + (["tl"], ["fil"], []), + (["fil"], ["tgl"], []), + (["fil"], ["fil-PH"], []), + (["tl"], ["fil-PH"], []), + ], +) +def test_exact_match(requested, available, expected): + assert find_missing_langs(requested, available, exact=True) == expected + + +def test_order_preserved(): + assert find_missing_langs(["ja", "ko", "fr"], ["en"], exact=False) == ["ja", "ko", "fr"] + + +def test_duplicates_in_request(): + assert find_missing_langs(["fil", "fil", "en"], ["en"], exact=False) == ["fil", "fil"] + + +def test_zh_catalogue_simplified_only_misses_traditional(): + assert find_missing_langs(["zh-Hant", "zh-TW"], ["en", "zh-Hans"], exact=False) == ["zh-Hant", "zh-TW"] + + +def test_mixed_zh_fil_request(): + assert find_missing_langs(["en", "zh-Hans", "fil"], ["en", "tl"], exact=False) == ["zh-Hans"] + + +def test_zh_cn_request_with_tw_catalogue(): + assert find_missing_langs(["zh-CN"], ["zh-TW"], exact=False) == ["zh-CN"] + assert find_missing_langs(["zh-CN"], ["zh-TW"], exact=True) == ["zh-CN"] diff --git a/tests/lang_selection/test_match_primitives.py b/tests/lang_selection/test_match_primitives.py new file mode 100644 index 0000000..cdd1a39 --- /dev/null +++ b/tests/lang_selection/test_match_primitives.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import pytest + +from unshackle.core.utilities import is_close_match, is_exact_match + + +@pytest.mark.parametrize( + "needle,haystack,expected", + [ + ("en", ["en"], True), + ("fr", ["en", "de"], False), + ("es", ["es-419"], True), + ("es", ["es-ES"], True), + ("es-419", ["es"], True), + ("en", ["en-US"], True), + ("en-US", ["en-GB"], True), + ("EN", ["en"], True), + ("en", ["EN"], True), + ("ja", ["ko"], False), + ("fil", ["en", "fr", "de"], False), + ("en", [], False), + ("en", [None, "en"], True), + ("en", [None], False), + ("zh", ["zh-Hans"], True), + ("zh-CN", ["zh-Hans"], True), + ("zh-Hans", ["zh-CN"], True), + ("zh-TW", ["zh-Hant"], True), + ("zh-Hant", ["zh-TW"], True), + ("zh", ["zh-Hant"], False), + ("zh-Hans", ["zh-Hant"], False), + ("zh-CN", ["zh-TW"], False), + ("zh-HK", ["zh-Hant"], True), + ("zh", ["cmn"], True), + ("cmn", ["zh"], True), + ("zh", ["yue"], False), + ("yue", ["zh-HK"], False), + ("fil", ["tl"], True), + ("tl", ["fil"], True), + ("fil", ["tgl"], True), + ("tgl", ["fil"], True), + ("fil", ["fil-PH"], True), + ("tl", ["fil-PH"], True), + ], +) +def test_is_close_match(needle, haystack, expected): + assert is_close_match(needle, haystack) is expected + + +@pytest.mark.parametrize( + "needle,haystack,expected", + [ + ("es", ["es-419"], False), + ("es-419", ["es"], False), + ("es-419", ["es-419"], True), + ("en-US", ["en-GB"], False), + ("en-US", ["en-US"], True), + ("en", ["en"], True), + ("EN", ["en"], True), + ("fr", ["de"], False), + ("fil", ["en"], False), + ("en", [], False), + ("zh", ["zh-Hans"], True), + ("zh-CN", ["zh-Hans"], True), + ("zh-TW", ["zh-Hant"], True), + ("zh", ["cmn"], True), + ("zh-HK", ["zh-Hant"], False), + ("zh-Hans", ["zh-Hant"], False), + ("zh-CN", ["zh-TW"], False), + ("fil", ["tl"], True), + ("tl", ["fil"], True), + ("fil", ["tgl"], True), + ("fil", ["fil-PH"], True), + ("tl", ["fil-PH"], True), + ], +) +def test_is_exact_match(needle, haystack, expected): + assert is_exact_match(needle, haystack) is expected diff --git a/tests/remote/unit/test_compression.py b/tests/remote/unit/test_compression.py index 54e73b8..59ffa20 100644 --- a/tests/remote/unit/test_compression.py +++ b/tests/remote/unit/test_compression.py @@ -24,7 +24,6 @@ def _run(coro): def test_skips_when_client_does_not_accept_gzip() -> None: - payload = b"x" * 4096 body_json = json.dumps({"data": "x" * 4096}).encode() async def handler(req): # noqa: ARG001 diff --git a/tests/remote/unit/test_errors.py b/tests/remote/unit/test_errors.py index 0a34354..147751c 100644 --- a/tests/remote/unit/test_errors.py +++ b/tests/remote/unit/test_errors.py @@ -6,13 +6,8 @@ import json import pytest -from unshackle.core.api.errors import ( - APIError, - APIErrorCode, - build_error_response, - categorize_exception, - handle_api_exception, -) +from unshackle.core.api.errors import (APIError, APIErrorCode, build_error_response, categorize_exception, + handle_api_exception) pytestmark = pytest.mark.unit diff --git a/tests/remote/unit/test_handlers_serialize.py b/tests/remote/unit/test_handlers_serialize.py index 038bca9..73efc99 100644 --- a/tests/remote/unit/test_handlers_serialize.py +++ b/tests/remote/unit/test_handlers_serialize.py @@ -5,16 +5,9 @@ from __future__ import annotations import pytest from langcodes import Language -from unshackle.core.api.handlers import ( - sanitize_log, - serialize_audio_track, - serialize_drm, - serialize_subtitle_track, - serialize_title, - serialize_video_track, - validate_download_parameters, - validate_service, -) +from unshackle.core.api.handlers import (sanitize_log, serialize_audio_track, serialize_drm, serialize_subtitle_track, + serialize_title, serialize_video_track, validate_download_parameters, + validate_service) from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie from unshackle.core.tracks import Audio, Subtitle, Video diff --git a/tests/remote/unit/test_remote_service_helpers.py b/tests/remote/unit/test_remote_service_helpers.py index 81665c3..032759f 100644 --- a/tests/remote/unit/test_remote_service_helpers.py +++ b/tests/remote/unit/test_remote_service_helpers.py @@ -6,16 +6,8 @@ from enum import Enum import pytest -from unshackle.core.remote_service import ( - _build_title, - _build_tracks, - _deserialize_audio, - _deserialize_subtitle, - _deserialize_video, - _enum_get, - _match_track, - _reconstruct_drm, -) +from unshackle.core.remote_service import (_build_title, _build_tracks, _deserialize_audio, _deserialize_subtitle, + _deserialize_video, _enum_get, _match_track, _reconstruct_drm) from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie from unshackle.core.tracks import Audio, Subtitle, Video diff --git a/tests/remote/unit/test_session_store.py b/tests/remote/unit/test_session_store.py index 077a4de..ec7f6fa 100644 --- a/tests/remote/unit/test_session_store.py +++ b/tests/remote/unit/test_session_store.py @@ -105,7 +105,7 @@ async def test_get_session_store_returns_singleton() -> None: async def test_max_sessions_evicts_oldest(store: SessionStore, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(type(store), "_max_sessions", property(lambda _: 2)) - a = await store.create("A", _FakeService(), session_id="a") + await store.create("A", _FakeService(), session_id="a") await asyncio.sleep(0.01) b = await store.create("B", _FakeService(), session_id="b") await asyncio.sleep(0.01) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 8f54ec8..965c5bc 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -56,8 +56,9 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.dv_fixup import apply_dv_fixup from unshackle.core.tracks.hybrid import Hybrid -from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger, - is_close_match, suggest_font_packages, time_elapsed_since) +from unshackle.core.utilities import (find_font_with_fallbacks, find_missing_langs, get_debug_logger, + get_system_fonts, init_debug_logger, is_close_match, suggest_font_packages, + time_elapsed_since) from unshackle.core.utils import tags from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, SLOW_DELAY_RANGE, ContextData, MultipleChoice, MultipleVideoCodecChoice, @@ -1872,19 +1873,34 @@ class dl: match_func = is_exact_match if exact_lang else is_close_match - missing_langs = [ - lang_ - for lang_ in s_lang - if not any(match_func(lang_, [sub.language]) for sub in title.tracks.subtitles) - ] + missing_langs = find_missing_langs( + s_lang, + [sub.language for sub in title.tracks.subtitles], + exact=exact_lang, + ) if missing_langs: - self.log.error(", ".join(missing_langs) + " not found in tracks") - sys.exit(1) + missing_str = ", ".join(missing_langs) + if best_available: + remaining = [tok for tok in s_lang if tok not in missing_langs] + if remaining: + self.log.warning( + f"{missing_str} not found in subtitle tracks, continuing with: {', '.join(remaining)}" + ) + s_lang = remaining + else: + self.log.warning( + f"{missing_str} not found in subtitle tracks, continuing without subtitles" + ) + title.tracks.subtitles = [] + else: + self.log.error(missing_str + " not found in tracks") + sys.exit(1) - title.tracks.select_subtitles(lambda x: match_func(x.language, s_lang)) - if not title.tracks.subtitles: - self.log.error(f"There's no {s_lang} Subtitle Track...") - sys.exit(1) + if s_lang and title.tracks.subtitles: + title.tracks.select_subtitles(lambda x: match_func(x.language, s_lang)) + if not title.tracks.subtitles and not best_available: + self.log.error(f"There's no {s_lang} Subtitle Track...") + sys.exit(1) if not forced_subs: title.tracks.select_subtitles(lambda x: not x.forced) @@ -1941,6 +1957,30 @@ class dl: if language not in processed_lang: processed_lang.append(language) + if not any(tok in processed_lang for tok in ("best", "all")): + missing_a_langs = find_missing_langs( + processed_lang, + [a.language for a in title.tracks.audio], + exact=exact_lang, + ) + if missing_a_langs: + missing_str = ", ".join(missing_a_langs) + if best_available: + remaining = [tok for tok in processed_lang if tok not in missing_a_langs] + if remaining: + self.log.warning( + f"{missing_str} not found in audio tracks, continuing with: {', '.join(remaining)}" + ) + processed_lang = remaining + else: + self.log.error( + f"{missing_str} not found in audio tracks and no fallback available" + ) + sys.exit(1) + else: + self.log.error(missing_str + " not found in audio tracks") + sys.exit(1) + if "best" in processed_lang or "all" in processed_lang: unique_languages = {track.language for track in title.tracks.audio} selected_audio = [] diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 2936d20..62de660 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -160,6 +160,18 @@ def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_EXACT_DISTANCE +def find_missing_langs( + requested: Sequence[str], + available: Sequence[Union[str, Language, None]], + *, + exact: bool = False, +) -> list[str]: + """Return requested language tokens with no match in available languages.""" + match_func = is_exact_match if exact else is_close_match + skip = {"all", "best", "orig"} + return [tok for tok in requested if tok not in skip and not match_func(tok, available)] + + def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box: # type: ignore """ Scan a byte array for a wanted MP4/ISOBMFF box, then parse and yield each find. diff --git a/uv.lock b/uv.lock index 26fc9b2..73dcde0 100644 --- a/uv.lock +++ b/uv.lock @@ -470,6 +470,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/35/4a113189f7138035a21bd255d30dc7bffc77c942c93b7948d2eac2e22429/ECPy-1.2.5-py3-none-any.whl", hash = "sha256:559c92e42406d9d1a6b2b8fc26e6ad7bc985f33903b72f426a56cb1073a25ce3", size = 43075, upload-time = "2020-10-26T11:56:13.613Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "fastjsonschema" version = "2.19.1" @@ -608,6 +620,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[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 = "iso8601" version = "2.1.0" @@ -965,6 +986,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -983,6 +1013,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] +[[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 = "pre-commit" version = "4.5.1" @@ -1250,6 +1289,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/bb/0dc8b9a38609701ca3f107dc516300b317641bed9b2ef1c964a9ad37ae9e/pysubs2-1.8.1-py3-none-any.whl", hash = "sha256:eb5d8872b7f87208070dd6f0d85fc110b7ad6cc2a7ec422f5b12363e9194e4e4", size = 44269, upload-time = "2026-03-19T17:24:59.953Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-aiohttp" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, +] + [[package]] name = "python-discovery" version = "1.2.0" @@ -1342,6 +1425,20 @@ socks = [ { name = "pysocks" }, ] +[[package]] +name = "responses" +version = "0.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/58/1fb6de3503428196df78638f991ec8095274f1ee9723e272ee4d9ff0092b/responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2", size = 83088, upload-time = "2026-05-21T19:56:39.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/31/6a620b4427d546b9e7cca8b3b8c5f0559d9cef2bb9eedcda7f73c1473c19/responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345", size = 35502, upload-time = "2026-05-21T19:56:38.046Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -1709,6 +1806,13 @@ dev = [ { name = "types-requests" }, { name = "virtualenv" }, ] +test = [ + { name = "pytest" }, + { name = "pytest-aiohttp" }, + { name = "pytest-asyncio" }, + { name = "pyyaml" }, + { name = "responses" }, +] [package.metadata] requires-dist = [ @@ -1767,6 +1871,13 @@ dev = [ { name = "types-requests", specifier = ">=2.31.0.20240406,<3" }, { name = "virtualenv", specifier = ">=20.36.1,<22" }, ] +test = [ + { name = "pytest", specifier = ">=8.3.0,<9" }, + { name = "pytest-aiohttp", specifier = ">=1.0.5,<2" }, + { name = "pytest-asyncio", specifier = ">=0.24.0,<1" }, + { name = "pyyaml", specifier = ">=6.0.1,<7" }, + { name = "responses", specifier = ">=0.25.0,<1" }, +] [[package]] name = "urllib3"