feat: Data and client framework

This commit is contained in:
2026-05-31 05:05:01 +10:00
parent 19da69b3e8
commit b188abcad0
18 changed files with 643 additions and 313 deletions
+8
View File
@@ -0,0 +1,8 @@
import logging
logger = logging.getLogger(__name__)
from .execuitor import Execuitor
from .context import ExecContext
from .terms import *
from .errors import CommandError, CommandValueError
+48
View File
@@ -0,0 +1,48 @@
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, initial_globals: dict[str, Any] = {}, **kwargs):
self.alias = alias
self.args = args
self.content = content
# Stack of scopes
# Initialising globals this way to avoid modifying default arg
globals = {} | initial_globals
self.scopes: list[dict[str, Any]] = [globals]
# Stack of terms that are *currently* executing with this context.
self.termstack: list['ExecutorTerm'] = []
def enter_scope(self, *args, **kwargs):
new_scope = dict(*args, **kwargs)
self.scopes.append(new_scope)
def leave_scope(self):
if len(self.scopes) > 1:
self.scopes.pop()
else:
raise ValueError("Cannot leave global scope")
@property
def scope(self):
# TODO: Move this to a more efficient ExecScope mapping type at some point
fullscope = {}
for scope in self.scopes:
fullscope |= scope
return fullscope
@property
def this_scope(self):
return self.scopes[-1]
+9
View File
@@ -0,0 +1,9 @@
class CommandError(Exception):
def __init__(self, msg=None, **kwargs):
super().__init__(**kwargs)
self.msg = msg
class CommandValueError(CommandError):
pass
+132
View File
@@ -0,0 +1,132 @@
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,
)
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)
+14
View File
@@ -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()
+254
View File
@@ -0,0 +1,254 @@
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
__all__ = (
"constant_term",
"define_equal",
"define_walrus",
"define_term",
"binop",
"compare",
"cmdarg_term",
"command_shell",
"get_var",
"ternary_term",
"run_function",
"cascade_term",
'ExecutorTerm',
'LateTerm',
'lazy_term',
)
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.this_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.this_scope[var] = result
return result
@lazy_term
async def define_term(ctx, var: str, value: ExecutorTerm):
ctx.this_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