From 58f89a422322f80b79325659a1673085931b21da Mon Sep 17 00:00:00 2001 From: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:04:00 -0500 Subject: [PATCH 1/8] refactor: update cli --- cyberdrop_dl/cli/__init__.py | 368 +++++++--------------------------- cyberdrop_dl/cli/arguments.py | 117 +++++++++++ cyberdrop_dl/cli/model.py | 134 +++++++++++++ pyproject.toml | 1 + uv.lock | 48 +++++ 5 files changed, 374 insertions(+), 294 deletions(-) create mode 100644 cyberdrop_dl/cli/arguments.py create mode 100644 cyberdrop_dl/cli/model.py diff --git a/cyberdrop_dl/cli/__init__.py b/cyberdrop_dl/cli/__init__.py index 1eeb6f9e3..00dc86789 100644 --- a/cyberdrop_dl/cli/__init__.py +++ b/cyberdrop_dl/cli/__init__.py @@ -1,200 +1,51 @@ -import dataclasses -import datetime +from __future__ import annotations + import sys -import time -import warnings -from argparse import SUPPRESS, ArgumentParser, BooleanOptionalAction, RawDescriptionHelpFormatter +from argparse import SUPPRESS, ArgumentParser, RawDescriptionHelpFormatter from argparse import _ArgumentGroup as ArgGroup -from collections.abc import Iterable, Sequence -from enum import StrEnum, auto -from pathlib import Path from shutil import get_terminal_size -from typing import Annotated, Any, Literal, NoReturn, Self +from typing import TYPE_CHECKING, Any, Final, NoReturn -from pydantic import BaseModel, Field, ValidationError, computed_field, field_validator, model_validator +from pydantic import BaseModel, ValidationError from cyberdrop_dl import __version__, env +from cyberdrop_dl.cli import arguments +from cyberdrop_dl.cli.model import CommandLineOnlyArgs from cyberdrop_dl.config import ConfigSettings, GlobalSettings from cyberdrop_dl.models import AliasModel -from cyberdrop_dl.models.types import HttpURL -from cyberdrop_dl.utils.yaml import handle_validation_error - - -class UIOptions(StrEnum): - DISABLED = auto() - ACTIVITY = auto() - SIMPLE = auto() - FULLSCREEN = auto() - -warnings.simplefilter("always", DeprecationWarning) -WARNING_TIMEOUT = 5 # seconds - - -def _check_mutually_exclusive(group: Iterable[Any], msg: str) -> None: - if sum(1 for value in group if value) >= 2: - raise ValueError(msg) +if TYPE_CHECKING: + from collections.abc import Sequence def is_terminal_in_portrait() -> bool: """Check if CDL is being run in portrait mode based on a few conditions.""" - # Return True if running in portrait mode, False otherwise (landscape mode) - - def check_terminal_size() -> bool: - terminal_size = get_terminal_size() - width, height = terminal_size.columns, terminal_size.lines - aspect_ratio = width / height - - # High aspect ratios are likely to be in landscape mode - if aspect_ratio >= 3.2: - return False - - # Check for mobile device in portrait mode - if (aspect_ratio < 1.5 and height >= 40) or (width <= 85 and aspect_ratio < 2.3): - return True - - # Assume landscape mode for other cases - return False if env.PORTRAIT_MODE: return True - return check_terminal_size() - - -_NOT_SET: Any = object() - - -@dataclasses.dataclass(slots=True, frozen=True, kw_only=True) -class CommandOptions: - nargs: int | str | None = _NOT_SET - const: Any = _NOT_SET - - def as_dict(self) -> dict[str, Any]: - return {k: v for k, v in dataclasses.asdict(self).items() if v is not _NOT_SET} - - -class CommandLineOnlyArgs(BaseModel): - links: list[HttpURL] = Field( - default=[], - description="link(s) to content to download (passing multiple links is supported)", - ) - appdata_folder: Path | None = Field( - default=None, - description="AppData folder path", - ) - completed_after: datetime.date | None = Field( - default=None, - description="only retry downloads that were completed on or after this date", - ) - completed_before: datetime.date | None = Field( - default=None, - description="only retry downloads that were completed on or before this date", - ) - - config_file: Path | None = Field( - default=None, - description="path to the CDL settings.yaml file to load", - ) - - download: bool = Field( - default=False, - description="skips UI, start download immediately", - ) - download_tiktok_audios: bool = Field( - default=False, - description="download TikTok audios from posts and save them as separate files", - ) - download_tiktok_src_quality_videos: bool = Field( - default=False, - description="download TikTok videos in source quality", - ) - impersonate: Annotated[ - Literal[ - "chrome", - "edge", - "safari", - "safari_ios", - "chrome_android", - "firefox", - ] - | bool - | None, - CommandOptions(nargs="?", const=True), - ] = Field( - default=None, - description="Use this target as impersonation for all scrape requests", - ) - max_items_retry: int = Field( - default=0, - description="max number of links to retry", - ) - portrait: bool = Field( - default=is_terminal_in_portrait(), - description="force CDL to run with a vertical layout", - ) - print_stats: bool = Field( - default=True, - description="show stats report at the end of a run", - ) - retry_all: bool = Field( - default=False, - description="retry all downloads", - ) - retry_failed: bool = Field( - default=False, - description="retry failed downloads", - ) - retry_maintenance: bool = Field( - default=False, - description="retry download of maintenance files (bunkr). Requires files to be hashed", - ) - show_supported_sites: bool = Field( - default=False, - description="shows a list of supported sites and exits", - ) - ui: UIOptions = Field( - default=UIOptions.FULLSCREEN, - description="DISABLED, ACTIVITY, SIMPLE or FULLSCREEN", - ) - - @property - def retry_any(self) -> bool: - return any((self.retry_all, self.retry_failed, self.retry_maintenance)) - - @property - def fullscreen_ui(self) -> bool: - return self.ui == UIOptions.FULLSCREEN - - @computed_field - def __computed__(self) -> dict[str, bool]: - return {"retry_any": self.retry_any, "fullscreen_ui": self.fullscreen_ui} - - @model_validator(mode="after") - def mutually_exclusive(self) -> Self: - group1 = [self.links, self.retry_all, self.retry_failed, self.retry_maintenance] - msg1 = "`--links`, '--retry-all', '--retry-maintenace' and '--retry-failed' are mutually exclusive" - _check_mutually_exclusive(group1, msg1) - return self + terminal_size = get_terminal_size() + width, height = terminal_size.columns, terminal_size.lines + aspect_ratio = width / height - @field_validator("ui", mode="before") - @classmethod - def lower(cls, value: str) -> str: - return value.lower() + # High aspect ratios are likely to be in landscape mode + if aspect_ratio >= 3.2: + return False + # Check for mobile device in portrait mode + if (aspect_ratio < 1.5 and height >= 40) or (width <= 85 and aspect_ratio < 2.3): + return True -class DeprecatedArgs(BaseModel): ... + # Assume landscape mode for other cases + return False class ParsedArgs(AliasModel): cli_only_args: CommandLineOnlyArgs = CommandLineOnlyArgs() config_settings: ConfigSettings = ConfigSettings() - deprecated_args: DeprecatedArgs = DeprecatedArgs() global_settings: GlobalSettings = GlobalSettings() def model_post_init(self, *_) -> None: - exit_on_warning = False - if self.cli_only_args.retry_all or self.cli_only_args.retry_maintenance: self.config_settings.runtime_options.ignore_history = True @@ -206,99 +57,10 @@ def model_post_init(self, *_) -> None: ): self.cli_only_args.download = True - if warnings_to_emit := self.prepare_warnings(): - for msg in warnings_to_emit: - warnings.warn(msg, DeprecationWarning, stacklevel=10) - if exit_on_warning: - sys.exit(1) - - time.sleep(WARNING_TIMEOUT) - - def prepare_warnings(self) -> set[str]: - warnings_to_emit: set[str] = set() - - def add_warning_msg_from(field_name: str) -> None: - if not field_name: - return - info = DeprecatedArgs.model_fields[field_name].deprecated - warnings_to_emit.add(str(info)) - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - pass - - return warnings_to_emit - - -def _add_args_from_model( - parser: ArgumentParser | ArgGroup, - model: type[BaseModel], - *, - cli_args: bool = False, - deprecated: bool = False, - prefix: str = "", -) -> None: - for name, field in model.model_fields.items(): - full_name = prefix + name - cli_name = full_name.replace("_", "-") - arg_type = type(field.default) - - if issubclass(arg_type, BaseModel): - _add_args_from_model(parser, arg_type, cli_args=cli_args, deprecated=deprecated, prefix=f"{cli_name}.") - continue - - if arg_type not in (list, set, bool): - arg_type = str - - help_text = field.description or "" - default = field.default if cli_args else SUPPRESS - default_options: dict[str, Any] = {"default": default, "dest": full_name, "help": help_text} - for meta in field.metadata: - if isinstance(meta, CommandOptions): - default_options |= meta.as_dict() - break - - name_or_flags = [f"--{cli_name}"] - alias = field.alias or field.validation_alias or field.serialization_alias - if alias and len(str(alias)) == 1: - name_or_flags.insert(0, f"-{alias}") - if arg_type is bool: - action = BooleanOptionalAction - default_options.pop("default") - if cli_args and not (cli_name == "portrait" and env.RUNNING_IN_TERMUX): - action = "store_false" if default else "store_true" - if deprecated: - default_options = default_options | {"default": SUPPRESS} - parser.add_argument(*name_or_flags, action=action, **default_options) - continue - - if cli_name == "links": - _ = default_options.pop("dest") - _ = parser.add_argument(cli_name, metavar="LINK(S)", nargs="*", action="extend", **default_options) - continue - - if arg_type in (list, set): - _ = parser.add_argument(*name_or_flags, nargs="*", action="extend", **default_options) - continue - - _ = parser.add_argument(*name_or_flags, type=arg_type, **default_options) - - -def _create_groups_from_nested_models(parser: ArgumentParser, model: type[BaseModel]) -> list[ArgGroup]: - groups: list[ArgGroup] = [] - for name, field in model.model_fields.items(): - submodel = field.annotation - assert submodel and issubclass(submodel, BaseModel) - submodel_group = parser.add_argument_group(name) - _add_args_from_model(submodel_group, submodel) - groups.append(submodel_group) - - return groups - class CustomHelpFormatter(RawDescriptionHelpFormatter): - MAX_HELP_POS = 80 - INDENT_INCREMENT = 2 + MAX_HELP_POS: Final = 80 + INDENT_INCREMENT: Final = 2 def __init__(self, prog: str, width: int | None = None) -> None: super().__init__(prog, self.INDENT_INCREMENT, self.MAX_HELP_POS, width) @@ -309,19 +71,19 @@ def _get_help_string(self, action) -> str | None: return action.help -USING_DEPRECATED_ARGS: bool = bool(DeprecatedArgs.model_fields) - - def make_parser() -> tuple[ArgumentParser, dict[str, list[ArgGroup]]]: + kwargs: dict[str, Any] = {"color": True} if sys.version_info > (3, 14) else {} parser = ArgumentParser( description="Bulk asynchronous downloader for multiple file hosts", usage="cyberdrop-dl [OPTIONS] URL [URL...]", + allow_abbrev=False, formatter_class=CustomHelpFormatter, + **kwargs, ) _ = parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") cli_only = parser.add_argument_group("CLI-only options") - _add_args_from_model(cli_only, CommandLineOnlyArgs, cli_args=True) + _add_args_from_model(cli_only, CommandLineOnlyArgs) groups_mapping = { "config_settings": _create_groups_from_nested_models(parser, ConfigSettings), @@ -329,39 +91,14 @@ def make_parser() -> tuple[ArgumentParser, dict[str, list[ArgGroup]]]: "cli_only_args": [cli_only], } - if USING_DEPRECATED_ARGS: - deprecated = parser.add_argument_group("deprecated") - _add_args_from_model(deprecated, DeprecatedArgs, cli_args=True, deprecated=True) - groups_mapping["deprecated_args"] = [deprecated] - return parser, groups_mapping -def get_parsed_args_dict(args: Sequence[str] | None = None) -> dict[str, dict[str, Any]]: - parser, groups_mapping = make_parser() - namespace = parser.parse_intermixed_args(args) - parsed_args: dict[str, dict[str, Any]] = {} - for name, groups in groups_mapping.items(): - parsed_args[name] = {} - for group in groups: - group_dict = { - arg.dest: getattr(namespace, arg.dest) - for arg in group._group_actions - if getattr(namespace, arg.dest, None) is not None - } - if group_dict: - assert group.title - parsed_args[name][group.title] = parse_nested_values(group_dict) - - if USING_DEPRECATED_ARGS: - parsed_args["deprecated_args"] = parsed_args["deprecated_args"].get("deprecated") or {} - parsed_args["cli_only_args"] = parsed_args["cli_only_args"]["CLI-only options"] - return parsed_args - - def parse_args(args: Sequence[str] | None = None) -> ParsedArgs: """Parses the command line arguments passed into the program.""" - parsed_args_dict = get_parsed_args_dict(args) + from cyberdrop_dl.utils.yaml import handle_validation_error + + parsed_args_dict = _parse_args_dict(args) try: parsed_args_model = ParsedArgs.model_validate(parsed_args_dict) @@ -385,10 +122,10 @@ def show_supported_sites() -> NoReturn: sys.exit(0) -def parse_nested_values(data_list: dict[str, Any]) -> dict[str, Any]: +def _unflatten_nested_args(data: dict[str, Any]) -> dict[str, Any]: result: dict[str, Any] = {} - for command_name, value in data_list.items(): + for command_name, value in data.items(): inner_names = command_name.split(".") current_level = result for index, key in enumerate(inner_names): @@ -399,3 +136,46 @@ def parse_nested_values(data_list: dict[str, Any]) -> dict[str, Any]: else: current_level[key] = value return result + + +def _add_args_from_model(parser: ArgumentParser | ArgGroup, model: type[BaseModel]) -> None: + cli_args = model is CommandLineOnlyArgs + + for arg in arguments.parse(model): + options = arg.compose_options() + + if cli_args and arg.arg_type is bool and not (arg.cli_name == "portrait" and env.RUNNING_IN_TERMUX): + default = arg.default if cli_args else SUPPRESS + options["action"] = "store_false" if default else "store_true" + + _ = parser.add_argument(*arg.name_or_flags, **options) + + +def _create_groups_from_nested_models(parser: ArgumentParser, model: type[BaseModel]) -> list[ArgGroup]: + groups: list[ArgGroup] = [] + for name, field in model.model_fields.items(): + submodel = field.annotation + assert submodel and issubclass(submodel, BaseModel) + submodel_group = parser.add_argument_group(name) + _add_args_from_model(submodel_group, submodel) + groups.append(submodel_group) + + return groups + + +def _parse_args_dict(args: Sequence[str] | None = None) -> dict[str, dict[str, Any]]: + parser, groups_mapping = make_parser() + namespace = parser.parse_intermixed_args(args) + parsed_args: dict[str, dict[str, Any]] = {} + for name, groups in groups_mapping.items(): + parsed_args[name] = {} + for group in groups: + group_dict = { + arg.dest: v for arg in group._group_actions if (v := getattr(namespace, arg.dest, None)) is not None + } + if group_dict: + assert group.title + parsed_args[name][group.title] = _unflatten_nested_args(group_dict) + + parsed_args["cli_only_args"] = parsed_args["cli_only_args"]["CLI-only options"] + return parsed_args diff --git a/cyberdrop_dl/cli/arguments.py b/cyberdrop_dl/cli/arguments.py new file mode 100644 index 000000000..dbcbd33b5 --- /dev/null +++ b/cyberdrop_dl/cli/arguments.py @@ -0,0 +1,117 @@ +import dataclasses +from argparse import BooleanOptionalAction +from collections.abc import Generator, Iterable +from typing import Any, Literal, TypedDict + +from pydantic import BaseModel + +_NOT_SET: Any = object() + + +class _ArgumentParams(TypedDict, total=False): + action: str + nargs: int | str | None + const: Any + default: Any + choices: Iterable[Any] | None + required: bool + help: str | None + metavar: str | tuple[str, ...] | None + dest: str | None + + +@dataclasses.dataclass(slots=True, frozen=True, kw_only=True) +class ArgumentParams: + positional_only: bool = dataclasses.field(default=False, metadata={"exclude": True}) + nargs: Literal["?", "*", "+"] | str | None = _NOT_SET + const: Any = _NOT_SET + dest: str = _NOT_SET + choices: Iterable[Any] | None = _NOT_SET + metavar: str | tuple[str, ...] | None = _NOT_SET + + def as_dict(self) -> _ArgumentParams: + return {name: v for name in _params if (v := getattr(self, name)) is not _NOT_SET} # pyright: ignore[reportReturnType] + + +_params = tuple(f.name for f in dataclasses.fields(ArgumentParams) if not f.metadata.get("exclude")) + + +@dataclasses.dataclass(slots=True, kw_only=True) +class Argument: + name_or_flags: list[str] = dataclasses.field(init=False) + cli_name: str + aliases: tuple[str, ...] + required: bool + default: Any + annotation: Any + help: str | None + metadata: list[Any] + arg_type: type = dataclasses.field(init=False) + + def __post_init__(self) -> None: + self.arg_type = type(self.default) + + if self.arg_type not in (list, set, bool): + self.arg_type = str + + self.name_or_flags = [f"{'' if self.required else '--'}{self.cli_name}"] + + for alias in self.aliases: + if alias and len(alias) == 1: + self.name_or_flags.insert(0, f"-{alias}") + else: + self.name_or_flags.append(alias) + + def compose_options(self) -> _ArgumentParams: + options = self._options() + if override := self._overrides(): + return options | override.as_dict() + + return options + + def _overrides(self) -> ArgumentParams | None: + for meta in self.metadata: + if isinstance(meta, ArgumentParams): + return meta + + def _options(self) -> _ArgumentParams: + default = dict( # noqa: C408 + default=self.default, + help=self.help, + action="store", + ) + if not self.required: + default["dest"] = self.cli_name + + if self.arg_type is bool: + default["action"] = BooleanOptionalAction + + elif self.arg_type in (list, set): + default.update(nargs="*", action="extend") + + else: + default["type"] = self.arg_type + + return default # pyright: ignore[reportReturnType] + + +def parse(model: type[BaseModel]) -> Generator[Argument]: + for python_name, field in model.model_fields.items(): + aliases = filter( + None, + ( + field.alias, + field.validation_alias, + field.serialization_alias, + ), + ) + + yield Argument( + cli_name=python_name.replace("_", "-"), + aliases=tuple(map(str, aliases)), + annotation=field.annotation, + default=field.default, + required=field.is_required(), + metadata=field.metadata, + help=field.description or None, + ) diff --git a/cyberdrop_dl/cli/model.py b/cyberdrop_dl/cli/model.py new file mode 100644 index 000000000..0b8544be4 --- /dev/null +++ b/cyberdrop_dl/cli/model.py @@ -0,0 +1,134 @@ +import datetime +from collections.abc import Iterable +from enum import StrEnum, auto +from pathlib import Path +from typing import Annotated, Any, Literal, Self + +from pydantic import BaseModel, Field, computed_field, field_validator, model_validator + +from cyberdrop_dl.cli.arguments import ArgumentParams +from cyberdrop_dl.models.types import HttpURL + + +class UIOptions(StrEnum): + DISABLED = auto() + ACTIVITY = auto() + SIMPLE = auto() + FULLSCREEN = auto() + + +class CommandLineOnlyArgs(BaseModel): + links: Annotated[ + list[HttpURL], + ArgumentParams(positional_only=True, metavar="LINK(s)"), + ] = Field( + default=[], + description="link(s) to content to download (passing multiple links is supported)", + ) + appdata_folder: Path | None = Field( + default=None, + description="AppData folder path", + ) + completed_after: datetime.date | None = Field( + default=None, + description="only retry downloads that were completed on or after this date", + ) + completed_before: datetime.date | None = Field( + default=None, + description="only retry downloads that were completed on or before this date", + ) + + config_file: Path | None = Field( + default=None, + description="path to the CDL settings.yaml file to load", + ) + + download: bool = Field( + default=False, + description="skips UI, start download immediately", + ) + download_tiktok_audios: bool = Field( + default=False, + description="download TikTok audios from posts and save them as separate files", + ) + download_tiktok_src_quality_videos: bool = Field( + default=False, + description="download TikTok videos in source quality", + ) + impersonate: Annotated[ + Literal[ + "chrome", + "edge", + "safari", + "safari_ios", + "chrome_android", + "firefox", + ] + | bool + | None, + ArgumentParams(nargs="?", const=True), + ] = Field( + default=None, + description="Use this target as impersonation for all scrape requests", + ) + max_items_retry: int = Field( + default=0, + description="max number of links to retry", + ) + portrait: bool = Field( + default=False, + description="force CDL to run with a vertical layout", + ) + print_stats: bool = Field( + default=True, + description="show stats report at the end of a run", + ) + retry_all: bool = Field( + default=False, + description="retry all downloads", + ) + retry_failed: bool = Field( + default=False, + description="retry failed downloads", + ) + retry_maintenance: bool = Field( + default=False, + description="retry download of maintenance files (bunkr). Requires files to be hashed", + ) + show_supported_sites: bool = Field( + default=False, + description="shows a list of supported sites and exits", + ) + ui: UIOptions = Field( + default=UIOptions.FULLSCREEN, + description="DISABLED, ACTIVITY, SIMPLE or FULLSCREEN", + ) + + @property + def retry_any(self) -> bool: + return any((self.retry_all, self.retry_failed, self.retry_maintenance)) + + @property + def fullscreen_ui(self) -> bool: + return self.ui == UIOptions.FULLSCREEN + + @computed_field + def __computed__(self) -> dict[str, bool]: + return {"retry_any": self.retry_any, "fullscreen_ui": self.fullscreen_ui} + + @model_validator(mode="after") + def mutually_exclusive(self) -> Self: + group1 = [self.links, self.retry_all, self.retry_failed, self.retry_maintenance] + msg1 = "`--links`, '--retry-all', '--retry-maintenace' and '--retry-failed' are mutually exclusive" + _check_mutually_exclusive(group1, msg1) + return self + + @field_validator("ui", mode="before") + @classmethod + def lower(cls, value: str) -> str: + return value.lower() + + +def _check_mutually_exclusive(group: Iterable[Any], msg: str) -> None: + if sum(1 for value in group if value) >= 2: + raise ValueError(msg) diff --git a/pyproject.toml b/pyproject.toml index fa2fbac93..9e6536d5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "beautifulsoup4 >=4.14.3", "certifi >=2026.1.4", "curl-cffi >=0.13,<0.14; implementation_name == 'cpython' ", + "cyclopts>=4.5.4", "dateparser >=1.2.2", "imagesize >=1.4.1", "inquirerpy >=0.3.4", diff --git a/uv.lock b/uv.lock index c1c1de2ba..179ae2423 100644 --- a/uv.lock +++ b/uv.lock @@ -730,6 +730,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "certifi" }, { name = "curl-cffi", marker = "implementation_name == 'cpython'" }, + { name = "cyclopts" }, { name = "dateparser" }, { name = "imagesize" }, { name = "inquirerpy" }, @@ -775,6 +776,7 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "certifi", specifier = ">=2026.1.4" }, { name = "curl-cffi", marker = "implementation_name == 'cpython'", specifier = ">=0.13,<0.14" }, + { name = "cyclopts", specifier = ">=4.5.4" }, { name = "dateparser", specifier = ">=1.2.2" }, { name = "imagesize", specifier = ">=1.4.1" }, { name = "inquirerpy", specifier = ">=0.3.4" }, @@ -807,6 +809,21 @@ dev = [ ] extras = [{ name = "apprise", specifier = ">=1.9.7" }] +[[package]] +name = "cyclopts" +version = "4.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/d2/f37df900b163f51b4faacdb01bf4895c198906d67c5b2a85c2522de85459/cyclopts-4.5.4.tar.gz", hash = "sha256:eed4d6c76d4391aa796d8fcaabd50e5aad7793261792beb19285f62c5c456c8b", size = 162438, upload-time = "2026-02-20T00:58:46.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/0f/119fa63fa93e0a331fbedcb27162d8f88d3ba2f38eba1567e3e44307b857/cyclopts-4.5.4-py3-none-any.whl", hash = "sha256:ad001986ec403ca1dc1ed20375c439d62ac796295ea32b451dfe25d6696bc71a", size = 200225, upload-time = "2026-02-20T00:58:47.275Z" }, +] + [[package]] name = "dateparser" version = "1.3.0" @@ -831,6 +848,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "filelock" version = "3.24.3" @@ -1897,6 +1932,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + [[package]] name = "ruff" version = "0.15.2" From a4592c05eedee6c68001ac46f36d3b8023698e70 Mon Sep 17 00:00:00 2001 From: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:33:07 -0500 Subject: [PATCH 2/8] fix: dest name --- cyberdrop_dl/cli/__init__.py | 9 ++++----- cyberdrop_dl/cli/arguments.py | 8 +++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cyberdrop_dl/cli/__init__.py b/cyberdrop_dl/cli/__init__.py index 00dc86789..7b3b937df 100644 --- a/cyberdrop_dl/cli/__init__.py +++ b/cyberdrop_dl/cli/__init__.py @@ -100,7 +100,7 @@ def parse_args(args: Sequence[str] | None = None) -> ParsedArgs: parsed_args_dict = _parse_args_dict(args) try: - parsed_args_model = ParsedArgs.model_validate(parsed_args_dict) + parsed_args_model = ParsedArgs.model_validate(parsed_args_dict, extra="forbid") except ValidationError as e: handle_validation_error(e, title="CLI arguments") @@ -165,14 +165,13 @@ def _create_groups_from_nested_models(parser: ArgumentParser, model: type[BaseMo def _parse_args_dict(args: Sequence[str] | None = None) -> dict[str, dict[str, Any]]: parser, groups_mapping = make_parser() - namespace = parser.parse_intermixed_args(args) + namespace = dict(sorted(vars(parser.parse_intermixed_args(args)).items())) parsed_args: dict[str, dict[str, Any]] = {} + for name, groups in groups_mapping.items(): parsed_args[name] = {} for group in groups: - group_dict = { - arg.dest: v for arg in group._group_actions if (v := getattr(namespace, arg.dest, None)) is not None - } + group_dict = {arg.dest: v for arg in group._group_actions if (v := namespace.get(arg.dest)) is not None} if group_dict: assert group.title parsed_args[name][group.title] = _unflatten_nested_args(group_dict) diff --git a/cyberdrop_dl/cli/arguments.py b/cyberdrop_dl/cli/arguments.py index dbcbd33b5..d75fe3f17 100644 --- a/cyberdrop_dl/cli/arguments.py +++ b/cyberdrop_dl/cli/arguments.py @@ -39,7 +39,8 @@ def as_dict(self) -> _ArgumentParams: @dataclasses.dataclass(slots=True, kw_only=True) class Argument: name_or_flags: list[str] = dataclasses.field(init=False) - cli_name: str + name: str + cli_name: str = dataclasses.field(init=False) aliases: tuple[str, ...] required: bool default: Any @@ -49,6 +50,7 @@ class Argument: arg_type: type = dataclasses.field(init=False) def __post_init__(self) -> None: + self.cli_name = self.name.replace("_", "-") self.arg_type = type(self.default) if self.arg_type not in (list, set, bool): @@ -81,7 +83,7 @@ def _options(self) -> _ArgumentParams: action="store", ) if not self.required: - default["dest"] = self.cli_name + default["dest"] = self.name if self.arg_type is bool: default["action"] = BooleanOptionalAction @@ -107,7 +109,7 @@ def parse(model: type[BaseModel]) -> Generator[Argument]: ) yield Argument( - cli_name=python_name.replace("_", "-"), + name=python_name, aliases=tuple(map(str, aliases)), annotation=field.annotation, default=field.default, From a96d0825b972f120db1c5947ecd252f3261d66da Mon Sep 17 00:00:00 2001 From: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:57:48 -0500 Subject: [PATCH 3/8] refactor: add CLIParser class --- cyberdrop_dl/cli/__init__.py | 81 ++++++++++++++++-------------------- cyberdrop_dl/cli/model.py | 19 +++++++++ scripts/tools/update_docs.py | 6 +-- 3 files changed, 58 insertions(+), 48 deletions(-) diff --git a/cyberdrop_dl/cli/__init__.py b/cyberdrop_dl/cli/__init__.py index 7b3b937df..dc36a39f9 100644 --- a/cyberdrop_dl/cli/__init__.py +++ b/cyberdrop_dl/cli/__init__.py @@ -1,8 +1,8 @@ from __future__ import annotations +import dataclasses import sys from argparse import SUPPRESS, ArgumentParser, RawDescriptionHelpFormatter -from argparse import _ArgumentGroup as ArgGroup from shutil import get_terminal_size from typing import TYPE_CHECKING, Any, Final, NoReturn @@ -10,11 +10,11 @@ from cyberdrop_dl import __version__, env from cyberdrop_dl.cli import arguments -from cyberdrop_dl.cli.model import CommandLineOnlyArgs +from cyberdrop_dl.cli.model import CommandLineOnlyArgs, ParsedArgs from cyberdrop_dl.config import ConfigSettings, GlobalSettings -from cyberdrop_dl.models import AliasModel if TYPE_CHECKING: + from argparse import _ArgumentGroup as ArgGroup # pyright: ignore[reportPrivateUsage] from collections.abc import Sequence @@ -40,24 +40,6 @@ def is_terminal_in_portrait() -> bool: return False -class ParsedArgs(AliasModel): - cli_only_args: CommandLineOnlyArgs = CommandLineOnlyArgs() - config_settings: ConfigSettings = ConfigSettings() - global_settings: GlobalSettings = GlobalSettings() - - def model_post_init(self, *_) -> None: - if self.cli_only_args.retry_all or self.cli_only_args.retry_maintenance: - self.config_settings.runtime_options.ignore_history = True - - if ( - not self.cli_only_args.fullscreen_ui - or self.cli_only_args.retry_any - or self.cli_only_args.config_file - or self.config_settings.sorting.sort_downloads - ): - self.cli_only_args.download = True - - class CustomHelpFormatter(RawDescriptionHelpFormatter): MAX_HELP_POS: Final = 80 INDENT_INCREMENT: Final = 2 @@ -71,7 +53,33 @@ def _get_help_string(self, action) -> str | None: return action.help -def make_parser() -> tuple[ArgumentParser, dict[str, list[ArgGroup]]]: +@dataclasses.dataclass(slots=True) +class CLIParser: + parser: ArgumentParser + groups: dict[str, list[ArgGroup]] + + def parse_args(self, args: Sequence[str] | None = None) -> dict[str, dict[str, Any]]: + return self._unflatten(self._parse_args(args)) + + def _parse_args(self, args: Sequence[str] | None = None) -> dict[str, Any]: + return dict(sorted(vars(self.parser.parse_intermixed_args(args)).items())) + + def _unflatten(self, namespace: dict[str, Any]) -> dict[str, dict[str, Any]]: + parsed_args: dict[str, dict[str, Any]] = {} + + for name, groups in self.groups.items(): + parsed_args[name] = {} + for group in groups: + group_dict = {arg.dest: v for arg in group._group_actions if (v := namespace.get(arg.dest)) is not None} + if group_dict: + assert group.title + parsed_args[name][group.title] = _unflatten_nested_args(group_dict) + + parsed_args["cli_only_args"] = parsed_args["cli_only_args"]["CLI-only options"] + return parsed_args + + +def make_parser() -> CLIParser: kwargs: dict[str, Any] = {"color": True} if sys.version_info > (3, 14) else {} parser = ArgumentParser( description="Bulk asynchronous downloader for multiple file hosts", @@ -85,31 +93,31 @@ def make_parser() -> tuple[ArgumentParser, dict[str, list[ArgGroup]]]: cli_only = parser.add_argument_group("CLI-only options") _add_args_from_model(cli_only, CommandLineOnlyArgs) - groups_mapping = { + groups = { "config_settings": _create_groups_from_nested_models(parser, ConfigSettings), "global_settings": _create_groups_from_nested_models(parser, GlobalSettings), "cli_only_args": [cli_only], } - return parser, groups_mapping + return CLIParser(parser, groups) def parse_args(args: Sequence[str] | None = None) -> ParsedArgs: """Parses the command line arguments passed into the program.""" from cyberdrop_dl.utils.yaml import handle_validation_error - parsed_args_dict = _parse_args_dict(args) + parsed_args = make_parser().parse_args(args) try: - parsed_args_model = ParsedArgs.model_validate(parsed_args_dict, extra="forbid") + model = ParsedArgs.model_validate(parsed_args, extra="forbid") except ValidationError as e: handle_validation_error(e, title="CLI arguments") sys.exit(1) - if parsed_args_model.cli_only_args.show_supported_sites: + if model.cli_only_args.show_supported_sites: show_supported_sites() - return parsed_args_model + return model def show_supported_sites() -> NoReturn: @@ -161,20 +169,3 @@ def _create_groups_from_nested_models(parser: ArgumentParser, model: type[BaseMo groups.append(submodel_group) return groups - - -def _parse_args_dict(args: Sequence[str] | None = None) -> dict[str, dict[str, Any]]: - parser, groups_mapping = make_parser() - namespace = dict(sorted(vars(parser.parse_intermixed_args(args)).items())) - parsed_args: dict[str, dict[str, Any]] = {} - - for name, groups in groups_mapping.items(): - parsed_args[name] = {} - for group in groups: - group_dict = {arg.dest: v for arg in group._group_actions if (v := namespace.get(arg.dest)) is not None} - if group_dict: - assert group.title - parsed_args[name][group.title] = _unflatten_nested_args(group_dict) - - parsed_args["cli_only_args"] = parsed_args["cli_only_args"]["CLI-only options"] - return parsed_args diff --git a/cyberdrop_dl/cli/model.py b/cyberdrop_dl/cli/model.py index 0b8544be4..224be6a89 100644 --- a/cyberdrop_dl/cli/model.py +++ b/cyberdrop_dl/cli/model.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field, computed_field, field_validator, model_validator from cyberdrop_dl.cli.arguments import ArgumentParams +from cyberdrop_dl.config import ConfigSettings, GlobalSettings from cyberdrop_dl.models.types import HttpURL @@ -132,3 +133,21 @@ def lower(cls, value: str) -> str: def _check_mutually_exclusive(group: Iterable[Any], msg: str) -> None: if sum(1 for value in group if value) >= 2: raise ValueError(msg) + + +class ParsedArgs(BaseModel): + cli_only_args: CommandLineOnlyArgs = CommandLineOnlyArgs() + config_settings: ConfigSettings = ConfigSettings() + global_settings: GlobalSettings = GlobalSettings() + + def model_post_init(self, *_) -> None: + if self.cli_only_args.retry_all or self.cli_only_args.retry_maintenance: + self.config_settings.runtime_options.ignore_history = True + + if ( + not self.cli_only_args.fullscreen_ui + or self.cli_only_args.retry_any + or self.cli_only_args.config_file + or self.config_settings.sorting.sort_downloads + ): + self.cli_only_args.download = True diff --git a/scripts/tools/update_docs.py b/scripts/tools/update_docs.py index 6de56331e..e235aa4fd 100644 --- a/scripts/tools/update_docs.py +++ b/scripts/tools/update_docs.py @@ -1,7 +1,7 @@ from pathlib import Path from cyberdrop_dl import __version__ -from cyberdrop_dl.cli import CDL_EPILOG, CustomHelpFormatter, make_parser +from cyberdrop_dl.cli import CustomHelpFormatter, make_parser from cyberdrop_dl.utils.markdown import get_crawlers_info_as_markdown_table REPO_ROOT = Path(__file__).parents[2] @@ -10,7 +10,7 @@ def update_cli_overview() -> None: - parser, _ = make_parser() + parser = make_parser().parser def get_wide_formatter(_=None) -> CustomHelpFormatter: return CustomHelpFormatter(parser.prog, width=300) @@ -18,7 +18,7 @@ def get_wide_formatter(_=None) -> CustomHelpFormatter: parser._get_formatter = get_wide_formatter help_text = parser.format_help() shell = "```shell" - cli_overview, *_ = help_text.partition(CDL_EPILOG) + cli_overview, *_ = help_text.partition("") current_content = CLI_ARGUMENTS_MD.read_text(encoding="utf8") new_content, *_ = current_content.partition(shell) new_content += f"{shell}\n{cli_overview}```\n" From 7ada14c5ad29f9ba458e484857bb71052ad686d3 Mon Sep 17 00:00:00 2001 From: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:01:12 -0500 Subject: [PATCH 4/8] tests: update tests --- cyberdrop_dl/config/config_model.py | 2 +- tests/test_cli.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cyberdrop_dl/config/config_model.py b/cyberdrop_dl/config/config_model.py index c8e58fdac..0b33aa67e 100755 --- a/cyberdrop_dl/config/config_model.py +++ b/cyberdrop_dl/config/config_model.py @@ -66,7 +66,7 @@ def valid_format(cls, value: str) -> str: class Files(AliasModel): download_folder: Path = Field(default=DEFAULT_DOWNLOAD_STORAGE, validation_alias="d") dump_json: bool = Field(default=False, validation_alias="j") - input_file: Path = Field(default=DEFAULT_APP_STORAGE / "Configs{config}/URLs.txt", validation_alias="i") + input_file: Path = Field(default=DEFAULT_APP_STORAGE / "Configs/{config}/URLs.txt", validation_alias="i") save_pages_html: bool = False diff --git a/tests/test_cli.py b/tests/test_cli.py index a6b190981..6fc36026f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,7 +26,7 @@ def test_command_by_console_output(tmp_cwd: Path, capsys: pytest.CaptureFixture[ def test_startup_logger_should_not_be_created_on_a_successful_run(tmp_cwd: Path) -> None: run("--download") - startup_file = Path.cwd() / "startup.log" + startup_file = tmp_cwd / "startup.log" assert not startup_file.exists() @@ -41,7 +41,7 @@ def test_startup_logger_should_not_be_created_on_invalid_cookies(tmp_cwd: Path) logs = director.manager.path_manager.main_log.read_text(encoding="utf8") assert "does not look like a Netscape format cookies file" in logs - startup_file = Path.cwd() / "startup.log" + startup_file = tmp_cwd / "startup.log" assert not startup_file.exists() @@ -56,7 +56,7 @@ def test_startup_logger_is_created_on_yaml_error(tmp_cwd: Path) -> None: except SystemExit: pass - startup_file = Path.cwd() / "startup.log" + startup_file = tmp_cwd / "startup.log" assert startup_file.exists() logs = startup_file.read_text(encoding="utf8") @@ -80,7 +80,7 @@ def test_startup_logger_when_manager_startup_fails( run("--download") except SystemExit: pass - startup_file = Path.cwd() / "startup.log" + startup_file = tmp_cwd / "startup.log" assert startup_file.exists() == exists @@ -89,7 +89,7 @@ def test_startup_logger_should_not_be_created_when_using_invalid_cli_args(tmp_cw run("--invalid-command") except SystemExit: pass - startup_file = Path.cwd() / "startup.log" + startup_file = tmp_cwd / "startup.log" assert not startup_file.exists() From 4351c999be3a9b9ab24f1a2fac02a7f093d35092 Mon Sep 17 00:00:00 2001 From: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:06:34 -0500 Subject: [PATCH 5/8] refactor: use python name --- cyberdrop_dl/cli/arguments.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cyberdrop_dl/cli/arguments.py b/cyberdrop_dl/cli/arguments.py index d75fe3f17..865b1c8ec 100644 --- a/cyberdrop_dl/cli/arguments.py +++ b/cyberdrop_dl/cli/arguments.py @@ -39,7 +39,7 @@ def as_dict(self) -> _ArgumentParams: @dataclasses.dataclass(slots=True, kw_only=True) class Argument: name_or_flags: list[str] = dataclasses.field(init=False) - name: str + python_name: str cli_name: str = dataclasses.field(init=False) aliases: tuple[str, ...] required: bool @@ -50,13 +50,14 @@ class Argument: arg_type: type = dataclasses.field(init=False) def __post_init__(self) -> None: - self.cli_name = self.name.replace("_", "-") + self.cli_name = self.python_name.replace("_", "-") self.arg_type = type(self.default) if self.arg_type not in (list, set, bool): self.arg_type = str - self.name_or_flags = [f"{'' if self.required else '--'}{self.cli_name}"] + positional = override.positional_only if (override := self._overrides()) else False + self.name_or_flags = [f"{'' if positional else '--'}{self.cli_name}"] for alias in self.aliases: if alias and len(alias) == 1: @@ -83,7 +84,7 @@ def _options(self) -> _ArgumentParams: action="store", ) if not self.required: - default["dest"] = self.name + default["dest"] = self.python_name if self.arg_type is bool: default["action"] = BooleanOptionalAction @@ -109,7 +110,7 @@ def parse(model: type[BaseModel]) -> Generator[Argument]: ) yield Argument( - name=python_name, + python_name=python_name, aliases=tuple(map(str, aliases)), annotation=field.annotation, default=field.default, From d48aa9eaccba20d1f7c3098421594601ad919758 Mon Sep 17 00:00:00 2001 From: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:09:03 -0500 Subject: [PATCH 6/8] chore: update lock --- pyproject.toml | 1 - uv.lock | 48 ------------------------------------------------ 2 files changed, 49 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e6536d5b..fa2fbac93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ dependencies = [ "beautifulsoup4 >=4.14.3", "certifi >=2026.1.4", "curl-cffi >=0.13,<0.14; implementation_name == 'cpython' ", - "cyclopts>=4.5.4", "dateparser >=1.2.2", "imagesize >=1.4.1", "inquirerpy >=0.3.4", diff --git a/uv.lock b/uv.lock index 179ae2423..c1c1de2ba 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,6 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "certifi" }, { name = "curl-cffi", marker = "implementation_name == 'cpython'" }, - { name = "cyclopts" }, { name = "dateparser" }, { name = "imagesize" }, { name = "inquirerpy" }, @@ -776,7 +775,6 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "certifi", specifier = ">=2026.1.4" }, { name = "curl-cffi", marker = "implementation_name == 'cpython'", specifier = ">=0.13,<0.14" }, - { name = "cyclopts", specifier = ">=4.5.4" }, { name = "dateparser", specifier = ">=1.2.2" }, { name = "imagesize", specifier = ">=1.4.1" }, { name = "inquirerpy", specifier = ">=0.3.4" }, @@ -809,21 +807,6 @@ dev = [ ] extras = [{ name = "apprise", specifier = ">=1.9.7" }] -[[package]] -name = "cyclopts" -version = "4.5.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/d2/f37df900b163f51b4faacdb01bf4895c198906d67c5b2a85c2522de85459/cyclopts-4.5.4.tar.gz", hash = "sha256:eed4d6c76d4391aa796d8fcaabd50e5aad7793261792beb19285f62c5c456c8b", size = 162438, upload-time = "2026-02-20T00:58:46.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/0f/119fa63fa93e0a331fbedcb27162d8f88d3ba2f38eba1567e3e44307b857/cyclopts-4.5.4-py3-none-any.whl", hash = "sha256:ad001986ec403ca1dc1ed20375c439d62ac796295ea32b451dfe25d6696bc71a", size = 200225, upload-time = "2026-02-20T00:58:47.275Z" }, -] - [[package]] name = "dateparser" version = "1.3.0" @@ -848,24 +831,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] -[[package]] -name = "docstring-parser" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, -] - [[package]] name = "filelock" version = "3.24.3" @@ -1932,19 +1897,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] -[[package]] -name = "rich-rst" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, -] - [[package]] name = "ruff" version = "0.15.2" From 638fedf5f03278fb9b6e9b8da21f2c819ed01b43 Mon Sep 17 00:00:00 2001 From: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:22:21 -0500 Subject: [PATCH 7/8] fix: links --- cyberdrop_dl/cli/arguments.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/cyberdrop_dl/cli/arguments.py b/cyberdrop_dl/cli/arguments.py index 865b1c8ec..85536a4ee 100644 --- a/cyberdrop_dl/cli/arguments.py +++ b/cyberdrop_dl/cli/arguments.py @@ -23,7 +23,7 @@ class _ArgumentParams(TypedDict, total=False): @dataclasses.dataclass(slots=True, frozen=True, kw_only=True) class ArgumentParams: positional_only: bool = dataclasses.field(default=False, metadata={"exclude": True}) - nargs: Literal["?", "*", "+"] | str | None = _NOT_SET + nargs: Literal["?", "*", "+"] | None = _NOT_SET const: Any = _NOT_SET dest: str = _NOT_SET choices: Iterable[Any] | None = _NOT_SET @@ -47,6 +47,7 @@ class Argument: annotation: Any help: str | None metadata: list[Any] + positional_only: bool = dataclasses.field(init=False) arg_type: type = dataclasses.field(init=False) def __post_init__(self) -> None: @@ -56,8 +57,9 @@ def __post_init__(self) -> None: if self.arg_type not in (list, set, bool): self.arg_type = str - positional = override.positional_only if (override := self._overrides()) else False - self.name_or_flags = [f"{'' if positional else '--'}{self.cli_name}"] + self.positional_only = override.positional_only if (override := self._overrides()) else False + cli_command = f"{'' if self.positional_only else '--'}{self.cli_name}" + self.name_or_flags = [cli_command] for alias in self.aliases: if alias and len(alias) == 1: @@ -78,24 +80,24 @@ def _overrides(self) -> ArgumentParams | None: return meta def _options(self) -> _ArgumentParams: - default = dict( # noqa: C408 + options = dict( # noqa: C408 default=self.default, help=self.help, action="store", ) - if not self.required: - default["dest"] = self.python_name + if not self.positional_only: + options["dest"] = self.python_name if self.arg_type is bool: - default["action"] = BooleanOptionalAction + options["action"] = BooleanOptionalAction elif self.arg_type in (list, set): - default.update(nargs="*", action="extend") + options.update(nargs="*", action="extend") else: - default["type"] = self.arg_type + options["type"] = self.arg_type - return default # pyright: ignore[reportReturnType] + return options # pyright: ignore[reportReturnType] def parse(model: type[BaseModel]) -> Generator[Argument]: From edbbd18fc7ca1e337bd9fcecc30fd203034e1adc Mon Sep 17 00:00:00 2001 From: NTFSvolume <172021377+NTFSvolume@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:26:45 -0500 Subject: [PATCH 8/8] refactor: rename --- cyberdrop_dl/cli/__init__.py | 6 +++--- cyberdrop_dl/cli/model.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cyberdrop_dl/cli/__init__.py b/cyberdrop_dl/cli/__init__.py index dc36a39f9..d7d1af189 100644 --- a/cyberdrop_dl/cli/__init__.py +++ b/cyberdrop_dl/cli/__init__.py @@ -10,7 +10,7 @@ from cyberdrop_dl import __version__, env from cyberdrop_dl.cli import arguments -from cyberdrop_dl.cli.model import CommandLineOnlyArgs, ParsedArgs +from cyberdrop_dl.cli.model import CLIargs, ParsedArgs from cyberdrop_dl.config import ConfigSettings, GlobalSettings if TYPE_CHECKING: @@ -91,7 +91,7 @@ def make_parser() -> CLIParser: _ = parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") cli_only = parser.add_argument_group("CLI-only options") - _add_args_from_model(cli_only, CommandLineOnlyArgs) + _add_args_from_model(cli_only, CLIargs) groups = { "config_settings": _create_groups_from_nested_models(parser, ConfigSettings), @@ -147,7 +147,7 @@ def _unflatten_nested_args(data: dict[str, Any]) -> dict[str, Any]: def _add_args_from_model(parser: ArgumentParser | ArgGroup, model: type[BaseModel]) -> None: - cli_args = model is CommandLineOnlyArgs + cli_args = model is CLIargs for arg in arguments.parse(model): options = arg.compose_options() diff --git a/cyberdrop_dl/cli/model.py b/cyberdrop_dl/cli/model.py index 224be6a89..59a1ad8b5 100644 --- a/cyberdrop_dl/cli/model.py +++ b/cyberdrop_dl/cli/model.py @@ -18,7 +18,7 @@ class UIOptions(StrEnum): FULLSCREEN = auto() -class CommandLineOnlyArgs(BaseModel): +class CLIargs(BaseModel): links: Annotated[ list[HttpURL], ArgumentParams(positional_only=True, metavar="LINK(s)"), @@ -136,7 +136,7 @@ def _check_mutually_exclusive(group: Iterable[Any], msg: str) -> None: class ParsedArgs(BaseModel): - cli_only_args: CommandLineOnlyArgs = CommandLineOnlyArgs() + cli_only_args: CLIargs = CLIargs() config_settings: ConfigSettings = ConfigSettings() global_settings: GlobalSettings = GlobalSettings()