[BACKEND] More strict validation for config files (#301)
This commit is contained in:
parent
3e4a995f6e
commit
44ec463ab3
27 changed files with 579 additions and 117 deletions
|
|
@ -86,14 +86,15 @@ presets:
|
||||||
|
|
||||||
# Preset to download subtitles (either by file or embedded)
|
# Preset to download subtitles (either by file or embedded)
|
||||||
add_subtitles:
|
add_subtitles:
|
||||||
# Embed subtitles into the video
|
subtitles:
|
||||||
embed_subtitles: True
|
# Embed subtitles into the video
|
||||||
# And/or download them as a file. Uncomment to download as file:
|
embed_subtitles: True
|
||||||
# subtitles_name: "{episode_file_path}.{lang}.{subtitles_ext}"
|
# And/or download them as a file. Uncomment to download as file:
|
||||||
# subtitles_type: "srt"
|
# subtitles_name: "{episode_file_path}.{lang}.{subtitles_ext}"
|
||||||
|
# subtitles_type: "srt"
|
||||||
|
|
||||||
languages: "en" # supports list of multiple languages
|
languages: "en" # supports list of multiple languages
|
||||||
allow_auto_generated_subtitles: True # allow auto subtitles
|
allow_auto_generated_subtitles: True # allow auto subtitles
|
||||||
|
|
||||||
####################################################################################################
|
####################################################################################################
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Tuple
|
||||||
from mergedeep import mergedeep
|
from mergedeep import mergedeep
|
||||||
|
|
||||||
from ytdl_sub.cli.main_args_parser import MainArgs
|
from ytdl_sub.cli.main_args_parser import MainArgs
|
||||||
from ytdl_sub.config.config_file import ConfigOptions
|
from ytdl_sub.config.config_validator import ConfigOptions
|
||||||
from ytdl_sub.utils.exceptions import InvalidDlArguments
|
from ytdl_sub.utils.exceptions import InvalidDlArguments
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,19 @@
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import mergedeep
|
from ytdl_sub.config.config_validator import ConfigValidator
|
||||||
|
from ytdl_sub.config.preset import Preset
|
||||||
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
|
|
||||||
from ytdl_sub.utils.yaml import load_yaml
|
from ytdl_sub.utils.yaml import load_yaml
|
||||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
|
||||||
from ytdl_sub.validators.validators import LiteralDictValidator
|
|
||||||
from ytdl_sub.validators.validators import StringValidator
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigOptions(StrictDictValidator):
|
class ConfigFile(ConfigValidator):
|
||||||
_required_keys = {"working_directory"}
|
|
||||||
_optional_keys = {"umask", "dl_aliases"}
|
|
||||||
|
|
||||||
def __init__(self, name: str, value: Any):
|
|
||||||
super().__init__(name, value)
|
|
||||||
|
|
||||||
self._working_directory = self._validate_key(
|
|
||||||
key="working_directory", validator=StringValidator
|
|
||||||
)
|
|
||||||
self._umask = self._validate_key_if_present(
|
|
||||||
key="umask", validator=StringValidator, default="022"
|
|
||||||
)
|
|
||||||
self._dl_aliases = self._validate_key_if_present(
|
|
||||||
key="dl_aliases", validator=LiteralDictValidator
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def working_directory(self) -> str:
|
|
||||||
"""
|
|
||||||
The directory to temporarily store downloaded files before moving them into their final
|
|
||||||
directory.
|
|
||||||
"""
|
|
||||||
return self._working_directory.value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def umask(self) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Umask (octal format) to apply to every created file. Defaults to "022".
|
|
||||||
"""
|
|
||||||
return self._umask.value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dl_aliases(self) -> Optional[Dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Alias definitions to shorten ``ytdl-sub dl`` arguments. For example,
|
|
||||||
|
|
||||||
.. code-block:: yaml
|
|
||||||
|
|
||||||
configuration:
|
|
||||||
dl_aliases:
|
|
||||||
mv: "--preset yt_music_video"
|
|
||||||
v: "--youtube.video_url"
|
|
||||||
|
|
||||||
Simplifies
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
ytdl-sub dl --preset "yt_music_video" --youtube.video_url "youtube.com/watch?v=a1b2c3"
|
|
||||||
|
|
||||||
to
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
ytdl-sub dl --mv --v "youtube.com/watch?v=a1b2c3"
|
|
||||||
"""
|
|
||||||
if self._dl_aliases:
|
|
||||||
return self._dl_aliases.dict
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFile(StrictDictValidator):
|
|
||||||
_required_keys = {"configuration", "presets"}
|
_required_keys = {"configuration", "presets"}
|
||||||
|
|
||||||
def __init__(self, name: str, value: Any):
|
def __init__(self, name: str, value: Any):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self.config_options = self._validate_key("configuration", ConfigOptions)
|
|
||||||
|
|
||||||
prebuilt_presets = PREBUILT_PRESETS
|
for preset_name, preset_dict in self.presets.dict.items():
|
||||||
|
Preset.preset_partial_validate(config=self, name=preset_name, value=preset_dict)
|
||||||
# Make sure presets is a dictionary. Will be validated in `PresetValidator`
|
|
||||||
self.presets = self._validate_key("presets", LiteralDictValidator)
|
|
||||||
|
|
||||||
# Ensure custom presets do not collide with prebuilt presets
|
|
||||||
for preset_name in self.presets.keys:
|
|
||||||
if preset_name in prebuilt_presets:
|
|
||||||
raise self._validation_exception(
|
|
||||||
f"preset name '{preset_name}' conflicts with a prebuilt preset"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Merge prebuilt presets into the config so custom presets can use them
|
|
||||||
mergedeep.merge(self.presets._value, prebuilt_presets)
|
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
92
src/ytdl_sub/config/config_validator.py
Normal file
92
src/ytdl_sub/config/config_validator.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
from typing import Any
|
||||||
|
from typing import Dict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from mergedeep import mergedeep
|
||||||
|
|
||||||
|
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
|
||||||
|
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||||
|
from ytdl_sub.validators.validators import LiteralDictValidator
|
||||||
|
from ytdl_sub.validators.validators import StringValidator
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigOptions(StrictDictValidator):
|
||||||
|
_required_keys = {"working_directory"}
|
||||||
|
_optional_keys = {"umask", "dl_aliases"}
|
||||||
|
|
||||||
|
def __init__(self, name: str, value: Any):
|
||||||
|
super().__init__(name, value)
|
||||||
|
|
||||||
|
self._working_directory = self._validate_key(
|
||||||
|
key="working_directory", validator=StringValidator
|
||||||
|
)
|
||||||
|
self._umask = self._validate_key_if_present(
|
||||||
|
key="umask", validator=StringValidator, default="022"
|
||||||
|
)
|
||||||
|
self._dl_aliases = self._validate_key_if_present(
|
||||||
|
key="dl_aliases", validator=LiteralDictValidator
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def working_directory(self) -> str:
|
||||||
|
"""
|
||||||
|
The directory to temporarily store downloaded files before moving them into their final
|
||||||
|
directory.
|
||||||
|
"""
|
||||||
|
return self._working_directory.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def umask(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Umask (octal format) to apply to every created file. Defaults to "022".
|
||||||
|
"""
|
||||||
|
return self._umask.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dl_aliases(self) -> Optional[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Alias definitions to shorten ``ytdl-sub dl`` arguments. For example,
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
configuration:
|
||||||
|
dl_aliases:
|
||||||
|
mv: "--preset yt_music_video"
|
||||||
|
v: "--youtube.video_url"
|
||||||
|
|
||||||
|
Simplifies
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
ytdl-sub dl --preset "yt_music_video" --youtube.video_url "youtube.com/watch?v=a1b2c3"
|
||||||
|
|
||||||
|
to
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
ytdl-sub dl --mv --v "youtube.com/watch?v=a1b2c3"
|
||||||
|
"""
|
||||||
|
if self._dl_aliases:
|
||||||
|
return self._dl_aliases.dict
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValidator(StrictDictValidator):
|
||||||
|
_required_keys = {"configuration", "presets"}
|
||||||
|
|
||||||
|
def __init__(self, name: str, value: Any):
|
||||||
|
super().__init__(name, value)
|
||||||
|
self.config_options = self._validate_key("configuration", ConfigOptions)
|
||||||
|
|
||||||
|
# Make sure presets is a dictionary. Will be validated in `PresetValidator`
|
||||||
|
self.presets = self._validate_key("presets", LiteralDictValidator)
|
||||||
|
|
||||||
|
# Ensure custom presets do not collide with prebuilt presets
|
||||||
|
for preset_name in self.presets.keys:
|
||||||
|
if preset_name in PREBUILT_PRESETS:
|
||||||
|
raise self._validation_exception(
|
||||||
|
f"preset name '{preset_name}' conflicts with a prebuilt preset"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge prebuilt presets into the config so custom presets can use them
|
||||||
|
mergedeep.merge(self.presets._value, PREBUILT_PRESETS)
|
||||||
|
|
@ -11,7 +11,7 @@ from typing import Union
|
||||||
|
|
||||||
from mergedeep import mergedeep
|
from mergedeep import mergedeep
|
||||||
|
|
||||||
from ytdl_sub.config.config_file import ConfigFile
|
from ytdl_sub.config.config_validator import ConfigValidator
|
||||||
from ytdl_sub.config.preset_class_mappings import DownloadStrategyMapping
|
from ytdl_sub.config.preset_class_mappings import DownloadStrategyMapping
|
||||||
from ytdl_sub.config.preset_class_mappings import PluginMapping
|
from ytdl_sub.config.preset_class_mappings import PluginMapping
|
||||||
from ytdl_sub.config.preset_options import OutputOptions
|
from ytdl_sub.config.preset_options import OutputOptions
|
||||||
|
|
@ -22,6 +22,9 @@ from ytdl_sub.downloaders.downloader import DownloaderValidator
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.plugins.plugin import Plugin
|
from ytdl_sub.plugins.plugin import Plugin
|
||||||
from ytdl_sub.plugins.plugin import PluginOptions
|
from ytdl_sub.plugins.plugin import PluginOptions
|
||||||
|
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES
|
||||||
|
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
|
||||||
|
from ytdl_sub.utils.exceptions import ValidationException
|
||||||
from ytdl_sub.utils.yaml import dump_yaml
|
from ytdl_sub.utils.yaml import dump_yaml
|
||||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||||
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
|
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
|
||||||
|
|
@ -33,6 +36,7 @@ from ytdl_sub.validators.validators import ListValidator
|
||||||
from ytdl_sub.validators.validators import StringListValidator
|
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
|
||||||
|
from ytdl_sub.validators.validators import validation_exception
|
||||||
|
|
||||||
PRESET_KEYS = {
|
PRESET_KEYS = {
|
||||||
"preset",
|
"preset",
|
||||||
|
|
@ -44,6 +48,19 @@ PRESET_KEYS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_preset_error_message(
|
||||||
|
current_preset_name: str, parent_preset_name: str, presets: List[str]
|
||||||
|
) -> ValidationException:
|
||||||
|
user_defined_presets = set(presets) - PREBUILT_PRESET_NAMES - {current_preset_name}
|
||||||
|
|
||||||
|
return validation_exception(
|
||||||
|
name=current_preset_name,
|
||||||
|
error_message=f"preset '{parent_preset_name}' does not exist in the provided config.\n"
|
||||||
|
f"Available prebuilt presets: {', '.join(sorted(PUBLISHED_PRESET_NAMES))}\n"
|
||||||
|
f"Your presets: {', '.join(sorted(user_defined_presets))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PresetPlugins:
|
class PresetPlugins:
|
||||||
_TPluginOptions = TypeVar("_TPluginOptions", bound=PluginOptions)
|
_TPluginOptions = TypeVar("_TPluginOptions", bound=PluginOptions)
|
||||||
|
|
||||||
|
|
@ -113,6 +130,11 @@ class DownloadStrategyValidator(StrictDictValidator):
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
The downloader class
|
The downloader class
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValidationException
|
||||||
|
If the download strategy is invalid
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return DownloadStrategyMapping.get(
|
return DownloadStrategyMapping.get(
|
||||||
|
|
@ -122,12 +144,90 @@ class DownloadStrategyValidator(StrictDictValidator):
|
||||||
raise self._validation_exception(error_message=value_exc)
|
raise self._validation_exception(error_message=value_exc)
|
||||||
|
|
||||||
|
|
||||||
class Preset(StrictDictValidator):
|
class _PresetShell(StrictDictValidator):
|
||||||
# Have all present keys optional since parent presets could not have all the
|
# Have all present keys optional since parent presets could not have all the
|
||||||
# required keys. They will get validated in the init after the mergedeep of dicts
|
# required keys. They will get validated in the init after the mergedeep of dicts
|
||||||
# and ensure required keys are present.
|
# and ensure required keys are present.
|
||||||
_optional_keys = PRESET_KEYS
|
_optional_keys = PRESET_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
class Preset(_PresetShell):
|
||||||
|
@classmethod
|
||||||
|
def _validate_download_strategy(cls, name: str, value: Dict) -> None:
|
||||||
|
sources: List[str] = []
|
||||||
|
for source_name in DownloadStrategyMapping.sources():
|
||||||
|
if source_name in value:
|
||||||
|
sources.append(source_name)
|
||||||
|
|
||||||
|
if len(sources) > 1:
|
||||||
|
raise validation_exception(
|
||||||
|
name=name,
|
||||||
|
error_message=f"Contains the sources {', '.join(sources)} but can only have one",
|
||||||
|
)
|
||||||
|
|
||||||
|
# If no sources, nothing more to validate
|
||||||
|
if not sources:
|
||||||
|
return
|
||||||
|
|
||||||
|
source_name = sources[0]
|
||||||
|
source_dict = copy.deepcopy(value[source_name])
|
||||||
|
|
||||||
|
downloader = DownloadStrategyValidator(name=f"{name}.{source_name}", value=source_dict).get(
|
||||||
|
downloader_source=source_name
|
||||||
|
)
|
||||||
|
del source_dict["download_strategy"]
|
||||||
|
|
||||||
|
downloader.downloader_options_type.partial_validate(
|
||||||
|
name=f"{name}.{source_name}", value=source_dict
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def preset_partial_validate(cls, config: ConfigValidator, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validates a preset. Used to ensure every preset in a ConfigFile looks sane.
|
||||||
|
Cannot fully validate each preset using the Preset init because required fields could
|
||||||
|
be missing, which become filled in a child preset.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config
|
||||||
|
Config that this preset belongs to
|
||||||
|
name
|
||||||
|
Preset name
|
||||||
|
value
|
||||||
|
Preset value
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValidationException
|
||||||
|
If validation fails
|
||||||
|
"""
|
||||||
|
# Ensure value is a dict
|
||||||
|
_ = _PresetShell(name=name, value=value)
|
||||||
|
assert isinstance(value, dict)
|
||||||
|
|
||||||
|
cls._validate_download_strategy(name, value)
|
||||||
|
cls._partial_validate_key(name, value, "output_options", OutputOptions)
|
||||||
|
cls._partial_validate_key(name, value, "ytdl_options", YTDLOptions)
|
||||||
|
cls._partial_validate_key(name, value, "overrides", Overrides)
|
||||||
|
|
||||||
|
for plugin_name in PluginMapping.plugins():
|
||||||
|
cls._partial_validate_key(
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
key=plugin_name,
|
||||||
|
validator=PluginMapping.get(plugin_name).plugin_options_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
parent_presets = StringListValidator(name=f"{name}.preset", value=value.get("preset", []))
|
||||||
|
for parent_preset_name in parent_presets.list:
|
||||||
|
if parent_preset_name.value not in config.presets.keys:
|
||||||
|
raise _parent_preset_error_message(
|
||||||
|
current_preset_name=name,
|
||||||
|
parent_preset_name=parent_preset_name.value,
|
||||||
|
presets=config.presets.keys,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _source_variables(self) -> List[str]:
|
def _source_variables(self) -> List[str]:
|
||||||
return Entry.source_variables()
|
return Entry.source_variables()
|
||||||
|
|
@ -278,7 +378,7 @@ class Preset(StrictDictValidator):
|
||||||
self.__validate_override_string_formatter_validator(validator_value)
|
self.__validate_override_string_formatter_validator(validator_value)
|
||||||
|
|
||||||
def _get_presets_to_merge(
|
def _get_presets_to_merge(
|
||||||
self, parent_presets: str | List[str], seen_presets: List[str], config: ConfigFile
|
self, parent_presets: str | List[str], seen_presets: List[str], config: ConfigValidator
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
presets_to_merge: List[Dict] = []
|
presets_to_merge: List[Dict] = []
|
||||||
|
|
||||||
|
|
@ -294,9 +394,10 @@ class Preset(StrictDictValidator):
|
||||||
|
|
||||||
# Make sure the parent preset actually exists
|
# Make sure the parent preset actually exists
|
||||||
if parent_preset not in config.presets.keys:
|
if parent_preset not in config.presets.keys:
|
||||||
raise self._validation_exception(
|
raise _parent_preset_error_message(
|
||||||
f"preset '{parent_preset}' does not exist in the provided config. "
|
current_preset_name=self._name,
|
||||||
f"Available presets: {', '.join(config.presets.keys)}"
|
parent_preset_name=parent_preset,
|
||||||
|
presets=config.presets.keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
parent_preset_dict = copy.deepcopy(config.presets.dict[parent_preset])
|
parent_preset_dict = copy.deepcopy(config.presets.dict[parent_preset])
|
||||||
|
|
@ -313,7 +414,7 @@ class Preset(StrictDictValidator):
|
||||||
|
|
||||||
return presets_to_merge
|
return presets_to_merge
|
||||||
|
|
||||||
def __merge_parent_preset_dicts_if_present(self, config: ConfigFile):
|
def __merge_parent_preset_dicts_if_present(self, config: ConfigValidator):
|
||||||
parent_preset_validator = self._validate_key_if_present(
|
parent_preset_validator = self._validate_key_if_present(
|
||||||
key="preset", validator=StringListValidator
|
key="preset", validator=StringListValidator
|
||||||
)
|
)
|
||||||
|
|
@ -332,7 +433,7 @@ class Preset(StrictDictValidator):
|
||||||
mergedeep.merge({}, *reversed(presets_to_merge), strategy=mergedeep.Strategy.ADDITIVE)
|
mergedeep.merge({}, *reversed(presets_to_merge), strategy=mergedeep.Strategy.ADDITIVE)
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, config: ConfigFile, name: str, value: Any):
|
def __init__(self, config: ConfigValidator, name: str, value: Any):
|
||||||
super().__init__(name=name, value=value)
|
super().__init__(name=name, value=value)
|
||||||
|
|
||||||
# Perform the merge of parent presets before validating any keys
|
# Perform the merge of parent presets before validating any keys
|
||||||
|
|
@ -367,7 +468,7 @@ class Preset(StrictDictValidator):
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, config: ConfigFile, preset_name: str, preset_dict: Dict) -> "Preset":
|
def from_dict(cls, config: ConfigValidator, preset_name: str, preset_dict: Dict) -> "Preset":
|
||||||
"""
|
"""
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -182,6 +183,18 @@ class OutputOptions(StrictDictValidator):
|
||||||
"keep_files_after",
|
"keep_files_after",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate output options
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["output_directory"] = value.get("output_directory", "placeholder")
|
||||||
|
value["file_name"] = value.get("file_name", "placeholder")
|
||||||
|
# Set this to True by default in partial validate to avoid failing from keep_files
|
||||||
|
value["maintain_download_archive"] = value.get("maintain_download_archive", True)
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -42,6 +43,15 @@ class CollectionUrlValidator(StrictDictValidator):
|
||||||
_required_keys = {"url"}
|
_required_keys = {"url"}
|
||||||
_optional_keys = {"variables", "source_thumbnails", "playlist_thumbnails"}
|
_optional_keys = {"variables", "source_thumbnails", "playlist_thumbnails"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate a YouTube collection url
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["url"] = value.get("url", "placeholder")
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
|
|
@ -149,6 +159,15 @@ class CollectionValidator(StrictDictValidator, AddsVariablesMixin):
|
||||||
|
|
||||||
_required_keys = {"urls"}
|
_required_keys = {"urls"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate a generic collection
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["urls"] = value.get("urls", [{"url": "placeholder"}])
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._urls = self._validate_key(key="urls", validator=CollectionUrlListValidator)
|
self._urls = self._validate_key(key="urls", validator=CollectionUrlListValidator)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
|
|
@ -32,6 +33,15 @@ class SoundcloudAlbumsAndSinglesDownloadOptions(DownloaderValidator):
|
||||||
_required_keys = {"url"}
|
_required_keys = {"url"}
|
||||||
_optional_keys = {"skip_premiere_tracks"}
|
_optional_keys = {"skip_premiere_tracks"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate a Soundcloud source
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["url"] = value.get("url", "https://soundcloud.com/jessebannon")
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._url = self._validate_key(
|
self._url = self._validate_key(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -34,6 +35,17 @@ class YoutubeChannelDownloaderOptions(DownloaderValidator):
|
||||||
"channel_banner_path",
|
"channel_banner_path",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate a YouTube channel source
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["channel_url"] = value.get(
|
||||||
|
"channel_url", "https://www.youtube.com/c/ProjectZombie603"
|
||||||
|
)
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._channel_url = self._validate_key(
|
self._channel_url = self._validate_key(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -31,6 +32,17 @@ class YoutubePlaylistDownloaderOptions(DownloaderValidator):
|
||||||
_required_keys = {"playlist_url"}
|
_required_keys = {"playlist_url"}
|
||||||
_optional_keys = {"playlist_thumbnail_name"}
|
_optional_keys = {"playlist_thumbnail_name"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate a YouTube playlist source
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["playlist_url"] = value.get(
|
||||||
|
"playlist_url", "https://www.youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg"
|
||||||
|
)
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._playlist_url = self._validate_key(
|
self._playlist_url = self._validate_key(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from ytdl_sub.downloaders.downloader import Downloader
|
from ytdl_sub.downloaders.downloader import Downloader
|
||||||
|
|
@ -32,6 +33,15 @@ class YoutubeVideoDownloaderOptions(DownloaderValidator):
|
||||||
|
|
||||||
_required_keys = {"video_url"}
|
_required_keys = {"video_url"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate a YouTube video source
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["video_url"] = value.get("video_url", "youtube.com/watch?v=VMAPTo7RVDo")
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._video_url = self._validate_key("video_url", YoutubeVideoUrlValidator).video_url
|
self._video_url = self._validate_key("video_url", YoutubeVideoUrlValidator).video_url
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import os.path
|
import os.path
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -27,7 +28,17 @@ class AudioExtractOptions(PluginOptions):
|
||||||
quality: 128
|
quality: 128
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_optional_keys = {"codec", "quality"}
|
_required_keys = {"codec"}
|
||||||
|
_optional_keys = {"quality"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate audio extract options
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["codec"] = value.get("codec", "mp3")
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -27,6 +28,15 @@ class FileConvertOptions(PluginOptions):
|
||||||
|
|
||||||
_required_keys = {"convert_to"}
|
_required_keys = {"convert_to"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate file_convert
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["convert_to"] = value.get("convert_to", "mp3")
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._convert_to = self._validate_key(key="convert_to", validator=FileTypeValidator).value
|
self._convert_to = self._validate_key(key="convert_to", validator=FileTypeValidator).value
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
import mediafile
|
import mediafile
|
||||||
|
|
@ -34,6 +35,15 @@ class MusicTagsOptions(PluginOptions):
|
||||||
|
|
||||||
_required_keys = {"tags"}
|
_required_keys = {"tags"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate music tags
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["tags"] = value.get("tags", {})
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import os
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -28,6 +29,18 @@ class SharedNfoTagsOptions(PluginOptions):
|
||||||
_required_keys = {"nfo_name", "nfo_root", "tags"}
|
_required_keys = {"nfo_name", "nfo_root", "tags"}
|
||||||
_optional_keys = {"kodi_safe"}
|
_optional_keys = {"kodi_safe"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate NFO tag options
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["nfo_name"] = value.get("nfo_name", "placeholder")
|
||||||
|
value["nfo_root"] = value.get("nfo_root", "placeholder")
|
||||||
|
value["tags"] = value.get("tags", {})
|
||||||
|
|
||||||
|
_ = cls(name=name, value=value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -149,6 +150,15 @@ class RegexOptions(PluginOptions):
|
||||||
_required_keys = {"from"}
|
_required_keys = {"from"}
|
||||||
_optional_keys = {"skip_if_match_fails"}
|
_optional_keys = {"skip_if_match_fails"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate regex
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["from"] = value.get("from", {})
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._from = self._validate_key(key="from", validator=FromSourceVariablesRegex)
|
self._from = self._validate_key(key="from", validator=FromSourceVariablesRegex)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import copy
|
import copy
|
||||||
import os.path
|
import os.path
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
@ -67,6 +68,12 @@ class SplitByChaptersOptions(PluginOptions):
|
||||||
|
|
||||||
_required_keys = {"when_no_chapters"}
|
_required_keys = {"when_no_chapters"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["when_no_chapters"] = value.get("when_no_chapters", "pass")
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._when_no_chapters = self._validate_key(
|
self._when_no_chapters = self._validate_key(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
|
|
@ -27,6 +28,15 @@ class VideoTagsOptions(PluginOptions):
|
||||||
|
|
||||||
_required_keys = {"tags"}
|
_required_keys = {"tags"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Partially validate video tags
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value["tags"] = value.get("tags", {})
|
||||||
|
_ = cls(name, value)
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._tags = self._validate_key(key="tags", validator=DictFormatterValidator)
|
self._tags = self._validate_key(key="tags", validator=DictFormatterValidator)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import pathlib
|
import pathlib
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
import mergedeep
|
import mergedeep
|
||||||
|
|
||||||
|
|
@ -21,3 +22,8 @@ def _merge_presets() -> Dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
PREBUILT_PRESETS: Dict[str, Any] = _merge_presets()
|
PREBUILT_PRESETS: Dict[str, Any] = _merge_presets()
|
||||||
|
|
||||||
|
PREBUILT_PRESET_NAMES: Set[str] = set(PREBUILT_PRESETS.keys())
|
||||||
|
PUBLISHED_PRESET_NAMES: Set[str] = {
|
||||||
|
name for name in PREBUILT_PRESET_NAMES if not name.startswith("_")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
presets:
|
|
||||||
|
|
||||||
kodi-music-videos:
|
|
||||||
preset:
|
|
||||||
- "jellyfin-music-videos"
|
|
||||||
- "kodi-safe"
|
|
||||||
|
|
@ -3,7 +3,7 @@ presets:
|
||||||
kodi_music_video:
|
kodi_music_video:
|
||||||
generic:
|
generic:
|
||||||
download_strategy: "source"
|
download_strategy: "source"
|
||||||
music_video_url: "{music_video_url}"
|
url: "{music_video_url}"
|
||||||
|
|
||||||
output_options:
|
output_options:
|
||||||
output_directory: "{music_video_directory}"
|
output_directory: "{music_video_directory}"
|
||||||
|
|
@ -18,6 +18,7 @@ presets:
|
||||||
|
|
||||||
collection_season_1:
|
collection_season_1:
|
||||||
generic:
|
generic:
|
||||||
|
download_strategy: "collection"
|
||||||
urls:
|
urls:
|
||||||
- url: "{collection_season_1_url}"
|
- url: "{collection_season_1_url}"
|
||||||
variables:
|
variables:
|
||||||
|
|
@ -42,6 +43,7 @@ presets:
|
||||||
|
|
||||||
collection_season_2:
|
collection_season_2:
|
||||||
generic:
|
generic:
|
||||||
|
download_strategy: "collection"
|
||||||
urls:
|
urls:
|
||||||
- url: "{collection_season_2_url}"
|
- url: "{collection_season_2_url}"
|
||||||
variables:
|
variables:
|
||||||
|
|
@ -60,6 +62,7 @@ presets:
|
||||||
|
|
||||||
collection_season_3:
|
collection_season_3:
|
||||||
generic:
|
generic:
|
||||||
|
download_strategy: "collection"
|
||||||
urls:
|
urls:
|
||||||
- url: "{collection_season_3_url}"
|
- url: "{collection_season_3_url}"
|
||||||
variables:
|
variables:
|
||||||
|
|
@ -78,6 +81,7 @@ presets:
|
||||||
|
|
||||||
collection_season_4:
|
collection_season_4:
|
||||||
generic:
|
generic:
|
||||||
|
download_strategy: "collection"
|
||||||
urls:
|
urls:
|
||||||
- url: "{collection_season_4_url}"
|
- url: "{collection_season_4_url}"
|
||||||
variables:
|
variables:
|
||||||
|
|
@ -96,6 +100,7 @@ presets:
|
||||||
|
|
||||||
collection_season_5:
|
collection_season_5:
|
||||||
generic:
|
generic:
|
||||||
|
download_strategy: "collection"
|
||||||
urls:
|
urls:
|
||||||
- url: "{collection_season_5_url}"
|
- url: "{collection_season_5_url}"
|
||||||
variables:
|
variables:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from abc import ABC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
from ytdl_sub.config.config_file import ConfigOptions
|
from ytdl_sub.config.config_validator import ConfigOptions
|
||||||
from ytdl_sub.config.preset import Preset
|
from ytdl_sub.config.preset import Preset
|
||||||
from ytdl_sub.config.preset import PresetPlugins
|
from ytdl_sub.config.preset import PresetPlugins
|
||||||
from ytdl_sub.config.preset_options import OutputOptions
|
from ytdl_sub.config.preset_options import OutputOptions
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,29 @@ ValidationExceptionT = TypeVar("ValidationExceptionT", bound=ValidationException
|
||||||
ValidatorT = TypeVar("ValidatorT", bound="Validator")
|
ValidatorT = TypeVar("ValidatorT", bound="Validator")
|
||||||
|
|
||||||
|
|
||||||
|
def validation_exception(
|
||||||
|
name: str,
|
||||||
|
error_message: str | Exception,
|
||||||
|
exception_class: Type[ValidationExceptionT] = ValidationException,
|
||||||
|
) -> ValidationExceptionT:
|
||||||
|
"""
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name
|
||||||
|
Name of the validator
|
||||||
|
error_message
|
||||||
|
Error message to include in the ValidationException
|
||||||
|
exception_class
|
||||||
|
Class of the exception
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Validation exception with a consistent prefix.
|
||||||
|
"""
|
||||||
|
prefix = f"Validation error in {name}: "
|
||||||
|
return exception_class(f"{prefix}{error_message}")
|
||||||
|
|
||||||
|
|
||||||
class Validator(ABC):
|
class Validator(ABC):
|
||||||
"""
|
"""
|
||||||
Used to validate the value of a python object. This is the 'base' class that will first
|
Used to validate the value of a python object. This is the 'base' class that will first
|
||||||
|
|
@ -29,6 +52,18 @@ class Validator(ABC):
|
||||||
# When raising an error, call the type this value instead of its python name
|
# When raising an error, call the type this value instead of its python name
|
||||||
_expected_value_type_name: Optional[str] = None
|
_expected_value_type_name: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def partial_validate(cls, name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name
|
||||||
|
Name of the validator
|
||||||
|
value
|
||||||
|
Value of the validator
|
||||||
|
"""
|
||||||
|
_ = cls(name=name, value=value)
|
||||||
|
|
||||||
def __init__(self, name: str, value: Any):
|
def __init__(self, name: str, value: Any):
|
||||||
self._name = name
|
self._name = name
|
||||||
self._value = copy.deepcopy(value) # Always deep copy to avoid editing references
|
self._value = copy.deepcopy(value) # Always deep copy to avoid editing references
|
||||||
|
|
@ -56,8 +91,7 @@ class Validator(ABC):
|
||||||
-------
|
-------
|
||||||
Validation exception with a consistent prefix.
|
Validation exception with a consistent prefix.
|
||||||
"""
|
"""
|
||||||
prefix = f"Validation error in {self._name}: "
|
return validation_exception(self._name, error_message, exception_class)
|
||||||
return exception_class(f"{prefix}{error_message}")
|
|
||||||
|
|
||||||
|
|
||||||
class ValueValidator(Validator, ABC, Generic[ValueT]):
|
class ValueValidator(Validator, ABC, Generic[ValueT]):
|
||||||
|
|
@ -231,6 +265,15 @@ class DictValidator(Validator):
|
||||||
|
|
||||||
return self._validate_key(key=key, validator=validator, default=default)
|
return self._validate_key(key=key, validator=validator, default=default)
|
||||||
|
|
||||||
|
@final
|
||||||
|
@classmethod
|
||||||
|
def _partial_validate_key(
|
||||||
|
cls, name: str, value: Any, key: str, validator: Type[ValidatorT]
|
||||||
|
) -> None:
|
||||||
|
value_dict = DictValidator(name=name, value=value)
|
||||||
|
if key in value_dict._dict:
|
||||||
|
validator.partial_validate(name=f"{name}.{key}", value=value_dict._dict[key])
|
||||||
|
|
||||||
|
|
||||||
class LiteralDictValidator(DictValidator):
|
class LiteralDictValidator(DictValidator):
|
||||||
"""DictValidator with exposed dict and keys method"""
|
"""DictValidator with exposed dict and keys method"""
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import pytest
|
||||||
from ytdl_sub.cli.download_args_parser import DownloadArgsParser
|
from ytdl_sub.cli.download_args_parser import DownloadArgsParser
|
||||||
from ytdl_sub.cli.main_args_parser import MainArgs
|
from ytdl_sub.cli.main_args_parser import MainArgs
|
||||||
from ytdl_sub.cli.main_args_parser import parser
|
from ytdl_sub.cli.main_args_parser import parser
|
||||||
from ytdl_sub.config.config_file import ConfigOptions
|
from ytdl_sub.config.config_validator import ConfigOptions
|
||||||
from ytdl_sub.utils.exceptions import InvalidDlArguments
|
from ytdl_sub.utils.exceptions import InvalidDlArguments
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
155
tests/unit/config/test_config_file.py
Normal file
155
tests/unit/config/test_config_file.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import re
|
||||||
|
from typing import Dict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ytdl_sub.config.config_file import ConfigFile
|
||||||
|
from ytdl_sub.config.preset import PRESET_KEYS
|
||||||
|
from ytdl_sub.config.preset_class_mappings import DownloadStrategyMapping
|
||||||
|
from ytdl_sub.config.preset_class_mappings import PluginMapping
|
||||||
|
from ytdl_sub.utils.exceptions import ValidationException
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigFilePartiallyValidatesPresets:
|
||||||
|
@classmethod
|
||||||
|
def _partial_validate(
|
||||||
|
cls, preset_dict: Dict, expected_error_message: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
def _config_create() -> None:
|
||||||
|
_ = ConfigFile(
|
||||||
|
name="test_partial_validate",
|
||||||
|
value={
|
||||||
|
"configuration": {"working_directory": "."},
|
||||||
|
"presets": {"partial_preset": preset_dict},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_error_message:
|
||||||
|
with pytest.raises(ValidationException, match=re.escape(expected_error_message)):
|
||||||
|
_config_create()
|
||||||
|
else:
|
||||||
|
_config_create()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"preset_dict",
|
||||||
|
[
|
||||||
|
{"nfo_tags": {"tags": {"key-1": "preset_0"}}},
|
||||||
|
{"output_directory_nfo_tags": {"nfo_root": "test"}},
|
||||||
|
{"output_options": {"file_name": "test"}},
|
||||||
|
{"output_options": {"keep_files_after": "today"}},
|
||||||
|
{"ytdl_options": {"format": "best"}},
|
||||||
|
{"overrides": {"a": "b"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_success(self, preset_dict: Dict):
|
||||||
|
self._partial_validate(preset_dict)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["output_options", "overrides", "ytdl_options"])
|
||||||
|
def test_success__empty_preset_keys(self, key):
|
||||||
|
self._partial_validate({key: {}})
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("plugin", PluginMapping.plugins())
|
||||||
|
def test_success__empty_plugins(self, plugin: str):
|
||||||
|
self._partial_validate({plugin: {}})
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source", DownloadStrategyMapping.sources())
|
||||||
|
def test_success__empty_sources(self, source: str):
|
||||||
|
for download_strategy in DownloadStrategyMapping.source_download_strategies(source):
|
||||||
|
self._partial_validate({source: {"download_strategy": download_strategy}})
|
||||||
|
|
||||||
|
def test_error__bad_preset_section(self):
|
||||||
|
self._partial_validate(
|
||||||
|
preset_dict={"does_not_exist": "lol"},
|
||||||
|
expected_error_message="Validation error in partial_preset: "
|
||||||
|
"'partial_preset' contains the field 'does_not_exist' which is not allowed. "
|
||||||
|
f"Allowed fields: {', '.join(sorted(PRESET_KEYS))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error__multiple_sources(self):
|
||||||
|
self._partial_validate(
|
||||||
|
preset_dict={"youtube": {}, "generic": {}},
|
||||||
|
expected_error_message="Validation error in partial_preset: "
|
||||||
|
"Contains the sources generic, youtube but can only have one",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error__no_download_strategy(self):
|
||||||
|
self._partial_validate(
|
||||||
|
preset_dict={"generic": {}},
|
||||||
|
expected_error_message="Validation error in partial_preset.generic: "
|
||||||
|
"missing the required field 'download_strategy'",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error__bad_download_strategy(self):
|
||||||
|
self._partial_validate(
|
||||||
|
preset_dict={"generic": {"download_strategy": "fail"}},
|
||||||
|
expected_error_message="Validation error in partial_preset.generic: "
|
||||||
|
"Tried to use download strategy 'fail' with source 'generic', "
|
||||||
|
"which does not exist. Available download strategies: collection, source",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error__bad_download_strategy_args(self):
|
||||||
|
self._partial_validate(
|
||||||
|
preset_dict={"generic": {"download_strategy": "collection", "bad_key": "nope"}},
|
||||||
|
expected_error_message="Validation error in partial_preset.generic: "
|
||||||
|
"'partial_preset.generic' contains the field 'bad_key' which is not allowed. "
|
||||||
|
"Allowed fields: urls",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"preset_dict",
|
||||||
|
[
|
||||||
|
{"nfo_tags": {"tags": {"key-1": {"attributes": {"test": "2"}}}}},
|
||||||
|
{"generic": {"urls": [{"variables_to_set": {"name": "value"}}]}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_partial_validate__incomplete_list_item(self, preset_dict):
|
||||||
|
with pytest.raises(ValidationException):
|
||||||
|
_ = ConfigFile(
|
||||||
|
name="test_partial_validate",
|
||||||
|
value={
|
||||||
|
"configuration": {"working_directory": "."},
|
||||||
|
"presets": {"partial_preset": preset_dict},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"preset_dict",
|
||||||
|
[
|
||||||
|
{"overrides": "not a dict"},
|
||||||
|
{"overrides": {"nested": {"dict": "value"}}},
|
||||||
|
{"overrides": ["list"]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_error__bad_overrides(self, preset_dict):
|
||||||
|
self._partial_validate(
|
||||||
|
preset_dict=preset_dict,
|
||||||
|
expected_error_message="Validation error in partial_preset.overrides",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"preset_dict",
|
||||||
|
[
|
||||||
|
{"ytdl_options": "not a dict"},
|
||||||
|
{"ytdl_options": ["list"]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_error__bad_ytdl_options(self, preset_dict):
|
||||||
|
self._partial_validate(
|
||||||
|
preset_dict=preset_dict,
|
||||||
|
expected_error_message="Validation error in partial_preset.ytdl_options",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"preset_dict",
|
||||||
|
[
|
||||||
|
{"preset": "DNE"},
|
||||||
|
{"preset": ["DNE"]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_error__non_existent_preset(self, preset_dict):
|
||||||
|
self._partial_validate(
|
||||||
|
preset_dict=preset_dict,
|
||||||
|
expected_error_message="Validation error in partial_preset: "
|
||||||
|
"preset 'DNE' does not exist in the provided config.",
|
||||||
|
)
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ytdl_sub.config.preset import Preset
|
from ytdl_sub.config.preset import Preset
|
||||||
|
|
@ -102,7 +100,7 @@ class TestPreset:
|
||||||
"youtube": youtube_video,
|
"youtube": youtube_video,
|
||||||
"output_options": dict(
|
"output_options": dict(
|
||||||
output_options,
|
output_options,
|
||||||
**{"maintain_download_archive": True, "keep_files_after": "today-{ttl}"}
|
**{"maintain_download_archive": True, "keep_files_after": "today-{ttl}"},
|
||||||
),
|
),
|
||||||
"overrides": {"ttl": "2months"},
|
"overrides": {"ttl": "2months"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue