test lambda num arguments
This commit is contained in:
parent
094613c8c2
commit
5dfde1fb23
9 changed files with 230 additions and 5 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue