diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index c6d381f6..cf07437d 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -15,11 +15,11 @@ from ytdl_sub.script.types.resolvable import String from ytdl_sub.script.types.syntax_tree import SyntaxTree from ytdl_sub.script.types.variable import FunctionArgument 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 IncompatibleFunctionArguments from ytdl_sub.script.utils.exceptions import InvalidSyntaxException from ytdl_sub.script.utils.exceptions import UserException -from ytdl_sub.script.utils.parser_exception_formatter import ParserExceptionFormatter from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.validators.string_formatter_validators import is_valid_source_variable_name diff --git a/src/ytdl_sub/script/types/function.py b/src/ytdl_sub/script/types/function.py index 1c854021..1247cfe7 100644 --- a/src/ytdl_sub/script/types/function.py +++ b/src/ytdl_sub/script/types/function.py @@ -7,7 +7,6 @@ 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 @@ -17,94 +16,17 @@ 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 NamedType +from ytdl_sub.script.types.resolvable import FunctionLike from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.variable import FunctionArgument from ytdl_sub.script.types.variable import Variable from ytdl_sub.script.types.variable_dependency import VariableDependency -from ytdl_sub.script.utils.exceptions import UNREACHABLE -from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments -from ytdl_sub.script.utils.type_checking import get_optional_type -from ytdl_sub.script.utils.type_checking import is_optional -from ytdl_sub.script.utils.type_checking import is_type_compatible +from ytdl_sub.script.utils.exception_formatters import FunctionArgumentsExceptionFormatter +from ytdl_sub.script.utils.type_checking import FunctionInputSpec from ytdl_sub.script.utils.type_checking import is_union from ytdl_sub.utils.exceptions import StringFormattingException -@dataclass(frozen=True) -class FunctionInputSpec: - args: Optional[List[Type[Resolvable | Optional[Resolvable]]]] = None - varargs: Optional[Type[Resolvable]] = None - - def __post_init__(self): - assert (self.args is None) ^ (self.varargs is None) - - @classmethod - def _is_arg_compatible( - cls, arg: NamedType, expected_arg_type: Type[Resolvable | Optional[Resolvable]] - ): - input_arg_type = arg.__class__ - if isinstance(arg, BuiltInFunction): - input_arg_type = arg.output_type - elif isinstance(arg, Variable): - return True # unresolved variables can be anything, so pass for now - - return is_type_compatible(arg_type=input_arg_type, expected_arg_type=expected_arg_type) - - def _is_args_compatible(self, input_args: List[ArgumentType]) -> bool: - assert self.args is not None - - if len(input_args) > len(self.args): - return False - - for idx, arg in enumerate(self.args): - input_arg = input_args[idx] if idx < len(input_args) else None - if not self._is_arg_compatible(arg=input_arg, expected_arg_type=arg): - return False - - return True - - def _is_varargs_compatible(self, input_args: List[ArgumentType]) -> bool: - assert self.varargs is not None - - for input_arg in input_args: - if not self._is_arg_compatible(arg=input_arg, expected_arg_type=self.varargs): - return False - - return True - - def is_compatible(self, input_args: List[ArgumentType]) -> bool: - if self.args is not None: - return self._is_args_compatible(input_args=input_args) - if self.varargs is not None: - return self._is_varargs_compatible(input_args=input_args) - - raise UNREACHABLE # TODO: functions with no args - - def expected_args_str(self) -> str: - def to_human_readable_name(python_type: Type[NamedType] | Type[Union[NamedType]]) -> str: - if is_optional(python_type): - return f"Optional[{to_human_readable_name(get_optional_type(python_type))}]" - if is_union(python_type): - return ", ".join(to_human_readable_name(arg) for arg in python_type.__args__) - return python_type.type_name() - - if self.args is not None: - return f"({', '.join([to_human_readable_name(type_) for type_ in self.args])})" - if self.varargs is not None: - return f"({to_human_readable_name(self.varargs.__name__)}, ...)" - return "()" - - @classmethod - def from_function(cls, func: "BuiltInFunction") -> "FunctionInputSpec": - if func.arg_spec.varargs: - return FunctionInputSpec(varargs=func.arg_spec.annotations[func.arg_spec.varargs]) - - return FunctionInputSpec( - args=[func.arg_spec.annotations[arg_name] for arg_name in func.arg_spec.args] - ) - - @dataclass(frozen=True) class Function(VariableDependency, ArgumentType, ABC): name: str @@ -182,33 +104,14 @@ class CustomFunction(Function): raise StringFormattingException(f"Custom function {self.name} does not exist") -class BuiltInFunction(Function): - def _expected_received_error_msg(self) -> str: - received_type_names: List[str] = [] - for arg in self.args: - if isinstance(arg, BuiltInFunction): - if is_union(arg.output_type): - # TODO: Move naming to separate function, deal with Union input naming - received_type_names.append( - f"%{arg.name}(...)->Union[" - f"{', '.join(type_.type_name() for type_ in arg.output_type.__args__)}" - f"]" - ) - else: - received_type_names.append(f"%{arg.name}(...)->{arg.output_type.type_name()}") - else: - received_type_names.append(arg.type_name()) - - received_args_str = f"({', '.join(name for name in received_type_names)})" - - return f"Expected {self.input_spec.expected_args_str()}\nReceived {received_args_str}" - +class BuiltInFunction(Function, FunctionLike): def validate_args(self) -> "BuiltInFunction": if not self.input_spec.is_compatible(input_args=self.args): - raise IncompatibleFunctionArguments( - f"Incompatible arguments passed to function {self.name}.\n" - f"{self._expected_received_error_msg()}" - ) + raise FunctionArgumentsExceptionFormatter( + input_spec=self.input_spec, + function_instance=self, + ).highlight() + return self @property @@ -226,15 +129,19 @@ class BuiltInFunction(Function): @property def input_spec(self) -> FunctionInputSpec: - return FunctionInputSpec.from_function(self) + if self.arg_spec.varargs: + return FunctionInputSpec(varargs=self.arg_spec.annotations[self.arg_spec.varargs]) + + return FunctionInputSpec( + args=[self.arg_spec.annotations[arg_name] for arg_name in self.arg_spec.args] + ) @classmethod def _arg_output_type(cls, arg: ArgumentType) -> Type[ArgumentType]: if isinstance(arg, BuiltInFunction): - return arg.output_type + return arg.output_type() return type(arg) - @property def output_type(self) -> Type[Resolvable]: output_type = self.arg_spec.annotations["return"] if is_union(output_type): diff --git a/src/ytdl_sub/script/types/resolvable.py b/src/ytdl_sub/script/types/resolvable.py index fb36d677..fa1a0253 100644 --- a/src/ytdl_sub/script/types/resolvable.py +++ b/src/ytdl_sub/script/types/resolvable.py @@ -1,8 +1,11 @@ import json from abc import ABC +from abc import abstractmethod from dataclasses import dataclass from typing import Any from typing import Generic +from typing import List +from typing import Type from typing import TypeVar T = TypeVar("T") @@ -111,3 +114,12 @@ class Boolean(ResolvableT[bool], Hashable, ArgumentType): @dataclass(frozen=True) class String(ResolvableT[str], Hashable, ArgumentType): pass + + +class FunctionLike(NamedType): + name: str + args: List[ArgumentType] + + @abstractmethod + def output_type(self) -> Type[Resolvable]: + pass diff --git a/src/ytdl_sub/script/utils/exception_formatters.py b/src/ytdl_sub/script/utils/exception_formatters.py new file mode 100644 index 00000000..268d1063 --- /dev/null +++ b/src/ytdl_sub/script/utils/exception_formatters.py @@ -0,0 +1,136 @@ +import sys +from typing import List +from typing import Type +from typing import TypeVar +from typing import Union + +from ytdl_sub.script.types.resolvable import FunctionLike +from ytdl_sub.script.types.resolvable import NamedType +from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments +from ytdl_sub.script.utils.exceptions import UserException +from ytdl_sub.script.utils.type_checking import FunctionInputSpec +from ytdl_sub.script.utils.type_checking import get_optional_type +from ytdl_sub.script.utils.type_checking import is_optional +from ytdl_sub.script.utils.type_checking import is_union + +TUserException = TypeVar("TUserException", bound=UserException) + + +class ParserExceptionFormatter: + def __init__(self, text: str, start: int, end: int, exception: TUserException): + self._text = text + self._start = start + self._end = end + self._exception = exception + + def exception_text(self, border: int): + text_left = max(0, self._start - border) + text_right = min(len(self._text), self._start + border) + relative_start = self._start - text_left + + exception_text: str = "" + if text_left > 3: + exception_text = "… " + relative_start += len(exception_text) + + exception_text += self._text[text_left:text_right] + + if text_right < len(self._text) - 3: + exception_text += " …" + + exception_text += "\n" + exception_text += f"{' ' * relative_start}^" + + return "\n" + exception_text + + @property + def is_multi_line(self) -> bool: + return "\n" in self._text + + def exception_text_lines(self, border_lines: int = 0) -> str: + split_text = self._text.split("\n") + + start_line: int = sys.maxsize + end_line: int = -1 + pos: int = 0 + for idx, line in enumerate(split_text): + if self._start <= pos < self._end: + start_line = min(start_line, idx) + end_line = max(end_line, idx + 1) + pos += len(line) + + true_start_line = start_line + start_line = max(0, start_line - border_lines) + end_line = min(len(split_text), end_line + border_lines) + + # Get min leading spaces between all lines to return + min_leading_spaces = sys.maxsize + for line in split_text[start_line:end_line]: + min_leading_spaces = min(min_leading_spaces, len(line) - len(line.lstrip())) + + to_return: List[str] = [] + for idx in range(start_line, end_line): + if idx == true_start_line: + to_return.append(f">>> {split_text[idx][min_leading_spaces:]}") + else: + to_return.append(f" {split_text[idx][min_leading_spaces:]}") + + return "\n" + "\n".join(to_return) + + def highlight(self) -> TUserException: + if self.is_multi_line: + invalid_syntax = self.exception_text_lines(border_lines=3) + else: + invalid_syntax = self.exception_text(border=20) + + return self._exception.__class__(f"{invalid_syntax}\n{str(self._exception)}") + + +class FunctionArgumentsExceptionFormatter: + def __init__( + self, + input_spec: FunctionInputSpec, + function_instance: FunctionLike, + ): + self._args = input_spec.args + self._varargs = input_spec.varargs + self._name = function_instance.name + self._input_args = function_instance.args + + @classmethod + def _to_human_readable_name(cls, python_type: Type[NamedType] | Type[Union[NamedType]]) -> str: + if is_optional(python_type): + return f"Optional[{cls._to_human_readable_name(get_optional_type(python_type))}]" + if is_union(python_type): + return ", ".join(cls._to_human_readable_name(arg) for arg in python_type.__args__) + return python_type.type_name() + + def _expected_args_str(self) -> str: + if self._args is not None: + return f"({', '.join([self._to_human_readable_name(type_) for type_ in self._args])})" + if self._varargs is not None: + return f"({self._to_human_readable_name(self._varargs)}, ...)" + return "()" + + def highlight(self) -> IncompatibleFunctionArguments: + received_type_names: List[str] = [] + for arg in self._input_args: + if isinstance(arg, FunctionLike): + if is_union(arg.output_type()): + # TODO: Move naming to separate function, deal with Union input naming + received_type_names.append( + f"%{arg.name}(...)->Union[" + f"{', '.join(type_.type_name() for type_ in arg.output_type().__args__)}" + f"]" + ) + else: + received_type_names.append(f"%{arg.name}(...)->{arg.output_type().type_name()}") + else: + received_type_names.append(arg.type_name()) + + received_args_str = f"({', '.join(name for name in received_type_names)})" + + return IncompatibleFunctionArguments( + f"Incompatible arguments passed to function {self._name}.\n" + f"Expected {self._expected_args_str()}\nReceived {received_args_str}" + ) diff --git a/src/ytdl_sub/script/utils/parser_exception_formatter.py b/src/ytdl_sub/script/utils/parser_exception_formatter.py deleted file mode 100644 index c30e42bc..00000000 --- a/src/ytdl_sub/script/utils/parser_exception_formatter.py +++ /dev/null @@ -1,77 +0,0 @@ -import sys -from typing import List -from typing import TypeVar - -from ytdl_sub.script.utils.exceptions import UserException - -TUserException = TypeVar("TUserException", bound=UserException) - - -class ParserExceptionFormatter: - def __init__(self, text: str, start: int, end: int, exception: TUserException): - self._text = text - self._start = start - self._end = end - self._exception = exception - - def exception_text(self, border: int): - text_left = max(0, self._start - border) - text_right = min(len(self._text), self._start + border) - relative_start = self._start - text_left - - exception_text: str = "" - if text_left > 3: - exception_text = "… " - relative_start += len(exception_text) - - exception_text += self._text[text_left:text_right] - - if text_right < len(self._text) - 3: - exception_text += " …" - - exception_text += "\n" - exception_text += f"{' ' * relative_start}^" - - return "\n" + exception_text - - @property - def is_multi_line(self) -> bool: - return "\n" in self._text - - def exception_text_lines(self, border_lines: int = 0) -> str: - split_text = self._text.split("\n") - - start_line: int = sys.maxsize - end_line: int = -1 - pos: int = 0 - for idx, line in enumerate(split_text): - if self._start <= pos < self._end: - start_line = min(start_line, idx) - end_line = max(end_line, idx + 1) - pos += len(line) - - true_start_line = start_line - start_line = max(0, start_line - border_lines) - end_line = min(len(split_text), end_line + border_lines) - - # Get min leading spaces between all lines to return - min_leading_spaces = sys.maxsize - for line in split_text[start_line:end_line]: - min_leading_spaces = min(min_leading_spaces, len(line) - len(line.lstrip())) - - to_return: List[str] = [] - for idx in range(start_line, end_line): - if idx == true_start_line: - to_return.append(f">>> {split_text[idx][min_leading_spaces:]}") - else: - to_return.append(f" {split_text[idx][min_leading_spaces:]}") - - return "\n" + "\n".join(to_return) - - def highlight(self) -> TUserException: - if self.is_multi_line: - invalid_syntax = self.exception_text_lines(border_lines=3) - else: - invalid_syntax = self.exception_text(border=20) - - return self._exception.__class__(f"{invalid_syntax}\n{str(self._exception)}") diff --git a/src/ytdl_sub/script/utils/type_checking.py b/src/ytdl_sub/script/utils/type_checking.py index 55a85c6a..a02910eb 100644 --- a/src/ytdl_sub/script/utils/type_checking.py +++ b/src/ytdl_sub/script/utils/type_checking.py @@ -1,10 +1,16 @@ +from dataclasses import dataclass +from typing import List from typing import Optional from typing import Type from typing import Union from typing import get_origin +from ytdl_sub.script.types.resolvable import ArgumentType +from ytdl_sub.script.types.resolvable import FunctionLike from ytdl_sub.script.types.resolvable import NamedType from ytdl_sub.script.types.resolvable import Resolvable +from ytdl_sub.script.types.variable import Variable +from ytdl_sub.script.utils.exceptions import UNREACHABLE def is_union(arg_type: Type) -> bool: @@ -20,9 +26,15 @@ def get_optional_type(optional_type: Type) -> Type[NamedType]: def is_type_compatible( - arg_type: Type[NamedType], + arg: NamedType, expected_arg_type: Type[Resolvable | Optional[Resolvable]], ) -> bool: + arg_type: Type[NamedType] = arg.__class__ + if isinstance(arg, FunctionLike): + arg_type = arg.output_type() + elif isinstance(arg, Variable): + return True # unresolved variables can be anything, so pass for now + if is_union(expected_arg_type): # See if the arg is a valid against the union valid_type = False @@ -50,3 +62,42 @@ def is_type_compatible( return False return True + + +@dataclass(frozen=True) +class FunctionInputSpec: + args: Optional[List[Type[Resolvable | Optional[Resolvable]]]] = None + varargs: Optional[Type[Resolvable]] = None + + def __post_init__(self): + assert (self.args is None) ^ (self.varargs is None) + + def _is_args_compatible(self, input_args: List[ArgumentType]) -> bool: + assert self.args is not None + + if len(input_args) > len(self.args): + return False + + for idx, arg in enumerate(self.args): + input_arg = input_args[idx] if idx < len(input_args) else None + if not is_type_compatible(arg=input_arg, expected_arg_type=arg): + return False + + return True + + def _is_varargs_compatible(self, input_args: List[ArgumentType]) -> bool: + assert self.varargs is not None + + for input_arg in input_args: + if not is_type_compatible(arg=input_arg, expected_arg_type=self.varargs): + return False + + return True + + def is_compatible(self, input_args: List[ArgumentType]) -> bool: + if self.args is not None: + return self._is_args_compatible(input_args=input_args) + if self.varargs is not None: + return self._is_varargs_compatible(input_args=input_args) + + raise UNREACHABLE # TODO: functions with no args diff --git a/tests/unit/script/test_parser.py b/tests/unit/script/test_parser.py index a6c62af2..6641cde3 100644 --- a/tests/unit/script/test_parser.py +++ b/tests/unit/script/test_parser.py @@ -44,7 +44,7 @@ class TestParser: ), ] ) - assert parsed.ast[1].output_type == Union[String, Float] + assert parsed.ast[1].output_type() == Union[String, Float] def test_conditional_as_input_same_outputs(self): parsed = parse("hello {%concat(%if(True, 'hi', 'mom'), 'and dad')}")