From 19da69b3e8e53085f6c751d25ad2be1cb2fe1cd3 Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 30 May 2026 04:57:04 +1000 Subject: [PATCH] Initial Commit Working base grammar, visitor, client, context. Good test coverage for grammar constructs. --- __init__.py | 1 + assets/grammar.ppeg | 56 +++++++++ customcmd/__init__.py | 3 + customcmd/client.py | 54 +++++++++ customcmd/context.py | 24 ++++ customcmd/errors.py | 9 ++ customcmd/lib.py | 14 +++ customcmd/parser.py | 273 ++++++++++++++++++++++++++++++++++++++++++ customcmd/terms.py | 235 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 21 ++++ tests/grammar.py | 227 +++++++++++++++++++++++++++++++++++ 11 files changed, 917 insertions(+) create mode 100644 __init__.py create mode 100644 assets/grammar.ppeg create mode 100644 customcmd/__init__.py create mode 100644 customcmd/client.py create mode 100644 customcmd/context.py create mode 100644 customcmd/errors.py create mode 100644 customcmd/lib.py create mode 100644 customcmd/parser.py create mode 100644 customcmd/terms.py create mode 100644 pyproject.toml create mode 100644 tests/grammar.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..2447b50 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from .customcmd import * diff --git a/assets/grammar.ppeg b/assets/grammar.ppeg new file mode 100644 index 0000000..0324e6c --- /dev/null +++ b/assets/grammar.ppeg @@ -0,0 +1,56 @@ +command = "${" component "}" + +cmdarg = "$" ~r"\d+\*?" + + +parenthised = ("(" component ")") / ("{" component "}") +true = "True" / "true" +false = "False" / "false" +bool = true / false + +basicterm = bool / numeric / parenthised / quoted / cmdarg / var +term = ws* (function / basicterm) ws* + +var = (parenthised / keyword) ("." keyword)* +function = var (ws+ basicterm)+ + +type = keyword +typecast = basicterm "::" type + +test_eq = term "==" term +test_neq = term "!=" term +test_gt = term ">" term +test_geq = term ">=" term +test_lt = term "<" term +test_leq = term "<=" term +test = (test_eq/test_neq/test_gt/test_geq/test_lt/test_leq) + +op_or = term ("||" term)+ +op_and = term ("&&" term)+ +op_add = term ("+" term)+ +op_minus = term ("-" term)+ +op_times = term ("*" term)+ +op_exp = term ("**" term)+ +op_divide = term ("/" term)+ +op_div = term ("//" term)+ +op_mod = term ("%" term)+ +binop = (op_or/op_and/op_add/op_minus/op_times/op_exp/op_divide/op_div/op_mod) + + +cascade = term ("|" term)+ + +ternary = term "?" term ":" term +ifthenelse = "if" ws term ws "then" ws term (ws "elif" ws term ws "then" ws term)* (ws "else" ws term)? "fi" + +equals = keyword ws* '=' ws* term +defines = keyword ws* ":=" ws* term +definesterm = keyword ws* "::=" ws* term + +component = ws* (cascade / ternary / equals / defines / definesterm / binop / test / term) ws* + + +word = ~r"[_\w]+" +keyword = word +quoted = ~'"[^\"]*"' +numeric = ~r"[\d]+" +ws = ~r"\s+" diff --git a/customcmd/__init__.py b/customcmd/__init__.py new file mode 100644 index 0000000..eea436a --- /dev/null +++ b/customcmd/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) diff --git a/customcmd/client.py b/customcmd/client.py new file mode 100644 index 0000000..722348b --- /dev/null +++ b/customcmd/client.py @@ -0,0 +1,54 @@ +import regex + +from parsimonious.grammar import Grammar + +from .context import ExecContext +from .parser import Execuitor +from .terms import ExecutorTerm + +fragment_re = regex.compile(r"\$(?
{(?:[^{}]++|(?&br))*})") + + +class ExecClient: + def __init__(self): + self.grammer: Grammar = self.load_grammar() + self.execuitor: Execuitor = Execuitor() + + self.globals = {} + + def load_grammar(self): + with open("assets/grammar.ppeg") as f: + return Grammar(f.read()) + + def _parse_fragment(self, cmd: str) -> ExecutorTerm: + """ + Parse a provided fragment into an ExecutorTerm. + + This could reasonably be LRU cached. + """ + tree = self.grammer.parse(cmd) + term = self.execuitor.visit(tree) + return term + + async def run_command(self, ctx: ExecContext, command: str) -> str: + """ + Process the given command string, + executing and replacing each fragment. + + Executes in the given context, + which will generally be constructed through subclass methods. + """ + result = [] + last_end = 0 + + for match in regex.finditer(fragment_re, command): + result.append(command[last_end : match.start()]) + + fragment = command[match.start() : match.end()] + parsed = self._parse_fragment(fragment) + fragment_result = await parsed.execute(ctx) + + result.append(fragment_result) + last_end = match.end() + + return "".join(result) diff --git a/customcmd/context.py b/customcmd/context.py new file mode 100644 index 0000000..53ed35d --- /dev/null +++ b/customcmd/context.py @@ -0,0 +1,24 @@ +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from .terms import ExecutorTerm + +class ExecContext: + """ + Base context class that carries the instantiation state. + + This is passed to all CommandTerms upon execution. + """ + + def __init__(self, *, alias: str, args: list[str], content: str, **kwargs): + self.alias = alias + self.args = args + self.content = content + + # Dictionary of local variables for the command context. + # Useful if the Terms need to share state + self.scope: dict[str, Any] = {} + + # Stack of terms that are *currently* executing with this context. + self.termstack: list['ExecutorTerm'] = [] diff --git a/customcmd/errors.py b/customcmd/errors.py new file mode 100644 index 0000000..0665533 --- /dev/null +++ b/customcmd/errors.py @@ -0,0 +1,9 @@ + +class CommandError(Exception): + def __init__(self, msg=None, **kwargs): + super().__init__(**kwargs) + self.msg = msg + +class CommandValueError(CommandError): + pass + diff --git a/customcmd/lib.py b/customcmd/lib.py new file mode 100644 index 0000000..4d2eb0b --- /dev/null +++ b/customcmd/lib.py @@ -0,0 +1,14 @@ +import re +import regex + + +async def resub_async(pattern, coro, string, flags=0): + result = [] + last_end = 0 + + for match in re.finditer(pattern, string, flags=flags): + result.append(string[last_end:match.start()]) + result.append(await coro(string)) + last_end = match.end() + + diff --git a/customcmd/parser.py b/customcmd/parser.py new file mode 100644 index 0000000..818f186 --- /dev/null +++ b/customcmd/parser.py @@ -0,0 +1,273 @@ +import asyncio + + +from parsimonious.grammar import Grammar +from parsimonious.nodes import NodeVisitor + +from . import logger +from .terms import ( + constant_term, + define_equal, + define_walrus, + define_term, + binop, + compare, + cmdarg_term, + command_shell, + get_var, + ternary_term, + run_function, + cascade_term, +) + + +""" +User defined functions are 'kind of special'. +We also want users to be able to define objects with attributes in future. +Attributes which are looked up live. + +Probably want a protected class of functions that can't be redefined. + +Maybe have a 'decorateable class' with type-decorated instance variables, +construction logic, and methods. +And make sure only those variables are accessible via lookup. + +Then we can wrap a user in say, +UserCmdCtx.channel = CmdCtxUser.from_user(ctx.channel) + +Note that function 'names' might be vars then, and may need to be computed. +E.g. channel.followage $1::user + +A 'function' is ultimately an ExecTerm that returns a callable on context execution. +Would even like to distinguish functions and keywords a little +so that functions with no arguments actually get called. + +Now, permissions. +Commands have their own level of permissions, and also whether permissions are inherited by the child functions. +I guess permissions are up to the implementor, not the abstract grammar. +We should probably try to define the 'contructors' though, so the implementor doesn't have to interact with the grammar. +While that won't have permissions baked in, it *should* have some kind of check or guard decorator ability baked in. + +Client? + +UserCommandClient +- holds grammar +- context class to instantiate +- functions and global scope +- function guards +- Method to actually execute/run full command +- Caching for parsed commands +- Decorators for adding functions? +- Decorators for adding objects? +- We might want to create the client in existing context? Or purely rely on the context... It doesn't need to handle events? + + +- Actually there's a thought, it would be really easy now to say 'respond with this custom command response' in response to, well, any event right. Could be a redeem, could be stream start, could be VIP add, could be subscription. Would just need the event object, and to construct the context locals correctly for that event, and then in the docs detail the 'available variables and functions' for each event... huh + - follower -> "Welcome in ${follower.name}! We now have ${channel.total} members." + - redeem (checkin) -> "${(counter checkins).add redeemer.id 1 "New checkin"}" + - Here 'redeemer.id' is a computed key, that could be any expression. The string is an optional description for the log. + + + + +Channels could want to define their own functions available for every command? +Scopes.. client provides global ExecScope, +then generate a scope for the channel and instantiate the context with joined scopes. + + +Each function probably has an 'inherent' permission. + +Need a check decorator, basically. + +Also, how do we pass the functions in? + +Eventually, we might want scope, as a stack of scopes that cascade with a lookup. +ExecScope.enter_scope() +ExecScope.get(varname) +ExecScope.exit_scope() + +We also really need some level of exception hierachy and unrolling nicely and error reporting. +And reporting for parse errors at some level. + +fragment_builder creates a CommandFragment. +This hold instructions of how a command will be run. + +Any child node should return a command fragment. +They don't have to + +The ExecutionContext holds the current state +when we get around to executing the command. + +With this setup, we could actually compile and cache all the commands into +CommandFragments, +and then run them on demand with the execution.. that probably makes sense. + +Now, a CommandFragment instance needs to be callable, and the first arg needs to be a context. +It.. either takes the other arguments as callable vars or has them already saved? + +The generic execution of a CommandFragment instance takes the arguments its been given, +which are also usually CommandFragments, +and then.. yeah.. + +So a 'function' is a single command fragment, executed by cmdfunction(ctx) probably. +Let's do the permission check on the dynamic side? +Considering extensibility.. + + +return ternary_fragment(condition, arg1, arg2) with those being fragments. +Maybe functions, maybe not.. they are 'term's + +(maybe Term is a better notation for this) + +Okay, when these are called at the higher level.. +We might want to pass them arguments from other pieces maybe? +Or just have the other pieces modify the context and pass that in.. +essentially scope. + +For a function, +- Choice one, we have FunctionTerm as a command fragment. + + +Okay yeah. Let's make a single FunctionTerm that will handle finding the functions itself. +We can subclass CommandTerm if we like.. + +Remember that the nodes need to return an *instance* of CommandFrag +or ExecTerm + +make_fragment produces a function that makes an instance that can be called +with the single ctx. +""" + + +# TODO: Function term builder +# @function_term("user") +# def user_function_term(ctx, *args): ... +# TODO: Syntax for defining functions inline +# Implement ifthenelse +# Implement typecasts +# TODO: Relatively safe attribute getter (custom objects?) + +""" +Functions can be obtained from context, as a special type of var. +Actually vars will summon the function parser anyway.. +vars are functions? +""" + +# NOTE: Whatever constructs the context should accept a context subclass. +# This allows for carrying additional information in the context +# And thus extending the functions and available Terms. +# We'll make the base parser and executor fully context-agnostic. + + +class Execuitor(NodeVisitor): + """ + NodeVisitor that visits each node in a parsed command component, + and builds a forward-directional ExecTerm from them. + """ + + def lift_child_log(self, node, visited_children): + print(f"NODE: {node} WITH {visited_children}") + return self.lift_child(node, visited_children) + + visit_basicterm = lift_child_log + + def visit_parenthised(self, node, visited_children): + print(f"PARENTH: {visited_children}") + _, child, _ = visited_children[0] + return child + + def visit_command(self, node, visited_children): + print(f"COMMAND: {visited_children}") + _, component, _ = visited_children + return command_shell(node.text, component) + + def visit_bool(self, node, boolterm): + (value,) = boolterm[0] + if value.text.lower() == "true": + return constant_term(node.text, True) + elif value.text.lower() == "false": + return constant_term(node.text, False) + else: + raise ValueError(f"Unrecognised bool {value.text}") + + def visit_test(self, node, testterm): + (expr,) = testterm + term1, op, term2 = expr + return compare(node.text, term1, op.text, term2) + + def generic_visit(self, node, visited_children): + """If we don't have a rule for this node return it as raw text, but evaluatable""" + print(f"GENERIC: {node} WITH {visited_children}") + return visited_children or node + + def visit_quoted(self, node, visited_children): + return constant_term(node.text, node.text[1:-1]) + + def visit_keyword(self, node, visited_children): + return node.text + + def visit_word(self, node, visited_children): + return node.text + + def visit_equals(self, node, defineterms): + var, _, _, _, term = defineterms + return define_equal(node.text, str(var), term) + + def visit_defines(self, node, defineterms): + var, _, _, _, term = defineterms + return define_walrus(node.text, str(var), term) + + def visit_definesterm(self, node, defineterms): + var, _, _, _, term = defineterms + return define_term(node.text, str(var), term) + + def visit_numeric(self, node, numeric_term): + return constant_term(node.text, int(node.text)) + + def visit_cmdarg(self, node, visited_children): + _, argspec = visited_children + return cmdarg_term( + node.text, int(argspec.text.strip("$*")), argspec.text.endswith("*") + ) + + def visit_ternary(self, node, visited_children): + print(f"TERNARY: {visited_children}") + cond, _, arg1, _, arg2 = visited_children + return ternary_term(node.text, cond, arg1, arg2) + + def visit_term(self, node, visited_children): + print(f"TERM: {visited_children}") + _, value, _ = visited_children + (child,) = value + return child + + def visit_component(self, node, visited_children): + print(f"COMPONENT: {visited_children}") + _, value, _ = visited_children + (child,) = value + return child + + def visit_var(self, node, varterm): + root, remaining = varterm + (root,) = root + terms = (root, *(attr for _, attr in remaining)) + return get_var(node.text, *terms) + + def visit_function(self, node, visited_children): + print(f"FUNCTION: {node.text}, {visited_children}") + func, remaining = visited_children + args = (term for _, term in remaining) + return run_function(node.text, func, *args) + + def visit_cascade(self, node, visited_children): + print(f"CASCADE: {visited_children}") + term, remaining = visited_children + terms = (term, *(bterm for _, bterm in remaining)) + return cascade_term(node.text, *terms) + + def visit_binop(self, node, binopterm): + (expr,) = binopterm + term1, remaining = expr + op = remaining[0][0] + terms = (term1, *(bterm for _, bterm in remaining)) + return binop(node.text, op.text, *terms) diff --git a/customcmd/terms.py b/customcmd/terms.py new file mode 100644 index 0000000..877e6ab --- /dev/null +++ b/customcmd/terms.py @@ -0,0 +1,235 @@ +from typing import Any, Awaitable, Callable, Concatenate, ParamSpec, TypeVar, Union, TYPE_CHECKING +from functools import wraps, reduce +import operator + +from . import logger +from .errors import CommandValueError + +if TYPE_CHECKING: + from .context import ExecContext + +P = ParamSpec("P") +R = TypeVar("R") +T = TypeVar("T") +T1 = TypeVar("T1") +T2 = TypeVar("T2") + + +TermResult = TypeVar("TermResult") + + +class ExecutorTerm[TermResult]: + # May add more here for initial parsing context + + def __init__(self, *args, node_text: str | None = None, **kwargs): + self.text = node_text + + def __call__(self, ctx: "ExecContext") -> Awaitable[TermResult]: + return self._execute(ctx) + + async def _execute(self, ctx: "ExecContext") -> Any: + logger.debug(f"Executing {self} in {ctx}") + try: + ctx.termstack.append(self) + return await self.execute(ctx) + except Exception: + logger.exception(f"Unhandled exception running {self} in {ctx}") + # TODO: Custom execution error class to wrap things + # TODO: And safe errors to stop execution, e.g. Permission errors or explicit early termination + raise + finally: + ctx.termstack.pop() + + async def execute(self, ctx: "ExecContext") -> Any: + raise NotImplementedError + + +class LateTerm(ExecutorTerm[TermResult]): + """ + A LateTerm is an ExecTerm that runs a given coroutine upon execution 'late'. + """ + + def __init__( + self, + coro: Callable[P, Awaitable[TermResult]], + coroargs, + corokwargs, + name: str | None = None, + **kwargs, + ): + self.coro: Callable = coro + self.coroargs = coroargs + self.corokwargs = corokwargs + self.name = name or coro.__name__ + super().__init__(**kwargs) + + async def execute(self, ctx: "ExecContext") -> TermResult: + return await self.coro(ctx, *self.coroargs, **self.corokwargs) + + def __repr__(self): + return " ".join( + ( + "<{self.__class__.__name__}", + "name={self.name!r}", + "text={self.text!r}", + "coroargs={self.coroargs!r}", + "corokwargs={self.corokwargs!r}", + "coro={self.coro!r}", + ">", + ) + ).format(self=self) + + +def lazy_term( + coro: Callable[Concatenate["ExecContext", P], Awaitable[R]], +) -> Callable[Concatenate[str, P], LateTerm[R]]: + """ + Build a LazyTerm instance builder (args) => LazyTerm wrapping the given coroutine. + The coroutine should accept a "ExecContext" as the first argument. + + When the builder is called, it will use the arguments it is called with + to create a LazyTerm which may then later be executed. + + Executing the constructed LazyTerm will execute coro with the given + execution context, as well as the arguments provided in the builder. + """ + + def term_wrapper(text: str, *args: P.args, **kwargs: P.kwargs): + return LateTerm(coro, args, kwargs, node_text=text) + + return term_wrapper + + +@lazy_term +async def ternary_term( + ctx: "ExecContext", + condition: ExecutorTerm, + arg1: ExecutorTerm[T1], + arg2: ExecutorTerm[T2], +) -> Union[T1, T2]: + """condition ? arg1 : arg2""" + if await condition(ctx): + return await arg1(ctx) + else: + return await arg2(ctx) + + +@lazy_term +async def constant_term(ctx: "ExecContext", constant: T) -> T: + """Return the provided constant.""" + return constant + + +@lazy_term +async def cmdarg_term(ctx, argn: int, greedy: bool) -> str: + args = (ctx.alias, *ctx.args) + if greedy: + return " ".join(args[argn:]) + elif argn >= len(args): + return '' + else: + return args[argn] + + +@lazy_term +async def cascade_term(ctx, *terms: ExecutorTerm): + for term in terms: + eval = await term(ctx) + if eval: + return eval + return "" + +@lazy_term +async def command_shell(ctx, component_term: ExecutorTerm): + if isinstance(component_term, ExecutorTerm): + result = await component_term(ctx) + else: + logger.warning( + f"command_shell LazyTerm stringifying non-term {component_term}" + ) + result = component_term + return str(result) + +@lazy_term +async def define_equal(ctx, var: str, value: ExecutorTerm): + ctx.scope[var] = await value.execute(ctx) + return None + +@lazy_term +async def define_walrus(ctx, var: str, value: ExecutorTerm): + result = await value.execute(ctx) + ctx.scope[var] = result + return result + +@lazy_term +async def define_term(ctx, var: str, value: ExecutorTerm): + ctx.scope[var] = value + return None + +@lazy_term +async def run_function(ctx, var: ExecutorTerm, *args: ExecutorTerm): + varlue = await var.execute(ctx) + argues = [await arg.execute(ctx) for arg in args] + if args and not callable(varlue): + raise CommandValueError(f"{var} is not callable") + elif not args: + return varlue + else: + return await varlue(ctx, *argues) + +@lazy_term +async def get_var(ctx, root: str | ExecutorTerm, *attrs: str): + if isinstance(root, ExecutorTerm): + value = await root.execute(ctx) + name = [root.text or ''] + elif root not in ctx.scope: + return ' '.join((root, *attrs)) + else: + value = ctx.scope[root] + name = [root] + + if isinstance(value, ExecutorTerm): + # ExecTerms only end up in scope from delayed assignment + value = await value.execute(ctx) + + for attr in attrs: + try: + value = getattr(value, '_exec_attr_'+attr) + name.append(attr) + except AttributeError: + raise CommandValueError(f"{'.'.join(name)} has no attribute {attr}") + return value + +@lazy_term +async def compare(ctx, term1: ExecutorTerm, op: str, term2: ExecutorTerm): + opmap = { + '==': operator.eq, + '!=': operator.ne, + '>': operator.gt, + '>=': operator.ge, + '<': operator.lt, + '<=': operator.le, + } + value1 = await term1.execute(ctx) + value2 = await term2.execute(ctx) + oppy = opmap[op] + return oppy(value1, value2) + +@lazy_term +async def binop(ctx, op: str, *terms): + opmap = { + '||': operator.or_, + '&&': operator.and_, + '+': operator.add, + '-': operator.sub, + '*': operator.mul, + '**': operator.pow, + '/': operator.truediv, + '//': operator.floordiv, + '%': operator.mod, + } + # Left associative + oppy = opmap[op] + values = [await term.execute(ctx) for term in terms] + result = reduce(oppy, values) + return result diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a5ea73c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "custom-commands" +version = "0.1a" +dependencies = [ + "parsimonious", + "regex", +] +requires-python = ">=3.13" + +[project.optional-dependencies] +testing = [ + "pytest", + "pytest-asyncio" +] + +[pytest] +asyncio_debug = true +asyncio_mode = "auto" + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/tests/grammar.py b/tests/grammar.py new file mode 100644 index 0000000..5a6e292 --- /dev/null +++ b/tests/grammar.py @@ -0,0 +1,227 @@ +from parsimonious.grammar import Grammar +from customcmd.client import ExecClient +from customcmd.parser import Execuitor +from customcmd.context import ExecContext + +import pytest +import asyncio + + +@pytest.fixture +def grammar(): + with open("assets/grammar.ppeg") as f: + return Grammar(f.read()) + + +@pytest.fixture +def executor(): + return Execuitor() + + +@pytest.fixture +def context(): + ctx = ExecContext( + alias="test_cmd", args=["testarg1", "testarg2"], content="Base content" + ) + return ctx + + +@pytest.fixture +def client(): + return ExecClient() + + +class TestObject: + _exec_attr_name = "testingobj" + _exec_attr_number = 7 + + async def _exec_attr_functor(self, ctx, arg1): + return f"Rec {arg1}" + + async def _exec_attr_functown(self, ctx, arg1): + return TestObject() + + def __call__(self, ctx, arg1): + return self.dummy_call(ctx, arg1) + + async def dummy_call(self, ctx, arg1): + return TestObject() + + def __str__(self): + return "Testing Object" + + +class TestGrammar: + async def run_command(self, grammar, executor, context, command): + tree = grammar.parse(command) + lazyterm = executor.visit(tree) + return await lazyterm.execute(context) + + @pytest.mark.asyncio + async def test_literals(self, grammar, executor, context): + expected_results = [ + ('${""}', ""), + ('${"abc"}', "abc"), + ('${ "abc" }', "abc"), + ("${1}", "1"), + ("${True}", "True"), + ("${False}", "False"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_ternary(self, grammar, executor, context): + expected_results = [ + ('${"a"?"b":"c"}', "b"), + ('${""?"b":"c"}', "c"), + ('${ "a" ? "b" : "c" }', "b"), + ('${ 1 ? "b" : "c" }', "b"), + ('${ 0 ? "b" : "c" }', "c"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_cmdarg(self, grammar, executor, context): + expected_results = [ + ("${$0}", "test_cmd"), + ("${$1}", "testarg1"), + ("${$1*}", "testarg1 testarg2"), + ("${$3}", ""), + ("${ $2 }", "testarg2"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_cascade(self, grammar, executor, context): + expected_results = [ + ('${""|"result"}', "result"), + ('${"first"|"result"}', "first"), + ('${"first"|"second"|"result"}', "first"), + ('${""|"second"|"result"}', "second"), + ('${""|""|"result"}', "result"), + ('${""|0|"result"}', "result"), + ('${0|1|"result"}', "1"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_define_equals(self, grammar, executor, context): + expected_results = [ + ("${a}", "a"), + ("${(b = 1) | b}", "1"), + ("${(d = 1) | (c = d) | c}", "1"), + ("${(e = f) | (f = 1) | e}", "f"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_define_walrus(self, grammar, executor, context): + expected_results = [ + ("${a}", "a"), + ("${b := 1}", "1"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_define_term(self, grammar, executor, context): + expected_results = [ + ("${a}", "a"), + ("${(b ::= 1) | b}", "1"), + ("${(d ::= 1) | (c ::= d) | c}", "1"), + ("${(e ::= f) | (f ::= 1) | e}", "1"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_basic_obj(self, grammar, executor, context): + context.scope["obj"] = TestObject() + + expected_results = [ + ("${obj}", "Testing Object"), + ("${obj.name}", "testingobj"), + ("${obj.number}", "7"), + ('${obj.functor "abc"}', "Rec abc"), + ("${(obj.functown abc).functor abc}", "Rec abc"), + ("${(obj abc).functor abc}", "Rec abc"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_compare(self, grammar, executor, context): + expected_results = [ + ("${1 == 1}", "True"), + ("${1 != 2}", "True"), + ("${1 <= 2}", "True"), + ("${1 <= 1}", "True"), + ("${1 < 2}", "True"), + ("${1 > 2}", "False"), + ("${1 >= 2}", "False"), + ("${1 == 2}", "False"), + ('${(1 == 2)?"a":"b"}', "b"), + ('${(1 == 1)?"a":"b"}', "a"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_ops(self, grammar, executor, context): + expected_results = [ + ("${1 + 1}", "2"), + ("${1 + 2 + 3}", "6"), + ("${1 - 1}", "0"), + ("${4 / 2}", "2.0"), + ("${5 % 2}", "1"), + ("${5 * 2}", "10"), + ("${5 * 2 * 3}", "30"), + ("${5 // 2}", "2"), + ("${2 ** 3}", "8"), + ("${2 ** 3}", "8"), + ("${True && False}", "False"), + ("${True || False}", "True"), + ("${True && True}", "True"), + ("${False || False}", "False"), + ('${"abc" + "def"}', "abcdef"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + @pytest.mark.asyncio + async def test_compound(self, grammar, executor, context): + expected_results = [ + ('${(a = 1) | (b = 1) | ((a == b)? 1 :"Failed")}', "1"), + ] + for command, expected in expected_results: + actual = await self.run_command(grammar, executor, context, command) + assert actual == expected + + +class TestClient: + @pytest.mark.asyncio + async def test_command_1(self, client, context): + actual = await client.run_command( + context, '${(a = 1) | (b = 1) | ((a == b)? 1 :"Failed")}' + ) + assert actual == "1" + + @pytest.mark.asyncio + async def test_command_2(self, client, context): + actual = await client.run_command(context, "A test ${2} of the client ${$2}") + assert actual == "A test 2 of the client testarg2"