clean up function file

This commit is contained in:
Jesse Bannon 2023-11-18 00:35:04 -08:00
parent f0cbc1bbd3
commit f4cd0fc781
7 changed files with 218 additions and 189 deletions

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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}"
)

View file

@ -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)}")

View file

@ -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

View file

@ -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')}")