From 5dfde1fb23b54eb3c1695520bb071deb521cd2e5 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Fri, 24 Nov 2023 00:18:36 -0800 Subject: [PATCH] test lambda num arguments --- src/ytdl_sub/script/functions/__init__.py | 15 ++- .../script/functions/array_functions.py | 3 +- src/ytdl_sub/script/script.py | 98 +++++++++++++++++++ src/ytdl_sub/script/types/resolvable.py | 15 +++ .../script/types/variable_dependency.py | 5 + src/ytdl_sub/script/utils/exceptions.py | 4 + src/ytdl_sub/script/utils/type_checking.py | 26 ++++- .../script/functions/test_array_functions.py | 2 +- .../unit/script/types/test_lambda_function.py | 67 +++++++++++++ 9 files changed, 230 insertions(+), 5 deletions(-) diff --git a/src/ytdl_sub/script/functions/__init__.py b/src/ytdl_sub/script/functions/__init__.py index b8d75d8d..5c23fa8b 100644 --- a/src/ytdl_sub/script/functions/__init__.py +++ b/src/ytdl_sub/script/functions/__init__.py @@ -1,3 +1,5 @@ +from typing import Callable + from ytdl_sub.script.functions.array_functions import ArrayFunctions from ytdl_sub.script.functions.boolean_functions import BooleanFunctions from ytdl_sub.script.functions.conditional_functions import ConditionalFunctions @@ -5,6 +7,8 @@ from ytdl_sub.script.functions.error_functions import ErrorFunctions from ytdl_sub.script.functions.map_functions import MapFunctions from ytdl_sub.script.functions.numeric_functions import NumericFunctions from ytdl_sub.script.functions.string_functions import StringFunctions +from ytdl_sub.script.types.resolvable import Resolvable +from ytdl_sub.script.utils.exceptions import FunctionDoesNotExistRuntimeException class Functions( @@ -18,4 +22,13 @@ class Functions( ): @classmethod def is_built_in(cls, name: str) -> bool: - return hasattr(cls, name) or hasattr(cls, name + "_") + return hasattr(cls, name) or hasattr(cls, f"{name}_") + + @classmethod + def get(cls, name: str) -> Callable[..., Resolvable]: + if hasattr(cls, name): + return getattr(cls, name) + if hasattr(cls, f"{name}_"): + return getattr(cls, f"{name}_") + + raise FunctionDoesNotExistRuntimeException(f"The function {name} does not exist") diff --git a/src/ytdl_sub/script/functions/array_functions.py b/src/ytdl_sub/script/functions/array_functions.py index 4de02a28..0f7e536b 100644 --- a/src/ytdl_sub/script/functions/array_functions.py +++ b/src/ytdl_sub/script/functions/array_functions.py @@ -4,6 +4,7 @@ from ytdl_sub.script.types.array import Array from ytdl_sub.script.types.array import ResolvedArray from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import Lambda +from ytdl_sub.script.types.resolvable import Lambda2 from ytdl_sub.script.types.resolvable import Resolvable @@ -57,7 +58,7 @@ class ArrayFunctions: return ResolvedArray([ResolvedArray([val]) for val in array.value]) @staticmethod - def array_enumerate(array: Array, lambda_function: Lambda) -> Array: + def array_enumerate(array: Array, lambda_function: Lambda2) -> Array: """ Apply a lambda function on every element in the Array, where each arg passed to the lambda function is ``idx, element`` as two separate args. diff --git a/src/ytdl_sub/script/script.py b/src/ytdl_sub/script/script.py index 53d3dc02..b70caed5 100644 --- a/src/ytdl_sub/script/script.py +++ b/src/ytdl_sub/script/script.py @@ -4,15 +4,19 @@ from typing import List from typing import Optional from typing import Set +from ytdl_sub.script.functions import Functions from ytdl_sub.script.parser import parse +from ytdl_sub.script.types.resolvable import Lambda from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.syntax_tree import SyntaxTree from ytdl_sub.script.types.variable import Variable from ytdl_sub.script.utils.exceptions import UNREACHABLE from ytdl_sub.script.utils.exceptions import CycleDetected +from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments from ytdl_sub.script.utils.exceptions import InvalidCustomFunctionArguments from ytdl_sub.script.utils.exceptions import RuntimeException from ytdl_sub.script.utils.name_validation import validate_variable_name +from ytdl_sub.script.utils.type_checking import FunctionSpec # pylint: disable=missing-raises-doc @@ -126,6 +130,7 @@ class Script: f"{nested_custom_function.num_input_args}" ) + # TODO: DEDUPLICATE for function_name, function_definition in self._functions.items(): for nested_custom_function in function_definition.custom_functions: if nested_custom_function.name == function_name: @@ -144,11 +149,104 @@ class Script: f"{nested_custom_function.num_input_args}" ) + def _ensure_lambda_usage_num_input_arguments_valid(self): + for variable_name, variable_definition in self._variables.items(): + for function in variable_definition.built_in_functions: + spec = FunctionSpec.from_callable(Functions.get(function.name)) + if lambda_type := spec.is_lambda_function: + + lambda_function_names = set( + [lamb.value for lamb in function.args if isinstance(lamb, Lambda)] + ) + + # Only case len(lambda_function_names) > 1 is when used in if-statements + for lambda_function_name in lambda_function_names: + if Functions.is_built_in(lambda_function_name): + lambda_spec = FunctionSpec.from_callable( + Functions.get(lambda_function_name) + ) + if not lambda_spec.is_num_args_compatible(lambda_type.num_input_args()): + expected_args_str = str(lambda_spec.num_required_args) + if lambda_spec.num_required_args != len(lambda_spec.args): + expected_args_str = ( + f"{expected_args_str} - {len(lambda_spec.args)}" + ) + + raise IncompatibleFunctionArguments( + f"Variable {variable_name} has invalid usage of the " + f"function %{lambda_function_name} as a lambda: " + f"Expects {expected_args_str} " + f"argument{'s' if expected_args_str != '1' else ''} but will " + f"receive {lambda_type.num_input_args()}." + ) + else: # is custom function + if lambda_function_name not in self._functions: + raise UNREACHABLE # Custom function should have been validated + + expected_num_arguments = len( + self._functions[lambda_function_name].function_arguments + ) + if lambda_type.num_input_args() != expected_num_arguments: + raise IncompatibleFunctionArguments( + f"Variable {variable_name} has invalid usage of the custom " + f"function %{lambda_function_name} as a lambda: " + f"Expects {expected_num_arguments} " + f"argument{'s' if expected_num_arguments > 1 else ''} but will " + f"receive {lambda_type.num_input_args()}." + ) + + # TODO: DEDUPLICATE + for function_name, function_definition in self._functions.items(): + for function in function_definition.built_in_functions: + spec = FunctionSpec.from_callable(Functions.get(function.name)) + if lambda_type := spec.is_lambda_function: + + lambda_function_names = set( + [lamb.value for lamb in function.args if isinstance(lamb, Lambda)] + ) + + # Only case len(lambda_function_names) > 1 is when used in if-statements + for lambda_function_name in lambda_function_names: + if Functions.is_built_in(lambda_function_name): + lambda_spec = FunctionSpec.from_callable( + Functions.get(lambda_function_name) + ) + if not lambda_spec.is_num_args_compatible(lambda_type.num_input_args()): + expected_args_str = str(lambda_spec.num_required_args) + if lambda_spec.num_required_args != len(lambda_spec.args): + expected_args_str = ( + f"{expected_args_str} - {len(lambda_spec.args)}" + ) + + raise IncompatibleFunctionArguments( + f"Custom function %{function_name} has invalid usage of the " + f"function %{lambda_function_name} as a lambda: " + f"Expects {expected_args_str} " + f"argument{'s' if expected_args_str != '1' else ''} but will " + f"receive {lambda_type.num_input_args()}." + ) + else: # is custom function + if lambda_function_name not in self._functions: + raise UNREACHABLE # Custom function should have been validated + + expected_num_arguments = len( + self._functions[lambda_function_name].function_arguments + ) + if lambda_type.num_input_args() != expected_num_arguments: + raise IncompatibleFunctionArguments( + f"Custom function %{function_name} has invalid usage of the custom " + f"function %{lambda_function_name} as a lambda: " + f"Expects {expected_num_arguments} " + f"argument{'s' if expected_num_arguments > 1 else ''} but will " + f"receive {lambda_type.num_input_args()}." + ) + def _validate(self) -> None: self._ensure_no_custom_function_cycles() self._ensure_custom_function_arguments_valid() self._ensure_no_variable_cycles() self._ensure_custom_function_usage_num_input_arguments_valid() + self._ensure_lambda_usage_num_input_arguments_valid() def __init__(self, script: Dict[str, str]): function_names: Set[str] = { diff --git a/src/ytdl_sub/script/types/resolvable.py b/src/ytdl_sub/script/types/resolvable.py index e38f7f7b..071a700d 100644 --- a/src/ytdl_sub/script/types/resolvable.py +++ b/src/ytdl_sub/script/types/resolvable.py @@ -154,3 +154,18 @@ class Lambda(Resolvable): def native(self) -> Any: return f"%{self.value}" + + @classmethod + def num_input_args(cls) -> int: + return 1 + + +@dataclass(frozen=True) +class Lambda2(Lambda): + """ + Type-hinting for functions that apply lambdas with two inputs per element + """ + + @classmethod + def num_input_args(cls) -> int: + return 2 diff --git a/src/ytdl_sub/script/types/variable_dependency.py b/src/ytdl_sub/script/types/variable_dependency.py index 84a77464..8bda071c 100644 --- a/src/ytdl_sub/script/types/variable_dependency.py +++ b/src/ytdl_sub/script/types/variable_dependency.py @@ -46,6 +46,11 @@ class VariableDependency(ABC): def variables(self) -> Set[Variable]: return set(self._recurse_get(Variable)) + @final + @property + def built_in_functions(self) -> List[BuiltInFunctionType]: + return self._recurse_get(BuiltInFunctionType) + @final @property def function_arguments(self) -> Set[FunctionArgument]: diff --git a/src/ytdl_sub/script/utils/exceptions.py b/src/ytdl_sub/script/utils/exceptions.py index 7317387d..a4cd169f 100644 --- a/src/ytdl_sub/script/utils/exceptions.py +++ b/src/ytdl_sub/script/utils/exceptions.py @@ -55,6 +55,10 @@ class KeyNotHashableRuntimeException(RuntimeException): """Map tried to use a non-hashable key at runtime""" +class FunctionDoesNotExistRuntimeException(RuntimeException): + """Tried to get a function that does not exist""" + + class UserThrownRuntimeError(ValidationException): """An error explicitly thrown by the user via a function""" diff --git a/src/ytdl_sub/script/utils/type_checking.py b/src/ytdl_sub/script/utils/type_checking.py index c7f6c0e7..3cf6aa69 100644 --- a/src/ytdl_sub/script/utils/type_checking.py +++ b/src/ytdl_sub/script/utils/type_checking.py @@ -12,6 +12,7 @@ from ytdl_sub.script.types.resolvable import Argument from ytdl_sub.script.types.resolvable import BuiltInFunctionType from ytdl_sub.script.types.resolvable import FunctionType from ytdl_sub.script.types.resolvable import Lambda +from ytdl_sub.script.types.resolvable import Lambda2 from ytdl_sub.script.types.resolvable import NamedType from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.variable import Variable @@ -59,6 +60,12 @@ def is_type_compatible( arg_type: Type[NamedType] = arg.__class__ if isinstance(arg, BuiltInFunctionType): arg_type = arg.output_type() # built-in function + elif isinstance(arg, Lambda): + # lambda, check if expected_arg_type is a subclass + # Do not return on just that to also allow lambdas to be returned as + # ReturnableArguments (i.e in an %if statement) + if issubclass(expected_arg_type, arg_type): + return True elif isinstance(arg, FunctionType): return True # custom-function, can be anything, so pass for now elif isinstance(arg, Variable): @@ -137,9 +144,24 @@ class FunctionSpec: raise UNREACHABLE # TODO: functions with no args + def is_num_args_compatible(self, num_input_args: int) -> bool: + if self.args is not None: + return self.num_required_args <= num_input_args <= len(self.args) + return True # varargs can take any number + @property - def is_lambda_function(self) -> bool: - return Lambda in (self.args or []) + def num_required_args(self) -> int: + if self.args is not None: + return sum(1 for arg in self.args if not is_optional(arg)) + return 0 # varargs can take any number + + @property + def is_lambda_function(self) -> Optional[Type[Lambda | Lambda2]]: + if Lambda2 in (self.args or []): + return Lambda2 + elif Lambda in (self.args or []): + return Lambda + return None @classmethod def from_callable(cls, callable_ref: Callable[..., Resolvable]) -> "FunctionSpec": diff --git a/tests/unit/script/functions/test_array_functions.py b/tests/unit/script/functions/test_array_functions.py index df007315..e044c01d 100644 --- a/tests/unit/script/functions/test_array_functions.py +++ b/tests/unit/script/functions/test_array_functions.py @@ -80,7 +80,7 @@ class TestArrayFunctions: { "%enumerate_output": "{[$0, $1]}", "array1": "{['a', 'b', 'c']}", - "output": "{%array_apply(array1, %enumerate_output)}", + "output": "{%array_enumerate(array1, %enumerate_output)}", } ) .resolve(update=True) diff --git a/tests/unit/script/types/test_lambda_function.py b/tests/unit/script/types/test_lambda_function.py index f010d033..d564fad6 100644 --- a/tests/unit/script/types/test_lambda_function.py +++ b/tests/unit/script/types/test_lambda_function.py @@ -1,6 +1,11 @@ +import re + +import pytest + 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 IncompatibleFunctionArguments class TestLambdaFunction: @@ -47,3 +52,65 @@ class TestLambdaFunction: "output": "{%array_at(%array_apply([2], %nest1), 0)}", } ).resolve() == {"output": Integer(4)} + + def test_custom_function_lambda_in_variable_incompatible_number_of_args(self): + with pytest.raises( + IncompatibleFunctionArguments, + match=re.escape( + "Variable output has invalid usage of the custom function " + "%enumerate_output as a lambda: Expects 2 arguments but will receive 1." + ), + ): + Script( + { + "%enumerate_output": "{[$0, $1]}", + "array1": "{['a', 'b', 'c']}", + "output": "{%array_apply(array1, %enumerate_output)}", + } + ) + + def test_function_lambda_in_variable_incompatible_number_of_args(self): + with pytest.raises( + IncompatibleFunctionArguments, + match=re.escape( + "Variable output has invalid usage of the function %replace as a lambda: " + "Expects 3 - 4 arguments but will receive 1." + ), + ): + Script( + { + "array1": "{['a', 'b', 'c']}", + "output": "{%array_apply(array1, %replace)}", + } + ) + + def test_custom_function_lambda_in_custom_function_incompatible_number_of_args(self): + with pytest.raises( + IncompatibleFunctionArguments, + match=re.escape( + "Custom function %output has invalid usage of the custom function " + "%enumerate_output as a lambda: Expects 3 arguments but will receive 2." + ), + ): + Script( + { + "%enumerate_output": "{[$0, $1, $2]}", + "array1": "{['a', 'b', 'c']}", + "%output": "{%array_enumerate(array1, %enumerate_output)}", + } + ) + + def test_function_lambda_in_custom_function_incompatible_number_of_args(self): + with pytest.raises( + IncompatibleFunctionArguments, + match=re.escape( + "Custom function %output has invalid usage of the function " + "%replace as a lambda: Expects 3 - 4 arguments but will receive 2." + ), + ): + Script( + { + "array1": "{['a', 'b', 'c']}", + "%output": "{%array_enumerate(array1, %replace)}", + } + )