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/
This commit is contained in:
imSp4rky
2026-05-22 13:52:35 -06:00
parent b0ae88812c
commit 7654e91ebc
11 changed files with 365 additions and 42 deletions

View File

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 = []

View File

@@ -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.

111
uv.lock generated
View File

@@ -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"