From c90b38ae924242dd4ed20df54b3a7ad06555f47c Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Wed, 22 Nov 2023 13:23:42 -0800 Subject: [PATCH] custom function dne --- src/ytdl_sub/script/parser.py | 61 +++++++++++++++---- src/ytdl_sub/script/script.py | 27 ++++++-- src/ytdl_sub/script/types/function.py | 12 +--- .../unit/script/types/test_custom_function.py | 14 +++++ tests/unit/script/types/test_float.py | 4 +- tests/unit/script/types/test_string.py | 2 +- 6 files changed, 92 insertions(+), 28 deletions(-) diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index f53dde82..85dd77a7 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -2,9 +2,13 @@ from enum import Enum from typing import Dict from typing import List from typing import Optional +from typing import Set +from ytdl_sub.script.functions import Functions from ytdl_sub.script.types.array import UnresolvedArray from ytdl_sub.script.types.function import Argument +from ytdl_sub.script.types.function import BuiltInFunction +from ytdl_sub.script.types.function import CustomFunction from ytdl_sub.script.types.function import Function from ytdl_sub.script.types.map import UnresolvedMap from ytdl_sub.script.types.resolvable import Boolean @@ -19,6 +23,7 @@ from ytdl_sub.script.types.variable import Variable from ytdl_sub.script.utils.exception_formatters import ParserExceptionFormatter from ytdl_sub.script.utils.exceptions import UNREACHABLE from ytdl_sub.script.utils.exceptions import CycleDetected +from ytdl_sub.script.utils.exceptions import FunctionDoesNotExist from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments from ytdl_sub.script.utils.exceptions import InvalidSyntaxException from ytdl_sub.script.utils.exceptions import UserException @@ -109,9 +114,17 @@ def _is_boolean_false(string: Optional[str]) -> bool: class _Parser: - def __init__(self, text: str, custom_function_name: Optional[str]): + def __init__( + self, + text: str, + name: Optional[str], + custom_function_names: Optional[Set[str]], + variable_names: Optional[Set[str]], + ): self._text = text - self._custom_function_name = custom_function_name + self._name = name + self._custom_function_names = custom_function_names + self._variable_names = variable_names self._pos = 0 self._error_highlight_pos = 0 self._ast: List[Argument] = [] @@ -195,9 +208,7 @@ class _Parser: if not var_name: raise StringFormattingException("invalid var name") - return FunctionArgument.from_idx( - idx=int(var_name), custom_function_name=self._custom_function_name - ) + return FunctionArgument.from_idx(idx=int(var_name), custom_function_name=self._name) def _parse_numeric(self) -> Integer | Float: numeric_string = "" @@ -337,17 +348,33 @@ class _Parser: if ch == ")": # Had '(' to indicate there are args if function_args is not None: - if self._custom_function_name == function_name: + if self._name == function_name: self._set_highlight_position(function_start_pos) raise CycleDetected( f"The custom function %{function_name} cannot call itself." ) - try: - return Function.from_name_and_args(name=function_name, args=function_args) - except IncompatibleFunctionArguments: + if Functions.is_built_in(function_name): + try: + return BuiltInFunction( + name=function_name, args=function_args + ).validate_args() + except IncompatibleFunctionArguments: + self._set_highlight_position(function_start_pos) + raise + + # Is custom function + if ( + self._custom_function_names is not None + and function_name not in self._custom_function_names + ): self._set_highlight_position(function_start_pos) - raise + raise FunctionDoesNotExist( + f"Function %{function_name} does not exist as a built-in or " + "custom function." + ) + + return CustomFunction(name=function_name, args=function_args) # Go back one so the parent function can close using the ')' self._pos -= 1 @@ -507,11 +534,21 @@ class _Parser: return SyntaxTree(ast=self._ast) -def parse(text: str, custom_function_name: Optional[str] = None) -> SyntaxTree: +def parse( + text: str, + name: Optional[str] = None, + custom_function_names: Optional[Set[str]] = None, + variable_names: Optional[Set[str]] = None, +) -> SyntaxTree: """ Entrypoint for parsing ytdl-sub code into a Syntax Tree """ - return _Parser(text=text, custom_function_name=custom_function_name).ast + return _Parser( + text=text, + name=name, + custom_function_names=custom_function_names, + variable_names=variable_names, + ).ast # pylint: enable=invalid-name diff --git a/src/ytdl_sub/script/script.py b/src/ytdl_sub/script/script.py index ab18ad6f..55c41454 100644 --- a/src/ytdl_sub/script/script.py +++ b/src/ytdl_sub/script/script.py @@ -1,6 +1,7 @@ from typing import Dict from typing import List from typing import Optional +from typing import Set from ytdl_sub.script.parser import parse from ytdl_sub.script.types.resolvable import Resolvable @@ -70,6 +71,9 @@ class Script: deps: List[str], ) -> None: for dep in custom_function_dependency.custom_functions: + if dep.name not in self._functions: + continue # does not exist, will throw downstream + if custom_function_name in deps + [dep.name]: cycle_deps = [custom_function_name] + deps + [dep.name] cycle_deps_str = " -> ".join([f"%{name}" for name in cycle_deps]) @@ -92,20 +96,35 @@ class Script: ) def __init__(self, overrides: Dict[str, str]): + function_names: Set[str] = { + self._function_name(name) for name in overrides.keys() if self._is_function(name) + } + variable_names: Set[str] = { + name for name in overrides.keys() if not self._is_function(name) + } + 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) self._function_name(function_key): parse( - text=function_value, custom_function_name=self._function_name(function_key) + text=function_value, + name=self._function_name(function_key), + custom_function_names=function_names, + variable_names=variable_names, ) for function_key, function_value in overrides.items() if self._is_function(function_key) } self._variables: Dict[str, SyntaxTree] = { - override_name: parse(override_value) - for override_name, override_value in overrides.items() - if not self._is_function(override_name) + variable_key: parse( + text=variable_value, + name=variable_key, + custom_function_names=function_names, + variable_names=variable_names, + ) + for variable_key, variable_value in overrides.items() + if not self._is_function(variable_key) } self._ensure_no_custom_function_cycles() diff --git a/src/ytdl_sub/script/types/function.py b/src/ytdl_sub/script/types/function.py index c706eeac..a1427a75 100644 --- a/src/ytdl_sub/script/types/function.py +++ b/src/ytdl_sub/script/types/function.py @@ -41,12 +41,6 @@ class Function(FunctionType, VariableDependency, ABC): def _iterable_arguments(self) -> List[Argument]: return self.args - @classmethod - def from_name_and_args(cls, name: str, args: List[Argument]) -> "Function": - if Functions.is_built_in(name): - return BuiltInFunction(name=name, args=args).validate_args() - return CustomFunction(name=name, args=args) - class CustomFunction(Function, NamedCustomFunction): def resolve( @@ -82,9 +76,9 @@ class CustomFunction(Function, NamedCustomFunction): custom_functions=custom_functions, ) - raise FunctionDoesNotExist( - f"Function %{self.name} does not exist as a built-in or custom function." - ) + # Implies the custom function does not exist. This should have + # been checked in the parser with + raise UNREACHABLE class BuiltInFunction(Function, TypeHintedFunctionType): diff --git a/tests/unit/script/types/test_custom_function.py b/tests/unit/script/types/test_custom_function.py index 26cbbba9..2c9d2692 100644 --- a/tests/unit/script/types/test_custom_function.py +++ b/tests/unit/script/types/test_custom_function.py @@ -6,6 +6,7 @@ from ytdl_sub.script.script import Script from ytdl_sub.script.types.array import ResolvedArray from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.utils.exceptions import CycleDetected +from ytdl_sub.script.utils.exceptions import FunctionDoesNotExist class TestCustomFunction: @@ -66,3 +67,16 @@ class TestCustomFunction: "output": "{%cycle_func0(1)}", } ).resolve() + + def test_custom_function_uses_non_existent_function(self): + with pytest.raises( + FunctionDoesNotExist, + match=re.escape("Function %lolnope does not exist as a built-in or custom function."), + ): + Script( + { + "%func1": "{%mul(%lolnope(1), $0)}", + "%func0": "{%mul(%func1(1), $0)}", + "output": "{%func(1)}", + } + ).resolve() diff --git a/tests/unit/script/types/test_float.py b/tests/unit/script/types/test_float.py index b6401293..104a38a8 100644 --- a/tests/unit/script/types/test_float.py +++ b/tests/unit/script/types/test_float.py @@ -39,8 +39,8 @@ class TestFloat: ], ) def test_float(self, float_: str, expected_float: int): - assert Script({"float": float_, "as_string": "{%string(float)}"}).resolve() == { - "float": Float(expected_float), + assert Script({"out": float_, "as_string": "{%string(out)}"}).resolve() == { + "out": Float(expected_float), "as_string": String(str(expected_float)), } diff --git a/tests/unit/script/types/test_string.py b/tests/unit/script/types/test_string.py index 581740c7..1a40229b 100644 --- a/tests/unit/script/types/test_string.py +++ b/tests/unit/script/types/test_string.py @@ -46,7 +46,7 @@ class TestString: ], ) def test_string(self, string: str, expected_string: str): - assert Script({"string": string}).resolve() == {"string": String(expected_string)} + assert Script({"out": string}).resolve() == {"out": String(expected_string)} @pytest.mark.parametrize( "string",