[FEATURE] Add multiple preset inheritance (#204)

This commit is contained in:
Jesse Bannon 2022-09-01 10:43:07 -07:00 committed by GitHub
parent 5782445366
commit 6d84e52467
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 176 additions and 51 deletions

View file

@ -157,6 +157,8 @@ Presets support inheritance by defining a parent preset:
.. code-block:: yaml .. code-block:: yaml
presets: presets:
custom_preset:
...
parent_preset: parent_preset:
... ...
child_preset: child_preset:
@ -166,6 +168,19 @@ In the example above, ``child_preset`` inherits all fields defined in ``parent_p
It is advantageous to use parent presets where possible to reduce duplicate yaml It is advantageous to use parent presets where possible to reduce duplicate yaml
definitions. definitions.
Presets also support inheritance from multiple presets:
.. code-block:: yaml
child_preset:
preset:
- "custom_preset"
- "parent_preset"
In this example, ``child_preset`` will inherit all fields from ``custom_preset``
and ``parent_preset`` in that order. The bottom-most preset has the highest
priority.
------------------------------------------------------------------------------- -------------------------------------------------------------------------------

View file

@ -6,6 +6,7 @@ from typing import List
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from typing import Type from typing import Type
from typing import TypeVar
from typing import Union from typing import Union
from mergedeep import mergedeep from mergedeep import mergedeep
@ -27,6 +28,7 @@ from ytdl_sub.validators.string_formatter_validators import OverridesDictFormatt
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.validators import DictValidator from ytdl_sub.validators.validators import DictValidator
from ytdl_sub.validators.validators import StringListValidator
from ytdl_sub.validators.validators import StringValidator from ytdl_sub.validators.validators import StringValidator
from ytdl_sub.validators.validators import Validator from ytdl_sub.validators.validators import Validator
@ -41,6 +43,8 @@ PRESET_KEYS = {
class PresetPlugins: class PresetPlugins:
_TPluginOptions = TypeVar("_TPluginOptions", bound=PluginOptions)
def __init__(self): def __init__(self):
self.plugin_types: List[Type[Plugin]] = [] self.plugin_types: List[Type[Plugin]] = []
self.plugin_options: List[PluginOptions] = [] self.plugin_options: List[PluginOptions] = []
@ -61,6 +65,22 @@ class PresetPlugins:
""" """
return zip(self.plugin_types, self.plugin_options) return zip(self.plugin_types, self.plugin_options)
def get(self, plugin_type: Type[_TPluginOptions]) -> Optional[_TPluginOptions]:
"""
Parameters
----------
plugin_type
Fetch the plugin options for this type
Returns
-------
Options of this plugin if they exit. Otherwise, return None.
"""
plugin_option_types = [type(plugin_options) for plugin_options in self.plugin_options]
if plugin_type in plugin_option_types:
return self.plugin_options[plugin_option_types.index(plugin_type)]
return None
class DownloadStrategyValidator(StrictDictValidator): class DownloadStrategyValidator(StrictDictValidator):
""" """
@ -248,36 +268,40 @@ class Preset(StrictDictValidator):
self.__validate_override_string_formatter_validator(validator_value) self.__validate_override_string_formatter_validator(validator_value)
def __merge_parent_preset_dicts_if_present(self, config: ConfigFile): def __merge_parent_preset_dicts_if_present(self, config: ConfigFile):
parent_presets = set()
parent_preset_validator = self._validate_key_if_present( parent_preset_validator = self._validate_key_if_present(
key="preset", validator=StringValidator key="preset", validator=StringListValidator
) )
parent_preset = parent_preset_validator.value if parent_preset_validator else None
while parent_preset: if parent_preset_validator is None:
# Make sure the parent preset actually exists return
if parent_preset not in config.presets.keys:
raise self._validation_exception( for parent_preset in [preset.value for preset in parent_preset_validator.list]:
f"preset '{parent_preset}' does not exist in the provided config. " parent_presets = set()
f"Available presets: {', '.join(config.presets.keys)}"
while parent_preset:
# Make sure the parent preset actually exists
if parent_preset not in config.presets.keys:
raise self._validation_exception(
f"preset '{parent_preset}' does not exist in the provided config. "
f"Available presets: {', '.join(config.presets.keys)}"
)
# Make sure we do not hit an infinite loop
if parent_preset in parent_presets:
raise self._validation_exception(
f"preset loop detected with the preset '{parent_preset}'"
)
parent_preset_dict = copy.deepcopy(config.presets.dict[parent_preset])
parent_presets.add(parent_preset)
parent_preset = parent_preset_dict.get("preset")
# Override the parent preset with the contents of this preset
self._value = mergedeep.merge(
parent_preset_dict, self._value, strategy=mergedeep.Strategy.REPLACE
) )
# Make sure we do not hit an infinite loop
if parent_preset in parent_presets:
raise self._validation_exception(
f"preset loop detected with the preset '{parent_preset}'"
)
parent_preset_dict = copy.deepcopy(config.presets.dict[parent_preset])
parent_presets.add(parent_preset)
parent_preset = parent_preset_dict.get("preset")
# Override the parent preset with the contents of this preset
self._value = mergedeep.merge(
parent_preset_dict, self._value, strategy=mergedeep.Strategy.REPLACE
)
def __init__(self, config: ConfigFile, name: str, value: Any): def __init__(self, config: ConfigFile, name: str, value: Any):
super().__init__(name=name, value=value) super().__init__(name=name, value=value)

View file

@ -4,13 +4,39 @@ import pytest
from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.preset import Preset from ytdl_sub.config.preset import Preset
from ytdl_sub.plugins.nfo_tags import NfoTagsOptions
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture @pytest.fixture
def config_file() -> ConfigFile: def config_file() -> ConfigFile:
return ConfigFile( return ConfigFile(
name="config", value={"configuration": {"working_directory": "."}, "presets": {}} name="config",
value={
"configuration": {"working_directory": "."},
"presets": {
"parent_preset_0": {"nfo_tags": {"tags": {"key-1": "preset_0"}}},
"parent_preset_1": {
"preset": "parent_preset_0",
"nfo_tags": {
"nfo_name": "{uid}.nfo",
"nfo_root": "root",
"tags": {"key-2": "preset_1"},
},
},
"parent_preset_2": {
"nfo_tags": {
"nfo_name": "{uid}.nfo",
"nfo_root": "root",
"tags": {"key-2": "preset_2", "key-3": "preset_2"},
}
},
"preset_self_loop": {"preset": "preset_self_loop"},
"preset_loop_0": {"preset": "preset_loop_1"},
"preset_loop_1": {"preset": "preset_loop_0"},
},
},
) )
@ -22,6 +48,14 @@ def output_options() -> Dict:
} }
@pytest.fixture
def youtube_video() -> Dict:
return {
"download_strategy": "video",
"video_url": "youtube.com/watch?v=123abc",
}
class TestPreset: class TestPreset:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"source, download_strategy", "source, download_strategy",
@ -48,21 +82,79 @@ class TestPreset:
value={source: download_strategy, "output_options": output_options}, value={source: download_strategy, "output_options": output_options},
) )
def test_preset_with_override_variable(self, config_file, output_options): def test_preset_with_override_variable(self, config_file, output_options, youtube_video):
_ = Preset( _ = Preset(
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": { "youtube": youtube_video,
"download_strategy": "video",
"video_url": "youtube.com/watch?v=123abc",
},
"output_options": {"output_directory": "dir", "file_name": "{dne_var}"}, "output_options": {"output_directory": "dir", "file_name": "{dne_var}"},
"overrides": {"dne_var": "not dne"}, "overrides": {"dne_var": "not dne"},
}, },
) )
def test_preset_error__source_variable_does_not_exist(self, config_file, output_options): def test_preset_parent(self, config_file, output_options, youtube_video):
preset = Preset(
config=config_file,
name="test",
value={
"preset": "parent_preset_1",
"youtube": youtube_video,
"output_options": output_options,
"nfo_tags": {"tags": {"key-2": "this-preset"}},
},
)
nfo_options: NfoTagsOptions = preset.plugins.get(NfoTagsOptions)
tags_string_dict = {
key: formatter.format_string for key, formatter in nfo_options.tags.string_tags.items()
}
assert tags_string_dict == {"key-1": "preset_0", "key-2": "this-preset"}
def test_preset_multiple_parents(self, config_file, output_options, youtube_video):
preset = Preset(
config=config_file,
name="test",
value={
"preset": ["parent_preset_1", "parent_preset_2"],
"youtube": youtube_video,
"output_options": output_options,
"nfo_tags": {"tags": {"key-3": "this-preset"}},
},
)
nfo_options: NfoTagsOptions = preset.plugins.get(NfoTagsOptions)
tags_string_dict = {
key: formatter.format_string for key, formatter in nfo_options.tags.string_tags.items()
}
assert tags_string_dict == {
"key-1": "preset_0",
"key-2": "preset_1",
"key-3": "this-preset",
}
@pytest.mark.parametrize(
"parent_preset", ["preset_self_loop", "preset_loop_0", "preset_loop_1"]
)
def test_preset_error__parent_loop(
self, config_file, output_options, youtube_video, parent_preset
):
with pytest.raises(ValidationException, match="preset loop detected"):
_ = Preset(
config=config_file,
name="test",
value={
"preset": parent_preset,
"youtube": youtube_video,
"output_options": output_options,
},
)
def test_preset_error__source_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises( with pytest.raises(
StringFormattingVariableNotFoundException, StringFormattingVariableNotFoundException,
match="Format variable 'dne_var' does not exist", match="Format variable 'dne_var' does not exist",
@ -71,15 +163,14 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": { "youtube": youtube_video,
"download_strategy": "video",
"video_url": "youtube.com/watch?v=123abc",
},
"output_options": {"output_directory": "dir", "file_name": "{dne_var}"}, "output_options": {"output_directory": "dir", "file_name": "{dne_var}"},
}, },
) )
def test_preset_error__override_variable_does_not_exist(self, config_file, output_options): def test_preset_error__override_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises( with pytest.raises(
StringFormattingVariableNotFoundException, StringFormattingVariableNotFoundException,
match="Override variable 'dne_var' does not exist", match="Override variable 'dne_var' does not exist",
@ -88,15 +179,14 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": { "youtube": youtube_video,
"download_strategy": "video",
"video_url": "youtube.com/watch?v=123abc",
},
"output_options": {"output_directory": "{dne_var}", "file_name": "file"}, "output_options": {"output_directory": "{dne_var}", "file_name": "file"},
}, },
) )
def test_preset_error__dict_source_variable_does_not_exist(self, config_file, output_options): def test_preset_error__dict_source_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises( with pytest.raises(
StringFormattingVariableNotFoundException, StringFormattingVariableNotFoundException,
match="Format variable 'dne_var' does not exist", match="Format variable 'dne_var' does not exist",
@ -105,10 +195,7 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": { "youtube": youtube_video,
"download_strategy": "video",
"video_url": "youtube.com/watch?v=123abc",
},
"output_options": {"output_directory": "dir", "file_name": "file"}, "output_options": {"output_directory": "dir", "file_name": "file"},
"nfo_tags": { "nfo_tags": {
"nfo_name": "the nfo name", "nfo_name": "the nfo name",
@ -118,7 +205,9 @@ class TestPreset:
}, },
) )
def test_preset_error__dict_override_variable_does_not_exist(self, config_file, output_options): def test_preset_error__dict_override_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises( with pytest.raises(
StringFormattingVariableNotFoundException, StringFormattingVariableNotFoundException,
match="Override variable 'dne_var' does not exist", match="Override variable 'dne_var' does not exist",
@ -127,10 +216,7 @@ class TestPreset:
config=config_file, config=config_file,
name="test", name="test",
value={ value={
"youtube": { "youtube": youtube_video,
"download_strategy": "video",
"video_url": "youtube.com/watch?v=123abc",
},
"output_options": {"output_directory": "dir", "file_name": "file"}, "output_options": {"output_directory": "dir", "file_name": "file"},
"output_directory_nfo_tags": { "output_directory_nfo_tags": {
"nfo_name": "the nfo name", "nfo_name": "the nfo name",