better recursion functionality

This commit is contained in:
jbannon 2022-04-06 07:16:07 +00:00
parent 5fed8a7b64
commit cbe73adbf7
5 changed files with 66 additions and 45 deletions

View file

@ -8,6 +8,7 @@ from ytdl_subscribe.validators.base.string_formatter_validators import (
from ytdl_subscribe.validators.config.overrides.overrides_validator import ( from ytdl_subscribe.validators.config.overrides.overrides_validator import (
OverridesValidator, OverridesValidator,
) )
from ytdl_subscribe.validators.exceptions import StringFormattingException
from ytdl_subscribe.validators.exceptions import ValidationException from ytdl_subscribe.validators.exceptions import ValidationException
@ -98,14 +99,16 @@ class TestEntry(object):
}, },
) )
# Max depth is 3 so should go level_a -(0)-> level_b -(1)-> level_a -(2)-> level_b
expected_error_msg = ( expected_error_msg = (
f"Attempted to format 'test' but failed after reaching max recursion depth of 3. " "Validation error in test: Attempted to format but failed after reaching max recursion "
f"Try to keep variables dependent on only one other variable." "depth of 3. Try to keep variables dependent on only one other variable at max. "
"Unresolved variables: level_b"
) )
format_string = StringFormatterValidator(name="test", value="{level_a}") format_string = StringFormatterValidator(name="test", value="{level_a}")
with pytest.raises(ValidationException, match=expected_error_msg): with pytest.raises(StringFormattingException, match=expected_error_msg):
_ = mock_entry.apply_formatter(format_string, overrides=overrides) _ = mock_entry.apply_formatter(format_string, overrides=overrides)
def test_entry_missing_kwarg(self, mock_entry): def test_entry_missing_kwarg(self, mock_entry):
@ -121,7 +124,10 @@ class TestEntry(object):
name="test", value=f"prefix {{bah_humbug}} suffix" name="test", value=f"prefix {{bah_humbug}} suffix"
) )
available_fields = ", ".join(sorted(mock_entry.to_dict().keys())) available_fields = ", ".join(sorted(mock_entry.to_dict().keys()))
expected_error_msg = f"Format variable 'bah_humbug' does not exist for Entry. Available fields: {available_fields}" expected_error_msg = (
f"Validation error in test: Format variable 'bah_humbug' does not exist. "
f"Available variables: {available_fields}"
)
with pytest.raises(ValueError, match=expected_error_msg): with pytest.raises(StringFormattingException, match=expected_error_msg):
assert mock_entry.apply_formatter(format_string) assert mock_entry.apply_formatter(format_string)

View file

@ -20,8 +20,6 @@ class Entry:
Entry object to represent a single media object returned from yt-dlp. Entry object to represent a single media object returned from yt-dlp.
""" """
_MAX_FORMATTER_RECURSION = 3
def __init__(self, **kwargs): def __init__(self, **kwargs):
""" """
Initialize the entry using ytdl metadata Initialize the entry using ytdl metadata
@ -122,38 +120,6 @@ class Entry:
""" """
entry_dict = self.to_dict() entry_dict = self.to_dict()
if overrides: if overrides:
# TODO: need to check recursively populate format variables
entry_dict = dict(entry_dict, **overrides.dict_with_format_strings) entry_dict = dict(entry_dict, **overrides.dict_with_format_strings)
for field_name in formatter.format_variables: return formatter.apply_formatter(variable_dict=entry_dict)
if field_name not in entry_dict:
available_fields = ", ".join(sorted(entry_dict.keys()))
raise ValueError(
f"Format variable '{field_name}' does not exist "
f"for {self.__class__.__name__}. Available fields: {available_fields}"
)
format_string = formatter.format_string
variables_present = True
recursion_depth = 0
while variables_present and recursion_depth < self._MAX_FORMATTER_RECURSION:
format_string = format_string.format(**OrderedDict(entry_dict))
variables_present = (
len(
StringFormatterValidator(
name="__recursive_formatter_update__",
value=format_string,
).format_variables
)
> 0
)
recursion_depth += 1
if variables_present:
raise ValidationException(
f"Attempted to format '{formatter._name}' but failed after reaching max recursion "
f"depth of {self._MAX_FORMATTER_RECURSION}. Try to keep variables dependent on "
f"only one other variable."
)
return format_string

View file

@ -1,4 +1,5 @@
import re import re
from collections import OrderedDict
from keyword import iskeyword from keyword import iskeyword
from typing import Dict from typing import Dict
from typing import List from typing import List
@ -6,6 +7,7 @@ from typing import final
from ytdl_subscribe.validators.base.validators import LiteralDictValidator from ytdl_subscribe.validators.base.validators import LiteralDictValidator
from ytdl_subscribe.validators.base.validators import Validator from ytdl_subscribe.validators.base.validators import Validator
from ytdl_subscribe.validators.exceptions import StringFormattingException
class StringFormatterValidator(Validator): class StringFormatterValidator(Validator):
@ -18,6 +20,8 @@ class StringFormatterValidator(Validator):
__fields_validator = re.compile(r"{([a-z_]+?)}") __fields_validator = re.compile(r"{([a-z_]+?)}")
__max_format_recursion = 3
def __validate_and_get_format_variables(self) -> List[str]: def __validate_and_get_format_variables(self) -> List[str]:
""" """
Returns Returns
@ -36,7 +40,8 @@ class StringFormatterValidator(Validator):
if open_bracket_count != close_bracket_count: if open_bracket_count != close_bracket_count:
raise self._validation_exception( raise self._validation_exception(
"Brackets are reserved for {variable_names} and should contain " "Brackets are reserved for {variable_names} and should contain "
"a single open and close bracket." "a single open and close bracket.",
exception_class=StringFormattingException,
) )
format_variables: List[str] = list( format_variables: List[str] = list(
@ -46,13 +51,15 @@ class StringFormatterValidator(Validator):
if len(format_variables) != open_bracket_count: if len(format_variables) != open_bracket_count:
raise self._validation_exception( raise self._validation_exception(
"{variable_names} should only contain " "{variable_names} should only contain "
"lowercase letters and underscores with a single open and close bracket." "lowercase letters and underscores with a single open and close bracket.",
exception_class=StringFormattingException,
) )
for variable in format_variables: for variable in format_variables:
if iskeyword(variable): if iskeyword(variable):
raise self._validation_exception( raise self._validation_exception(
f"'{variable}' is a Python keyword and cannot be used as a variable." f"'{variable}' is a Python keyword and cannot be used as a variable.",
exception_class=StringFormattingException,
) )
return format_variables return format_variables
@ -71,6 +78,40 @@ class StringFormatterValidator(Validator):
""" """
return self._value return self._value
@final
def apply_formatter(self, variable_dict: Dict[str, str]) -> str:
# Ensure the variable names exist within the entry and overrides
for variable_name in self.format_variables:
if variable_name not in variable_dict:
available_fields = ", ".join(sorted(variable_dict.keys()))
raise self._validation_exception(
f"Format variable '{variable_name}' does not exist. "
f"Available variables: {available_fields}",
exception_class=StringFormattingException,
)
# Keep formatting the format string until no format_variables are present
formatter = self
recursion_depth = 0
max_depth = StringFormatterValidator.__max_format_recursion
while formatter.format_variables and recursion_depth < max_depth:
formatter = StringFormatterValidator(
name=self._name,
value=formatter.format_string.format(**OrderedDict(variable_dict)),
)
recursion_depth += 1
if formatter.format_variables:
raise self._validation_exception(
f"Attempted to format but failed after reaching max recursion depth of "
f"{max_depth}. Try to keep variables dependent on only one other variable at max. "
f"Unresolved variables: {', '.join(sorted(formatter.format_variables))}",
exception_class=StringFormattingException,
)
return formatter.format_string
class DictFormatterValidator(LiteralDictValidator): class DictFormatterValidator(LiteralDictValidator):
""" """

View file

@ -10,6 +10,8 @@ from typing import final
from ytdl_subscribe.validators.exceptions import ValidationException from ytdl_subscribe.validators.exceptions import ValidationException
V = TypeVar("V", bound=ValidationException)
class Validator(ABC): class Validator(ABC):
""" """
@ -38,7 +40,9 @@ class Validator(ABC):
error_message=f"should be of type {expected_value_type_name}." error_message=f"should be of type {expected_value_type_name}."
) )
def _validation_exception(self, error_message: str) -> ValidationException: def _validation_exception(
self, error_message: str, exception_class: Type[V] = ValidationException
) -> V:
""" """
Parameters Parameters
---------- ----------
@ -50,7 +54,7 @@ class Validator(ABC):
Validation exception with a consistent prefix. Validation exception with a consistent prefix.
""" """
prefix = f"Validation error in {self._name}: " prefix = f"Validation error in {self._name}: "
return ValidationException(f"{prefix}{error_message}") return exception_class(f"{prefix}{error_message}")
class BoolValidator(Validator): class BoolValidator(Validator):

View file

@ -1,2 +1,6 @@
class ValidationException(ValueError): class ValidationException(ValueError):
"""Any user-caused configuration error should result in this error""" """Any user-caused configuration error should result in this error"""
class StringFormattingException(ValidationException):
"""Tried to format a string but failed due to user misconfigured variables"""