From 5d713cd90a99b01fc2c5f75ad304262e3a507961 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Tue, 21 Nov 2023 01:08:47 -0800 Subject: [PATCH] lambdas back! --- src/ytdl_sub/script/functions/__init__.py | 4 +- .../script/functions/array_functions.py | 9 ++++ src/ytdl_sub/script/parser.py | 26 +++++++---- src/ytdl_sub/script/types/function.py | 44 +++++++++++++++++-- src/ytdl_sub/script/types/lambda.py | 5 --- src/ytdl_sub/script/types/resolvable.py | 5 +++ src/ytdl_sub/script/types/variable.py | 2 + tests/unit/script/test_parser.py | 16 +++++++ tests/unit/script/test_script.py | 2 +- tests/unit/script/types/test_array.py | 2 +- tests/unit/script/types/test_function.py | 7 +++ 11 files changed, 103 insertions(+), 19 deletions(-) delete mode 100644 src/ytdl_sub/script/types/lambda.py diff --git a/src/ytdl_sub/script/functions/__init__.py b/src/ytdl_sub/script/functions/__init__.py index f1404036..b8d75d8d 100644 --- a/src/ytdl_sub/script/functions/__init__.py +++ b/src/ytdl_sub/script/functions/__init__.py @@ -16,4 +16,6 @@ class Functions( BooleanFunctions, ErrorFunctions, ): - pass + @classmethod + def is_built_in(cls, name: str) -> bool: + return hasattr(cls, name) or hasattr(cls, name + "_") diff --git a/src/ytdl_sub/script/functions/array_functions.py b/src/ytdl_sub/script/functions/array_functions.py index 880afe58..cc20f854 100644 --- a/src/ytdl_sub/script/functions/array_functions.py +++ b/src/ytdl_sub/script/functions/array_functions.py @@ -1,7 +1,9 @@ from typing import List 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 Resolvable @@ -44,3 +46,10 @@ class ArrayFunctions: Reverse an Array. """ return Array(list(reversed(array.value))) + + @staticmethod + def array_apply(array: Array, lambda_function: Lambda) -> Array: + """ + Reverse an Array. + """ + return ResolvedArray([ResolvedArray([val]) for val in array.value]) diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index 7c098755..37d1c7ae 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -10,6 +10,7 @@ from ytdl_sub.script.types.map import UnresolvedMap from ytdl_sub.script.types.resolvable import Boolean from ytdl_sub.script.types.resolvable import Float from ytdl_sub.script.types.resolvable import Integer +from ytdl_sub.script.types.resolvable import Lambda from ytdl_sub.script.types.resolvable import NonHashable from ytdl_sub.script.types.resolvable import String from ytdl_sub.script.types.syntax_tree import SyntaxTree @@ -172,7 +173,7 @@ class _Parser: def _parse_custom_function_argument(self) -> FunctionArgument: """ - Begin parsing function args after the first ``$``, i.e. ``$1`` + Begin parsing function args after the first ``$``, i.e. ``$0`` """ var_name = "" while ch := self._read(increment_pos=False): @@ -320,26 +321,35 @@ class _Parser: return arguments - def _parse_function(self) -> Function: + def _parse_function(self) -> Function | Lambda: """ Begin parsing a function after reading the first ``%`` """ function_name: str = "" - function_args: List[ArgumentType] = [] + function_args: Optional[List[ArgumentType]] = None function_start_pos = self._pos while ch := self._read(): if ch == ")": - try: - return Function.from_name_and_args(name=function_name, args=function_args) - except IncompatibleFunctionArguments: - self._set_highlight_position(function_start_pos) - raise + if function_args is not None: + # Had '(' to indicate there are args + try: + return Function.from_name_and_args(name=function_name, args=function_args) + except IncompatibleFunctionArguments: + self._set_highlight_position(function_start_pos) + raise + + # Go back one so the parent function can close using the ')' + self._pos -= 1 + return Lambda(function_name=function_name) if _is_function_name_char(ch): function_name += ch elif ch == "(": function_args = self._parse_args(argument_parser=ParsedArgType.FUNCTION) + elif ch.isspace() or ch == ",": + # function with no args, it's a lambda + return Lambda(function_name=function_name) else: break diff --git a/src/ytdl_sub/script/types/function.py b/src/ytdl_sub/script/types/function.py index 0853ac5a..f8a6a862 100644 --- a/src/ytdl_sub/script/types/function.py +++ b/src/ytdl_sub/script/types/function.py @@ -7,16 +7,21 @@ from inspect import FullArgSpec from typing import Callable from typing import Dict from typing import List +from typing import Optional from typing import Set from typing import Type from typing import Union from ytdl_sub.script.functions import Functions +from ytdl_sub.script.types.array import Array +from ytdl_sub.script.types.array import ResolvedArray +from ytdl_sub.script.types.array import UnresolvedArray from ytdl_sub.script.types.resolvable import AnyTypeReturnable from ytdl_sub.script.types.resolvable import AnyTypeReturnableA from ytdl_sub.script.types.resolvable import AnyTypeReturnableB from ytdl_sub.script.types.resolvable import ArgumentType from ytdl_sub.script.types.resolvable import FunctionType +from ytdl_sub.script.types.resolvable import Lambda from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.resolvable import TypeHintedFunctionType from ytdl_sub.script.types.variable import FunctionArgument @@ -67,9 +72,8 @@ class Function(FunctionType, VariableDependency, ABC): @classmethod def from_name_and_args(cls, name: str, args: List[ArgumentType]) -> "Function": - if hasattr(Functions, name) or hasattr(Functions, name + "_"): + if Functions.is_built_in(name): return BuiltInFunction(name=name, args=args).validate_args() - return CustomFunction(name=name, args=args) @@ -92,7 +96,7 @@ class CustomFunction(Function): resolved_variables_with_args = copy.deepcopy(resolved_variables) for i, arg in enumerate(resolved_args): - function_arg = FunctionArgument(name=f"${i+1}") # Function args are 1-based + function_arg = FunctionArgument(name=f"${i}") # Function args are 1-based if function_arg in resolved_variables_with_args: raise StringFormattingException("nested custom functions???") resolved_variables_with_args[function_arg] = arg @@ -139,6 +143,12 @@ class BuiltInFunction(Function, TypeHintedFunctionType): args=[self.arg_spec.annotations[arg_name] for arg_name in self.arg_spec.args] ) + @property + def lambda_function(self) -> Optional[str]: + if Lambda in (self.input_spec.args or []): + return [lam for lam in self.args if isinstance(lam, Lambda)][0].function_name + return None + @classmethod def _arg_output_type(cls, arg: ArgumentType) -> Type[ArgumentType]: if isinstance(arg, BuiltInFunction): @@ -165,6 +175,34 @@ class BuiltInFunction(Function, TypeHintedFunctionType): resolved_variables: Dict[Variable, Resolvable], custom_functions: Dict[str, "VariableDependency"], ) -> Resolvable: + if lambda_function := self.lambda_function: + resolved_args: List[Resolvable] = [ + self._resolve_argument_type( + arg=arg, + resolved_variables=resolved_variables, + custom_functions=custom_functions, + ) + for arg in self.args + if not isinstance(arg, Lambda) + ] + lambda_arg = [arg for arg in self.args if isinstance(arg, Lambda)] + + lambda_args = self.callable(*(resolved_args + lambda_arg)) + assert isinstance(lambda_args, ResolvedArray) + + return self._resolve_argument_type( + arg=UnresolvedArray( + [ + BuiltInFunction(name=lambda_function, args=lambda_arg.value) + if Functions.is_built_in(lambda_function) + else CustomFunction(name=lambda_function, args=lambda_arg.value) + for lambda_arg in lambda_args.value + ] + ), + resolved_variables=resolved_variables, + custom_functions=custom_functions, + ) + resolved_args: List[Resolvable] = [ self._resolve_argument_type( arg=arg, resolved_variables=resolved_variables, custom_functions=custom_functions diff --git a/src/ytdl_sub/script/types/lambda.py b/src/ytdl_sub/script/types/lambda.py deleted file mode 100644 index d016c4ac..00000000 --- a/src/ytdl_sub/script/types/lambda.py +++ /dev/null @@ -1,5 +0,0 @@ -from ytdl_sub.script.types.resolvable import ArgumentType - - -class Lambda(ArgumentType): - function_name: str diff --git a/src/ytdl_sub/script/types/resolvable.py b/src/ytdl_sub/script/types/resolvable.py index 65d56a1a..d41d3c0d 100644 --- a/src/ytdl_sub/script/types/resolvable.py +++ b/src/ytdl_sub/script/types/resolvable.py @@ -132,3 +132,8 @@ class TypeHintedFunctionType(FunctionType, ABC): @abstractmethod def output_type(self) -> Type[Resolvable]: pass + + +@dataclass(frozen=True) +class Lambda(ArgumentType): + function_name: str diff --git a/src/ytdl_sub/script/types/variable.py b/src/ytdl_sub/script/types/variable.py index 7af9d36f..9b4d7948 100644 --- a/src/ytdl_sub/script/types/variable.py +++ b/src/ytdl_sub/script/types/variable.py @@ -10,4 +10,6 @@ class Variable(ArgumentType): @dataclass(frozen=True) class FunctionArgument(Variable): + """Arguments for custom functions, i.e. $0, $1, etc""" + pass diff --git a/tests/unit/script/test_parser.py b/tests/unit/script/test_parser.py index 6641cde3..ee4abea7 100644 --- a/tests/unit/script/test_parser.py +++ b/tests/unit/script/test_parser.py @@ -8,10 +8,13 @@ from ytdl_sub.script.parser import _UNEXPECTED_CHAR_ARGUMENT from ytdl_sub.script.parser import BRACKET_NOT_CLOSED from ytdl_sub.script.parser import ParsedArgType from ytdl_sub.script.parser import parse +from ytdl_sub.script.types.array import Array +from ytdl_sub.script.types.array import UnresolvedArray from ytdl_sub.script.types.function import BuiltInFunction from ytdl_sub.script.types.resolvable import Boolean from ytdl_sub.script.types.resolvable import Float from ytdl_sub.script.types.resolvable import Integer +from ytdl_sub.script.types.resolvable import Lambda from ytdl_sub.script.types.resolvable import String from ytdl_sub.script.types.syntax_tree import SyntaxTree from ytdl_sub.script.types.variable import Variable @@ -155,6 +158,19 @@ class TestParser: ) assert parsed.variables == {Variable(name="variable_name")} + def test_lambda_function(self): + assert parse("{%array_apply([1], %times_two)}") == SyntaxTree( + [ + BuiltInFunction( + name="array_apply", + args=[ + UnresolvedArray(value=[Integer(1)]), + Lambda(function_name="times_two"), + ], + ) + ] + ) + class TestParserBracketFailures: def test_bracket_open(self): diff --git a/tests/unit/script/test_script.py b/tests/unit/script/test_script.py index c7b84a7b..556ae9fd 100644 --- a/tests/unit/script/test_script.py +++ b/tests/unit/script/test_script.py @@ -9,7 +9,7 @@ class TestSyntaxTree: def test_custom_function(self): assert Script( { - "%custom_func": "return {[$1, $2]}", + "%custom_func": "return {[$0, $1]}", "aa": "a", "bb": "b", "cc": "{%custom_func(aa, bb)}", diff --git a/tests/unit/script/types/test_array.py b/tests/unit/script/types/test_array.py index 5bc17734..554e9fa4 100644 --- a/tests/unit/script/types/test_array.py +++ b/tests/unit/script/types/test_array.py @@ -107,7 +107,7 @@ class TestArray: def test_custom_function(self): assert Script( { - "%custom_func": "return {[$1, $2]}", + "%custom_func": "return {[$0, $1]}", "aa": "a", "bb": "b", "cc": "{%custom_func(aa, bb)}", diff --git a/tests/unit/script/types/test_function.py b/tests/unit/script/types/test_function.py index 0f3e6997..0151af38 100644 --- a/tests/unit/script/types/test_function.py +++ b/tests/unit/script/types/test_function.py @@ -4,7 +4,9 @@ import pytest from ytdl_sub.script.parser import FUNCTION_INVALID_CHAR from ytdl_sub.script.script import Script +from ytdl_sub.script.types.array import ResolvedArray from ytdl_sub.script.types.resolvable import Boolean +from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import String from ytdl_sub.script.utils.exceptions import FunctionDoesNotExist from ytdl_sub.script.utils.exceptions import FunctionRuntimeException @@ -114,3 +116,8 @@ class TestFunction: match=re.escape(str(FUNCTION_INVALID_CHAR)), ): Script({"dne": "{%throw}"}).resolve() + + def test_lambda_function(self): + assert Script( + {"%times_two": "{%mul($0, 2)}", "wip": "{%array_apply([1, 2, 3], %times_two)}"} + ).resolve() == {"wip": ResolvedArray([Integer(2), Integer(4), Integer(6)])}