From 3e41e0f59e9898fc12d474a582e8a642991ff060 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Wed, 27 Sep 2023 21:37:36 -0700 Subject: [PATCH] [FEATURE] Ability to add subscription value via config (#744) `Subscription Value` is the mechanism used to achieve one-liner subscriptions. It works by having the subscription `"Subscription Name": "value"`, and assigning `"value"` to an override variable via setting the subscription value to that override variable's name. This feature allows you to set subscription value within the config. In the near future, ytdl-sub will strive to make subscription files simpler by hiding most of the "under-the-hood" logic within the config file. --- docs/config.rst | 23 +-- docs/index.rst | 2 +- src/ytdl_sub/config/config_validator.py | 12 ++ src/ytdl_sub/subscriptions/subscription.py | 40 +++-- tests/unit/config/test_subscription.py | 189 +++++++++++++-------- 5 files changed, 169 insertions(+), 97 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index b8aae8e4..01e4ada9 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -2,7 +2,7 @@ Config ====== ytdl-sub is configured using a ``config.yaml`` file. -.. _config_yaml: +.. _config: config.yaml ----------- @@ -318,8 +318,10 @@ by using the file-wide ``__preset__``: This ``subscription.yaml`` is equivalent to the one above it because all subscriptions automatically set ``__preset__`` as a `parent preset`_. -File Subscription Value -^^^^^^^^^^^^^^^^^^^^^^^ +.. _subscription value: + +Subscription Value +^^^^^^^^^^^^^^^^^^^ With a clever config and use of ``__preset__``, your subscriptions can typically boil down to a name and url. You can set ``__value__`` to the name of an override variable, and use the override variable ``subscription_name`` to achieve one-liner subscriptions. @@ -329,20 +331,19 @@ Using the example above, we can do: :caption: subscription.yaml __preset__: - preset: "playlist_preset_ex" + preset: + - "tv_show" overrides: - playlist_name: "{subscription_name}" + tv_show_name: "{subscription_name}" __value__: "url" - # single-line subscription - "diy-playlist": "https://youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg" - -``"diy-playlist"`` gets assigned to the ``playlist_name`` override variable by setting -it with ``subscription_name`` , and the url gets assigned to ``url`` by setting ``__value__`` -to write values to it. + # single-line subscription, sets "Brandon Acker" and the subscription value + # to the override variables tv_show_name and url + "Brandon Acker": "https://www.youtube.com/@brandonacker" Traditional subscriptions that can override presets will still work when using ``__value__``. +``__value__`` can also be set within a :ref:`config`. ------------------------------------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 1ef6df9f..246ff447 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,7 +13,7 @@ ytdl-sub configs, check out the For navigating config docs, use the left-side bar on the -:ref:`config_yaml` page to find every available ytdl-sub field. +:ref:`config` page to find every available ytdl-sub field. Contents ======== diff --git a/src/ytdl_sub/config/config_validator.py b/src/ytdl_sub/config/config_validator.py index 5e375c8a..20341088 100644 --- a/src/ytdl_sub/config/config_validator.py +++ b/src/ytdl_sub/config/config_validator.py @@ -106,6 +106,7 @@ class ConfigOptions(StrictDictValidator): "ffprobe_path", "file_name_max_bytes", "experimental", + "subscription_value", } def __init__(self, name: str, value: Any): @@ -138,6 +139,9 @@ class ConfigOptions(StrictDictValidator): self._file_name_max_bytes = self._validate_key( key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES ) + self._subscription_value = self._validate_key_if_present( + key="subscription_value", validator=StringValidator + ) @property def working_directory(self) -> str: @@ -230,6 +234,14 @@ class ConfigOptions(StrictDictValidator): """ return self._ffprobe_path.value + @property + def subscription_value(self) -> Optional[str]: + """ + Optional. Sets the :ref:`subscription value` for subscription + files that use this config. + """ + return self._subscription_value.value if self._subscription_value else None + class ConfigValidator(StrictDictValidator): _required_keys = {"configuration", "presets"} diff --git a/src/ytdl_sub/subscriptions/subscription.py b/src/ytdl_sub/subscriptions/subscription.py index 829505f8..4c4c819e 100644 --- a/src/ytdl_sub/subscriptions/subscription.py +++ b/src/ytdl_sub/subscriptions/subscription.py @@ -1,6 +1,7 @@ import copy from typing import Dict from typing import List +from typing import Optional from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.preset import Preset @@ -63,6 +64,20 @@ class Subscription(SubscriptionDownload): config=config, ) + @classmethod + def _maybe_get_subscription_value( + cls, config: ConfigFile, subscription_dict: Dict + ) -> Optional[str]: + if FILE_SUBSCRIPTION_VALUE_KEY in subscription_dict: + if not isinstance(subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY], str): + raise ValidationException( + f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription" + f"must be a string that corresponds to an override variable" + ) + return subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY] + + return config.config_options.subscription_value # can be None + @classmethod def from_file_path(cls, config: ConfigFile, subscription_path: str) -> List["Subscription"]: """ @@ -90,7 +105,9 @@ class Subscription(SubscriptionDownload): subscription_dict = load_yaml(file_path=subscription_path) has_file_preset = FILE_PRESET_APPLY_KEY in subscription_dict - has_file_subscription_value = FILE_SUBSCRIPTION_VALUE_KEY in subscription_dict + file_subscription_value: Optional[str] = cls._maybe_get_subscription_value( + config=config, subscription_dict=subscription_dict + ) # If a file preset is present... if has_file_preset: @@ -104,13 +121,6 @@ class Subscription(SubscriptionDownload): config = copy.deepcopy(config) config.presets.dict[FILE_PRESET_APPLY_KEY] = file_preset.dict - if has_file_subscription_value: - if not isinstance(subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY], str): - raise ValidationException( - f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription" - f"must be a string that corresponds to an override variable" - ) - for subscription_key, subscription_object in subscription_dict.items(): # Skip file preset or value @@ -119,18 +129,14 @@ class Subscription(SubscriptionDownload): # If the subscription obj is just a string, set it to the override variable # defined in FILE_SUBSCRIPTION_VALUE_KEY - if isinstance(subscription_object, str) and has_file_subscription_value: - subscription_object = { - "overrides": { - subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY]: subscription_object - } - } + if isinstance(subscription_object, str) and file_subscription_value: + subscription_object = {"overrides": {file_subscription_value: subscription_object}} elif isinstance(subscription_object, dict): pass - elif isinstance(subscription_object, str) and not has_file_subscription_value: + elif isinstance(subscription_object, str) and not file_subscription_value: raise ValidationException( - f"Subscription {subscription_key} is a string, but " - f"{FILE_SUBSCRIPTION_VALUE_KEY} is not set to an override variable" + f"Subscription {subscription_key} is a string, but the subscription value " + f"is not set to an override variable" ) else: raise ValidationException( diff --git a/tests/unit/config/test_subscription.py b/tests/unit/config/test_subscription.py index fd9e706c..e2631e66 100644 --- a/tests/unit/config/test_subscription.py +++ b/tests/unit/config/test_subscription.py @@ -1,8 +1,10 @@ import re +from contextlib import contextmanager from typing import Dict from unittest.mock import patch import pytest +from mergedeep import mergedeep from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.plugins.nfo_tags import NfoTagsOptions @@ -12,73 +14,70 @@ from ytdl_sub.utils.exceptions import ValidationException @pytest.fixture -def preset_file(youtube_video: Dict, output_options: Dict): +def config_file_with_subscription_value(config_file: ConfigFile): + config_dict = config_file.as_dict() + mergedeep.merge( + config_dict, {"configuration": {"subscription_value": "test_config_subscription_value"}} + ) + return ConfigFile.from_dict(config_dict) + + +@contextmanager +def mock_load_yaml(preset_dict: Dict) -> None: with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: - mock_load_yaml.return_value = { - "__preset__": { - "download": youtube_video, - "output_options": output_options, - "nfo_tags": { - "tags": {"key-3": "file_preset"}, - }, - "overrides": {"test_override": "original"}, - }, - "__value__": "test_override", - "test_preset": { - "preset": "parent_preset_3", - "nfo_tags": { - "tags": {"key-4": "test_preset"}, - }, - }, - } + mock_load_yaml.return_value = preset_dict yield @pytest.fixture -def preset_file_with_value(youtube_video: Dict, output_options: Dict): - with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: - mock_load_yaml.return_value = { - "__preset__": { - "preset": "parent_preset_3", - "download": youtube_video, - "output_options": output_options, - "nfo_tags": { - "tags": {"key-3": "file_preset"}, - }, - "overrides": {"test_override": "original"}, +def preset_with_file_preset(youtube_video: Dict, output_options: Dict): + return { + "__preset__": { + "download": youtube_video, + "output_options": output_options, + "nfo_tags": { + "nfo_name": "eh", + "nfo_root": "eh", + "tags": {"key-3": "file_preset"}, }, - "__value__": "test_override", + "overrides": { + "test_file_subscription_value": "original", + "test_config_subscription_value": "original", + }, + }, + "test_preset": { + "preset": "parent_preset_3", + "nfo_tags": { + "tags": {"key-4": "test_preset"}, + }, + }, + } + + +@pytest.fixture +def preset_with_subscription_value(preset_with_file_preset: Dict): + return dict( + preset_with_file_preset, + **{ "test_value": "is_overwritten", - } - yield + }, + ) @pytest.fixture -def preset_file_invalid_value(): - with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: - mock_load_yaml.return_value = { - "__value__": {"should be": "string"}, - } - yield +def preset_with_subscription_file_value(preset_with_subscription_value: Dict): + return dict( + preset_with_subscription_value, + **{ + "__value__": "test_file_subscription_value", + "test_value": "is_overwritten", + }, + ) -@pytest.fixture -def preset_file_subscription_using_value_when_not_defined(): - with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: - mock_load_yaml.return_value = {"sub_name": "single value, __value__ not defined"} - yield - - -@pytest.fixture -def preset_file_subscription_with_invalid_form(): - with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: - mock_load_yaml.return_value = {"sub_name": 4332} - yield - - -@pytest.mark.usefixtures(preset_file.__name__) -def test_subscription_file_preset_applies(config_file: ConfigFile): - subs = Subscription.from_file_path(config=config_file, subscription_path="mocked") +def test_subscription_file_preset_applies(config_file: ConfigFile, preset_with_file_preset: Dict): + with mock_load_yaml(preset_dict=preset_with_file_preset): + subs = Subscription.from_file_path(config=config_file, subscription_path="mocked") assert len(subs) == 1 # Test __preset__ worked correctly @@ -96,19 +95,73 @@ def test_subscription_file_preset_applies(config_file: ConfigFile): } -@pytest.mark.usefixtures(preset_file_with_value.__name__) -def test_subscription_file_value_applies(config_file: ConfigFile): - subs = Subscription.from_file_path(config=config_file, subscription_path="mocked") - assert len(subs) == 1 +def test_subscription_file_value_applies( + config_file: ConfigFile, preset_with_subscription_file_value: Dict +): + with mock_load_yaml(preset_dict=preset_with_subscription_file_value): + subs = Subscription.from_file_path(config=config_file, subscription_path="mocked") + assert len(subs) == 2 # Test __value__ worked correctly - value_sub = subs[0] - assert value_sub.overrides.dict_with_format_strings.get("test_override") == "is_overwritten" + value_sub = subs[1] + assert value_sub.name == "test_value" + assert ( + value_sub.overrides.dict_with_format_strings.get("test_file_subscription_value") + == "is_overwritten" + ) + assert ( + value_sub.overrides.dict_with_format_strings.get("test_config_subscription_value") + == "original" + ) + + +def test_subscription_file_value_applies_sub_file_takes_precedence( + config_file_with_subscription_value: ConfigFile, + preset_with_subscription_file_value: Dict, +): + with mock_load_yaml(preset_dict=preset_with_subscription_file_value): + subs = Subscription.from_file_path( + config=config_file_with_subscription_value, subscription_path="mocked" + ) + assert len(subs) == 2 + + # Test __value__ worked correctly + value_sub = subs[1] + assert value_sub.name == "test_value" + assert ( + value_sub.overrides.dict_with_format_strings.get("test_file_subscription_value") + == "is_overwritten" + ) + assert ( + value_sub.overrides.dict_with_format_strings.get("test_config_subscription_value") + == "original" + ) + + +def test_subscription_file_value_applies_from_config( + config_file_with_subscription_value: ConfigFile, preset_with_subscription_value: Dict +): + with mock_load_yaml(preset_dict=preset_with_subscription_value): + subs = Subscription.from_file_path( + config=config_file_with_subscription_value, subscription_path="mocked" + ) + assert len(subs) == 2 + + # Test __value__ worked correctly from the config + value_sub = subs[1] + assert value_sub.name == "test_value" + assert ( + value_sub.overrides.dict_with_format_strings.get("test_file_subscription_value") + == "original" + ) + assert ( + value_sub.overrides.dict_with_format_strings.get("test_config_subscription_value") + == "is_overwritten" + ) -@pytest.mark.usefixtures(preset_file_invalid_value.__name__) def test_subscription_file_bad_value(config_file: ConfigFile): - with pytest.raises( + with mock_load_yaml(preset_dict={"__value__": {"should be": "string"}}), pytest.raises( ValidationException, match=re.escape( f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription" @@ -118,21 +171,21 @@ def test_subscription_file_bad_value(config_file: ConfigFile): _ = Subscription.from_file_path(config=config_file, subscription_path="mocked") -@pytest.mark.usefixtures(preset_file_subscription_using_value_when_not_defined.__name__) def test_subscription_file_using_value_when_not_defined(config_file: ConfigFile): - with pytest.raises( + with mock_load_yaml( + preset_dict={"sub_name": "single value, __value__ not defined"} + ), pytest.raises( ValidationException, match=re.escape( f"Subscription sub_name is a string, but " - f"{FILE_SUBSCRIPTION_VALUE_KEY} is not set to an override variable" + f"the subscription value is not set to an override variable" ), ): _ = Subscription.from_file_path(config=config_file, subscription_path="mocked") -@pytest.mark.usefixtures(preset_file_subscription_with_invalid_form.__name__) def test_subscription_file_invalid_form(config_file: ConfigFile): - with pytest.raises( + with mock_load_yaml(preset_dict={"sub_name": 4332}), pytest.raises( ValidationException, match=re.escape(f"Subscription sub_name should be in the form of a preset"), ):