From cbe73adbf706c0fc475268518ad3a7e278d821ca Mon Sep 17 00:00:00 2001 From: jbannon Date: Wed, 6 Apr 2022 07:16:07 +0000 Subject: [PATCH] better recursion functionality --- tests/unit/entries/test_entry.py | 16 +++++-- ytdl_subscribe/entries/entry.py | 36 +------------- .../base/string_formatter_validators.py | 47 +++++++++++++++++-- ytdl_subscribe/validators/base/validators.py | 8 +++- ytdl_subscribe/validators/exceptions.py | 4 ++ 5 files changed, 66 insertions(+), 45 deletions(-) diff --git a/tests/unit/entries/test_entry.py b/tests/unit/entries/test_entry.py index 398ed173..14fdb741 100644 --- a/tests/unit/entries/test_entry.py +++ b/tests/unit/entries/test_entry.py @@ -8,6 +8,7 @@ from ytdl_subscribe.validators.base.string_formatter_validators import ( from ytdl_subscribe.validators.config.overrides.overrides_validator import ( OverridesValidator, ) +from ytdl_subscribe.validators.exceptions import StringFormattingException 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 = ( - f"Attempted to format 'test' but failed after reaching max recursion depth of 3. " - f"Try to keep variables dependent on only one other variable." + "Validation error in test: Attempted to format but failed after reaching max recursion " + "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}") - with pytest.raises(ValidationException, match=expected_error_msg): + with pytest.raises(StringFormattingException, match=expected_error_msg): _ = mock_entry.apply_formatter(format_string, overrides=overrides) def test_entry_missing_kwarg(self, mock_entry): @@ -121,7 +124,10 @@ class TestEntry(object): name="test", value=f"prefix {{bah_humbug}} suffix" ) 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) diff --git a/ytdl_subscribe/entries/entry.py b/ytdl_subscribe/entries/entry.py index 2d3b2ebf..cb4101c5 100644 --- a/ytdl_subscribe/entries/entry.py +++ b/ytdl_subscribe/entries/entry.py @@ -20,8 +20,6 @@ class Entry: Entry object to represent a single media object returned from yt-dlp. """ - _MAX_FORMATTER_RECURSION = 3 - def __init__(self, **kwargs): """ Initialize the entry using ytdl metadata @@ -122,38 +120,6 @@ class Entry: """ entry_dict = self.to_dict() if overrides: - # TODO: need to check recursively populate format variables entry_dict = dict(entry_dict, **overrides.dict_with_format_strings) - for field_name in formatter.format_variables: - 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 + return formatter.apply_formatter(variable_dict=entry_dict) diff --git a/ytdl_subscribe/validators/base/string_formatter_validators.py b/ytdl_subscribe/validators/base/string_formatter_validators.py index 3201571a..23a35dba 100644 --- a/ytdl_subscribe/validators/base/string_formatter_validators.py +++ b/ytdl_subscribe/validators/base/string_formatter_validators.py @@ -1,4 +1,5 @@ import re +from collections import OrderedDict from keyword import iskeyword from typing import Dict 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 Validator +from ytdl_subscribe.validators.exceptions import StringFormattingException class StringFormatterValidator(Validator): @@ -18,6 +20,8 @@ class StringFormatterValidator(Validator): __fields_validator = re.compile(r"{([a-z_]+?)}") + __max_format_recursion = 3 + def __validate_and_get_format_variables(self) -> List[str]: """ Returns @@ -36,7 +40,8 @@ class StringFormatterValidator(Validator): if open_bracket_count != close_bracket_count: raise self._validation_exception( "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( @@ -46,13 +51,15 @@ class StringFormatterValidator(Validator): if len(format_variables) != open_bracket_count: raise self._validation_exception( "{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: if iskeyword(variable): 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 @@ -71,6 +78,40 @@ class StringFormatterValidator(Validator): """ 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): """ diff --git a/ytdl_subscribe/validators/base/validators.py b/ytdl_subscribe/validators/base/validators.py index 9c0728f1..ffa2f1f2 100644 --- a/ytdl_subscribe/validators/base/validators.py +++ b/ytdl_subscribe/validators/base/validators.py @@ -10,6 +10,8 @@ from typing import final from ytdl_subscribe.validators.exceptions import ValidationException +V = TypeVar("V", bound=ValidationException) + class Validator(ABC): """ @@ -38,7 +40,9 @@ class Validator(ABC): 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 ---------- @@ -50,7 +54,7 @@ class Validator(ABC): Validation exception with a consistent prefix. """ prefix = f"Validation error in {self._name}: " - return ValidationException(f"{prefix}{error_message}") + return exception_class(f"{prefix}{error_message}") class BoolValidator(Validator): diff --git a/ytdl_subscribe/validators/exceptions.py b/ytdl_subscribe/validators/exceptions.py index 5172f0d9..97a228a5 100644 --- a/ytdl_subscribe/validators/exceptions.py +++ b/ytdl_subscribe/validators/exceptions.py @@ -1,2 +1,6 @@ class ValidationException(ValueError): """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"""