test lambda num arguments

This commit is contained in:
Jesse Bannon 2023-11-24 00:18:36 -08:00
parent 094613c8c2
commit 5dfde1fb23
9 changed files with 230 additions and 5 deletions

View file

@ -1,3 +1,5 @@
from typing import Callable
from ytdl_sub.script.functions.array_functions import ArrayFunctions from ytdl_sub.script.functions.array_functions import ArrayFunctions
from ytdl_sub.script.functions.boolean_functions import BooleanFunctions from ytdl_sub.script.functions.boolean_functions import BooleanFunctions
from ytdl_sub.script.functions.conditional_functions import ConditionalFunctions 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.map_functions import MapFunctions
from ytdl_sub.script.functions.numeric_functions import NumericFunctions from ytdl_sub.script.functions.numeric_functions import NumericFunctions
from ytdl_sub.script.functions.string_functions import StringFunctions 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( class Functions(
@ -18,4 +22,13 @@ class Functions(
): ):
@classmethod @classmethod
def is_built_in(cls, name: str) -> bool: 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")

View file

@ -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.array import ResolvedArray
from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import Lambda from ytdl_sub.script.types.resolvable import Lambda
from ytdl_sub.script.types.resolvable import Lambda2
from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.resolvable import Resolvable
@ -57,7 +58,7 @@ class ArrayFunctions:
return ResolvedArray([ResolvedArray([val]) for val in array.value]) return ResolvedArray([ResolvedArray([val]) for val in array.value])
@staticmethod @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 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. passed to the lambda function is ``idx, element`` as two separate args.

View file

@ -4,15 +4,19 @@ from typing import List
from typing import Optional from typing import Optional
from typing import Set from typing import Set
from ytdl_sub.script.functions import Functions
from ytdl_sub.script.parser import parse 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.resolvable import Resolvable
from ytdl_sub.script.types.syntax_tree import SyntaxTree from ytdl_sub.script.types.syntax_tree import SyntaxTree
from ytdl_sub.script.types.variable import Variable from ytdl_sub.script.types.variable import Variable
from ytdl_sub.script.utils.exceptions import UNREACHABLE from ytdl_sub.script.utils.exceptions import UNREACHABLE
from ytdl_sub.script.utils.exceptions import CycleDetected 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 InvalidCustomFunctionArguments
from ytdl_sub.script.utils.exceptions import RuntimeException from ytdl_sub.script.utils.exceptions import RuntimeException
from ytdl_sub.script.utils.name_validation import validate_variable_name 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 # pylint: disable=missing-raises-doc
@ -126,6 +130,7 @@ class Script:
f"{nested_custom_function.num_input_args}" f"{nested_custom_function.num_input_args}"
) )
# TODO: DEDUPLICATE
for function_name, function_definition in self._functions.items(): for function_name, function_definition in self._functions.items():
for nested_custom_function in function_definition.custom_functions: for nested_custom_function in function_definition.custom_functions:
if nested_custom_function.name == function_name: if nested_custom_function.name == function_name:
@ -144,11 +149,104 @@ class Script:
f"{nested_custom_function.num_input_args}" 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: def _validate(self) -> None:
self._ensure_no_custom_function_cycles() self._ensure_no_custom_function_cycles()
self._ensure_custom_function_arguments_valid() self._ensure_custom_function_arguments_valid()
self._ensure_no_variable_cycles() self._ensure_no_variable_cycles()
self._ensure_custom_function_usage_num_input_arguments_valid() self._ensure_custom_function_usage_num_input_arguments_valid()
self._ensure_lambda_usage_num_input_arguments_valid()
def __init__(self, script: Dict[str, str]): def __init__(self, script: Dict[str, str]):
function_names: Set[str] = { function_names: Set[str] = {

View file

@ -154,3 +154,18 @@ class Lambda(Resolvable):
def native(self) -> Any: def native(self) -> Any:
return f"%{self.value}" 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

View file

@ -46,6 +46,11 @@ class VariableDependency(ABC):
def variables(self) -> Set[Variable]: def variables(self) -> Set[Variable]:
return set(self._recurse_get(Variable)) return set(self._recurse_get(Variable))
@final
@property
def built_in_functions(self) -> List[BuiltInFunctionType]:
return self._recurse_get(BuiltInFunctionType)
@final @final
@property @property
def function_arguments(self) -> Set[FunctionArgument]: def function_arguments(self) -> Set[FunctionArgument]:

View file

@ -55,6 +55,10 @@ class KeyNotHashableRuntimeException(RuntimeException):
"""Map tried to use a non-hashable key at runtime""" """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): class UserThrownRuntimeError(ValidationException):
"""An error explicitly thrown by the user via a function""" """An error explicitly thrown by the user via a function"""

View file

@ -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 BuiltInFunctionType
from ytdl_sub.script.types.resolvable import FunctionType from ytdl_sub.script.types.resolvable import FunctionType
from ytdl_sub.script.types.resolvable import Lambda 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 NamedType
from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.resolvable import Resolvable
from ytdl_sub.script.types.variable import Variable from ytdl_sub.script.types.variable import Variable
@ -59,6 +60,12 @@ def is_type_compatible(
arg_type: Type[NamedType] = arg.__class__ arg_type: Type[NamedType] = arg.__class__
if isinstance(arg, BuiltInFunctionType): if isinstance(arg, BuiltInFunctionType):
arg_type = arg.output_type() # built-in function 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): elif isinstance(arg, FunctionType):
return True # custom-function, can be anything, so pass for now return True # custom-function, can be anything, so pass for now
elif isinstance(arg, Variable): elif isinstance(arg, Variable):
@ -137,9 +144,24 @@ class FunctionSpec:
raise UNREACHABLE # TODO: functions with no args 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 @property
def is_lambda_function(self) -> bool: def num_required_args(self) -> int:
return Lambda in (self.args or []) 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 @classmethod
def from_callable(cls, callable_ref: Callable[..., Resolvable]) -> "FunctionSpec": def from_callable(cls, callable_ref: Callable[..., Resolvable]) -> "FunctionSpec":

View file

@ -80,7 +80,7 @@ class TestArrayFunctions:
{ {
"%enumerate_output": "{[$0, $1]}", "%enumerate_output": "{[$0, $1]}",
"array1": "{['a', 'b', 'c']}", "array1": "{['a', 'b', 'c']}",
"output": "{%array_apply(array1, %enumerate_output)}", "output": "{%array_enumerate(array1, %enumerate_output)}",
} }
) )
.resolve(update=True) .resolve(update=True)

View file

@ -1,6 +1,11 @@
import re
import pytest
from ytdl_sub.script.script import Script from ytdl_sub.script.script import Script
from ytdl_sub.script.types.array import ResolvedArray from ytdl_sub.script.types.array import ResolvedArray
from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments
class TestLambdaFunction: class TestLambdaFunction:
@ -47,3 +52,65 @@ class TestLambdaFunction:
"output": "{%array_at(%array_apply([2], %nest1), 0)}", "output": "{%array_at(%array_apply([2], %nest1), 0)}",
} }
).resolve() == {"output": Integer(4)} ).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)}",
}
)