diff --git a/src/ytdl_sub/config/preset.py b/src/ytdl_sub/config/preset.py index 055c0cc6..fc7e9ac5 100644 --- a/src/ytdl_sub/config/preset.py +++ b/src/ytdl_sub/config/preset.py @@ -180,7 +180,7 @@ class Preset(_PresetShell): return added_variables - def __validate_and_get_plugins(self) -> PresetPlugins: + def _validate_and_get_plugins(self) -> PresetPlugins: preset_plugins = PresetPlugins() for key in self._keys: @@ -194,7 +194,7 @@ class Preset(_PresetShell): return preset_plugins - def _validate_added_variables(self) -> Script: + def _validate_variable_usage(self) -> None: """ Validate variables resolve as plugins are executed, and return a mock script which contains actualized added variables from the plugins @@ -216,77 +216,100 @@ class Preset(_PresetShell): added_variables = plugin_options.added_source_variables( unresolved_variables=unresolved_variables ).get(PluginOperation.MODIFY_ENTRY_METADATA, set()) - script.add(ScriptUtils.add_dummy_variables(added_variables)) - unresolved_variables -= added_variables + if added_variables: + script.add(ScriptUtils.add_dummy_variables(added_variables)) + unresolved_variables -= added_variables + + _ = script.resolve(unresolvable=unresolved_variables, update=True) for _, plugin_options in sorted( self.plugins.zipped(), key=lambda pl: pl[0].priority.modify_entry ): added_variables = plugin_options.added_source_variables( unresolved_variables=unresolved_variables ).get(PluginOperation.MODIFY_ENTRY, set()) - script.add(ScriptUtils.add_dummy_variables(added_variables)) - unresolved_variables -= added_variables + + if added_variables: + script.add(ScriptUtils.add_dummy_variables(added_variables)) + unresolved_variables -= added_variables + + _ = script.resolve(unresolvable=unresolved_variables, update=True) + + # Validate that any formatter in the plugin options can resolve + self._validate_formatters( + mock_script=script, + unresolved_variables=unresolved_variables, + validator=plugin_options, + ) + + self._validate_formatters( + mock_script=script, + unresolved_variables=unresolved_variables, + validator=self.output_options, + ) assert not unresolved_variables - _ = script.resolve(update=True) + return script - @functools.cache - def _get_unresolvable_variables( - self, - formatter_validator: Union[StringFormatterValidator, OverridesStringFormatterValidator], - ) -> Optional[Set[str]]: - unresolvable = ( - self._added_variables.union([VARIABLES.entry_metadata.variable_name]) - if isinstance(formatter_validator, OverridesStringFormatterValidator) - else None - ) - return unresolvable - - def __validate_override_string_formatter_validator( + def _validate_string_formatter_validator( self, mock_script: Script, + unresolved_variables: Set[str], formatter_validator: Union[StringFormatterValidator, OverridesStringFormatterValidator], ) -> None: try: + unresolvable = unresolved_variables + if isinstance(formatter_validator, OverridesStringFormatterValidator): + unresolvable = unresolved_variables.union({VARIABLES.entry_metadata.variable_name}) + mock_script.resolve_once( {"tmp_var": formatter_validator.format_string}, - unresolvable=self._get_unresolvable_variables(formatter_validator), + unresolvable=unresolvable, ) except VariableDoesNotExist as exc: raise StringFormattingVariableNotFoundException(exc) from exc - def __recursive_preset_validate( + def _validate_formatters( self, mock_script: Script, - validator: Optional[Validator] = None, + unresolved_variables: Set[str], + validator: Validator, ) -> None: """ Ensure all OverridesStringFormatterValidator's only contain variables from the overrides and resolve. """ - if validator is None: - validator = self - if isinstance(validator, DictValidator): # pylint: disable=protected-access # Usage of protected variables in other validators is fine. The reason to keep # them protected is for readability when using them in subscriptions. for validator_value in validator._validator_dict.values(): - self.__recursive_preset_validate(mock_script=mock_script, validator=validator_value) + self._validate_formatters( + mock_script=mock_script, + unresolved_variables=unresolved_variables, + validator=validator_value, + ) # pylint: enable=protected-access elif isinstance(validator, ListValidator): for list_value in validator.list: - self.__recursive_preset_validate(mock_script=mock_script, validator=list_value) + self._validate_formatters( + mock_script=mock_script, + unresolved_variables=unresolved_variables, + validator=list_value, + ) elif isinstance(validator, (StringFormatterValidator, OverridesStringFormatterValidator)): - self.__validate_override_string_formatter_validator( - mock_script=mock_script, formatter_validator=validator + self._validate_string_formatter_validator( + mock_script=mock_script, + unresolved_variables=unresolved_variables, + formatter_validator=validator, ) elif isinstance(validator, (DictFormatterValidator, OverridesDictFormatterValidator)): for validator_value in validator.dict.values(): - self.__validate_override_string_formatter_validator( - mock_script=mock_script, formatter_validator=validator_value + self._validate_string_formatter_validator( + mock_script=mock_script, + unresolved_variables=unresolved_variables, + formatter_validator=validator_value, ) def _get_presets_to_merge( @@ -364,7 +387,7 @@ class Preset(_PresetShell): key="ytdl_options", validator=YTDLOptions, default={} ) - self.plugins: PresetPlugins = self.__validate_and_get_plugins() + self.plugins: PresetPlugins = self._validate_and_get_plugins() self.overrides = self._validate_key( key="overrides", validator=Overrides, default={} ).initialize_script( @@ -374,11 +397,7 @@ class Preset(_PresetShell): } ) - mock_script = self._validate_added_variables() - - # After all options are initialized, perform a recursive post-validate that requires - # values from multiple validators - self.__recursive_preset_validate(mock_script=mock_script) + self._validate_variable_usage() @property def name(self) -> str: diff --git a/src/ytdl_sub/downloaders/url/validators.py b/src/ytdl_sub/downloaders/url/validators.py index fc4308f7..40840cfa 100644 --- a/src/ytdl_sub/downloaders/url/validators.py +++ b/src/ytdl_sub/downloaders/url/validators.py @@ -1,14 +1,11 @@ import copy from typing import Any from typing import Dict -from typing import List from typing import Optional from typing import Set from ytdl_sub.config.preset_options import OptionsValidator from ytdl_sub.config.preset_options import PluginOperation -from ytdl_sub.script.script import Script -from ytdl_sub.script.script import ScriptBuilder 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 OverridesStringFormatterValidator diff --git a/src/ytdl_sub/script/script.py b/src/ytdl_sub/script/script.py index 178ef337..ad1a93c7 100644 --- a/src/ytdl_sub/script/script.py +++ b/src/ytdl_sub/script/script.py @@ -402,126 +402,3 @@ class Script: return resolvable raise RuntimeException(f"Tried to get unresolved variable {variable_name}") - - -class ScriptBuilder: - """ - Takes a dictionary of both - ``{ variable_names: syntax }`` - and - ``{ %custom_function: syntax }`` - """ - - def __init__(self, script: Dict[str, str]): - self._functions: Dict[str, SyntaxTree] = { - # custom_function_name must be passed to properly type custom function - # arguments uniquely if they're nested (i.e. $0 to $custom_func___0) - _function_name(function_key): parse( - text=function_value, - name=_function_name(function_key), - ) - for function_key, function_value in script.items() - if _is_function(function_key) - } - - self._variables: Dict[str, SyntaxTree] = { - variable_key: parse( - text=variable_value, - name=variable_key, - ) - for variable_key, variable_value in script.items() - if not _is_function(variable_key) - } - - def add(self, variables: Dict[str, str]) -> "ScriptBuilder": - for variable_name, variable_definition in variables.items(): - self._variables[variable_name] = parse( - text=variable_definition, - name=variable_name, - ) - return self - - def add_resolved(self, variables: Dict[str, Resolvable]) -> "ScriptBuilder": - for variable_name, resolvable in variables.items(): - self._variables[variable_name] = SyntaxTree(ast=[resolvable]) - - return self - - @property - def _missing_metadata(self) -> Tuple[Dict[str, Set[str]], Dict[str, Set[str]]]: - variables_missing_metadata: Dict[str, Set[str]] = defaultdict(set) - functions_missing_metadata: Dict[str, Set[str]] = defaultdict(set) - - defined_variables: Set[str] = set(self._variables.keys()) - defined_functions: Set[str] = set(self._functions.keys()) - - while True: - variables_missing_metadata_snapshot = copy.deepcopy(variables_missing_metadata) - functions_missing_metadata_snapshot = copy.deepcopy(functions_missing_metadata) - - for name, variable in self._variables.items(): - if diff := {var.name for var in variable.variables}.difference(defined_variables): - variables_missing_metadata[name].update(diff) - - if diff := {fun.name for fun in variable.custom_functions}.difference( - defined_functions - ): - variables_missing_metadata[name].update(diff) - - for name, function in self._functions.items(): - if diff := {var.name for var in function.variables}.difference(defined_variables): - functions_missing_metadata[name].update(diff) - - if diff := {fun.name for fun in function.custom_functions}.difference( - defined_functions - ): - functions_missing_metadata[name].update(diff) - - if ( - variables_missing_metadata == variables_missing_metadata_snapshot - and functions_missing_metadata == functions_missing_metadata_snapshot - ): - break - - defined_variables -= set(variables_missing_metadata.keys()) - defined_functions -= set(functions_missing_metadata.keys()) - - return variables_missing_metadata, functions_missing_metadata - - @classmethod - def _build(cls, variables: Dict[str, SyntaxTree], functions: Dict[str, SyntaxTree]) -> Script: - script = Script({}) - script._variables = variables - script._functions = functions - script._validate() - return script - - def partial_build(self) -> Script: - missing_variables, missing_functions = self._missing_metadata - maybe_resolvable_variables: Dict[str, SyntaxTree] = { - name: variable - for name, variable in self._variables.items() - if name not in missing_variables - } - maybe_resolvable_functions: Dict[str, SyntaxTree] = { - name: function - for name, function in self._functions.items() - if name not in missing_functions - } - - script = self._build( - variables=maybe_resolvable_variables, functions=maybe_resolvable_functions - ) - # Update internal variables with anything that is resolved - for variable_name, variable_output in script.resolve(update=True).output.items(): - self._variables[variable_name] = SyntaxTree([variable_output]) - return script - - def build(self) -> Script: - for missing_metadata in self._missing_metadata: - for name, missing in missing_metadata.items(): - raise ScriptBuilderMissingDefinitions( - f"{name} is missing the following definitions: {', '.join(missing)}" - ) - - return self._build(variables=self._variables, functions=self._functions) diff --git a/tests/unit/script/test_script_builder.py b/tests/unit/script/test_script_builder.py deleted file mode 100644 index e6f3d27d..00000000 --- a/tests/unit/script/test_script_builder.py +++ /dev/null @@ -1,55 +0,0 @@ -import re - -import pytest - -from ytdl_sub.script.script import Script -from ytdl_sub.script.script import ScriptBuilder -from ytdl_sub.script.types.map import Map -from ytdl_sub.script.types.resolvable import String -from ytdl_sub.script.utils.exceptions import ScriptBuilderMissingDefinitions -from ytdl_sub.script.utils.exceptions import VariableDoesNotExist - - -class TestScriptBuilder: - def test_partial_update_script(self): - # to be resolved later - entry_map = Map({String("title"): String("the title")}) - - script = ScriptBuilder( - { - "entry": "{ {} }", - "title": "{%map_get(entry, 'title', '')}", - "resolved_override": "{override} mom", - } - ) - - assert script.partial_build().resolve(unresolvable={"entry"}) - - with pytest.raises( - ScriptBuilderMissingDefinitions, - match=re.escape("resolved_override is missing the following definitions: override"), - ): - script.build() - - script.add({"override": "hi"}) - script.add_resolved({"entry": entry_map}) - - script.build() - - # script.resolve(unresolvable={"entry"}, update=True) - # assert script.get("override") == String("hi") - # assert script.get("resolved_override") == String("hi mom") - # - # script.add( - # { - # "new_variable_titlecase": "{%titlecase(new_variable_upper)}", - # "new_variable": "{resolved_override} {title}", - # "new_variable_upper": "{%upper(new_variable)}", - # } - # ).resolve(resolved={"entry": entry_map}, update=True) - # - # assert script.get("title") == String("the title") - # assert script.get("new_variable") == String("hi mom the title") - # assert script.get("new_variable_upper") == String("HI MOM THE TITLE") - # assert script.get("new_variable_titlecase") == String("Hi Mom The Title") - # assert script.get("entry") == entry_map