From f0cbc1bbd31afe8acc9a8f95008801098146c034 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Fri, 17 Nov 2023 23:54:16 -0800 Subject: [PATCH] move stuff into type-checking --- src/ytdl_sub/script/parser.py | 30 +++++----- src/ytdl_sub/script/types/function.py | 66 +++++----------------- src/ytdl_sub/script/utils/type_checking.py | 52 +++++++++++++++++ tests/unit/script/test_parser.py | 4 +- tests/unit/script/types/test_array.py | 8 +-- tests/unit/script/types/test_map.py | 4 +- 6 files changed, 89 insertions(+), 75 deletions(-) create mode 100644 src/ytdl_sub/script/utils/type_checking.py diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index 57e57445..c6d381f6 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -28,7 +28,7 @@ from ytdl_sub.validators.string_formatter_validators import is_valid_source_vari # pylint: disable=too-many-return-statements -class ArgumentParser(Enum): +class ParsedArgType(Enum): SCRIPT = "script" FUNCTION = "function" ARRAY = "array" @@ -57,12 +57,12 @@ BOOLEAN_ONLY_ARGS = InvalidSyntaxException( ) -def _UNEXPECTED_CHAR_ARGUMENT(parser: ArgumentParser): - return InvalidSyntaxException(f"Unexpected character when parsing {parser.value} arguments") +def _UNEXPECTED_CHAR_ARGUMENT(arg_type: ParsedArgType): + return InvalidSyntaxException(f"Unexpected character when parsing {arg_type.value} arguments") -def _UNEXPECTED_COMMA_ARGUMENT(parser: ArgumentParser): - return InvalidSyntaxException(f"Unexpected comma when parsing {parser.value} arguments") +def _UNEXPECTED_COMMA_ARGUMENT(arg_type: ParsedArgType): + return InvalidSyntaxException(f"Unexpected comma when parsing {arg_type.value} arguments") MAP_KEY_WITH_NO_VALUE = InvalidSyntaxException("Map has a key with no value") @@ -255,7 +255,7 @@ class _Parser: raise STRINGS_NOT_CLOSED - def _parse_function_arg(self, argument_parser: ArgumentParser) -> ArgumentType: + def _parse_function_arg(self, argument_parser: ParsedArgType) -> ArgumentType: if self._read(increment_pos=False) == "%": self._pos += 1 return self._parse_function() @@ -282,10 +282,10 @@ class _Parser: return self._parse_variable() self._set_highlight_position() - raise _UNEXPECTED_CHAR_ARGUMENT(parser=argument_parser) + raise _UNEXPECTED_CHAR_ARGUMENT(arg_type=argument_parser) def _parse_args( - self, argument_parser: ArgumentParser, breaking_chars: str = ")" + self, argument_parser: ParsedArgType, breaking_chars: str = ")" ) -> List[ArgumentType]: """ Begin parsing function args after the first ``(``, i.e. ``function_name(`` @@ -332,7 +332,7 @@ class _Parser: if ch != "(": function_name += ch else: - function_args = self._parse_args(argument_parser=ArgumentParser.FUNCTION) + function_args = self._parse_args(argument_parser=ParsedArgType.FUNCTION) raise StringFormattingException("Invalid function") @@ -348,7 +348,7 @@ class _Parser: return UnresolvedArray(value=function_args) function_args = self._parse_args( - argument_parser=ArgumentParser.ARRAY, breaking_chars="]" + argument_parser=ParsedArgType.ARRAY, breaking_chars="]" ) raise UNREACHABLE @@ -372,18 +372,18 @@ class _Parser: if ch == ",": if in_comma: - raise _UNEXPECTED_COMMA_ARGUMENT(ArgumentParser.MAP_KEY) + raise _UNEXPECTED_COMMA_ARGUMENT(ParsedArgType.MAP_KEY) if key is not None: raise MAP_KEY_WITH_NO_VALUE if not output: - raise _UNEXPECTED_COMMA_ARGUMENT(ArgumentParser.MAP_KEY) + raise _UNEXPECTED_COMMA_ARGUMENT(ParsedArgType.MAP_KEY) in_comma = True self._pos += 1 elif key is None: self._set_highlight_position() in_comma = False key_args = self._parse_args( - argument_parser=ArgumentParser.MAP_KEY, breaking_chars=":}" + argument_parser=ParsedArgType.MAP_KEY, breaking_chars=":}" ) if len(key_args) == 0 and self._read(increment_pos=False) == "}": @@ -397,7 +397,7 @@ class _Parser: self._set_highlight_position() self._pos += 1 value_args = self._parse_args( - argument_parser=ArgumentParser.MAP_VALUE, breaking_chars=",}" + argument_parser=ParsedArgType.MAP_VALUE, breaking_chars=",}" ) if len(value_args) == 0: raise MAP_KEY_WITH_NO_VALUE @@ -461,7 +461,7 @@ class _Parser: ) or _is_boolean_false(self._read(increment_pos=False, length=5)): raise BOOLEAN_ONLY_ARGS else: - raise _UNEXPECTED_CHAR_ARGUMENT(parser=ArgumentParser.SCRIPT) + raise _UNEXPECTED_CHAR_ARGUMENT(arg_type=ParsedArgType.SCRIPT) elif bracket_counter == 0: # Only accumulate literal str if not in brackets literal_str += ch diff --git a/src/ytdl_sub/script/types/function.py b/src/ytdl_sub/script/types/function.py index 4139ebc6..1c854021 100644 --- a/src/ytdl_sub/script/types/function.py +++ b/src/ytdl_sub/script/types/function.py @@ -11,7 +11,6 @@ from typing import Optional from typing import Set from typing import Type from typing import Union -from typing import get_origin from ytdl_sub.script.functions import Functions from ytdl_sub.script.types.resolvable import AnyTypeReturnable @@ -25,21 +24,13 @@ 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.type_checking import is_union from ytdl_sub.utils.exceptions import StringFormattingException -def is_union(arg_type: Type) -> bool: - return get_origin(arg_type) is Union - - -def is_optional(arg_type: Type) -> bool: - return is_union(arg_type) and type(None) in arg_type.__args__ - - -def get_optional_type(optional_type: Type) -> Type[NamedType]: - return [arg for arg in optional_type.__args__ if arg != type(None)][0] - - @dataclass(frozen=True) class FunctionInputSpec: args: Optional[List[Type[Resolvable | Optional[Resolvable]]]] = None @@ -49,45 +40,16 @@ class FunctionInputSpec: assert (self.args is None) ^ (self.varargs is None) @classmethod - def _is_type_compatible( - cls, - input_arg: ArgumentType, - expected_arg_type: Type[Resolvable | Optional[Resolvable]], - ) -> bool: - if isinstance(input_arg, BuiltInFunction): - input_arg_type = input_arg.output_type - elif isinstance(input_arg, Variable): + 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 - else: - input_arg_type = input_arg.__class__ - if is_union(expected_arg_type): - # See if the arg is a valid against the union - valid_type = False - - # if the input arg is a union, do a direct comparison - if is_union(input_arg_type): - valid_type = input_arg_type == expected_arg_type - # otherwise, iterate the union to see if it's compatible - else: - for union_type in expected_arg_type.__args__: - if issubclass(input_arg_type, union_type): - valid_type = True - break - - if not valid_type: - return False - # If the input is a union and the expected type is not, see if - # each possible union input is compatible with the expected type - elif is_union(input_arg_type): - for union_type in input_arg_type.__args__: - if not issubclass(union_type, expected_arg_type): - return False - - elif not issubclass(input_arg_type, expected_arg_type): - return False - - return True + 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 @@ -97,7 +59,7 @@ class FunctionInputSpec: for idx, arg in enumerate(self.args): input_arg = input_args[idx] if idx < len(input_args) else None - if not self._is_type_compatible(input_arg=input_arg, expected_arg_type=arg): + if not self._is_arg_compatible(arg=input_arg, expected_arg_type=arg): return False return True @@ -106,7 +68,7 @@ class FunctionInputSpec: assert self.varargs is not None for input_arg in input_args: - if not self._is_type_compatible(input_arg=input_arg, expected_arg_type=self.varargs): + if not self._is_arg_compatible(arg=input_arg, expected_arg_type=self.varargs): return False return True diff --git a/src/ytdl_sub/script/utils/type_checking.py b/src/ytdl_sub/script/utils/type_checking.py new file mode 100644 index 00000000..55a85c6a --- /dev/null +++ b/src/ytdl_sub/script/utils/type_checking.py @@ -0,0 +1,52 @@ +from typing import Optional +from typing import Type +from typing import Union +from typing import get_origin + +from ytdl_sub.script.types.resolvable import NamedType +from ytdl_sub.script.types.resolvable import Resolvable + + +def is_union(arg_type: Type) -> bool: + return get_origin(arg_type) is Union + + +def is_optional(arg_type: Type) -> bool: + return is_union(arg_type) and type(None) in arg_type.__args__ + + +def get_optional_type(optional_type: Type) -> Type[NamedType]: + return [arg for arg in optional_type.__args__ if arg != type(None)][0] + + +def is_type_compatible( + arg_type: Type[NamedType], + expected_arg_type: Type[Resolvable | Optional[Resolvable]], +) -> bool: + if is_union(expected_arg_type): + # See if the arg is a valid against the union + valid_type = False + + # if the input arg is a union, do a direct comparison + if is_union(arg_type): + valid_type = arg_type == expected_arg_type + # otherwise, iterate the union to see if it's compatible + else: + for union_type in expected_arg_type.__args__: + if issubclass(arg_type, union_type): + valid_type = True + break + + if not valid_type: + return False + # If the input is a union and the expected type is not, see if + # each possible union input is compatible with the expected type + elif is_union(arg_type): + for union_type in arg_type.__args__: + if not issubclass(union_type, expected_arg_type): + return False + + elif not issubclass(arg_type, expected_arg_type): + return False + + return True diff --git a/tests/unit/script/test_parser.py b/tests/unit/script/test_parser.py index 87dc3a3c..a6c62af2 100644 --- a/tests/unit/script/test_parser.py +++ b/tests/unit/script/test_parser.py @@ -6,7 +6,7 @@ import pytest from ytdl_sub.script.parser import _UNEXPECTED_CHAR_ARGUMENT from ytdl_sub.script.parser import BRACKET_NOT_CLOSED -from ytdl_sub.script.parser import ArgumentParser +from ytdl_sub.script.parser import ParsedArgType from ytdl_sub.script.parser import parse from ytdl_sub.script.types.function import BuiltInFunction from ytdl_sub.script.types.resolvable import Boolean @@ -168,6 +168,6 @@ class TestParserBracketFailures: def test_bracket_in_function(self): with pytest.raises( InvalidSyntaxException, - match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ArgumentParser.MAP_KEY))), + match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ParsedArgType.MAP_KEY))), ): parse("hello {%capitalize({as_arg)}") diff --git a/tests/unit/script/types/test_array.py b/tests/unit/script/types/test_array.py index ebb96492..36ead92a 100644 --- a/tests/unit/script/types/test_array.py +++ b/tests/unit/script/types/test_array.py @@ -4,7 +4,7 @@ import pytest from ytdl_sub.script.parser import _UNEXPECTED_CHAR_ARGUMENT from ytdl_sub.script.parser import _UNEXPECTED_COMMA_ARGUMENT -from ytdl_sub.script.parser import ArgumentParser +from ytdl_sub.script.parser import ParsedArgType from ytdl_sub.script.script import Script from ytdl_sub.script.types.array import ResolvedArray from ytdl_sub.script.types.resolvable import Boolean @@ -81,7 +81,7 @@ class TestArray: def test_unexpected_comma(self, array: str): with pytest.raises( InvalidSyntaxException, - match=re.escape(str(_UNEXPECTED_COMMA_ARGUMENT(ArgumentParser.ARRAY))), + match=re.escape(str(_UNEXPECTED_COMMA_ARGUMENT(ParsedArgType.ARRAY))), ): Script({"array": array}).resolve() @@ -98,7 +98,7 @@ class TestArray: def test_array_not_closed(self, array: str): with pytest.raises( InvalidSyntaxException, - match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ArgumentParser.ARRAY))), + match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ParsedArgType.ARRAY))), ): assert Script({"array": array}).resolve() @@ -112,7 +112,7 @@ class TestArray: def test_array_not_opened(self, array: str): with pytest.raises( InvalidSyntaxException, - match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ArgumentParser.SCRIPT))), + match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ParsedArgType.SCRIPT))), ): assert Script({"array": array}).resolve() diff --git a/tests/unit/script/types/test_map.py b/tests/unit/script/types/test_map.py index 1f61dbb7..b0502561 100644 --- a/tests/unit/script/types/test_map.py +++ b/tests/unit/script/types/test_map.py @@ -8,7 +8,7 @@ from ytdl_sub.script.parser import MAP_KEY_MULTIPLE_VALUES from ytdl_sub.script.parser import MAP_KEY_NOT_HASHABLE from ytdl_sub.script.parser import MAP_KEY_WITH_NO_VALUE from ytdl_sub.script.parser import MAP_MISSING_KEY -from ytdl_sub.script.parser import ArgumentParser +from ytdl_sub.script.parser import ParsedArgType from ytdl_sub.script.script import Script from ytdl_sub.script.types.map import ResolvedMap from ytdl_sub.script.types.resolvable import Boolean @@ -129,7 +129,7 @@ class TestMap: def test_map_unexpected_comma(self, value: str): with pytest.raises( InvalidSyntaxException, - match=re.escape(str(_UNEXPECTED_COMMA_ARGUMENT(ArgumentParser.MAP_KEY))), + match=re.escape(str(_UNEXPECTED_COMMA_ARGUMENT(ParsedArgType.MAP_KEY))), ): Script({"map": value}).resolve()