[FEATURE] Add multiple preset inheritance (#204)
This commit is contained in:
parent
5782445366
commit
6d84e52467
3 changed files with 176 additions and 51 deletions
|
|
@ -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.
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue