From ebf79896810f8b7cffd00d7add8285ff733c5367 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Tue, 18 Jul 2023 00:10:01 -0700 Subject: [PATCH] OverridesResolver --- src/ytdl_sub/script/overrides_resolver.py | 65 +++++++++++++++ src/ytdl_sub/script/parser.py | 8 +- .../script/{types.py => syntax_tree.py} | 79 ++++++++++++++++++- tests/unit/script/test_parser.py | 6 +- 4 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 src/ytdl_sub/script/overrides_resolver.py rename src/ytdl_sub/script/{types.py => syntax_tree.py} (51%) diff --git a/src/ytdl_sub/script/overrides_resolver.py b/src/ytdl_sub/script/overrides_resolver.py new file mode 100644 index 00000000..e3101e37 --- /dev/null +++ b/src/ytdl_sub/script/overrides_resolver.py @@ -0,0 +1,65 @@ +from typing import Dict +from typing import List +from typing import Optional +from typing import Set + +from ytdl_sub.script.functions import Resolvable +from ytdl_sub.script.parser import parse +from ytdl_sub.script.syntax_tree import SyntaxTree +from ytdl_sub.script.syntax_tree import Variable +from ytdl_sub.utils.exceptions import StringFormattingException + + +class OverridesResolver: + def __init__(self, overrides: Dict[str, str]): + self.overrides: Dict[Variable, SyntaxTree] = { + Variable(name=name): parse(value) for name, value in overrides.items() + } + + def _ensure_no_cycles(self) -> None: + variable_dependencies: Dict[Variable, Set[Variable]] = { + variable: ast.variables for variable, ast in self.overrides.items() + } + + def _traverse( + to_variable: Variable, visited_variables: Optional[List[Variable]] = None + ) -> None: + if visited_variables is None: + visited_variables = [] + + if to_variable in visited_variables: + raise StringFormattingException("Detected cycle in variables") + visited_variables.append(to_variable) + + for dep in variable_dependencies[to_variable]: + _traverse(to_variable=dep, visited_variables=visited_variables) + + for variable in variable_dependencies.keys(): + _traverse(variable) + + def resolve_overrides(self) -> Dict[str, str]: + self._ensure_no_cycles() + + unresolved_variables: List[Variable] = list(self.overrides.keys()) + resolved_variables: Dict[Variable, Resolvable] = {} + + while unresolved_variables: + unresolved_count: int = len(unresolved_variables) + + for variable in unresolved_variables: + if not self.overrides[variable].has_variable_dependency( + resolved_variables=resolved_variables + ): + resolved_variables[variable] = self.overrides[variable].resolve( + resolved_variables=resolved_variables + ) + unresolved_variables.remove(variable) + + assert ( + len(unresolved_variables) != unresolved_count + ), "did not resolve any variables, cycle detected" + + return { + variable.name: resolvable.resolve() + for variable, resolvable in resolved_variables.items() + } diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index beeb536a..dfda3843 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -5,10 +5,10 @@ from ytdl_sub.script.functions import Boolean from ytdl_sub.script.functions import Float from ytdl_sub.script.functions import Integer from ytdl_sub.script.functions import String -from ytdl_sub.script.types import ArgumentType -from ytdl_sub.script.types import Function -from ytdl_sub.script.types import SyntaxTree -from ytdl_sub.script.types import Variable +from ytdl_sub.script.syntax_tree import ArgumentType +from ytdl_sub.script.syntax_tree import Function +from ytdl_sub.script.syntax_tree import SyntaxTree +from ytdl_sub.script.syntax_tree import Variable 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.py b/src/ytdl_sub/script/syntax_tree.py similarity index 51% rename from src/ytdl_sub/script/types.py rename to src/ytdl_sub/script/syntax_tree.py index 7f3dd466..fe5fd469 100644 --- a/src/ytdl_sub/script/types.py +++ b/src/ytdl_sub/script/syntax_tree.py @@ -1,14 +1,18 @@ +from abc import ABC +from abc import abstractmethod from dataclasses import dataclass from typing import Dict from typing import List from typing import Optional from typing import Set from typing import Union +from typing import final from ytdl_sub.script.functions import Boolean from ytdl_sub.script.functions import Float from ytdl_sub.script.functions import Functions from ytdl_sub.script.functions import Integer +from ytdl_sub.script.functions import Resolvable from ytdl_sub.script.functions import String from ytdl_sub.utils.exceptions import StringFormattingException @@ -22,11 +26,33 @@ ArgumentType = Union[Integer, Float, String, Boolean, Variable, "Function"] @dataclass(frozen=True) -class Function: +class VariableDependency(ABC): + @property + @abstractmethod + def variables(self) -> Set[Variable]: + raise NotImplemented() + + @abstractmethod + def resolve(self, resolved_variables: Dict[Variable, Resolvable]) -> str: + raise NotImplemented() + + @final + def has_variable_dependency(self, resolved_variables: Dict[Variable, Resolvable]) -> bool: + """ + Returns + ------- + True if variable dependency. False otherwise. + """ + return self.variables.issubset(set(resolved_variables.keys())) + + +@dataclass(frozen=True) +class Function(VariableDependency): name: str args: List[ArgumentType] def __post_init__(self): + # TODO: Figure out resolution via introspecting args and outputs of function try: getattr(Functions, self.name) except AttributeError: @@ -48,9 +74,12 @@ class Function: return variables + def resolve(self, resolved_variables: Dict[Variable, Resolvable]) -> Resolvable: + raise NotImplemented() + @dataclass(frozen=True) -class SyntaxTree: +class SyntaxTree(VariableDependency): ast: List[String | Variable | Function] @property @@ -69,6 +98,20 @@ class SyntaxTree: return variables + def resolve(self, resolved_variables: Dict[Variable, Resolvable]) -> Resolvable: + output: str = "" + for token in self.ast: + if isinstance(token, String): + output += token.resolve() + elif isinstance(token, Variable): + output += resolved_variables[token].resolve() + elif isinstance(token, Function): + output += token.resolve(resolved_variables=resolved_variables) + else: + assert False, "should never reach" + + return String(output) + @classmethod def detect_cycles(cls, parsed_overrides: Dict[str, "SyntaxTree"]) -> None: """ @@ -98,5 +141,33 @@ class SyntaxTree: _traverse(variable) @classmethod - def resolve(cls, parsed_overrides: Dict[str, "SyntaxTree"]) -> Dict[str, str]: - raise NotImplemented() + def resolve_overrides(cls, parsed_overrides: Dict[str, "SyntaxTree"]) -> Dict[str, str]: + cls.detect_cycles(parsed_overrides=parsed_overrides) + + overrides: Dict[Variable, "SyntaxTree"] = { + Variable(name): ast for name, ast in parsed_overrides.items() + } + + unresolved_variables: List[Variable] = list(overrides.keys()) + resolved_variables: Dict[Variable, Resolvable] = {} + + while unresolved_variables: + unresolved_count: int = len(unresolved_variables) + + for variable in unresolved_variables: + if not overrides[variable].has_variable_dependency( + resolved_variables=resolved_variables + ): + resolved_variables[variable] = overrides[variable].resolve( + resolved_variables=resolved_variables + ) + unresolved_variables.remove(variable) + + assert ( + len(unresolved_variables) != unresolved_count + ), "did not resolve any variables, cycle detected" + + return { + variable.name: resolvable.resolve() + for variable, resolvable in resolved_variables.items() + } diff --git a/tests/unit/script/test_parser.py b/tests/unit/script/test_parser.py index 12d638da..b4fbb504 100644 --- a/tests/unit/script/test_parser.py +++ b/tests/unit/script/test_parser.py @@ -5,9 +5,9 @@ from ytdl_sub.script.functions import Float from ytdl_sub.script.functions import Integer from ytdl_sub.script.functions import String from ytdl_sub.script.parser import parse -from ytdl_sub.script.types import Function -from ytdl_sub.script.types import SyntaxTree -from ytdl_sub.script.types import Variable +from ytdl_sub.script.syntax_tree import Function +from ytdl_sub.script.syntax_tree import SyntaxTree +from ytdl_sub.script.syntax_tree import Variable from ytdl_sub.utils.exceptions import StringFormattingException