diff --git a/src/ytdl_sub/script/functions.py b/src/ytdl_sub/script/functions.py index c4095fba..93842cd1 100644 --- a/src/ytdl_sub/script/functions.py +++ b/src/ytdl_sub/script/functions.py @@ -1,7 +1,4 @@ - - class Functions: - @staticmethod def lower(string: str) -> str: """ @@ -28,4 +25,3 @@ class Functions: Capitalized string """ return string.capitalize() - \ No newline at end of file diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index c61dce0a..405885b0 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -1,75 +1,231 @@ from dataclasses import dataclass from queue import LifoQueue -from typing import Optional, List +from typing import List +from typing import Optional +from typing import Union +from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.validators.string_formatter_validators import is_valid_source_variable_name -@dataclass +# pylint: disable=invalid-name + + +@dataclass(frozen=True) +class Integer: + value: int + + +@dataclass(frozen=True) +class Float: + value: float + + +@dataclass(frozen=True) +class Boolean: + value: bool + + +@dataclass(frozen=True) +class String: + value: str + + +@dataclass(frozen=True) class Variable: name: str -@dataclass + +NumericType = Union[Integer, Float] +ArgumentType = Union[Integer, Float, String, Boolean, Variable, "Function"] + + +@dataclass(frozen=True) class Function: name: str - args: List[str] + args: List[ArgumentType] + + +@dataclass(frozen=True) +class LiteralString: + value: str class Parser: - def __init__(self, text: str): self._text = text self._pos = 0 - self._stack: LifoQueue[Variable | Function] = LifoQueue() + self._stack: LifoQueue[LiteralString | Variable | Function] = LifoQueue() - def read(self) -> Optional[str]: + def read(self, increment_pos: bool = True, length: int = 1) -> Optional[str]: try: - ch = self._text[self._pos] + ch = self._text[self._pos : (self._pos + length)] except IndexError: return None - self._pos += 1 + if increment_pos: + self._pos += length return ch def parse_variable(self) -> Variable: var_name = "" - while ch := self.read(): - if ch == "}": + while ch := self.read(increment_pos=False): + if ch.isspace() and not var_name: + self._pos += 1 + continue + if ch in ["}", ","] or ch.isspace(): break - var_name += ch - _ = is_valid_source_variable_name(var_name, raise_exception=True) + is_lower = ch.isascii() and ch.islower() + if not var_name and not is_lower: + raise StringFormattingException("invalid var name") + + if not (is_lower or ch.isnumeric() or ch == "_"): + raise StringFormattingException("invalid var name") + + var_name += ch + self._pos += 1 + + assert is_valid_source_variable_name(var_name, raise_exception=False) return Variable(var_name) - def parse_function(self) -> Function: - parenthesis_counter = 0 - func_name = "" - func_args = "" + def parse_numeric(self) -> NumericType: + numeric_string = "" + while ch := self.read(increment_pos=False): + if not (ch.isnumeric() or ch == "."): + break + + self._pos += 1 + numeric_string += ch + + try: + numeric_float = float(numeric_string) + except ValueError: + raise StringFormattingException(f"Invalid numeric: {numeric_string}") + + if (numeric_int := int(numeric_float)) == numeric_float: + return Integer(value=numeric_int) + + return Float(value=numeric_float) + + def parse_string(self) -> String: + """ + Begin parsing a string, including the quotation value + """ + string_value = "" + open_quotation_char = self.read() + assert open_quotation_char in ["'", '"'] while ch := self.read(): - if ch not in ['(', ')']: - if parenthesis_counter > 0: - func_args += ch + if ch == open_quotation_char: + return String(value=string_value) + string_value += ch + + raise StringFormattingException("String not closed") + + def parse_function_arg(self) -> ArgumentType: + if self.read(increment_pos=False) == "%": + self._pos += 1 + return self.parse_function() + if self.read(increment_pos=False).isnumeric(): + return self.parse_numeric() + if (self.read(increment_pos=False, length=4) or "").lower() == "true": + self._pos += 4 + return Boolean(value=True) + if (self.read(increment_pos=False, length=5) or "").lower() == "false": + self._pos += 5 + return Boolean(value=False) + if self.read(increment_pos=False) in ["'", '"']: + return self.parse_string() + if self.read(increment_pos=False).isascii() and self.read(increment_pos=False).islower(): + return self.parse_variable() + raise StringFormattingException( + "Invalid function argument, should be either a function, int, float, " + "string, boolean, or variable without brackets" + ) + + def parse_function_args(self) -> List[ArgumentType]: + """ + Begin parsing function args after the first ``(``, i.e. ``function_name(`` + """ + argument_index = 0 + comma_count = 0 + + arguments: List[ArgumentType] = [] + while ch := self.read(increment_pos=False): + if ch == ")": + break + + if ch.isspace(): + self._pos += 1 + elif ch == ",": + comma_count += 1 + if argument_index != comma_count: + raise StringFormattingException("Comma argument shenanigans") + + self._pos += 1 + else: + argument_index += 1 + arguments.append(self.parse_function_arg()) + + return arguments + + def parse_function(self) -> Function: + """ + Begin parsing a function after reading the first ``%`` + """ + function_name: str = "" + function_args: List[String | Variable | "Function"] = [] + + while ch := self.read(): + if ch == ")": + return Function(name=function_name, args=function_args) + + if ch != "(": + function_name += ch + else: + function_args = self.parse_function_args() + + raise StringFormattingException("Invalid function") + + def parse(self) -> "Parser": + bracket_counter = 0 + literal_str = "" + while ch := self.read(): + if ch == "}": + bracket_counter -= 1 + break + if ch == "{": + bracket_counter += 1 + if literal_str: + self._stack.put(LiteralString(literal_str)) + literal_str = "" + + # Allow whitespace after bracket opening + while ch1 := self.read(increment_pos=False): + if not ch1.isspace(): + break + self._pos += 1 + + if ch1 is None: + raise StringFormattingException( + "Open bracket at the end was not properly closed" + ) + + if ch1 == "%": + self._pos += 1 + self._stack.put(self.parse_function()) else: - func_name += ch - elif ch == '(': - parenthesis_counter += 1 - elif ch == ')': - parenthesis_counter -= 1 - if parenthesis_counter == 0: - break - - - - - - - - def parse(self): - while True: - ch = self.read() - if ch == '{': - self.parse_variable() - if ch == '%': - self.parse_function() + self._stack.put(self.parse_variable()) + else: + literal_str += ch + + if bracket_counter != 0: + raise StringFormattingException("Bracket count mismatch") + + if literal_str: + self._stack.put(LiteralString(literal_str)) + + return self +# pylint: enable=invalid-name diff --git a/tests/unit/script/__init__.py b/tests/unit/script/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/script/test_parser.py b/tests/unit/script/test_parser.py new file mode 100644 index 00000000..81eb0eb1 --- /dev/null +++ b/tests/unit/script/test_parser.py @@ -0,0 +1,60 @@ +import pytest + +from ytdl_sub.script.parser import Boolean +from ytdl_sub.script.parser import Float +from ytdl_sub.script.parser import Function +from ytdl_sub.script.parser import Integer +from ytdl_sub.script.parser import LiteralString +from ytdl_sub.script.parser import Parser +from ytdl_sub.script.parser import String +from ytdl_sub.script.parser import Variable +from ytdl_sub.utils.exceptions import StringFormattingException + + +class TestParser: + def test_simple(self): + parser = Parser("hello world").parse() + assert list(parser._stack.queue) == [LiteralString(value="hello world")] + + def test_single_function_one_arg(self): + parser = Parser("hello {%capitalize('hi mom')}").parse() + assert list(parser._stack.queue) == [ + LiteralString("hello "), + Function(name="capitalize", args=[String(value="hi mom")]), + ] + + @pytest.mark.parametrize('whitespace', ["", " ", " ", "\n", " \n "]) + def test_single_function_multiple_args(self, whitespace: str): + s = whitespace + parser = Parser( + f"hello{s}{{{s}%concat({s}'string'{s},{s}1{s},{s}2.4{s}," + f"{s}TRUE{s},{s}variable_name{s},{s}%capitalize({s}'hi'{s}){s}){s}}}" + ).parse() + assert list(parser._stack.queue) == [ + LiteralString(value=f"hello{s}"), + Function( + name="concat", + args=[ + String(value="string"), + Integer(value=1), + Float(value=2.4), + Boolean(value=True), + Variable(name="variable_name"), + Function(name="capitalize", args=[String(value="hi")]), + ], + ), + ] + ([LiteralString(value=s)] if s else []) + + +class TestParserBracketFailures: + def test_bracket_open(self): + with pytest.raises(StringFormattingException): + _ = Parser("{").parse() + + def test_bracket_close(self): + with pytest.raises(StringFormattingException): + _ = Parser("}").parse() + + def test_bracket_in_function(self): + with pytest.raises(StringFormattingException): + _ = Parser("hello {%capitalize({as_arg)}").parse()