From 5078cad67cfeb2155395ae4205c25f341daaa446 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Thu, 28 Sep 2023 00:22:35 -0700 Subject: [PATCH] [FEATURE] Ability to nest presets in subscription files --- src/ytdl_sub/subscriptions/subscription.py | 46 +++----- .../subscriptions/subscription_validators.py | 111 ++++++++++++++++++ src/ytdl_sub/validators/validators.py | 30 +++-- tests/unit/config/test_subscription.py | 36 +++++- 4 files changed, 181 insertions(+), 42 deletions(-) create mode 100644 src/ytdl_sub/subscriptions/subscription_validators.py diff --git a/src/ytdl_sub/subscriptions/subscription.py b/src/ytdl_sub/subscriptions/subscription.py index 4c4c819e..1479ec28 100644 --- a/src/ytdl_sub/subscriptions/subscription.py +++ b/src/ytdl_sub/subscriptions/subscription.py @@ -1,4 +1,5 @@ import copy +from typing import Any from typing import Dict from typing import List from typing import Optional @@ -6,6 +7,7 @@ from typing import Optional 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.subscriptions.subscription_validators import SubscriptionValidator from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.yaml import load_yaml from ytdl_sub.validators.validators import LiteralDictValidator @@ -121,39 +123,21 @@ class Subscription(SubscriptionDownload): config = copy.deepcopy(config) config.presets.dict[FILE_PRESET_APPLY_KEY] = file_preset.dict - for subscription_key, subscription_object in subscription_dict.items(): + subscriptions_dict: Dict[str, Any] = { + key: obj + for key, obj in subscription_dict.items() + if key not in [FILE_PRESET_APPLY_KEY, FILE_SUBSCRIPTION_VALUE_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 file_subscription_value: - subscription_object = {"overrides": {file_subscription_value: subscription_object}} - elif isinstance(subscription_object, dict): - pass - elif isinstance(subscription_object, str) and not file_subscription_value: - raise ValidationException( - f"Subscription {subscription_key} is a string, but the subscription value " - f"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", []) - # Preset can be a single string - if isinstance(parent_preset, str): - parent_preset = [parent_preset] - - # If it's not a string or list, it will fail downstream - if isinstance(parent_preset, list): - subscription_object["preset"] = parent_preset + [FILE_PRESET_APPLY_KEY] + subscriptions_dicts = SubscriptionValidator( + name="", + value=subscriptions_dict, + config=config, + presets=[FILE_PRESET_APPLY_KEY] if has_file_preset else [], + subscription_value=file_subscription_value, + ).subscription_dicts() + for subscription_key, subscription_object in subscriptions_dicts.items(): subscriptions.append( cls.from_dict( config=config, diff --git a/src/ytdl_sub/subscriptions/subscription_validators.py b/src/ytdl_sub/subscriptions/subscription_validators.py new file mode 100644 index 00000000..23d5ce9e --- /dev/null +++ b/src/ytdl_sub/subscriptions/subscription_validators.py @@ -0,0 +1,111 @@ +import copy +from abc import ABC +from abc import abstractmethod +from typing import Dict +from typing import List +from typing import Optional + +from ytdl_sub.config.config_file import ConfigFile +from ytdl_sub.validators.validators import DictValidator +from ytdl_sub.validators.validators import StringValidator +from ytdl_sub.validators.validators import Validator + + +class SubscriptionOutput(Validator, ABC): + def __init__(self, name, value, presets: List[str]): + super().__init__(name, value) + self._presets = copy.deepcopy(presets) + + @abstractmethod + def subscription_dicts(self) -> Dict[str, Dict]: + """ + Returns + ------- + Subscriptions in the form of ``{ subscription_name: preset_dict }`` + """ + + +class SubscriptionPresetDictValidator(SubscriptionOutput, DictValidator): + def subscription_dicts(self) -> Dict[str, Dict]: + output_dict = copy.deepcopy(self._dict) + parent_presets = output_dict.get("preset", []) + + # Preset can be a single string + if isinstance(parent_presets, str): + parent_presets = [parent_presets] + + output_dict["preset"] = parent_presets + self._presets + return {self._leaf_name: output_dict} + + +class SubscriptionValueValidator(SubscriptionOutput, StringValidator): + def __init__(self, name, value, presets: List[str], subscription_value: Optional[str]): + super().__init__(name, value, presets) + if subscription_value is None: + raise self._validation_exception( + f"Subscription {name} is a string, but the subscription value " + f"is not set to an override variable" + ) + + self._subscription_value: str = subscription_value + + def subscription_dicts(self) -> Dict[str, Dict]: + return { + self._leaf_name: { + "preset": self._presets, + "overrides": {self._subscription_value: self.value}, + } + } + + +class SubscriptionValidator(SubscriptionOutput): + """ + Top-level subscription validator + """ + + def __init__( + self, name, value, config: ConfigFile, presets: List[str], subscription_value: Optional[str] + ): + super().__init__(name, value, presets) + self._children: List[SubscriptionOutput] = [] + + for key, obj in value.items(): + obj_name = f"{name}.{key}" if name else key + + if isinstance(obj, str): + if key in config.presets.keys: + raise self._validation_exception( + f"{key} conflicts with an existing preset name and cannot be " + f"used as a subscription name" + ) + + self._children.append( + SubscriptionValueValidator( + name=obj_name, + value=obj, + presets=presets, + subscription_value=subscription_value, + ) + ) + elif isinstance(value, dict): + if key in config.presets.keys: + self._children.append( + SubscriptionValidator( + name=obj_name, + value=obj, + config=config, + presets=presets + [key], + subscription_value=subscription_value, + ) + ) + else: + self._children.append( + SubscriptionPresetDictValidator(name=obj_name, value=obj, presets=presets) + ) + + def subscription_dicts(self) -> Dict[str, Dict]: + subscription_dicts: Dict[str, Dict] = {} + for child in self._children: + subscription_dicts = dict(subscription_dicts, **child.subscription_dicts()) + + return subscription_dicts diff --git a/src/ytdl_sub/validators/validators.py b/src/ytdl_sub/validators/validators.py index 97e59411..91b1cf6c 100644 --- a/src/ytdl_sub/validators/validators.py +++ b/src/ytdl_sub/validators/validators.py @@ -93,6 +93,26 @@ class Validator(ABC): """ return validation_exception(self._name, error_message, exception_class) + @final + @property + def _root_name(self) -> str: + """ + Returns + ------- + "first" from the first.element.of.the.name + """ + return self._name.split(".")[0] + + @final + @property + def _leaf_name(self) -> str: + """ + Returns + ------- + "first" from the first.element.of.the.name + """ + return self._name.split(".")[-1] + class ValueValidator(Validator, ABC, Generic[ValueT]): """ @@ -179,16 +199,6 @@ class DictValidator(Validator): super().__init__(name, value) self.__validator_dict: Dict[str, Validator] = {} - @final - @property - def _root_name(self) -> str: - """ - Returns - ------- - "first" from the first.element.of.the.name - """ - return self._name.split(".")[0] - @final @property def _dict(self) -> dict: diff --git a/tests/unit/config/test_subscription.py b/tests/unit/config/test_subscription.py index e2631e66..1c60adf5 100644 --- a/tests/unit/config/test_subscription.py +++ b/tests/unit/config/test_subscription.py @@ -74,6 +74,19 @@ def preset_with_subscription_file_value(preset_with_subscription_value: Dict): }, ) +@pytest.fixture +def preset_with_subscription_file_value_nested_presets(preset_with_subscription_value: Dict): + return dict( + preset_with_subscription_value, + **{ + "parent_preset_2": { + "parent_preset_1": { + "test_2_1": "is_2_1_overwritten" + }, + "test_1": "is_1_overwritten" + } + } + ) def test_subscription_file_preset_applies(config_file: ConfigFile, preset_with_file_preset: Dict): with mock_load_yaml(preset_dict=preset_with_file_preset): @@ -160,6 +173,27 @@ def test_subscription_file_value_applies_from_config( ) +def test_subscription_file_value_applies_from_config_and_nested( + config_file_with_subscription_value: ConfigFile, preset_with_subscription_file_value_nested_presets: Dict +): + with mock_load_yaml(preset_dict=preset_with_subscription_file_value_nested_presets): + 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" + ) + def test_subscription_file_bad_value(config_file: ConfigFile): with mock_load_yaml(preset_dict={"__value__": {"should be": "string"}}), pytest.raises( ValidationException, @@ -187,6 +221,6 @@ def test_subscription_file_using_value_when_not_defined(config_file: ConfigFile) def test_subscription_file_invalid_form(config_file: ConfigFile): 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"), + match=re.escape(f"Validation error in sub_name: should be of type object."), ): _ = Subscription.from_file_path(config=config_file, subscription_path="mocked")