diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index a7b3677c..45a658a9 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -370,7 +370,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is "service": "EXAMPLE", "title_id": "12345", "quality": [2160], - "vcodec": "H265", + "vcodec": "HEVC", "range": ["HDR10"], "wanted": ["S01E01"] }' @@ -434,7 +434,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is | `output_dir` | string | `null` | Output directory, relative to the server's downloads directory. The server rejects a path that resolves outside it with `400`. | | `no_cache` / `reset_cache` | boolean | `false` | Title cache controls. | -**Validation.** Invalid values return `400 INVALID_PARAMETERS`. `vcodec` must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1. `acodec` must be one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS/DTSX/DTS-X. `range` must be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, and `HDR10+` is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the `*_only` flags. You cannot combine `no_subs` with `subs_only`, or `no_audio` with `audio_only`. +**Validation.** Invalid values return `400 INVALID_PARAMETERS`. `vcodec`, `acodec` and `sub_format` take the same spellings as the CLI options of the same name (`unshackle dl --help` prints each list). The API and the CLI read one list, so they cannot disagree. `range` must be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, and `HDR10+` is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the `*_only` flags. You cannot combine `no_subs` with `subs_only`, or `no_audio` with `audio_only`. **Service options.** A service's own CLI options travel in the same body. Send them in a nested `service_params` object, or as flat top-level keys. `service_params` wins over a flat key of the diff --git a/docs/dev/rest-api/quickstart.md b/docs/dev/rest-api/quickstart.md index e861421e..d802fd9b 100644 --- a/docs/dev/rest-api/quickstart.md +++ b/docs/dev/rest-api/quickstart.md @@ -194,7 +194,7 @@ curl -X POST "$BASE/api/download" \ "service": "EXAMPLE", "title_id": "12345", "quality": [1080], - "vcodec": "H265", + "vcodec": "HEVC", "range": ["SDR"], "wanted": ["S01E01", "S01E02"] }' diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index dd42c76f..9bf3069a 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -69,8 +69,8 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...] |---|---|---| | `-p`, `--profile` | - | Profile for credentials and cookies. | | `-q`, `--quality` | best | Resolution(s), comma-separated, e.g. `1080,720`. `-q 1080` also matches anamorphic tracks by 16:9 canvas. | -| `-v`, `--vcodec` | any | Video codec(s). Accepts names or values: `AVC`/`H.264`, `HEVC`/`H.265`, `VC1`, `VP8`, `VP9`, `AV1`. | -| `-a`, `--acodec` | any | Audio codec(s), comma-separated. Accepts `AAC`, `AC3`/`DD`, `EC3`/`DD+`/`eac3`/`ddp`, `AC4`, `OPUS`, `OGG`/`vorbis`, `DTS`, `DTSX`/`DTS-X`, `ALAC`, `FLAC`. | +| `-v`, `--vcodec` | any | Video codec(s). Accepts names or values: `AVC`/`H.264`/`H264`, `HEVC`/`H.265`/`H265`, `VC1`/`VC-1`, `VP8`, `VP9`, `AV1`. | +| `-a`, `--acodec` | any | Audio codec(s), comma-separated. Accepts `AAC`, `AC3`/`DD`, `EC3`/`DD+`/`eac3`/`ddp`, `AC4`/`AC-4`, `OPUS`, `OGG`/`VORB`/`vorbis`, `DTS`, `DTSX`/`DTS-X`, `ALAC`, `FLAC`. | | `-vb`, `--vbitrate` | highest | Exact video bitrate in kbps. | | `-ab`, `--abitrate` | highest | Exact audio bitrate in kbps. | | `-vb-range`, `--vbitrate-range` | - | Video bitrate range in kbps, e.g. `6000-7000`; picks highest within. Mutually exclusive with `--vbitrate`. | diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 5e5b37ef..439b546f 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -120,6 +120,8 @@ either enum **names** or their **values**, comma-separated. | `VP9` | `VP9` | | `AV1` | `AV1` | +`H264` and `H265` are aliases of `AVC` and `HEVC`. + ```shell title="Either spelling works" unshackle dl -v HEVC EXAMPLE 81234567 unshackle dl -v H.265 EXAMPLE 81234567 diff --git a/pyproject.toml b/pyproject.toml index d144c355..e90fbeb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ dependencies = [ "appdirs>=1.4.4,<2", "Brotli>=1.1.0,<2", - "click>=8.1.8,<9", + "click>=8.2,<9", "construct>=2.8.8,<3", "crccheck>=1.3.0,<2", "filelock>=3.20.3,<4", diff --git a/tests/core/test_click_types.py b/tests/core/test_click_types.py index 8bd063d7..e23e87a5 100644 --- a/tests/core/test_click_types.py +++ b/tests/core/test_click_types.py @@ -7,10 +7,15 @@ import click import pytest from unshackle.core.tracks.subtitle import Subtitle +from unshackle.core.tracks.video import Video from unshackle.core.utils.click_types import ( + AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SLOW_DELAY_RANGE, + SUBTITLE_CODEC, + VIDEO_CODEC_LIST, + MultipleChoice, SeasonRange, SubtitleCodecChoice, ) @@ -186,3 +191,56 @@ def test_comma_separated_dates_convert(): def test_bad_date_tokens_fail(token): with pytest.raises(click.UsageError): parse(token) + + +def test_audio_metavar_lists_every_accepted_spelling(): + """--help must not advertise fewer codecs than convert() takes, aliases included.""" + metavar = AUDIO_CODEC_LIST.get_metavar() + assert metavar.startswith("[") and metavar.endswith("]") + listed = metavar[1:-1].split("|") + assert listed == AUDIO_CODEC_LIST.choices + for spelling in ("ddp", "eac3", "vorbis", "ac-4", "vorb"): + assert spelling in listed + assert AUDIO_CODEC_LIST.convert(spelling) + + +@pytest.mark.parametrize("param_type", [AUDIO_CODEC_LIST, VIDEO_CODEC_LIST, SUBTITLE_CODEC]) +def test_no_duplicate_choices(param_type): + """A codec whose name and value are the same word (VP9, AV1) is listed once.""" + lowered = [choice.lower() for choice in param_type.choices] + assert len(lowered) == len(set(lowered)) + + +def test_api_codec_lists_come_from_the_param_types(): + from unshackle.core.api.handlers import VALID_ACODECS, VALID_SUB_FORMATS, VALID_VCODECS + + assert VALID_ACODECS == [c.upper() for c in AUDIO_CODEC_LIST.choices] + assert VALID_VCODECS == [c.upper() for c in VIDEO_CODEC_LIST.choices] + assert VALID_SUB_FORMATS == [c.upper() for c in SUBTITLE_CODEC.choices] + + +@pytest.mark.parametrize("value", ["avc,,hevc", ["avc", "", "hevc"], " avc , hevc "]) +def test_empty_video_codec_tokens_are_skipped(value): + """A None in the list reaches track filtering and crashes it, so blanks must drop out.""" + assert VIDEO_CODEC_LIST.convert(value) == [Video.Codec.AVC, Video.Codec.HEVC] + + +@pytest.mark.parametrize("spelling", ["H264", "h265", "H.265", "hevc"]) +def test_video_codec_aliases_resolve(spelling): + """The API advertised H264/H265 while dropping them; both paths now resolve the same.""" + assert VIDEO_CODEC_LIST.convert(spelling)[0] in (Video.Codec.AVC, Video.Codec.HEVC) + + +def test_audio_codec_list_shell_complete(): + """--acodec offered no completion at all; it now completes the segment after the last comma.""" + assert [c.value for c in AUDIO_CODEC_LIST.shell_complete(None, None, "fl")] == ["flac"] + assert [c.value for c in AUDIO_CODEC_LIST.shell_complete(None, None, "AAC,e")] == ["AAC,ec3", "AAC,eac3"] + assert AUDIO_CODEC_LIST.shell_complete(None, None, "zzz") == [] + + +def test_multiple_choice_shell_complete(): + """super(self) raised TypeError, so every --range completion crashed instead of completing.""" + range_choice = MultipleChoice(Video.Range, case_sensitive=False) + assert [c.value for c in range_choice.shell_complete(None, None, "hd")] == ["hdr10", "hdr10p"] + assert [c.value for c in range_choice.shell_complete(None, None, "sdr,hd")] == ["sdr,hdr10", "sdr,hdr10p"] + assert range_choice.shell_complete(None, None, "zzz") == [] diff --git a/tests/remote/unit/test_handlers_serialize.py b/tests/remote/unit/test_handlers_serialize.py index 1d967a1a..57f0f2a2 100644 --- a/tests/remote/unit/test_handlers_serialize.py +++ b/tests/remote/unit/test_handlers_serialize.py @@ -314,3 +314,17 @@ async def test_search_unknown_service_raises_invalid_service() -> None: with pytest.raises(APIError) as exc: await search_handler({"service": "NOPE_THIS_IS_NOT_REAL_", "query": "x"}, None) assert exc.value.error_code == APIErrorCode.INVALID_SERVICE + + +def test_resolve_vcodec_rejects_junk_as_invalid_input() -> None: + """Session routes skip validate_download_parameters, so a bad codec must be a 400, not a 500.""" + from unshackle.core.api.errors import APIErrorCode + from unshackle.core.api.handlers import resolve_vcodec + from unshackle.core.tracks import Video + + assert resolve_vcodec(["H265"]) == [Video.Codec.HEVC] + assert resolve_vcodec(None) is None + for junk in ("XVID", 5, [5]): + with pytest.raises(APIError) as exc: + resolve_vcodec(junk) + assert exc.value.error_code == APIErrorCode.INVALID_INPUT diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 8aae1794..03eb12c7 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -93,10 +93,10 @@ from unshackle.core.utils.click_types import ( QUALITY_LIST, SEASON_RANGE, SLOW_DELAY_RANGE, + SUBTITLE_CODEC, + VIDEO_CODEC_LIST, ContextData, MultipleChoice, - MultipleVideoCodecChoice, - SubtitleCodecChoice, ) from unshackle.core.utils.collections import ci_get, merge_dict from unshackle.core.utils.post_scripts import NO_POST_SCRIPTS, build_context, dispatch, season_context @@ -534,7 +534,7 @@ class dl: @click.option( "-v", "--vcodec", - type=MultipleVideoCodecChoice(Video.Codec), + type=VIDEO_CODEC_LIST, default=[], help="Video Codec(s) to download, defaults to any codec.", ) @@ -775,7 +775,7 @@ class dl: ) @click.option( "--sub-format", - type=SubtitleCodecChoice(Subtitle.Codec), + type=SUBTITLE_CODEC, default=None, help="Set Output Subtitle Format, only converting if necessary. Use 'original' to keep source format.", ) diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index d51082cc..5990aa0b 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -18,6 +18,7 @@ from typing import Any, Callable, Dict, List, Optional from unshackle.core.api.events import bus, publish_service_event from unshackle.core.api.sanitize import sanitize_log +from unshackle.core.utils.click_types import VIDEO_CODEC_LIST from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_path, redact_text log = logging.getLogger("download_manager") @@ -387,10 +388,7 @@ def perform_download( vcodec_raw = params.get("vcodec") if vcodec_raw: - if isinstance(vcodec_raw, str): - vcodec_raw = [vcodec_raw] - if isinstance(vcodec_raw, list) and vcodec_raw and not isinstance(vcodec_raw[0], Video.Codec): - params["vcodec"] = to_enum(vcodec_raw, Video.Codec) + params["vcodec"] = VIDEO_CODEC_LIST.convert(vcodec_raw) else: params["vcodec"] = [] diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 20cb5542..313e931c 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -15,6 +15,7 @@ from http.cookiejar import CookieJar, MozillaCookieJar from pathlib import Path from typing import Any, Dict, Iterable, List, Optional +import click from aiohttp import web from unshackle.core.api.compression import safe_inflate @@ -33,6 +34,7 @@ from unshackle.core.services import Services from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.utilities import declared_kwargs +from unshackle.core.utils.click_types import AUDIO_CODEC_LIST, SUBTITLE_CODEC, VIDEO_CODEC_LIST from unshackle.core.utils.collections import ci_get from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_all, redact_secrets, redact_text @@ -1513,24 +1515,23 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques ) -VALID_VCODECS = ["H264", "H265", "H.264", "H.265", "AVC", "HEVC", "VC1", "VC-1", "VP8", "VP9", "AV1"] -VALID_ACODECS = [ - "AAC", - "AC3", - "EC3", - "EAC3", - "DD", - "DD+", - "AC4", - "OPUS", - "FLAC", - "ALAC", - "VORBIS", - "OGG", - "DTS", - "DTSX", - "DTS-X", -] +VALID_VCODECS = [choice.upper() for choice in VIDEO_CODEC_LIST.choices] +VALID_ACODECS = [choice.upper() for choice in AUDIO_CODEC_LIST.choices] +VALID_SUB_FORMATS = [choice.upper() for choice in SUBTITLE_CODEC.choices] + + +def resolve_vcodec(value: Any) -> Optional[list]: + """Map a client's vcodec field to codec enums. + + Session routes do not run validate_download_parameters, so this must answer junk with a 400 + instead of letting click's UsageError surface as a 500. + """ + if not value: + return None + try: + return VIDEO_CODEC_LIST.convert(value) or None + except click.UsageError as e: + raise APIError(APIErrorCode.INVALID_INPUT, f"Invalid vcodec: {e.format_message()}") def check_codec(value: Any, allowed: List[str], name: str) -> Optional[str]: @@ -1590,9 +1591,8 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: return err if "sub_format" in data and data["sub_format"]: - valid_sub_formats = ["SRT", "VTT", "ASS", "SSA", "TTML", "STPP", "WVTT", "SMI", "SUB", "MPL2", "TMP"] - if data["sub_format"].upper() not in valid_sub_formats: - return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(valid_sub_formats)}" + if str(data["sub_format"]).upper() not in VALID_SUB_FORMATS: + return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(VALID_SUB_FORMATS)}" if "vbitrate" in data and data["vbitrate"] is not None: if not isinstance(data["vbitrate"], int) or data["vbitrate"] <= 0: @@ -2884,16 +2884,7 @@ def create_service_instance( pass range_values = range_values or None - vcodec_names = data.get("vcodec") - vcodec_values: Optional[list] = None - if vcodec_names: - vcodec_values = [] - for name in vcodec_names: - try: - vcodec_values.append(Video.Codec[name]) - except KeyError: - pass - vcodec_values = vcodec_values or None + vcodec_values = resolve_vcodec(data.get("vcodec")) extra_params = { "range_": range_values, diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index bbe49ad2..99b2576f 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -675,14 +675,14 @@ async def download(request: web.Request) -> web.Response: - type: array items: type: string - description: Video codec(s) to download (e.g., "H265" or ["H264", "H265"]) - accepts H264, H265, AVC, HEVC, VP8, VP9, AV1, VC1 (default - None) + description: Video codec(s) to download (e.g., "HEVC" or ["AVC", "HEVC"]) - accepts AVC, H.264, H264, HEVC, H.265, H265, VC1, VC-1, VP8, VP9, AV1 (default - None) acodec: oneOf: - type: string - type: array items: type: string - description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, EC3, AC4, OPUS, FLAC, ALAC, DTS, DTSX, DTS-X, OGG (default - None) + description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, DD, EC3, DD+, EAC3, DDP, AC4, AC-4, OPUS, OGG, VORB, VORBIS, DTS, DTSX, DTS-X, ALAC, FLAC (default - None) vbitrate: type: integer description: Video bitrate in kbps (default - None) @@ -756,7 +756,7 @@ async def download(request: web.Request) -> web.Response: description: Use exact language matching (no variants) (default - false) sub_format: type: string - description: Output subtitle format such as SRT or VTT (default - None) + description: Output subtitle format such as SRT or VTT, or "original" to keep the source format (default - None) video_only: type: boolean description: Only download video tracks (default - false) diff --git a/unshackle/core/utils/click_types.py b/unshackle/core/utils/click_types.py index b8793765..666a7593 100644 --- a/unshackle/core/utils/click_types.py +++ b/unshackle/core/utils/click_types.py @@ -7,6 +7,8 @@ from click.shell_completion import CompletionItem from pywidevine.cdm import Cdm as WidevineCdm from unshackle.core.tracks.audio import Audio +from unshackle.core.tracks.subtitle import Subtitle +from unshackle.core.tracks.video import Video class VideoCodecChoice(click.Choice): @@ -18,27 +20,31 @@ class VideoCodecChoice(click.Choice): - Enum values: H.264, H.265, VC-1, VP8, VP9, AV1 """ - def __init__(self, codec_enum): + def __init__(self, codec_enum: type[Video.Codec]) -> None: self.codec_enum = codec_enum - choices = [] + self._name_to_codec: dict[str, Video.Codec] = {} for codec in codec_enum: - choices.append(codec.name.lower()) # e.g., "avc", "hevc" - choices.append(codec.value) # e.g., "H.264", "H.265" - super().__init__(choices, case_sensitive=False) + for choice in (codec.name.lower(), codec.value.lower()): + self._name_to_codec[choice] = codec - def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None): + aliases = {"h264": "AVC", "h265": "HEVC"} + for alias, target in aliases.items(): + if target in codec_enum.__members__: + self._name_to_codec[alias] = codec_enum[target] + + super().__init__(list(self._name_to_codec), case_sensitive=False) + + def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None) -> Any: if not value: return None + if isinstance(value, self.codec_enum): + return value converted_value = super().convert(value, param, ctx) - - for codec in self.codec_enum: - if converted_value.lower() == codec.name.lower(): - return codec - if converted_value == codec.value: - return codec - - self.fail(f"'{value}' is not a valid video codec", param, ctx) + codec = self._name_to_codec.get(str(converted_value).lower()) + if codec is None: + self.fail(f"'{value}' is not a valid video codec", param, ctx) + return codec class MultipleVideoCodecChoice(VideoCodecChoice): @@ -64,7 +70,10 @@ class MultipleVideoCodecChoice(VideoCodecChoice): chosen_values: list[Any] = [] for v in values: - chosen_values.append(super().convert(v.strip(), param, ctx)) + token = v.strip() if isinstance(v, str) else str(v).strip() + if not token: + continue + chosen_values.append(super().convert(token, param, ctx)) return chosen_values @@ -351,12 +360,12 @@ class AudioCodecList(click.ParamType): name = "audio_codec_list" - def __init__(self, codec_enum): + def __init__(self, codec_enum: type[Audio.Codec]) -> None: self.codec_enum = codec_enum self._name_to_codec: dict[str, Audio.Codec] = {} for codec in codec_enum: - self._name_to_codec[codec.name.lower()] = codec - self._name_to_codec[codec.value.lower()] = codec + for choice in (codec.name.lower(), codec.value.lower()): + self._name_to_codec[choice] = codec aliases = { "eac3": "EC3", @@ -367,6 +376,14 @@ class AudioCodecList(click.ParamType): if target in codec_enum.__members__: self._name_to_codec[alias] = codec_enum[target] + @property + def choices(self) -> list[str]: + """Every spelling convert() accepts, in declaration order. Mirrors click.Choice.choices.""" + return list(self._name_to_codec) + + def get_metavar(self, *args: Any, **kwargs: Any) -> str: + return f"[{'|'.join(self.choices)}]" + def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None) -> list: if not value: return [] @@ -387,10 +404,21 @@ class AudioCodecList(click.ParamType): if key in self._name_to_codec: codecs.append(self._name_to_codec[key]) else: - valid = sorted(set(self._name_to_codec.keys())) - self.fail(f"'{val}' is not valid. Choices: {', '.join(valid)}", param, ctx) + self.fail(f"'{val}' is not valid. Choices: {', '.join(sorted(self.choices))}", param, ctx) return list(dict.fromkeys(codecs)) # Remove duplicates, preserve order + def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]: + """ + Complete the codec after the last comma. + + Parameters: + ctx: Invocation context for this command. + param: The parameter that requests completion. + incomplete: The value to complete. Can be empty. + """ + prefix, sep, last = incomplete.rpartition(",") + return [CompletionItem(f"{prefix}{sep}{choice}") for choice in self.choices if choice.startswith(last.lower())] + class MultipleChoice(click.Choice): """ @@ -425,15 +453,21 @@ class MultipleChoice(click.Choice): def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]: """ - Complete choices that start with the incomplete value. + Complete the choice after the last comma. Parameters: ctx: Invocation context for this command. param: The parameter that requests completion. incomplete: The value to complete. Can be empty. """ - incomplete = incomplete.rsplit(",")[-1] - return super(self).shell_complete(ctx, param, incomplete) + prefix, sep, last = incomplete.rpartition(",") + if not self.case_sensitive: + last = last.casefold() + return [ + CompletionItem(f"{prefix}{sep}{choice}") + for choice in (self.normalize_choice(c, ctx) for c in self.choices) + if choice.startswith(last) + ] class SlowDelayRange(click.ParamType): @@ -466,4 +500,6 @@ SEASON_RANGE = SeasonRange() LANGUAGE_RANGE = LanguageRange() QUALITY_LIST = QualityList() AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec) +VIDEO_CODEC_LIST = MultipleVideoCodecChoice(Video.Codec) +SUBTITLE_CODEC = SubtitleCodecChoice(Subtitle.Codec) SLOW_DELAY_RANGE = SlowDelayRange() diff --git a/uv.lock b/uv.lock index 619998b1..c0cc70f7 100644 --- a/uv.lock +++ b/uv.lock @@ -2211,7 +2211,7 @@ requires-dist = [ { name = "bandit", specifier = ">=1.9.4" }, { name = "brotli", specifier = ">=1.1.0,<2" }, { name = "chardet", specifier = ">=5.2.0,<6" }, - { name = "click", specifier = ">=8.1.8,<9" }, + { name = "click", specifier = ">=8.2,<9" }, { name = "construct", specifier = ">=2.8.8,<3" }, { name = "crccheck", specifier = ">=1.3.0,<2" }, { name = "cryptography", specifier = ">=45.0.0,<47" },