Complete implementation of stand-alone menu-template parser

This commit is contained in:
Griatch 2020-09-27 17:53:53 +02:00
parent 45614abae1
commit 4f4bae2f01
2 changed files with 174 additions and 101 deletions

View file

@ -43,71 +43,112 @@ not move on until that command has been tried).
"""
import re
from ast import literal_eval
from evennia import EvMenu
from fnmatch import fnmatch
# i18n
from django.utils.translation import gettext as _
# support # NODE name, #NODE name ...
_RE_NODE = re.compile(r"#\s*?NODE\s+?(?P<nodename>\S+?)$", re.I + re.M)
_RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS\s*?$", re.I + re.M)
_RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M)
_RE_CALLABLE = re.compile(
r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?=[\S\s]+?)\)|\(\))", re.I+re.M)
_HELP_NO_OPTION_MATCH = _("Choose an option or try 'help'.")
_OPTION_INPUT_MARKER = ">"
_OPTION_ALIAS_MARKER = ";"
_OPTION_SEP_MARKER = ":"
_OPTION_CALL_MARKER = "->"
_OPTION_COMMENT_START = "#"
def gotofunc(caller, raw_string, **kwargs):
# Input/option/goto handler functions that allows for dynamically generated
# nodes read from the menu template.
def _generated_goto_func(caller, raw_string, **kwargs):
goto = kwargs['goto']
callables = kwargs['callables']
goto_callables = kwargs['goto_callables']
current_nodename = kwargs['current_nodename']
if _RE_CALLABLE.match(goto):
gotofunc = goto.strip()[:-2]
if gotofunc in callables:
return callables[gotofunc](caller, raw_string, **kwargs)
return goto
if gotofunc in goto_callables:
goto = goto_callables[gotofunc](caller, raw_string, **kwargs)
if goto is None:
return goto, {"generated_nodename": current_nodename}
caller.msg(_HELP_NO_OPTION_MATCH)
return goto, {"generated_nodename": goto}
def inputgotofunc(caller, raw_string, **kwargs):
def _generated_input_goto_func(caller, raw_string, **kwargs):
gotomap = kwargs['gotomap']
callables = kwargs['callables']
goto_callables = kwargs['goto_callables']
current_nodename = kwargs['current_nodename']
# start with glob patterns
for pattern, goto in gotomap.items():
if fnmatch(raw_string.lower(), pattern):
if _RE_CALLABLE.match(goto):
gotofunc = goto.strip()[:-2]
if gotofunc in callables:
return callables[gotofunc](caller, raw_string, **kwargs)
return goto
match = _RE_CALLABLE.match(goto)
print(f"goto {goto} -> match: {match}")
if match:
gotofunc = match.group("funcname")
gotokwargs = match.group("kwargs") or ""
print(f"gotofunc: {gotofunc}, {gotokwargs}")
if gotofunc in goto_callables:
for kwarg in gotokwargs.split(","):
if kwarg and "=" in kwarg:
print(f"kwarg {kwarg}")
key, value = [part.strip() for part in kwarg.split("=", 1)]
try:
key = literal_eval(key)
except ValueError:
pass
try:
value = literal_eval(value)
except ValueError:
pass
kwargs[key] = value
goto = goto_callables[gotofunc](caller, raw_string, **kwargs)
if goto is None:
return goto, {"generated_nodename": current_nodename}
return goto, {"generated_nodename": goto}
# no glob pattern match; try regex
for pattern, goto in gotomap.items():
if re.match(pattern, raw_string.lower(), flags=re.I + re.M):
if _RE_CALLABLE.match(goto):
gotofunc = goto.strip()[:-2]
if gotofunc in callables:
return callables[gotofunc](caller, raw_string, **kwargs)
return goto
if gotofunc in goto_callables:
goto = goto_callables[gotofunc](caller, raw_string, **kwargs)
if goto is None:
return goto, {"generated_nodename": current_nodename}
return goto, {"generated_nodename": goto}
# no match, rerun current node
return None
caller.msg(_HELP_NO_OPTION_MATCH)
return None, {"generated_nodename": current_nodename}
def generated_node(caller, raw_string, text="", options=None,
nodename="", **kwargs):
def _generated_node(caller, raw_string, generated_nodename="", **kwargs):
text, options = caller.db._generated_menu_contents[generated_nodename]
return text, options
class ParseMenuForm:
def parse_menu_template(caller, menu_template, goto_callables=None):
"""
Parse menu-template string
def __init__(self, caller, formstr, callables=None):
self.caller = caller
self.formstr = formstr
self.callables = callables or {}
self.menutree = self.parse(formstr)
Args:
caller (Object or Account): Entity using the menu.
menu_template (str): Menu described using the templating format.
goto_callables (dict, optional): Mapping between call-names and callables
on the form `callable(caller, raw_string, **kwargs)`. These are what is
available to use in the `menu_template` string.
def _generate_node(self, nodename, text, options):
"""
Generate a node from the parsed string
"""
def node(caller, raw_string, nodename=nodename, **kwargs):
return text, options
return node
"""
def _parse_options(self, optiontxt):
def _parse_options(nodename, optiontxt, goto_callables):
"""
Parse option section into option dict.
"""
@ -117,40 +158,40 @@ class ParseMenuForm:
inputparsemap = {}
for inum, optline in enumerate(optionlist):
if optline.startswith("#") or not ":" in optline:
if (optline.startswith(_OPTION_COMMENT_START)
or _OPTION_SEP_MARKER not in optline):
# skip comments or invalid syntax
continue
key = ""
desc = ""
pattern = None
key, goto = [part.strip() for part in optline.split(":", 1)]
key, goto = [part.strip() for part in optline.split(_OPTION_SEP_MARKER, 1)]
# desc -> goto
if "->" in goto:
desc, goto = [part.strip() for part in goto.split("->", 1)]
if _OPTION_CALL_MARKER in goto:
desc, goto = [part.strip() for part in goto.split(_OPTION_CALL_MARKER, 1)]
# parse key [pattern]
key = [part.strip() for part in key.split(";")]
# parse key [;aliases|pattern]
key = [part.strip() for part in key.split(_OPTION_ALIAS_MARKER)]
if not key:
# fall back to this being the Nth option
key = [f"{inum + 1}"]
main_key = key[0]
if main_key.startswith(">input"):
key[0] = "_default"
pattern = main_key[6:].strip()
if pattern is not None:
if main_key.startswith(_OPTION_INPUT_MARKER):
# if we have a pattern, build the arguments for _default later
pattern = main_key[len(_OPTION_INPUT_MARKER):].strip()
inputparsemap[pattern] = goto
print(f"registering input goto {pattern} -> {goto}")
else:
# a regular goto string target
# a regular goto string/callable target
option = {
"key": key,
"goto": (gotofunc, {
"goto": (_generated_goto_func, {
"goto": goto,
"callables": self.callables})
"current_nodename": nodename,
"goto_callables": goto_callables})
}
if desc:
option["desc"] = desc
@ -160,58 +201,94 @@ class ParseMenuForm:
# if this exists we must create a _default entry too
options.append({
"key": "_default",
"goto": (inputgotofunc, {
"goto": (_generated_input_goto_func, {
"gotomap": inputparsemap,
"callables": self.callables
"current_nodename": nodename,
"goto_callables": goto_callables
})
})
return options
def parse(self, formstr):
def _parse(caller, menu_template, goto_callables):
"""
Parse the menu string format into a node tree.
"""
nodetree = {}
errors = []
splits = _RE_NODE.split(formstr)
splits = _RE_NODE.split(menu_template)
splits = splits[1:] if splits else []
# from evennia import set_trace;set_trace(term_size=(140,120))
content_map = {}
for node_ind in range(0, len(splits), 2):
nodename, nodetxt = splits[node_ind], splits[node_ind + 1]
text, *optiontxt = _RE_OPTIONS_SEP.split(nodetxt, maxsplit=2)
options = self._parse_options(optiontxt)
nodetree[nodename] = self._generate_node(nodename, text, options)
options = _parse_options(nodename, optiontxt, goto_callables)
content_map[nodename] = (text, options)
nodetree[nodename] = _generated_node
caller.db._generated_menu_contents = content_map
return nodetree
return _parse(caller, menu_template, goto_callables)
# class GameTutor(EvMenu):
#
# # tutorial helpers
#
# @staticmethod
# def nextprev(prevnode, nextnode, **kwargs):
# """
# Add return to options to add a prev/next entry
# """
# if kwargs:
# prevnode = (prevnode, kwargs)
# nextnode = (nextnode, kwargs)
#
# return (
# {"key": ("|w[p]|nrev", "prev", "p"),
# "goto": prevnode},
# {"key": ("|w[n]|next", "next", "n"),
# "goto": nextnode}
# )
def template2menu(caller, menu_template, goto_callables=None,
startnode="start", startnode_input=None, persistent=False,
**kwargs):
"""
Helper function to generate and start an EvMenu based on a menu template
string.
Args:
caller (Object or Account): The entity using the menu.
menu_template (str): The menu-template string describing the content
and structure of the menu. It can also be the python-path to, or a module
containing a `MENU_TEMPLATE` global variable with the template.
goto_callables (dict, optional): Mapping of callable-names to
module-global objects to reference by name in the menu-template.
Must be on the form `callable(caller, raw_string, **kwargs)`.
startnode (str, optional): The name of the startnode, if not 'start'.
startnode_input (str or tuple, optional): If a string, the `raw_string`
arg to pass into the starting node. Otherwise should be on form
`(raw_string, {kwargs})`, where `raw_string` and `**kwargs` will be
passed into the start node.
persistent (bool, optional): If the generated menu should be persistent.
**kwargs: Other kwargs will be passed to EvMenu.
"""
goto_callables = goto_callables or {}
startnode_raw = ""
startnode_kwargs = {"generated_nodename": startnode}
if isinstance(startnode_input, str):
startnode_raw = startnode_input
elif isinstance(startnode_input, (tuple, list)):
startnode_raw = startnode_input[0]
startnode_kwargs.update(startnode_input[1])
menu_tree = parse_menu_template(caller, menu_template, goto_callables)
EvMenu(caller, menu_tree,
startnode_input=(startnode_raw, startnode_kwargs),
persistent=True, **kwargs)
def gotonode3(caller, raw_string, **kwargs):
print("in gotonode3", caller, raw_string, kwargs)
return None
def foo(caller, raw_string, **kwargs):
print("in foo", caller, raw_string, kwargs)
return "node2"
def bar(caller, raw_string, **kwargs):
print("in bar", caller, raw_string, kwargs)
return "bar"
def test_generator(caller):
MENU_DESC = \
MENU_TEMPLATE = \
"""
# node start
@ -227,10 +304,11 @@ def test_generator(caller):
3: node3 -> gotonode3()
next;n: node2
top: start
>input: return to go back -> start
>input foo*: foo()
>input bar*: bar()
> foo*: foo()
> bar*: bar(a=4, boo=groo)
> [5,6]0+?: foo()
> great: node2
> fail: bar()
# node node1
@ -244,7 +322,7 @@ def test_generator(caller):
back: start
to node 2: node2
run foo (rerun node): foo()
>: return to go back -> start
# node node2
@ -262,29 +340,16 @@ def test_generator(caller):
## options
back: back to start -> start
end: end
# node end
In node end!
"""
def gotonode3(caller, raw_string, **kwargs):
print("in gotonode3", caller, raw_string, kwargs)
return None
def foo(caller, raw_string, **kwargs):
print("in foo", caller, raw_string, kwargs)
return "node2"
def bar(caller, raw_string, **kwargs):
print("in bar", caller, raw_string, kwargs)
return "bar"
callables = {"gotonode3": gotonode3, "foo": foo, "bar": bar}
mform = ParseMenuForm(caller, MENU_DESC, callables)
if isinstance(caller, str):
print(mform.menutree)
else:
EvMenu(caller, mform.menutree)
template2menu(caller, MENU_TEMPLATE, callables)
if __name__ == "__main__":

View file

@ -190,7 +190,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
from django.utils.translation import gettext as _
_ERR_NOT_IMPLEMENTED = _(
"Menu node '{nodename}' is either not implemented or " "caused an error. Make another choice."
"Menu node '{nodename}' is either not implemented or caused an error. "
"Make another choice or try 'q' to abort."
)
_ERR_GENERAL = _("Error in menu node '{nodename}'.")
_ERR_NO_OPTION_DESC = _("No description.")
@ -537,11 +538,18 @@ class EvMenu:
menu_cmdset.priority = int(cmdset_priority)
self.caller.cmdset.add(menu_cmdset, permanent=persistent)
reserved_startnode_kwargs = set(("nodename", "raw_string"))
startnode_kwargs = {}
if isinstance(startnode_input, (tuple, list)) and len(startnode_input) > 1:
startnode_input, startnode_kwargs = startnode_input[:2]
if not isinstance(startnode_kwargs, dict):
raise EvMenuError("startnode_input must be either a str or a tuple (str, dict).")
clashing_kwargs = reserved_startnode_kwargs.intersection(set(startnode_kwargs.keys()))
if clashing_kwargs:
raise RuntimeError(
f"Evmenu startnode_inputs includes kwargs {tuple(clashing_kwargs)} that "
"clashes with EvMenu's internal usage.")
# start the menu
self.goto(self._startnode, startnode_input, **startnode_kwargs)
@ -986,7 +994,7 @@ class EvMenu:
"""
cmd = strip_ansi(raw_string.strip().lower())
if cmd in self.options:
if self.options and cmd in self.options:
# this will take precedence over the default commands
# below
goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd]