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
-3
View File
@@ -1,3 +0,0 @@
import logging
logger = logging.getLogger(__name__)
+5 -6
View File
@@ -2,9 +2,7 @@ import regex
from parsimonious.grammar import Grammar
from .context import ExecContext
from .parser import Execuitor
from .terms import ExecutorTerm
from .parser import ExecContext, Execuitor, ExecutorTerm
fragment_re = regex.compile(r"\$(?<br>{(?:[^{}]++|(?&br))*})")
@@ -30,7 +28,7 @@ class ExecClient:
term = self.execuitor.visit(tree)
return term
async def run_command(self, ctx: ExecContext, command: str) -> str:
async def run_command(self, ctx: ExecContext, command: str, dryrun=False) -> str:
"""
Process the given command string,
executing and replacing each fragment.
@@ -46,9 +44,10 @@ class ExecClient:
fragment = command[match.start() : match.end()]
parsed = self._parse_fragment(fragment)
fragment_result = await parsed.execute(ctx)
if not dryrun:
fragment_result = await parsed.execute(ctx)
result.append(fragment_result)
result.append(fragment_result)
last_end = match.end()
return "".join(result)
-24
View File
@@ -1,24 +0,0 @@
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'] = []
+36
View File
@@ -0,0 +1,36 @@
from data import Registry, RowModel, Table
from data.columns import String, Timestamp, Integer, Bool
class CustomCmd(RowModel):
_tablename_ = 'customcmds'
_cache_ = {}
commandid = Integer(primary=True)
communityid = Integer()
name = String()
response = String()
cooldown_bucket_size = Integer()
cooldown_bucket_dur = Integer()
permlevel = Integer()
maskperms = Bool()
creatorid = Integer()
description = String()
docstring = String()
created_at = Timestamp()
_timestamp = Timestamp()
class CustomCmdData(Registry):
VERSION = ('CUSTOMCMD', 1)
customcmds = CustomCmd.table
customcmd_aliases = Table('customcmd_aliases')
-273
View File
@@ -1,273 +0,0 @@
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)
+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]
+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)
@@ -8,6 +8,25 @@ 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")
@@ -152,18 +171,18 @@ async def command_shell(ctx, component_term: ExecutorTerm):
@lazy_term
async def define_equal(ctx, var: str, value: ExecutorTerm):
ctx.scope[var] = await value.execute(ctx)
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.scope[var] = result
ctx.this_scope[var] = result
return result
@lazy_term
async def define_term(ctx, var: str, value: ExecutorTerm):
ctx.scope[var] = value
ctx.this_scope[var] = value
return None
@lazy_term