diff --git a/docs/config.rst b/docs/config.rst index 225034a3..99ce10cc 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -306,6 +306,32 @@ 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 +^^^^^^^^^^^^^^^^^^^^^^^ +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. +Using the example above, we can do: + +.. code-block:: yaml + :caption: subscription.yaml + + __preset__: + preset: "playlist_preset_ex" + overrides: + playlist_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. + +Traditional subscriptions that can override presets will still work when using ``__value__``. + ------------------------------------------------------------------------------- .. _source-variables: diff --git a/src/ytdl_sub/subscriptions/subscription.py b/src/ytdl_sub/subscriptions/subscription.py index 395958b7..829505f8 100644 --- a/src/ytdl_sub/subscriptions/subscription.py +++ b/src/ytdl_sub/subscriptions/subscription.py @@ -5,10 +5,12 @@ from typing import List from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.preset import Preset from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload +from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.yaml import load_yaml from ytdl_sub.validators.validators import LiteralDictValidator FILE_PRESET_APPLY_KEY = "__preset__" +FILE_SUBSCRIPTION_VALUE_KEY = "__value__" class Subscription(SubscriptionDownload): @@ -64,7 +66,9 @@ class Subscription(SubscriptionDownload): @classmethod def from_file_path(cls, config: ConfigFile, subscription_path: str) -> List["Subscription"]: """ - Loads subscriptions from a file and applies __preset__ to all of them if present. + Loads subscriptions from a file and applies ``__preset__`` to all of them if present. + If a subscription is in the form of key: value, it will set value to the override + variable defined in ``__value__``. Parameters ---------- @@ -76,11 +80,17 @@ class Subscription(SubscriptionDownload): Returns ------- List of subscriptions, for each one in the subscription yaml + + Raises + ------ + ValidationException + If subscription file is misconfigured """ subscriptions: List["Subscription"] = [] 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 # If a file preset is present... if has_file_preset: @@ -94,12 +104,39 @@ 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 - if subscription_key == FILE_PRESET_APPLY_KEY: + # Skip file preset or value + if subscription_key in [FILE_PRESET_APPLY_KEY, FILE_SUBSCRIPTION_VALUE_KEY]: continue + # 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 + } + } + elif isinstance(subscription_object, dict): + pass + elif isinstance(subscription_object, str) and not has_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" + ) + else: + raise ValidationException( + f"Subscription {subscription_key} should be in the form of a preset" + ) + # If it has file_preset, inject it as a parent preset if has_file_preset: parent_preset = subscription_object.get("preset", []) diff --git a/tests/unit/config/test_subscription.py b/tests/unit/config/test_subscription.py index 512a34d0..fd9e706c 100644 --- a/tests/unit/config/test_subscription.py +++ b/tests/unit/config/test_subscription.py @@ -1,3 +1,4 @@ +import re from typing import Dict from unittest.mock import patch @@ -5,44 +6,134 @@ import pytest from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.plugins.nfo_tags import NfoTagsOptions +from ytdl_sub.subscriptions.subscription import FILE_SUBSCRIPTION_VALUE_KEY from ytdl_sub.subscriptions.subscription import Subscription +from ytdl_sub.utils.exceptions import ValidationException @pytest.fixture -def preset_file(youtube_video: Dict, output_options: Dict) -> Dict: - return { - "__preset__": { - "download": youtube_video, - "output_options": output_options, - "nfo_tags": { - "tags": {"key-3": "file_preset"}, +def preset_file(youtube_video: Dict, output_options: Dict): + 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"}, }, - }, - "test_preset": { - "preset": "parent_preset_3", - "nfo_tags": { - "tags": {"key-4": "test_preset"}, + "__value__": "test_override", + "test_preset": { + "preset": "parent_preset_3", + "nfo_tags": { + "tags": {"key-4": "test_preset"}, + }, }, - }, + } + 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"}, + }, + "__value__": "test_override", + "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 + + +@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") + assert len(subs) == 1 + + # Test __preset__ worked correctly + preset_sub = subs[0] + nfo_options: NfoTagsOptions = preset_sub.plugins.get(NfoTagsOptions) + tags_string_dict = { + key: formatter[0].format_string for key, formatter in nfo_options.tags.string_tags.items() + } + + assert tags_string_dict == { + "key-1": "preset_0", + "key-2": "preset_2", + "key-3": "file_preset", + "key-4": "test_preset", } -def test_subscription_file_preset_applies(config_file: ConfigFile, preset_file: Dict): - with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: - mock_load_yaml.return_value = preset_file +@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 - subs = Subscription.from_file_path(config=config_file, subscription_path="mocked") - assert len(subs) == 1 + # Test __value__ worked correctly + value_sub = subs[0] + assert value_sub.overrides.dict_with_format_strings.get("test_override") == "is_overwritten" - nfo_options: NfoTagsOptions = subs[0].plugins.get(NfoTagsOptions) - tags_string_dict = { - key: formatter[0].format_string - for key, formatter in nfo_options.tags.string_tags.items() - } - assert tags_string_dict == { - "key-1": "preset_0", - "key-2": "preset_2", - "key-3": "file_preset", - "key-4": "test_preset", - } +@pytest.mark.usefixtures(preset_file_invalid_value.__name__) +def test_subscription_file_bad_value(config_file: ConfigFile): + with pytest.raises( + ValidationException, + match=re.escape( + f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription" + f"must be a string that corresponds to an override variable" + ), + ): + _ = 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( + ValidationException, + match=re.escape( + f"Subscription sub_name is a string, but " + f"{FILE_SUBSCRIPTION_VALUE_KEY} 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( + ValidationException, + match=re.escape(f"Subscription sub_name should be in the form of a preset"), + ): + _ = Subscription.from_file_path(config=config_file, subscription_path="mocked")