config validator working

This commit is contained in:
jbannon 2022-03-31 01:27:56 +00:00
parent d31c4313f2
commit 74169b9939
15 changed files with 187 additions and 122 deletions

View file

@ -1,12 +1,12 @@
config: # ytdl-sub needs a temporary place to download files. Choose that path here
working_directory: 'tmp' working_directory: 'tmp'
presets: presets:
soundcloud_with_id3_tags: soundcloud_with_id3_tags:
soundcloud: soundcloud:
download_strategy: "albums_then_tracks" download_strategy: "albums_and_singles"
skip_premiere_tracks: True skip_premiere_tracks: True
ytdl_opts: ytdl_options:
format: 'bestaudio[ext=mp3]' format: 'bestaudio[ext=mp3]'
post_process: post_process:
file_name: "[{album_year}] {sanitized_album}/{track_number_padded} - {sanitized_title}.{ext}" file_name: "[{album_year}] {sanitized_album}/{track_number_padded} - {sanitized_title}.{ext}"
@ -23,7 +23,7 @@ presets:
music_videos: music_videos:
youtube: youtube:
download_strategy: "playlist" download_strategy: "playlist"
ytdl_opts: ytdl_options:
format: 'best' format: 'best'
ignoreerrors: True ignoreerrors: True
post_process: post_process:

View file

@ -1,6 +1,6 @@
import pytest import pytest
from ytdl_subscribe.entries.entry import EntryFormatter from ytdl_subscribe.utils.formatter_validator import FormatterValidator
@pytest.fixture @pytest.fixture
@ -44,11 +44,11 @@ def error_message_unequal_regex_matches_generator(
class TestEntryFormatter(object): class TestEntryFormatter(object):
def test_parse(self): def test_parse(self):
format_string = "Here is my {var_one} and {var_two} 💩" format_string = "Here is my {var_one} and {var_two} 💩"
assert EntryFormatter(format_string).parse() == ["var_one", "var_two"] assert FormatterValidator(format_string).parse() == ["var_one", "var_two"]
def test_parse_no_variables(self): def test_parse_no_variables(self):
format_string = "No vars 💩" format_string = "No vars 💩"
assert EntryFormatter(format_string).parse() == [] assert FormatterValidator(format_string).parse() == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
"format_string", "format_string",
@ -65,7 +65,7 @@ class TestEntryFormatter(object):
expected_error_msg = error_message_unequal_brackets_generator(format_string) expected_error_msg = error_message_unequal_brackets_generator(format_string)
with pytest.raises(ValueError, match=expected_error_msg): with pytest.raises(ValueError, match=expected_error_msg):
assert EntryFormatter(format_string).parse() assert FormatterValidator(format_string).parse()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"format_string", "format_string",
@ -86,4 +86,4 @@ class TestEntryFormatter(object):
) )
with pytest.raises(ValueError, match=expected_error_msg): with pytest.raises(ValueError, match=expected_error_msg):
assert EntryFormatter(format_string).parse() assert FormatterValidator(format_string).parse()

View file

@ -1,48 +1,11 @@
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import List
from typing import Optional from typing import Optional
from sanitize_filename import sanitize from sanitize_filename import sanitize
from ytdl_subscribe.utils.formatter_validator import FormatterValidator
class EntryFormatter:
"""
Ensures user-created formatter strings are valid
"""
FIELDS_VALIDATOR = re.compile(r"{([a-z_]+?)}")
def __init__(self, format_string: str):
self.format_string = format_string
self._error_prefix = f"Format string '{self.format_string}' is invalid:"
def parse(self) -> List[str]:
"""
Returns
-------
List of fields to format
"""
open_bracket_count = self.format_string.count("{")
close_bracket_count = self.format_string.count("}")
if open_bracket_count != close_bracket_count:
raise ValueError(
f"{self._error_prefix} Brackets are reserved for {{variable_names}} "
f"and should contain a single open and close bracket."
)
parsed_fields = re.findall(EntryFormatter.FIELDS_VALIDATOR, self.format_string)
if len(parsed_fields) != open_bracket_count:
raise ValueError(
f"{self._error_prefix} {{variable_names}} should only contain "
f"lowercase letters and underscores with a single open and close bracket."
)
return sorted(parsed_fields)
class Entry: class Entry:
@ -141,7 +104,7 @@ class Entry:
if overrides: if overrides:
entry_dict = dict(entry_dict, **overrides) entry_dict = dict(entry_dict, **overrides)
field_names = EntryFormatter(format_string).parse() field_names = FormatterValidator(format_string).parse()
for field_name in field_names: for field_name in field_names:
if field_name not in entry_dict: if field_name not in entry_dict:

View file

@ -19,4 +19,4 @@ class SubscriptionSourceName(object):
class YAMLSection(object): class YAMLSection(object):
CONFIG_KEY = "config" CONFIG_KEY = "config"
PRESET_KEY = "presets" PRESET_KEY = "presets"
SUBSCRIPTIONS_KEY = "subscriptions" SUBSCRIPTIONS_KEY = "config"

View file

@ -1,8 +1,14 @@
import argparse import argparse
from typing import List
from ytdl_subscribe.parse import parse_presets from ytdl_subscribe.parse import parse_presets
from ytdl_subscribe.parse import parse_subscriptions from ytdl_subscribe.parse import parse_subscriptions
from ytdl_subscribe.parse import parse_subscriptions_file from ytdl_subscribe.parse import parse_subscriptions_file
from ytdl_subscribe.parse import read_config_file
###################################################################################################
# GLOBAL PARSER
from ytdl_subscribe.validators.config.config import Config
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="YoutubeDL-Subscribe: Download and organize your favorite media hassle-free." description="YoutubeDL-Subscribe: Download and organize your favorite media hassle-free."
@ -12,50 +18,46 @@ parser.add_argument(
"--config", "--config",
metavar="CONFIGPATH", metavar="CONFIGPATH",
type=str, type=str,
help="path to the config yaml, uses subscriptions.yaml if not provided", help="path to the config yaml, uses config.yaml if not provided",
default="subscriptions.yaml", default="config.yaml",
) )
###################################################################################################
# SUBSCRIPTION PARSER
subparsers = parser.add_subparsers(dest="subparser") subparsers = parser.add_subparsers(dest="subparser")
subscription_parser = subparsers.add_parser("s") subscription_parser = subparsers.add_parser("sub")
subscription_parser.add_argument( subscription_parser.add_argument(
"subscriptions", "subscription_paths",
metavar="SUB", metavar="SUBPATH",
nargs="*", nargs="*",
help="specific subscriptions to download, downloads all if not provided", help="path to subscription files, uses config.yaml if not provided",
default="config.yaml",
) )
###################################################################################################
# INTERACTIVE DOWNLOAD PARSER
download_parser = subparsers.add_parser("dl") download_parser = subparsers.add_parser("dl")
download_parser.add_argument( download_parser.add_argument(
"-p", "--preset", metavar="PRESET", help="which preset to apply" "url",
) help="url to the desired content to download, will prompt for further arguments needed",
download_parser.add_argument("-o", "--output", metavar="OUT", help="output path")
download_parser.add_argument(
"-r",
"--overrides",
metavar="NAME:VALUE",
nargs="+",
help="variable overrides formmatted as name:value",
) )
if __name__ == "__main__": if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
print(args) config: Config = Config.from_file_path(args.config)
if args.subparser == "sub":
subscription_paths: List[str] = args.subscription_paths
# subscriptions = parse_subscriptions(args.config) # for subscription_path in subscription_paths:
#
yaml_dict = parse_subscriptions_file(args.config, set_config_variables=True) #
presets = parse_presets(yaml_dict) # subscriptions = parse_subscriptions(
# yaml_dict, presets, subscriptions=args.subscriptions
# Subscription download # )
if args.subparser == "s": # for s in subscriptions:
subscriptions = parse_subscriptions( # s.extract_info()
yaml_dict, presets, subscriptions=args.subscriptions
)
for s in subscriptions:
s.extract_info()
# One-off download # One-off download
if args.subparser == "dl": if args.subparser == "dl":
preset = args.preset print("Interactive download is not supported yet. Stay tuned!")
output_path = args.output_path
overrides = args.overrides exit(0)

View file

@ -8,12 +8,20 @@ from ytdl_subscribe.enums import YAMLSection
from ytdl_subscribe.subscriptions.soundcloud import SoundcloudSubscription from ytdl_subscribe.subscriptions.soundcloud import SoundcloudSubscription
from ytdl_subscribe.subscriptions.subscription import Subscription from ytdl_subscribe.subscriptions.subscription import Subscription
from ytdl_subscribe.subscriptions.youtube import YoutubeSubscription from ytdl_subscribe.subscriptions.youtube import YoutubeSubscription
from ytdl_subscribe.validators.config.config import Config
def _set_config_variables(config): def _set_config_variables(config):
Subscription.WORKING_DIRECTORY = config.get("working_directory", "") Subscription.WORKING_DIRECTORY = config.get("working_directory", "")
def read_config_file(config_path: str) -> Config:
with open(config_path, "r", encoding="utf-8") as file:
config_dict = yaml.safe_load(file)
return Config(name="config", value=config_dict).validate()
def parse_subscriptions_file(subscription_yaml_path, set_config_variables=True): def parse_subscriptions_file(subscription_yaml_path, set_config_variables=True):
""" """
Reads and parses a subscription yaml from its path. Reads and parses a subscription yaml from its path.
@ -62,14 +70,14 @@ def parse_presets(yaml_dict):
def parse_subscriptions(yaml_dict: dict, presets: dict, subscriptions: Optional[list]): def parse_subscriptions(yaml_dict: dict, presets: dict, subscriptions: Optional[list]):
""" """
Parses subscriptions from a subscription yaml dict Parses config from a subscription yaml dict
Parameters Parameters
---------- ----------
yaml_dict: dict yaml_dict: dict
presets: dict presets: dict
subscriptions: list[str] or None subscriptions: list[str] or None
If present, only parse these subscriptions If present, only parse these config
Returns Returns
------- -------
@ -77,7 +85,7 @@ def parse_subscriptions(yaml_dict: dict, presets: dict, subscriptions: Optional[
""" """
parsed_subscriptions = [] parsed_subscriptions = []
# Parse all subscriptions even if all are not used for validation's sake # Parse all config even if all are not used for validation's sake
for name, subscription in yaml_dict[YAMLSection.SUBSCRIPTIONS_KEY].items(): for name, subscription in yaml_dict[YAMLSection.SUBSCRIPTIONS_KEY].items():
preset = {} preset = {}
if subscription.get("preset") in presets: if subscription.get("preset") in presets:
@ -104,7 +112,7 @@ def parse_subscriptions(yaml_dict: dict, presets: dict, subscriptions: Optional[
) )
) )
# Filter subscriptions if present # Filter config if present
if subscriptions: if subscriptions:
parsed_subscriptions = [ parsed_subscriptions = [
sub for sub in parsed_subscriptions if sub.name in subscriptions sub for sub in parsed_subscriptions if sub.name in subscriptions

View file

@ -0,0 +1,41 @@
import re
from typing import List
class FormatterValidator:
"""
Ensures user-created formatter strings are valid
"""
FIELDS_VALIDATOR = re.compile(r"{([a-z_]+?)}")
def __init__(self, format_string: str):
self.format_string = format_string
self._error_prefix = f"Format string '{self.format_string}' is invalid:"
def parse(self) -> List[str]:
"""
Returns
-------
List of fields to format
"""
open_bracket_count = self.format_string.count("{")
close_bracket_count = self.format_string.count("}")
if open_bracket_count != close_bracket_count:
raise ValueError(
f"{self._error_prefix} Brackets are reserved for {{variable_names}} "
f"and should contain a single open and close bracket."
)
parsed_fields = re.findall(
FormatterValidator.FIELDS_VALIDATOR, self.format_string
)
if len(parsed_fields) != open_bracket_count:
raise ValueError(
f"{self._error_prefix} {{variable_names}} should only contain "
f"lowercase letters and underscores with a single open and close bracket."
)
return sorted(parsed_fields)

View file

@ -0,0 +1,37 @@
from typing import Any
from typing import Dict
from typing import Optional
import yaml
from ytdl_subscribe.validators.config.preset import Preset
from ytdl_subscribe.validators.native.object_validator import ObjectValidator
class Config(ObjectValidator):
required_fields = {"working_directory", "presets"}
def __init__(self, name: str, value: Any):
super().__init__(name, value)
self.working_directory: Optional[str] = None
self.presets: Optional[Dict[str, Preset]] = None
def validate(self) -> "Config":
super().validate()
self.working_directory = self.get("working_directory")
self.presets = {}
for preset_name, preset_obj in self.get("presets").items():
self.presets[preset_name] = Preset(
name=f"{self.name}.presets.{preset_name}", value=preset_obj
).validate()
return self
@classmethod
def from_file_path(cls, config_path) -> "Config":
# TODO: Create separate yaml file loader class
with open(config_path, "r", encoding="utf-8") as file:
config_dict = yaml.safe_load(file)
return Config(name="config", value=config_dict).validate()

View file

@ -2,45 +2,46 @@ from typing import Any
from typing import Optional from typing import Optional
from ytdl_subscribe.enums import SubscriptionSourceName from ytdl_subscribe.enums import SubscriptionSourceName
from ytdl_subscribe.validators.config.sources import PresetSource
from ytdl_subscribe.validators.config.sources import SoundcloudSource
from ytdl_subscribe.validators.config.sources import YoutubeSource
from ytdl_subscribe.validators.exceptions import ValidationException
from ytdl_subscribe.validators.native.object_validator import ObjectValidator from ytdl_subscribe.validators.native.object_validator import ObjectValidator
from ytdl_subscribe.validators.subscriptions.sources import SoundcloudSource
from ytdl_subscribe.validators.subscriptions.sources import SubscriptionSource
from ytdl_subscribe.validators.subscriptions.sources import YoutubeSource
class SubscriptionPreset(ObjectValidator): class Preset(ObjectValidator):
required_fields = {"post_process"} required_fields = {"post_process"}
optional_fields = {"ytdl_options", *SubscriptionSourceName.all()} optional_fields = {"ytdl_options", *SubscriptionSourceName.all()}
allow_extra_fields = False allow_extra_fields = False
def __init__(self, key: str, value: Any): def __init__(self, name: str, value: Any):
super().__init__(key=key, value=value) super().__init__(name=name, value=value)
self.subscription_source: Optional[SubscriptionSource] = None self.subscription_source: Optional[PresetSource] = None
def _validate_subscription_source(self): def _validate_subscription_source(self):
# Make sure we only have a single subscription source # Make sure we only have a single subscription source
for object_key, object_value in self.object_items: for object_key, object_value in self.object_items:
if object_key in SubscriptionSourceName.all() and self.subscription_source: if object_key in SubscriptionSourceName.all() and self.subscription_source:
raise ValueError( raise ValidationException(
f"'{self.key}' can only have one of the following sources: {SubscriptionSourceName.pretty_all()}" f"'{self.name}' can only have one of the following sources: {SubscriptionSourceName.pretty_all()}"
) )
if object_key == SubscriptionSourceName.SOUNDCLOUD: if object_key == SubscriptionSourceName.SOUNDCLOUD:
self.subscription_source = SoundcloudSource( self.subscription_source = SoundcloudSource(
key=object_key, value=object_value name=f"{self.name}{object_key}", value=object_value
).validate() ).validate()
elif object_key == SubscriptionSourceName.YOUTUBE: elif object_key == SubscriptionSourceName.YOUTUBE:
self.subscription_source = YoutubeSource( self.subscription_source = YoutubeSource(
key=object_key, value=object_value name=f"{self.name}{object_key}", value=object_value
).validate() ).validate()
# If subscription source was not set, error # If subscription source was not set, error
if not self.subscription_source: if not self.subscription_source:
raise ValueError( raise ValidationException(
f"'{self.key} must have one of the following sources: {SubscriptionSourceName.pretty_all()}" f"'{self.name} must have one of the following sources: {SubscriptionSourceName.pretty_all()}"
) )
def validate(self) -> "SubscriptionPreset": def validate(self) -> "Preset":
super().validate() super().validate()
self._validate_subscription_source() self._validate_subscription_source()
return self return self

View file

@ -2,34 +2,35 @@ from typing import Any
from typing import Optional from typing import Optional
from typing import Set from typing import Set
from ytdl_subscribe.validators.exceptions import ValidationException
from ytdl_subscribe.validators.native.object_validator import ObjectValidator from ytdl_subscribe.validators.native.object_validator import ObjectValidator
class SubscriptionSource(ObjectValidator): class PresetSource(ObjectValidator):
required_fields = {"download_strategy"} required_fields = {"download_strategy"}
download_strategies: Set[str] = {} download_strategies: Set[str] = {}
def __init__(self, key: str, value: Any): def __init__(self, name: str, value: Any):
super().__init__(key=key, value=value) super().__init__(name=name, value=value)
self.download_strategy: Optional[str] = None self.download_strategy: Optional[str] = None
def validate(self) -> "SubscriptionSource": def validate(self) -> "PresetSource":
super().validate() super().validate()
if self.value["download_strategy"] not in self.download_strategies: if self.value["download_strategy"] not in self.download_strategies:
raise ValueError( raise ValidationException(
f"'download_strategy' must be one of the following: {', '.join(self.download_strategies)}" f"'download_strategy' must be one of the following: {', '.join(self.download_strategies)}"
) )
return self return self
class YoutubeSource(SubscriptionSource): class YoutubeSource(PresetSource):
optional_fields = {"playlist_id"} optional_fields = {"playlist_id"}
download_strategies = {"playlist"} download_strategies = {"playlist"}
def __init__(self, key: str, value: Any): def __init__(self, name: str, value: Any):
super().__init__(key=key, value=value) super().__init__(name=name, value=value)
self.playlist_id: Optional[str] = None self.playlist_id: Optional[str] = None
def validate(self) -> "YoutubeSource": def validate(self) -> "YoutubeSource":
@ -38,28 +39,30 @@ class YoutubeSource(SubscriptionSource):
if self.download_strategy == "playlist": if self.download_strategy == "playlist":
self.playlist_id = self.value.get("playlist_id") self.playlist_id = self.value.get("playlist_id")
if not self.playlist_id or not isinstance(self.playlist_id, str): if not self.playlist_id or not isinstance(self.playlist_id, str):
raise ValueError( raise ValidationException(
"Youtube download_strategy 'playlist' requires the field 'playlist_id' to be a string" "Youtube download_strategy 'playlist' requires the field 'playlist_id' to be a string"
) )
return self return self
class SoundcloudSource(SubscriptionSource): class SoundcloudSource(PresetSource):
optional_fields = {"username"} optional_fields = {"username", "skip_premiere_tracks"}
download_strategies = {"albums_and_singles"} download_strategies = {"albums_and_singles"}
def __init__(self, key: str, value: Any): def __init__(self, name: str, value: Any):
super().__init__(key=key, value=value) super().__init__(name=name, value=value)
self.username: Optional[str] = None self.username: Optional[str] = None
self.skip_premiere_tracks: Optional[bool] = None
def validate(self) -> "SoundcloudSource": def validate(self) -> "SoundcloudSource":
super().validate() super().validate()
self.skip_premiere_tracks = self.get("skip_premiere_tracks", True)
if self.download_strategy == "albums_and_singles": if self.download_strategy == "albums_and_singles":
self.username = self.value.get("username") self.username = self.value.get("username")
if not self.username or not isinstance(self.username, str): if not self.username or not isinstance(self.username, str):
raise ValueError( raise ValidationException(
"Soundcloud download_strategy 'albums_and_singles' requires the field 'username'" "Soundcloud download_strategy 'albums_and_singles' requires the field 'username'"
) )

View file

@ -0,0 +1,4 @@
class ValidationException(ValueError):
"""Any user-caused configuration error should result in this error"""
pass

View file

@ -1,5 +1,8 @@
from typing import Any
from typing import Optional
from typing import Set from typing import Set
from ytdl_subscribe.validators.exceptions import ValidationException
from ytdl_subscribe.validators.native.validator import Validator from ytdl_subscribe.validators.native.validator import Validator
@ -21,36 +24,39 @@ class ObjectValidator(Validator):
def object_items(self): def object_items(self):
return self.value.items() return self.value.items()
def get(self, object_key: str, default: Optional[Any] = None) -> Any:
return self.value.get(object_key, default)
def validate(self) -> "ObjectValidator": def validate(self) -> "ObjectValidator":
super().validate() super().validate()
# Ensure the value is an object # Ensure the value is an object
if not isinstance(self.value, dict): if not isinstance(self.value, dict):
error_msg = f"'{self.key}' must be an object" error_msg = f"'{self.name}' must be an object"
if self.required_fields: if self.required_fields:
error_msg = f"{error_msg} with the required field: {', '.join(self.required_fields)}" error_msg = f"{error_msg} with the required field: {', '.join(self.required_fields)}"
raise ValueError(error_msg) raise ValidationException(error_msg)
# Ensure the object contains all the required fields # Ensure the object contains all the required fields
for required_key in self.required_fields: for required_key in self.required_fields:
if required_key not in self.value: if required_key not in self.value:
error_msg = ( error_msg = (
f"'{self.key}' is missing the required field '{required_key}'" f"'{self.name}' is missing the required field '{required_key}'"
) )
raise ValueError(error_msg) raise ValidationException(error_msg)
# If extra fields are not allowed, ensure we only have required or optional keys # If extra fields are not allowed, ensure we only have required or optional keys
if not self.allow_extra_fields: if not self.allow_extra_fields:
for object_key in self.object_keys: for object_key in self.object_keys:
if ( if (
object_key not in self.required_fields object_key not in self.required_fields
or object_key not in self.optional_fields and object_key not in self.optional_fields
): ):
error_msg = ( error_msg = (
f"'{self.key}' contains the field '{object_key} which is not allowed. " f"'{self.name}' contains the field '{object_key}' which is not allowed. "
f"Allowed fields: {', '.join(self.allowed_fields)}" f"Allowed fields: {', '.join(self.allowed_fields)}"
) )
raise ValueError(error_msg) raise ValidationException(error_msg)
return self return self

View file

@ -2,8 +2,8 @@ from typing import Any
class Validator: class Validator:
def __init__(self, key: str, value: Any): def __init__(self, name: str, value: Any):
self.key = key self.name = name
self.value = value self.value = value
def validate(self) -> "Validator": def validate(self) -> "Validator":