Complete implementation of stand-alone menu-template parser

This commit is contained in:
Griatch 2020-09-27 17:53:53 +02:00
parent 78c7214a46
commit fad306b932
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 import re
from ast import literal_eval
from evennia import EvMenu from evennia import EvMenu
from fnmatch import fnmatch 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_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_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"\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'] goto = kwargs['goto']
callables = kwargs['callables'] goto_callables = kwargs['goto_callables']
current_nodename = kwargs['current_nodename']
if _RE_CALLABLE.match(goto): if _RE_CALLABLE.match(goto):
gotofunc = goto.strip()[:-2] gotofunc = goto.strip()[:-2]
if gotofunc in callables: if gotofunc in goto_callables:
return callables[gotofunc](caller, raw_string, **kwargs) goto = goto_callables[gotofunc](caller, raw_string, **kwargs)
return goto 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'] gotomap = kwargs['gotomap']
callables = kwargs['callables'] goto_callables = kwargs['goto_callables']
current_nodename = kwargs['current_nodename']
# start with glob patterns # start with glob patterns
for pattern, goto in gotomap.items(): for pattern, goto in gotomap.items():
if fnmatch(raw_string.lower(), pattern): if fnmatch(raw_string.lower(), pattern):
if _RE_CALLABLE.match(goto): match = _RE_CALLABLE.match(goto)
gotofunc = goto.strip()[:-2] print(f"goto {goto} -> match: {match}")
if gotofunc in callables: if match:
return callables[gotofunc](caller, raw_string, **kwargs) gotofunc = match.group("funcname")
return goto 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 # no glob pattern match; try regex
for pattern, goto in gotomap.items(): for pattern, goto in gotomap.items():
if re.match(pattern, raw_string.lower(), flags=re.I + re.M): if re.match(pattern, raw_string.lower(), flags=re.I + re.M):
if _RE_CALLABLE.match(goto): if _RE_CALLABLE.match(goto):
gotofunc = goto.strip()[:-2] gotofunc = goto.strip()[:-2]
if gotofunc in callables: if gotofunc in goto_callables:
return callables[gotofunc](caller, raw_string, **kwargs) goto = goto_callables[gotofunc](caller, raw_string, **kwargs)
return goto if goto is None:
return goto, {"generated_nodename": current_nodename}
return goto, {"generated_nodename": goto}
# no match, rerun current node # 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, def _generated_node(caller, raw_string, generated_nodename="", **kwargs):
nodename="", **kwargs): text, options = caller.db._generated_menu_contents[generated_nodename]
return text, options 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): Args:
self.caller = caller caller (Object or Account): Entity using the menu.
self.formstr = formstr menu_template (str): Menu described using the templating format.
self.callables = callables or {} goto_callables (dict, optional): Mapping between call-names and callables
self.menutree = self.parse(formstr) 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. Parse option section into option dict.
""" """
@ -117,40 +158,40 @@ class ParseMenuForm:
inputparsemap = {} inputparsemap = {}
for inum, optline in enumerate(optionlist): 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 # skip comments or invalid syntax
continue continue
key = "" key = ""
desc = "" desc = ""
pattern = None 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 # desc -> goto
if "->" in goto: if _OPTION_CALL_MARKER in goto:
desc, goto = [part.strip() for part in goto.split("->", 1)] desc, goto = [part.strip() for part in goto.split(_OPTION_CALL_MARKER, 1)]
# parse key [pattern] # parse key [;aliases|pattern]
key = [part.strip() for part in key.split(";")] key = [part.strip() for part in key.split(_OPTION_ALIAS_MARKER)]
if not key: if not key:
# fall back to this being the Nth option # fall back to this being the Nth option
key = [f"{inum + 1}"] key = [f"{inum + 1}"]
main_key = key[0] main_key = key[0]
if main_key.startswith(">input"): if main_key.startswith(_OPTION_INPUT_MARKER):
key[0] = "_default"
pattern = main_key[6:].strip()
if pattern is not None:
# if we have a pattern, build the arguments for _default later # if we have a pattern, build the arguments for _default later
pattern = main_key[len(_OPTION_INPUT_MARKER):].strip()
inputparsemap[pattern] = goto inputparsemap[pattern] = goto
print(f"registering input goto {pattern} -> {goto}")
else: else:
# a regular goto string target # a regular goto string/callable target
option = { option = {
"key": key, "key": key,
"goto": (gotofunc, { "goto": (_generated_goto_func, {
"goto": goto, "goto": goto,
"callables": self.callables}) "current_nodename": nodename,
"goto_callables": goto_callables})
} }
if desc: if desc:
option["desc"] = desc option["desc"] = desc
@ -160,58 +201,94 @@ class ParseMenuForm:
# if this exists we must create a _default entry too # if this exists we must create a _default entry too
options.append({ options.append({
"key": "_default", "key": "_default",
"goto": (inputgotofunc, { "goto": (_generated_input_goto_func, {
"gotomap": inputparsemap, "gotomap": inputparsemap,
"callables": self.callables "current_nodename": nodename,
"goto_callables": goto_callables
}) })
}) })
return options return options
def parse(self, formstr): def _parse(caller, menu_template, goto_callables):
""" """
Parse the menu string format into a node tree. Parse the menu string format into a node tree.
""" """
nodetree = {} nodetree = {}
errors = [] splits = _RE_NODE.split(menu_template)
splits = _RE_NODE.split(formstr)
splits = splits[1:] if splits else [] splits = splits[1:] if splits else []
# from evennia import set_trace;set_trace(term_size=(140,120)) # from evennia import set_trace;set_trace(term_size=(140,120))
content_map = {}
for node_ind in range(0, len(splits), 2): for node_ind in range(0, len(splits), 2):
nodename, nodetxt = splits[node_ind], splits[node_ind + 1] nodename, nodetxt = splits[node_ind], splits[node_ind + 1]
text, *optiontxt = _RE_OPTIONS_SEP.split(nodetxt, maxsplit=2) text, *optiontxt = _RE_OPTIONS_SEP.split(nodetxt, maxsplit=2)
options = self._parse_options(optiontxt) options = _parse_options(nodename, optiontxt, goto_callables)
nodetree[nodename] = self._generate_node(nodename, text, options) content_map[nodename] = (text, options)
nodetree[nodename] = _generated_node
caller.db._generated_menu_contents = content_map
return nodetree return nodetree
return _parse(caller, menu_template, goto_callables)
# class GameTutor(EvMenu):
# def template2menu(caller, menu_template, goto_callables=None,
# # tutorial helpers startnode="start", startnode_input=None, persistent=False,
# **kwargs):
# @staticmethod """
# def nextprev(prevnode, nextnode, **kwargs): Helper function to generate and start an EvMenu based on a menu template
# """ string.
# Add return to options to add a prev/next entry
# """ Args:
# if kwargs: caller (Object or Account): The entity using the menu.
# prevnode = (prevnode, kwargs) menu_template (str): The menu-template string describing the content
# nextnode = (nextnode, kwargs) 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.
# return ( goto_callables (dict, optional): Mapping of callable-names to
# {"key": ("|w[p]|nrev", "prev", "p"), module-global objects to reference by name in the menu-template.
# "goto": prevnode}, Must be on the form `callable(caller, raw_string, **kwargs)`.
# {"key": ("|w[n]|next", "next", "n"), startnode (str, optional): The name of the startnode, if not 'start'.
# "goto": nextnode} 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): def test_generator(caller):
MENU_DESC = \ MENU_TEMPLATE = \
""" """
# node start # node start
@ -227,10 +304,11 @@ def test_generator(caller):
3: node3 -> gotonode3() 3: node3 -> gotonode3()
next;n: node2 next;n: node2
top: start top: start
>input: return to go back -> start > foo*: foo()
>input foo*: foo() > bar*: bar(a=4, boo=groo)
>input bar*: bar() > [5,6]0+?: foo()
> great: node2
> fail: bar()
# node node1 # node node1
@ -244,7 +322,7 @@ def test_generator(caller):
back: start back: start
to node 2: node2 to node 2: node2
run foo (rerun node): foo() run foo (rerun node): foo()
>: return to go back -> start
# node node2 # node node2
@ -262,29 +340,16 @@ def test_generator(caller):
## options ## options
back: back to start -> start 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} callables = {"gotonode3": gotonode3, "foo": foo, "bar": bar}
template2menu(caller, MENU_TEMPLATE, callables)
mform = ParseMenuForm(caller, MENU_DESC, callables)
if isinstance(caller, str):
print(mform.menutree)
else:
EvMenu(caller, mform.menutree)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -190,7 +190,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
_ERR_NOT_IMPLEMENTED = _( _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_GENERAL = _("Error in menu node '{nodename}'.")
_ERR_NO_OPTION_DESC = _("No description.") _ERR_NO_OPTION_DESC = _("No description.")
@ -537,11 +538,18 @@ class EvMenu:
menu_cmdset.priority = int(cmdset_priority) menu_cmdset.priority = int(cmdset_priority)
self.caller.cmdset.add(menu_cmdset, permanent=persistent) self.caller.cmdset.add(menu_cmdset, permanent=persistent)
reserved_startnode_kwargs = set(("nodename", "raw_string"))
startnode_kwargs = {} startnode_kwargs = {}
if isinstance(startnode_input, (tuple, list)) and len(startnode_input) > 1: if isinstance(startnode_input, (tuple, list)) and len(startnode_input) > 1:
startnode_input, startnode_kwargs = startnode_input[:2] startnode_input, startnode_kwargs = startnode_input[:2]
if not isinstance(startnode_kwargs, dict): if not isinstance(startnode_kwargs, dict):
raise EvMenuError("startnode_input must be either a str or a tuple (str, 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 # start the menu
self.goto(self._startnode, startnode_input, **startnode_kwargs) self.goto(self._startnode, startnode_input, **startnode_kwargs)
@ -986,7 +994,7 @@ class EvMenu:
""" """
cmd = strip_ansi(raw_string.strip().lower()) 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 # this will take precedence over the default commands
# below # below
goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd] goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd]