diff --git a/src/ytdl_sub/cli/download_args_parser.py b/src/ytdl_sub/cli/download_args_parser.py index 8e8d95a7..5f0fa3df 100644 --- a/src/ytdl_sub/cli/download_args_parser.py +++ b/src/ytdl_sub/cli/download_args_parser.py @@ -1,10 +1,13 @@ import hashlib +import shlex from argparse import ArgumentError from typing import Dict from typing import List from mergedeep import mergedeep +from ytdl_sub.config.config_file import ConfigOptions + class DownloadArgsParser: """ @@ -14,11 +17,17 @@ class DownloadArgsParser: :class:`~ytdl_subscribe.validators.config.preset_validator.PresetValidator` """ - def __init__(self, extra_arguments: List[str]): + def __init__(self, extra_arguments: List[str], config_options: ConfigOptions): """ - :param extra_arguments: List of extra arguments from argparse + Parameters + ---------- + extra_arguments + List of extra arguments from argparse + config_options + Configuration portion of config.yaml """ self._unknown_arguments = extra_arguments + self._config_options = config_options @property def _argument_exception(self) -> ArgumentError: @@ -27,8 +36,7 @@ class DownloadArgsParser: """ return ArgumentError( argument=None, - message="dl arguments must be in the form of --subscription.option.name 'value' " - "proceeding the url", + message="dl arguments must be in the form of --subscription.option.name 'value'", ) @classmethod @@ -39,6 +47,20 @@ class DownloadArgsParser: """ return arg.startswith("--") + @classmethod + def _get_argument_name(cls, arg_name: str) -> str: + """ + Parameters + ---------- + arg_name + Argument name + + Returns + ------- + The argument name with the -- removed + """ + return arg_name[2:] + @classmethod def _argument_name_and_value_to_dict(cls, arg_name: str, arg_value: str) -> Dict: """ @@ -49,7 +71,7 @@ class DownloadArgsParser: argument_dict = {} # Remove the argument --'s, then split on period - arg_name_split = arg_name.replace("--", "", 1).split(".") + arg_name_split = cls._get_argument_name(arg_name).split(".") next_dict = argument_dict for next_arg_name in arg_name_split[:-1]: @@ -60,18 +82,36 @@ class DownloadArgsParser: return argument_dict + @classmethod + def _apply_aliases(cls, unknown_arguments: List[str], aliases: Dict) -> List[str]: + """ + Applies any aliases from the config to the unknown arguments + """ + applied_alias_arguments: List[str] = [] + + for arg in unknown_arguments: + if cls._is_argument_name(arg) and cls._get_argument_name(arg) in aliases: + arg = aliases[cls._get_argument_name(arg)] + applied_alias_arguments.extend(shlex.split(arg)) + else: + applied_alias_arguments.append(arg) + + return applied_alias_arguments + def to_subscription_dict(self) -> Dict: """ Converts the extra arguments into a dict equivalent to a subscription yaml. :return: dict containing argument names and values """ subscription_dict = {} - if len(self._unknown_arguments) % 2 != 0: + arguments = self._apply_aliases( + unknown_arguments=self._unknown_arguments, aliases=self._config_options.dl_aliases + ) + if len(arguments) % 2 != 0: raise self._argument_exception - for idx in range(0, len(self._unknown_arguments), 2): - argument_name = self._unknown_arguments[idx] - argument_value = self._unknown_arguments[idx + 1] + for idx in range(0, len(arguments), 2): + argument_name, argument_value = arguments[idx], arguments[idx + 1] if not self._is_argument_name(arg=argument_name): raise self._argument_exception diff --git a/src/ytdl_sub/config/config_file.py b/src/ytdl_sub/config/config_file.py index 9b6d1ca1..da14dba9 100644 --- a/src/ytdl_sub/config/config_file.py +++ b/src/ytdl_sub/config/config_file.py @@ -1,5 +1,6 @@ import os from typing import Any +from typing import Dict from typing import Optional from ytdl_sub.utils.yaml import load_yaml @@ -10,7 +11,7 @@ from ytdl_sub.validators.validators import StringValidator class ConfigOptions(StrictDictValidator): _required_keys = {"working_directory"} - _optional_keys = {"umask"} + _optional_keys = {"umask", "dl_aliases"} def __init__(self, name: str, value: Any): super().__init__(name, value) @@ -21,6 +22,9 @@ class ConfigOptions(StrictDictValidator): self._umask = self._validate_key_if_present( key="umask", validator=StringValidator, default="022" ) + self._dl_aliases = self._validate_key_if_present( + key="dl_aliases", validator=LiteralDictValidator + ) @property def working_directory(self) -> str: @@ -37,6 +41,34 @@ class ConfigOptions(StrictDictValidator): """ return self._umask.value + @property + def dl_aliases(self) -> Optional[Dict[str, str]]: + """ + Alias definitions to shorten ``ytdl-sub dl`` arguments. For example, + + .. code-block:: yaml + + configuration: + dl_aliases: + mv: "--preset yt_music_video" + v: "--youtube.video_id" + + Simplifies + + .. code-block:: bash + + ytdl-sub dl --preset yt_music_video --youtube.video_id a1b2c3 + + to + + .. code-block:: bash + + ytdl-sub dl --mv --v a1b2c3 + """ + if self._dl_aliases: + return self._dl_aliases.dict + return {} + class ConfigFile(StrictDictValidator): _required_keys = {"configuration", "presets"} diff --git a/src/ytdl_sub/main.py b/src/ytdl_sub/main.py index 256528b0..8a6bbcb9 100644 --- a/src/ytdl_sub/main.py +++ b/src/ytdl_sub/main.py @@ -40,7 +40,9 @@ def _download_subscription_from_cli(config: ConfigFile, extra_args: List[str]) - :param config: Configuration file :param extra_args: Extra arguments from argparse that contain dynamic subscription options """ - dl_args_parser = DownloadArgsParser(extra_arguments=extra_args) + dl_args_parser = DownloadArgsParser( + extra_arguments=extra_args, config_options=config.config_options + ) subscription_args_dict = dl_args_parser.to_subscription_dict() subscription_name = f"cli-dl-{dl_args_parser.get_args_hash()}" diff --git a/tests/unit/cli/__init__.py b/tests/unit/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/cli/test_download_args_parser.py b/tests/unit/cli/test_download_args_parser.py new file mode 100644 index 00000000..b3bee5d6 --- /dev/null +++ b/tests/unit/cli/test_download_args_parser.py @@ -0,0 +1,93 @@ +import shlex +from argparse import ArgumentError +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional + +import pytest + +from ytdl_sub.cli.download_args_parser import DownloadArgsParser +from ytdl_sub.cli.main_args_parser import parser +from ytdl_sub.config.config_file import ConfigOptions + + +@pytest.fixture +def config_options_generator() -> Callable: + def _config_options_generator(dl_aliases: Optional[Dict[str, str]] = None): + config_options_dict = { + "working_directory": ".", + } + if dl_aliases: + config_options_dict["dl_aliases"] = dl_aliases + + return ConfigOptions(name="config_options_unit_test", value=config_options_dict) + + return _config_options_generator + + +@pytest.fixture +def argument_error_msg() -> str: + return "dl arguments must be in the form of --subscription.option.name 'value'" + + +def _get_extra_arguments(cmd_string: str) -> List[str]: + sys_argv = shlex.split(cmd_string) + _, extra_args = parser.parse_known_args(args=sys_argv) + + return extra_args + + +class TestDownloadArgsParser: + @pytest.mark.parametrize( + "aliases, cmd, expected_sub_dict", + [ + ( + {"mv": "--preset yt_music_video", "v": "--youtube.video_id"}, + "dl --mv --v 123abc", + {"preset": "yt_music_video", "youtube": {"video_id": "123abc"}}, + ), + ( + { + "ch": "--preset yt_channel", + "c": "--youtube.channel_id", + "name": "--overrides.tv_name", + "concert": "--overrides.genre 'Some Genre Name'", + }, + "dl --ch --c 123abc --name 'Sweet TV Show' --concert", + { + "preset": "yt_channel", + "youtube": {"channel_id": "123abc"}, + "overrides": {"tv_name": "Sweet TV Show", "genre": "Some Genre Name"}, + }, + ), + (None, "dl --youtube.playlist_id 123abc", {"youtube": {"playlist_id": "123abc"}}), + ], + ) + def test_successful_args(self, config_options_generator, aliases, cmd, expected_sub_dict): + config_options = config_options_generator(dl_aliases=aliases) + extra_args = _get_extra_arguments(cmd_string=cmd) + + output_sub_dict = DownloadArgsParser( + extra_arguments=extra_args, config_options=config_options + ).to_subscription_dict() + + assert output_sub_dict == expected_sub_dict + + def test_error_uneven_args(self, config_options_generator, argument_error_msg): + config_options = config_options_generator() + extra_args = _get_extra_arguments(cmd_string="dl --preset") + + with pytest.raises(ArgumentError, match=argument_error_msg): + DownloadArgsParser( + extra_arguments=extra_args, config_options=config_options + ).to_subscription_dict() + + def test_error_adjacent_values(self, config_options_generator, argument_error_msg): + config_options = config_options_generator() + extra_args = _get_extra_arguments(cmd_string="dl --preset buttered toast --test") + + with pytest.raises(ArgumentError, match=argument_error_msg): + DownloadArgsParser( + extra_arguments=extra_args, config_options=config_options + ).to_subscription_dict()