Source code for evennia.utils.evmenu
"""
EvMenu
This implements a full menu system for Evennia.
To start the menu, just import the EvMenu class from this module.
Example usage:
```python
from evennia.utils.evmenu import EvMenu
EvMenu(caller, menu_module_path,
startnode="node1",
cmdset_mergetype="Replace", cmdset_priority=1,
auto_quit=True, cmd_on_exit="look", persistent=True)
```
Where `caller` is the Object to use the menu on - it will get a new
cmdset while using the Menu. The menu_module_path is the python path
to a python module containing function definitions. By adjusting the
keyword options of the Menu() initialization call you can start the
menu at different places in the menu definition file, adjust if the
menu command should overload the normal commands or not, etc.
The `persistent` keyword will make the menu survive a server reboot.
It is `False` by default. Note that if using persistent mode, every
node and callback in the menu must be possible to be *pickled*, this
excludes e.g. callables that are class methods or functions defined
dynamically or as part of another function. In non-persistent mode
no such restrictions exist.
The menu is defined in a module (this can be the same module as the
command definition too) with function definitions:
```python
def node1(caller):
# (this is the start node if called like above)
# code
return text, options
def node_with_other_name(caller, input_string):
# code
return text, options
def another_node(caller, input_string, **kwargs):
# code
return text, options
```
Where caller is the object using the menu and input_string is the
command entered by the user on the *previous* node (the command
entered to get to this node). The node function code will only be
executed once per node-visit and the system will accept nodes with
both one or two arguments interchangeably. It also accepts nodes
that takes `**kwargs`.
The menu tree itself is available on the caller as
`caller.ndb._evmenu`. This makes it a convenient place to store
temporary state variables between nodes, since this NAttribute is
deleted when the menu is exited.
The return values must be given in the above order, but each can be
returned as None as well. If the options are returned as None, the
menu is immediately exited and the default "look" command is called.
- `text` (str, tuple or None): Text shown at this node. If a tuple, the
second element in the tuple is a help text to display at this
node when the user enters the menu help command there.
- `options` (tuple, dict or None): If `None`, this exits the menu.
If a single dict, this is a single-option node. If a tuple,
it should be a tuple of option dictionaries. Option dicts have the following keys:
- `key` (str or tuple, optional): What to enter to choose this option.
If a tuple, it must be a tuple of strings, where the first string is the
key which will be shown to the user and the others are aliases.
If unset, the options' number will be used. The special key `_default`
marks this option as the default fallback when no other option matches
the user input. There can only be one `_default` option per node. It
will not be displayed in the list.
- `desc` (str, optional): This describes what choosing the option will do.
- `goto` (str, tuple or callable): If string, should be the name of node to go to
when this option is selected. If a callable, it has the signature
`callable(caller[,raw_input][,**kwargs])`. If a tuple, the first element
is the callable and the second is a dict with the `**kwargs` to pass to
the callable. Those kwargs will also be passed into the next node if possible.
Such a callable should return either a str or a (str, dict), where the
string is the name of the next node to go to and the dict is the new,
(possibly modified) kwarg to pass into the next node. If the callable returns
None or the empty string, the current node will be revisited.
If `key` is not given, the option will automatically be identified by
its number 1..N.
Example:
```python
# in menu_module.py
def node1(caller):
text = ("This is a node text",
"This is help text for this node")
options = ({"key": "testing",
"desc": "Select this to go to node 2",
"goto": ("node2", {"foo": "bar"}),
{"desc": "Go to node 3.",
"goto": "node3"})
return text, options
def callback1(caller):
# this is called when choosing the "testing" option in node1
# (before going to node2). If it returned a string, say 'node3',
# then the next node would be node3 instead of node2 as specified
# by the normal 'goto' option key above.
caller.msg("Callback called!")
def node2(caller, **kwargs):
text = '''
This is node 2. It only allows you to go back
to the original node1. This extra indent will
be stripped. We don't include a help text but
here are the variables passed to us: {}
'''.format(kwargs)
options = {"goto": "node1"}
return text, options
def node3(caller):
text = "This ends the menu since there are no options."
return text, None
```
When starting this menu with `Menu(caller, "path.to.menu_module")`,
the first node will look something like this:
::
This is a node text
______________________________________
testing: Select this to go to node 2
2: Go to node 3
Where you can both enter "testing" and "1" to select the first option.
If the client supports MXP, they may also mouse-click on "testing" to
do the same. When making this selection, a function "callback1" in the
same Using `help` will show the help text, otherwise a list of
available commands while in menu mode.
The menu tree is exited either by using the in-menu quit command or by
reaching a node without any options.
For a menu demo, import `CmdTestMenu` from this module and add it to
your default cmdset. Run it with this module, like `testmenu evennia.utils.evmenu`.
## Menu generation from template string
In evmenu.py is a helper function `parse_menu_template` that parses a
template-string and outputs a menu-tree dictionary suitable to pass into
EvMenu:
::
menutree = evmenu.parse_menu_template(caller, menu_template, goto_callables)
EvMenu(caller, menutree)
For maximum flexibility you can inject normally-created nodes in the menu tree
before passing it to EvMenu. If that's not needed, you can also create a menu
in one step with:
```python
evmenu.template2menu(caller, menu_template, goto_callables)
```
The `goto_callables` is a mapping `{"funcname": callable, ...}`, where each
callable must be a module-global function on the form
`funcname(caller, raw_string, **kwargs)` (like any goto-callable). The
`menu_template` is a multi-line string on the following form:
::
## node start
This is the text of the start node.
The text area can have multiple lines, line breaks etc.
Each option below is one of these forms
key: desc -> gotostr_or_func
key: gotostr_or_func
>: gotostr_or_func
> glob/regex: gotostr_or_func
## options
# comments are only allowed from beginning of line.
# Indenting is not necessary, but good for readability
1: Option number 1 -> node1
2: Option number 2 -> node2
next: This steps next -> go_back()
# the -> can be ignored if there is no desc
back: go_back(from_node=start)
abort: abort
## node node1
Text for Node1. Enter a message!
<return> to go back.
## options
# Beginner-Tutorial the option-line with >
# allows to perform different actions depending on
# what is inserted.
# this catches everything starting with foo
> foo*: handle_foo_message()
# regex are also allowed (this catches number inputs)
> [0-9]+?: handle_numbers()
# this catches the empty return
>: start
# this catches everything else
> *: handle_message(from_node=node1)
## node node2
Text for Node2. Just go back.
## options
>: start
## node abort
This exits the menu since there is no `## options` section.
Each menu node is defined by a `# node <name>` containing the text of the node,
followed by `## options` Also `## NODE` and `## OPTIONS` work. No python code
logics is allowed in the template, this code is not evaluated but parsed. More
advanced dynamic usage requires a full node-function (which can be added to the
generated dict, as said).
Adding `(..)` to a goto treats it as a callable and it must then be included in
the `goto_callable` mapping. Only named keywords (or no args at all) are
allowed, these will be added to the `**kwargs` going into the callable. Quoting
strings is only needed if wanting to pass strippable spaces, otherwise the
key:values will be converted to strings/numbers with literal_eval before passed
into the callable.
The \\> option takes a glob or regex to perform different actions depending
on user input. Make sure to sort these in increasing order of generality since
they will be tested in sequence.
----
"""
import inspect
import re
from ast import literal_eval
from fnmatch import fnmatch
from inspect import getfullargspec, isfunction
from math import ceil
from django.conf import settings
# i18n
from django.utils.translation import gettext as _
from evennia import CmdSet, Command
from evennia.commands import cmdhandler
from evennia.utils import logger
from evennia.utils.ansi import strip_ansi
from evennia.utils.evtable import EvColumn, EvTable
from evennia.utils.utils import crop, dedent, is_iter, m_len, make_iter, mod_import, pad, to_str
# read from protocol NAWS later?
_MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
# we use cmdhandler instead of evennia.syscmdkeys to
# avoid some cases of loading before evennia init'd
_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
# Return messages
_ERR_NOT_IMPLEMENTED = _(
"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.")
_HELP_FULL = _("Commands: <menu option>, help, quit")
_HELP_NO_QUIT = _("Commands: <menu option>, help")
_HELP_NO_OPTIONS = _("Commands: help, quit")
_HELP_NO_OPTIONS_NO_QUIT = _("Commands: help")
_HELP_NO_OPTION_MATCH = _("Choose an option or try 'help'.")
_ERROR_PERSISTENT_SAVING = """
{error}
|rThe menu state could not be saved for persistent mode. Switching
to non-persistent mode (which means the menu session won't survive
an eventual server reload).|n
"""
_TRACE_PERSISTENT_SAVING = (
"EvMenu persistent-mode error. Commonly, this is because one or "
"more of the EvEditor callbacks could not be pickled, for example "
"because it's a class method or is defined inside another function."
)
[docs]class EvMenuError(RuntimeError):
"""
Error raised by menu when facing internal errors.
"""
pass
[docs]class EvMenuGotoAbortMessage(RuntimeError):
"""
This can be raised by a goto-callable to abort the goto flow. The message
stored with the executable will be sent to the caller who will remain on
the current node. This can be used to pass single-line returns without
re-running the entire node with text and options.
Example:
raise EvMenuGotoMessage("That makes no sense.")
"""
# -------------------------------------------------------------
#
# Menu command and command set
#
# -------------------------------------------------------------
[docs]class CmdEvMenuNode(Command):
"""
Command to handle all user input targeted at the menu while the menu is active.
"""
key = _CMD_NOINPUT
aliases = [_CMD_NOMATCH]
locks = "cmd:all()"
help_category = "Menu"
auto_help_display_key = "<menu commands>"
def _update_aliases(self, menu):
"""Add aliases to make sure to override defaults if we defined we want it."""
new_aliases = [_CMD_NOMATCH]
if menu.auto_quit and "quit" not in self.aliases:
new_aliases.extend(["q", "quit"])
if menu.auto_look and "look" not in self.aliases:
new_aliases.extend(["l", "look"])
if menu.auto_help and "help" not in self.aliases:
new_aliases.extend(["h", "help"])
if len(new_aliases) > 1:
self.set_aliases(new_aliases)
self.msg(f"aliases: {self.aliases}")
[docs] def func(self):
"""
Implement all menu commands.
"""
def _restore(caller):
# check if there is a saved menu available.
# this will re-start a completely new evmenu call.
saved_options = caller.attributes.get("_menutree_saved")
if saved_options:
startnode_tuple = caller.attributes.get("_menutree_saved_startnode")
try:
startnode, startnode_input = startnode_tuple
except ValueError: # old form of startnode store
startnode, startnode_input = startnode_tuple, ""
if startnode:
saved_options[2]["startnode"] = startnode
saved_options[2]["startnode_input"] = startnode_input
MenuClass = saved_options[0]
# this will create a completely new menu call
menu = MenuClass(caller, *saved_options[1], **saved_options[2])
# self._update_aliases(menu)
return True
return None
caller = self.caller
# we store Session on the menu since this can be hard to
# get in multisession environments if caller is an Account.
menu = caller.ndb._evmenu
if not menu:
if _restore(caller):
return
orig_caller = caller
caller = caller.account if hasattr(caller, "account") else None
menu = caller.ndb._evmenu if caller else None
if not menu:
if caller and _restore(caller):
return
caller = self.session
menu = caller.ndb._evmenu
if not menu:
# can't restore from a session
err = "Menu object not found as %s.ndb._evmenu!" % orig_caller
orig_caller.msg(
err
) # don't give the session as a kwarg here, direct to original
raise EvMenuError(err)
# self._update_aliases(menu)
# we must do this after the caller with the menu has been correctly identified since it
# can be either Account, Object or Session (in the latter case this info will be
# superfluous).
caller.ndb._evmenu._session = self.session
# we have a menu, use it.
menu.parse_input(self.raw_string)
[docs]class EvMenuCmdSet(CmdSet):
"""
The Menu cmdset replaces the current cmdset.
"""
key = "menu_cmdset"
priority = 1
mergetype = "Replace"
no_objs = True
no_exits = True
no_channels = False
[docs] def at_cmdset_creation(self):
"""
Called when creating the set.
"""
self.add(CmdEvMenuNode())
# ------------------------------------------------------------
#
# Menu main class
#
# -------------------------------------------------------------
[docs]class EvMenu:
"""
This object represents an operational menu. It is initialized from
a menufile.py instruction.
"""
# convenient helpers for easy overloading
node_border_char = "_"
[docs] def __init__(
self,
caller,
menudata,
startnode="start",
cmdset_mergetype="Replace",
cmdset_priority=1,
auto_quit=True,
auto_look=True,
auto_help=True,
cmd_on_exit="look",
persistent=False,
startnode_input="",
session=None,
debug=False,
**kwargs,
):
"""
Initialize the menu tree and start the caller onto the first node.
Args:
caller (Object, Account or Session): The user of the menu.
menudata (str, module or dict): The full or relative path to the module
holding the menu tree data. All global functions in this module
whose name doesn't start with '_ ' will be parsed as menu nodes.
Also the module itself is accepted as input. Finally, a dictionary
menu tree can be given directly. This must then be a mapping
`{"nodekey":callable,...}` where `callable` must be called as
and return the data expected of a menu node. This allows for
dynamic menu creation.
startnode (str, optional): The starting node name in the menufile.
cmdset_mergetype (str, optional): 'Replace' (default) means the menu
commands will be exclusive - no other normal commands will
be usable while the user is in the menu. 'Union' means the
menu commands will be integrated with the existing commands
(it will merge with `merge_priority`), if so, make sure that
the menu's command names don't collide with existing commands
in an unexpected way. Also the CMD_NOMATCH and CMD_NOINPUT will
be overloaded by the menu cmdset. Other cmdser mergetypes
has little purpose for the menu.
cmdset_priority (int, optional): The merge priority for the
menu command set. The default (1) is usually enough for most
types of menus.
auto_quit (bool, optional): Allow user to use "q", "quit" or
"exit" to leave the menu at any point. Recommended during
development!
auto_look (bool, optional): Automatically make "looK" or "l" to
re-show the last node. Turning this off means you have to handle
re-showing nodes yourself, but may be useful if you need to
use "l" for some other purpose.
auto_help (bool, optional): Automatically make "help" or "h" show
the current help entry for the node. If turned off, eventual
help must be handled manually, but it may be useful if you
need 'h' for some other purpose, for example.
cmd_on_exit (callable, str or None, optional): When exiting the menu
(either by reaching a node with no options or by using the
in-built quit command (activated with `allow_quit`), this
callback function or command string will be executed.
The callback function takes two parameters, the caller then the
EvMenu object. This is called after cleanup is complete.
Set to None to not call any command.
persistent (bool, optional): Make the Menu persistent (i.e. it will
survive a reload. This will make the Menu cmdset persistent. Use
with caution - if your menu is buggy you may end up in a state
you can't get out of! Also note that persistent mode requires
that all formatters, menu nodes and callables are possible to
*pickle*. When the server is reloaded, the latest node shown will be completely
re-run with the same input arguments - so be careful if you are counting
up some persistent counter or similar - the counter may be run twice if
reload happens on the node that does that. Note that if `debug` is True,
this setting is ignored and assumed to be False.
startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if
a user input text from a fictional previous node. If including the dict, this will
be passed as **kwargs to that node. When the server reloads,
the latest visited node will be re-run as `node(caller, raw_string, **kwargs)`.
session (Session, optional): This is useful when calling EvMenu from an account
in multisession mode > 2. Note that this session only really relevant
for the very first display of the first node - after that, EvMenu itself
will keep the session updated from the command input. So a persistent
menu will *not* be using this same session anymore after a reload.
debug (bool, optional): If set, the 'menudebug' command will be made available
by default in all nodes of the menu. This will print out the current state of
the menu. Deactivate for production use! When the debug flag is active, the
`persistent` flag is deactivated.
**kwargs: All kwargs will become initialization variables on `caller.ndb._evmenu`,
to be available at run.
Raises:
EvMenuError: If the start/end node is not found in menu tree.
Notes:
While running, the menu is stored on the caller as `caller.ndb._evmenu`. Also
the current Session (from the Command, so this is still valid in multisession
environments) is available through `caller.ndb._evmenu._session`. The `_evmenu`
property is a good one for storing intermediary data on between nodes since it
will be automatically deleted when the menu closes.
In persistent mode, all nodes, formatters and callbacks in the menu must be
possible to be *pickled*, this excludes e.g. callables that are class methods
or functions defined dynamically or as part of another function. In
non-persistent mode no such restrictions exist.
"""
self._startnode = startnode
self._menutree = self._parse_menudata(menudata)
self._persistent = persistent if not debug else False
self._quitting = False
if startnode not in self._menutree:
raise EvMenuError("Start node '%s' not in menu tree!" % startnode)
# public variables made available to the command
self.caller = caller
# track EvMenu kwargs
self.auto_quit = auto_quit
self.auto_look = auto_look
self.auto_help = auto_help
self.debug_mode = debug
self._session = session
if isinstance(cmd_on_exit, str):
# At this point menu._session will have been replaced by the
# menu command to the actual session calling.
self.cmd_on_exit = lambda caller, menu: caller.execute_cmd(
cmd_on_exit, session=menu._session
)
elif callable(cmd_on_exit):
self.cmd_on_exit = cmd_on_exit
else:
self.cmd_on_exit = None
# current menu state
self.default = None
self.nodetext = None
self.helptext = None
self.options = None
self.nodename = None
self.node_kwargs = {}
# used for testing
self.test_options = {}
self.test_nodetext = ""
# assign kwargs as initialization vars on ourselves.
reserved_clash = set(
(
"_startnode",
"_menutree",
"_session",
"_persistent",
"cmd_on_exit",
"default",
"nodetext",
"helptext",
"options",
"cmdset_mergetype",
"auto_quit",
)
).intersection(set(kwargs.keys()))
if reserved_clash:
raise RuntimeError(
f"One or more of the EvMenu `**kwargs` ({list(reserved_clash)}) "
"is reserved by EvMenu for internal use."
)
for key, val in kwargs.items():
setattr(self, key, val)
if self.caller.ndb._evmenu:
# an evmenu already exists - we try to close it cleanly. Note that this will
# not fire the previous menu's end node.
try:
self.caller.ndb._evmenu.close_menu()
except Exception:
pass
# store ourself on the object
self.caller.ndb._evmenu = self
# TODO DEPRECATED - for backwards-compatibility. Use `.ndb._evmenu` instead
self.caller.ndb._menutree = self
if persistent:
# save the menu to the database
calldict = {
"startnode": startnode,
"cmdset_mergetype": cmdset_mergetype,
"cmdset_priority": cmdset_priority,
"auto_quit": auto_quit,
"auto_look": auto_look,
"auto_help": auto_help,
"cmd_on_exit": cmd_on_exit,
"persistent": persistent,
}
calldict.update(kwargs)
try:
caller.attributes.add("_menutree_saved", (self.__class__, (menudata,), calldict))
caller.attributes.add("_menutree_saved_startnode", (startnode, startnode_input))
except Exception as err:
self.msg(_ERROR_PERSISTENT_SAVING.format(error=err))
logger.log_trace(_TRACE_PERSISTENT_SAVING)
persistent = False
# set up the menu command on the caller
menu_cmdset = EvMenuCmdSet()
menu_cmdset.mergetype = str(cmdset_mergetype).lower().capitalize() or "Replace"
menu_cmdset.priority = int(cmdset_priority)
self.caller.cmdset.add(menu_cmdset, persistent=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)
def _parse_menudata(self, menudata):
"""
Parse a menufile for node functions and store in dictionary
map. Alternatively, accept a pre-made mapping dictionary of
node functions.
Args:
menudata (str, module or dict): The python.path to the menufile,
or the python module itself. If a dict, this should be a
mapping nodename:callable, where the callable must match
the criteria for a menu node.
Returns:
menutree (dict): A {nodekey: func}
"""
if isinstance(menudata, dict):
# This is assumed to be a pre-loaded menu tree.
return menudata
else:
# a python path of a module
module = mod_import(menudata)
return dict(
(key, func)
for key, func in module.__dict__.items()
if isfunction(func) and not key.startswith("_")
)
def _format_node(self, nodetext, optionlist):
"""
Format the node text + option section
Args:
nodetext (str): The node text
optionlist (list): List of (key, desc) pairs.
Returns:
string (str): The options section, including
all needed spaces.
Notes:
This will adjust the columns of the options, first to use
a maxiumum of 4 rows (expanding in columns), then gradually
growing to make use of the screen space.
"""
# handle the node text
nodetext = self.nodetext_formatter(nodetext)
# handle the options
optionstext = self.options_formatter(optionlist)
# format the entire node
return self.node_formatter(nodetext, optionstext)
def _safe_call(self, callback, raw_string, **kwargs):
"""
Call a node-like callable, with a variable number of raw_string, *args, **kwargs, all of
which should work also if not present (only `caller` is always required). Return its result.
"""
try:
try:
nargs = len(getfullargspec(callback).args)
except TypeError:
raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback))
supports_kwargs = bool(getfullargspec(callback).varkw)
if nargs <= 0:
raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback))
if supports_kwargs:
if nargs > 1:
ret = callback(self.caller, raw_string, **kwargs)
# callback accepting raw_string, **kwargs
else:
# callback accepting **kwargs
ret = callback(self.caller, **kwargs)
elif nargs > 1:
# callback accepting raw_string
ret = callback(self.caller, raw_string)
else:
# normal callback, only the caller as arg
ret = callback(self.caller)
except EvMenuError:
errmsg = _ERR_GENERAL.format(nodename=callback)
self.msg(errmsg)
logger.log_trace()
raise
return ret
def _execute_node(self, nodename, raw_string, **kwargs):
"""
Execute a node (-function) and get its returns.
Args:
nodename (str): Name of node.
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
kwargs (any, optional): Optional kwargs for the node.
Returns:
nodetext, options (tuple): The node text (a string or a
tuple and the options tuple, if any.
"""
try:
node = self._menutree[nodename]
except KeyError:
self.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename))
raise EvMenuError
try:
kwargs["_current_nodename"] = nodename
ret = self._safe_call(node, raw_string, **kwargs)
if isinstance(ret, (tuple, list)) and len(ret) > 1:
nodetext, options = ret[:2]
else:
nodetext, options = ret, None
except KeyError:
self.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename))
logger.log_trace()
raise EvMenuError
except Exception:
self.msg(_ERR_GENERAL.format(nodename=nodename))
logger.log_trace()
raise
# store options to make them easier to test
self.test_nodetext = nodetext
self.test_options = options
return nodetext, options
def _extract_goto(self, nodename, option_dict):
"""
Helper: Get callables and their eventual kwargs.
Args:
nodename (str): The current node name (used for error reporting).
option_dict (dict): The seleted option's dict.
Returns:
goto (str, callable or None): The goto directive in the option.
goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty.
"""
goto_kwargs = {}
goto = option_dict.get("goto", None)
if goto and isinstance(goto, (tuple, list)):
if len(goto) > 1:
goto, goto_kwargs = goto[:2] # ignore any extra arguments
if not hasattr(goto_kwargs, "__getitem__"):
# not a dict-like structure
raise EvMenuError(
"EvMenu node {}: goto kwargs is not a dict: {}".format(
nodename, goto_kwargs
)
)
else:
goto = goto[0]
return goto, goto_kwargs
[docs] def goto(self, nodename_or_callable, raw_string, **kwargs):
"""
Run a node by name, optionally dynamically generating that name first.
Args:
nodename_or_callable (str or callable): Name of node or a callable
to be called as `function(caller, raw_string, **kwargs)` or
`function(caller, **kwargs)`. This callable must return the node-name (str)
pointing to the next node.
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
**kwargs: Extra arguments to goto callables.
"""
inp_nodename = nodename_or_callable
if callable(nodename_or_callable):
# run the "goto" callable to get the next node to go to
nodename = self._safe_call(nodename_or_callable, raw_string, **kwargs)
if isinstance(nodename, (tuple, list)):
if not len(nodename) > 1 or not isinstance(nodename[1], dict):
raise EvMenuError(
"{}: goto callable must return str or (str, dict)".format(inp_nodename)
)
nodename, kwargs = nodename[:2]
if not nodename:
# no nodename return. Re-run current node
nodename = self.nodename
elif nodename_or_callable is None:
# repeat current node
nodename = self.nodename
else:
# the nodename given directly
nodename = nodename_or_callable
# one way or another, we have the nodename as a string now
try:
# execute the found nodename, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
except EvMenuError:
return
if self._persistent:
self.caller.attributes.add(
"_menutree_saved_startnode", (nodename, (raw_string, kwargs))
)
# validation of the node return values
# if the nodetext is a list/tuple, the second set is the help text.
helptext = ""
if is_iter(nodetext):
nodetext, *helptext = nodetext
helptext = helptext[0] if helptext else ""
nodetext = "" if nodetext is None else str(nodetext)
# handle the helptext
if helptext:
self.helptext = self.helptext_formatter(helptext)
elif options:
self.helptext = _HELP_FULL if self.auto_quit else _HELP_NO_QUIT
else:
self.helptext = _HELP_NO_OPTIONS if self.auto_quit else _HELP_NO_OPTIONS_NO_QUIT
# store the current node's data in the menu state
self.nodename = nodename
self.node_kwargs = kwargs
self.options = {}
self.default = None
display_options = [] # options will be displayed in this order
if options:
options = [options] if isinstance(options, dict) else options
for inum, dic in enumerate(options):
# homogenize the options dict
keys = make_iter(dic.get("key"))
desc = dic.get("desc", dic.get("text", None))
if "_default" in keys:
keys = [key for key in keys if key != "_default"]
goto, goto_kwargs = self._extract_goto(nodename, dic)
self.default = (goto, goto_kwargs)
else:
# use the key (only) if set, otherwise use the running number
keys = list(make_iter(dic.get("key", str(inum + 1).strip())))
goto, goto_kwargs = self._extract_goto(nodename, dic)
if keys:
display_options.append((keys[0], desc))
for key in keys:
self.options[strip_ansi(key).strip().lower()] = (goto, goto_kwargs)
# format the text
self.nodetext = self._format_node(nodetext, display_options)
# display self.nodetext to the user
self.display_nodetext()
# close menu if we have no more options to process
if not options:
self.close_menu()
[docs] def print_debug_info(self, arg):
"""
Messages the caller with the current menu state, for debug purposes.
Args:
arg (str): Arg to debug instruction, either nothing, 'full' or the name
of a property to inspect.
"""
all_props = inspect.getmembers(self)
all_methods = [name for name, _ in inspect.getmembers(self, predicate=inspect.ismethod)]
all_builtins = [name for name, _ in inspect.getmembers(self, predicate=inspect.isbuiltin)]
props = {
prop: value
for prop, value in all_props
if prop not in all_methods and prop not in all_builtins and not prop.endswith("__")
}
local = {
key: var
for key, var in locals().items()
if key not in all_props and not key.endswith("__")
}
if arg:
if arg in props:
debugtxt = " |y* {}:|n\n{}".format(arg, props[arg])
elif arg in local:
debugtxt = " |y* {}:|n\n{}".format(arg, local[arg])
elif arg == "full":
debugtxt = (
"|yMENU DEBUG full ... |n\n"
+ "\n".join(
"|y *|n {}: {}".format(key, val) for key, val in sorted(props.items())
)
+ "\n |yLOCAL VARS:|n\n"
+ "\n".join(
"|y *|n {}: {}".format(key, val) for key, val in sorted(local.items())
)
+ "\n |y... END MENU DEBUG|n"
)
else:
debugtxt = "|yUsage: menudebug full|<name of property>|n"
else:
debugtxt = (
"|yMENU DEBUG properties ... |n\n"
+ "\n".join(
"|y *|n {}: {}".format(key, crop(to_str(val, force_string=True), width=50))
for key, val in sorted(props.items())
)
+ "\n |yLOCAL VARS:|n\n"
+ "\n".join(
"|y *|n {}: {}".format(key, crop(to_str(val, force_string=True), width=50))
for key, val in sorted(local.items())
)
+ "\n |y... END MENU DEBUG|n"
)
self.msg(debugtxt)
[docs] def msg(self, txt):
"""
This is a central point for sending return texts to the caller. It
allows for a central point to add custom messaging when creating custom
EvMenu overrides.
Args:
txt (str): The text to send.
Notes:
By default this will send to the same session provided to EvMenu
(if `session` kwarg was provided to `EvMenu.__init__`). It will
also send it with a `type=menu` for the benefit of OOB/webclient.
"""
self.caller.msg(text=(txt, {"type": "menu"}), session=self._session)
[docs] def parse_input(self, raw_string):
"""
Parses the incoming string from the menu user. This is the entry-point for all input
into the menu.
Args:
raw_string (str): The incoming, unmodified string
from the user.
Notes:
This method is expected to parse input and use the result
to relay execution to the relevant methods of the menu. It
should also report errors directly to the user.
"""
# this is the input cmd given to the menu
cmd = strip_ansi(raw_string.strip().lower())
try:
if self.options and cmd in self.options:
# we chose one of the available options; this
# will take precedence over the default commands
goto_node, goto_kwargs = self.options[cmd]
self.goto(goto_node, raw_string, **(goto_kwargs or {}))
elif self.auto_look and cmd in ("look", "l"):
self.display_nodetext()
elif self.auto_help and cmd in ("help", "h"):
self.display_helptext()
elif self.auto_quit and cmd in ("quit", "q", "exit"):
self.close_menu()
elif self.debug_mode and cmd.startswith("menudebug"):
self.print_debug_info(cmd[9:].strip())
elif self.default:
goto_node, goto_kwargs = self.default
self.goto(goto_node, raw_string, **(goto_kwargs or {}))
else:
self.msg(_HELP_NO_OPTION_MATCH)
except EvMenuGotoAbortMessage as err:
# custom interrupt from inside a goto callable - print the message and
# stay on the current node.
self.msg(str(err))
# formatters - override in a child class
[docs] def nodetext_formatter(self, nodetext):
"""
Format the node text itself.
Args:
nodetext (str): The full node text (the text describing the node).
Returns:
nodetext (str): The formatted node text.
"""
return dedent(nodetext.strip("\n"), baseline_index=0).rstrip()
[docs] def helptext_formatter(self, helptext):
"""
Format the node's help text
Args:
helptext (str): The unformatted help text for the node.
Returns:
helptext (str): The formatted help text.
"""
return dedent(helptext.strip("\n"), baseline_index=0).rstrip()
[docs] def options_formatter(self, optionlist):
"""
Formats the option block.
Args:
optionlist (list): List of (key, description) tuples for every
option related to this node.
Returns:
options (str): The formatted option display.
"""
if not optionlist:
return ""
# column separation distance
colsep = 4
nlist = len(optionlist)
# get the widest option line in the table.
table_width_max = -1
table = []
for key, desc in optionlist:
if key or desc:
desc_string = f": {desc}" if desc else ""
table_width_max = max(
table_width_max,
max(m_len(p) for p in key.split("\n"))
+ max(m_len(p) for p in desc_string.split("\n"))
+ colsep,
)
raw_key = strip_ansi(key)
if raw_key != key:
# already decorations in key definition
table.append(f" |lc{raw_key}|lt{key}|le{desc_string}")
else:
# add a default white color to key
table.append(f" |lc{raw_key}|lt|w{key}|n|le{desc_string}")
ncols = _MAX_TEXT_WIDTH // table_width_max # number of columns
if ncols < 0:
# no visible options at all
return ""
ncols = 1 if ncols == 0 else ncols
# minimum number of rows in a column
min_rows = 4
# split the items into columns
split = max(min_rows, ceil(len(table) / ncols))
max_end = len(table)
cols_list = []
for icol in range(ncols):
start = icol * split
end = min(start + split, max_end)
cols_list.append(EvColumn(*table[start:end]))
return str(EvTable(table=cols_list, border="none"))
[docs] def node_formatter(self, nodetext, optionstext):
"""
Formats the entirety of the node.
Args:
nodetext (str): The node text as returned by `self.nodetext_formatter`.
optionstext (str): The options display as returned by `self.options_formatter`.
caller (Object, Account or None, optional): The caller of the node.
Returns:
node (str): The formatted node to display.
"""
sep = self.node_border_char
if self._session:
screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: _MAX_TEXT_WIDTH})[0]
else:
screen_width = _MAX_TEXT_WIDTH
nodetext_width_max = max(m_len(line) for line in nodetext.split("\n"))
options_width_max = max(m_len(line) for line in optionstext.split("\n"))
total_width = min(screen_width, max(options_width_max, nodetext_width_max))
separator1 = sep * total_width + "\n\n" if nodetext_width_max else ""
separator2 = "\n" + sep * total_width + "\n\n" if total_width else ""
return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext
# -----------------------------------------------------------
#
# List node (decorator turning a node into a list with
# look/edit/add functionality for the elements)
#
# -----------------------------------------------------------
[docs]def list_node(option_generator, select=None, pagesize=10):
"""
Decorator for making an EvMenu node into a multi-page list node. Will add new options,
prepending those options added in the node.
Args:
option_generator (callable or list): A list of strings indicating the options, or a callable
that is called as option_generator(caller) to produce such a list.
select (callable or str, optional): Node to redirect a selection to. Its `**kwargs` will
contain the `available_choices` list and `selection` will hold one of the elements in
that list. If a callable, it will be called as
`select(caller, menuchoice, **kwargs)` where menuchoice is the chosen option as a
string and `available_choices` is a kwarg mapping the option keys to the choices
offered by the option_generator. The callable whould return the name of the target node
to goto after this selection (or None to repeat the list-node). Note that if this is not
given, the decorated node must itself provide a way to continue from the node!
pagesize (int): How many options to show per page.
Example:
```python
def select(caller, selection, available_choices=None, **kwargs):
'''
This will be called by all auto-generated options except any 'extra_options'
you return from the node (those you need to handle normally).
Args:
caller (Object or Account): User of the menu.
selection (str): What caller chose in the menu
available_choices (list): The keys of elements available on the *current listing
page*.
**kwargs: Kwargs passed on from the node.
Returns:
tuple, str or None: A tuple (nextnodename, **kwargs) or just nextnodename. Return
`None` to go back to the listnode.
'''
# (do something with `selection` here)
return "nextnode", **kwargs
@list_node(['foo', 'bar'], select)
def node_index(caller):
text = "describing the list"
# optional extra options in addition to the list-options
extra_options = []
return text, extra_options
```
Notes:
All normal `goto` callables returned from the decorated nodes
will, if they accept `**kwargs`, get a new kwarg 'available_choices'
injected. These are the ordered list of named options (descs) visible
on the current node page.
"""
def decorator(func):
def _select_parser(caller, raw_string, **kwargs):
"""
Parse the select action
"""
available_choices = kwargs.pop("available_choices", [])
try:
index = int(raw_string.strip()) - 1
selection = available_choices[index]
except Exception:
caller.msg(_("|rInvalid choice.|n"))
else:
if callable(select):
try:
if bool(getfullargspec(select).varkw):
return select(
caller, selection, available_choices=available_choices, **kwargs
)
else:
return select(caller, selection, **kwargs)
except Exception:
logger.log_trace(
"Error in EvMenu.list_node decorator:\n "
f"select-callable: {select}\n with args: ({caller}"
f"{selection}, {available_choices}, {kwargs}) raised "
"exception."
)
elif select:
# we assume a string was given, we inject the result into the kwargs
# to pass on to the next node
kwargs["selection"] = selection
return str(select), kwargs
# this means the previous node will be re-run with these same kwargs
return None, kwargs
def _list_node(caller, raw_string, **kwargs):
option_list = (
option_generator(caller) if callable(option_generator) else option_generator
)
npages = 0
page_index = 0
page = []
options = []
if option_list:
nall_options = len(option_list)
pages = [
option_list[ind : ind + pagesize] for ind in range(0, nall_options, pagesize)
]
npages = len(pages)
page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0)))
page = pages[page_index]
text = ""
extra_text = None
# dynamic, multi-page option list. Each selection leads to the `select`
# callback being called with a result from the available choices
options.extend(
[
{"desc": opt, "goto": (_select_parser, {"available_choices": page, **kwargs})}
for opt in page
]
)
if npages > 1:
# if the goto callable returns None, the same node is rerun, and
# kwargs not used by the callable are passed on to the node. This
# allows us to call ourselves over and over, using different kwargs.
options.append(
{
"key": (_("|Wcurrent|n"), "c"),
"desc": "|W({}/{})|n".format(page_index + 1, npages),
"goto": (lambda caller: None, {"optionpage_index": page_index}),
}
)
if page_index > 0:
options.append(
{
"key": (_("|wp|Wrevious page|n"), "p"),
"goto": (lambda caller: None, {"optionpage_index": page_index - 1}),
}
)
if page_index < npages - 1:
options.append(
{
"key": (_("|wn|Wext page|n"), "n"),
"goto": (lambda caller: None, {"optionpage_index": page_index + 1}),
}
)
# add data from the decorated node
decorated_options = []
supports_kwargs = bool(getfullargspec(func).varkw)
try:
if supports_kwargs:
text, decorated_options = func(caller, raw_string, **kwargs)
else:
text, decorated_options = func(caller, raw_string)
except TypeError:
try:
if supports_kwargs:
text, decorated_options = func(caller, **kwargs)
else:
text, decorated_options = func(caller)
except Exception:
raise
except Exception:
logger.log_trace()
else:
if isinstance(decorated_options, dict):
decorated_options = [decorated_options]
else:
decorated_options = make_iter(decorated_options)
extra_options = []
if isinstance(decorated_options, dict):
decorated_options = [decorated_options]
for eopt in decorated_options:
cback = ("goto" in eopt and "goto") or None
if cback:
signature = eopt[cback]
if callable(signature):
# callable with no kwargs defined
eopt[cback] = (signature, {"available_choices": page})
elif is_iter(signature):
if len(signature) > 1 and isinstance(signature[1], dict):
signature[1]["available_choices"] = page
eopt[cback] = signature
elif signature:
# a callable alone in a tuple (i.e. no previous kwargs)
eopt[cback] = (signature[0], {"available_choices": page})
else:
# malformed input.
logger.log_err(
"EvMenu @list_node decorator found "
"malformed option to decorate: {}".format(eopt)
)
extra_options.append(eopt)
options.extend(extra_options)
text = text + "\n\n" + extra_text if extra_text else text
return text, options
return _list_node
return decorator
# -------------------------------------------------------------------------------------------------
#
# Simple input shortcuts
#
# -------------------------------------------------------------------------------------------------
[docs]class CmdGetInput(Command):
"""
Enter your data and press return.
"""
key = _CMD_NOMATCH
aliases = _CMD_NOINPUT
[docs] def func(self):
"""This is called when user enters anything."""
caller = self.caller
try:
getinput = caller.ndb._getinput
if not getinput and hasattr(caller, "account"):
getinput = caller.account.ndb._getinput
if getinput:
caller = caller.account
callback = getinput._callback
caller.ndb._getinput._session = self.session
prompt = caller.ndb._getinput._prompt
args = caller.ndb._getinput._args
kwargs = caller.ndb._getinput._kwargs
result = self.raw_string.rstrip() # we strip the ending line break caused by sending
ok = not callback(caller, prompt, result, *args, **kwargs)
if ok:
# only clear the state if the callback does not return
# anything
del caller.ndb._getinput
caller.cmdset.remove(InputCmdSet)
except Exception:
# make sure to clean up cmdset if something goes wrong
caller.msg("|rError in get_input. Choice not confirmed (report to admin)|n")
logger.log_trace("Error in get_input")
caller.cmdset.remove(InputCmdSet)
[docs]class InputCmdSet(CmdSet):
"""
This stores the input command
"""
key = "input_cmdset"
priority = 1
mergetype = "Replace"
no_objs = True
no_exits = True
no_channels = False
class _Prompt:
"""Dummy holder"""
pass
[docs]def get_input(caller, prompt, callback, session=None, *args, **kwargs):
"""
This is a helper function for easily request input from the caller.
Args:
caller (Account or Object): The entity being asked the question. This
should usually be an object controlled by a user.
prompt (str): This text will be shown to the user, in order to let them
know their input is needed.
callback (callable): A function that will be called
when the user enters a reply. It must take three arguments: the
`caller`, the `prompt` text and the `result` of the input given by
the user. If the callback doesn't return anything or return False,
the input prompt will be cleaned up and exited. If returning True,
the prompt will remain and continue to accept input.
session (Session, optional): This allows to specify the
session to send the prompt to. It's usually only needed if `caller`
is an Account in multisession modes greater than 2. The session is
then updated by the command and is available (for example in
callbacks) through `caller.ndb.getinput._session`.
*args (any): Extra arguments to pass to `callback`. To utilise `*args`
(and `**kwargs`), a value for the `session` argument must also be
provided.
**kwargs (any): Extra kwargs to pass to `callback`.
Raises:
RuntimeError: If the given callback is not callable.
Notes:
The result value sent to the callback is raw and not processed in any
way. This means that you will get the ending line return character from
most types of client inputs. So make sure to strip that before doing a
comparison.
When the prompt is running, a temporary object `caller.ndb._getinput`
is stored; this will be removed when the prompt finishes.
If you need the specific Session of the caller (which may not be easy
to get if caller is an account in higher multisession modes), then it
is available in the callback through `caller.ndb._getinput._session`.
This is why the `session` is required as input.
It's not recommended to 'chain' `get_input` into a sequence of
questions. This will result in the caller stacking ever more instances
of InputCmdSets. While they will all be cleared on concluding the
get_input chain, EvMenu should be considered for anything beyond a
single question.
"""
if not callable(callback):
raise RuntimeError("get_input: input callback is not callable.")
caller.ndb._getinput = _Prompt()
caller.ndb._getinput._callback = callback
caller.ndb._getinput._prompt = prompt
caller.ndb._getinput._session = session
caller.ndb._getinput._args = args
caller.ndb._getinput._kwargs = kwargs
caller.cmdset.add(InputCmdSet, persistent=False)
caller.msg(prompt, session=session)
[docs]class CmdYesNoQuestion(Command):
"""
Handle a prompt for yes or no. Press [return] for the default choice.
"""
key = _CMD_NOINPUT
aliases = [_CMD_NOMATCH, "yes", "no", "y", "n", "a", "abort"]
arg_regex = r"^$"
def _clean(self, caller):
del caller.ndb._yes_no_question
if not caller.cmdset.has(YesNoQuestionCmdSet) and hasattr(caller, "account"):
caller.account.cmdset.remove(YesNoQuestionCmdSet)
else:
caller.cmdset.remove(YesNoQuestionCmdSet)
[docs] def func(self):
"""This is called when user enters anything."""
caller = self.caller
try:
yes_no_question = caller.ndb._yes_no_question
if not yes_no_question and hasattr(caller, "account"):
yes_no_question = caller.account.ndb._yes_no_question
caller = caller.account
if not yes_no_question:
self._clean(caller)
return
inp = self.cmdname
if inp == _CMD_NOINPUT:
raw = self.raw_cmdname.strip()
if not raw:
# use default
inp = yes_no_question.default
else:
inp = raw
if inp in ("a", "abort") and yes_no_question.allow_abort:
caller.msg(_("Aborted."))
self._clean(caller)
return
caller.ndb._yes_no_question.session = self.session
args = yes_no_question.args
kwargs = yes_no_question.kwargs
kwargs["caller_session"] = self.session
if inp in ("yes", "y"):
yes_no_question.yes_callable(caller, *args, **kwargs)
elif inp in ("no", "n"):
yes_no_question.no_callable(caller, *args, **kwargs)
else:
# invalid input. Resend prompt without cleaning
caller.msg(yes_no_question.prompt, session=self.session)
return
# cleanup
self._clean(caller)
except Exception:
# make sure to clean up cmdset if something goes wrong
caller.msg(_("|rError in ask_yes_no. Choice not confirmed (report to admin)|n"))
logger.log_trace("Error in ask_yes_no")
self._clean(caller)
raise
[docs]class YesNoQuestionCmdSet(CmdSet):
"""
This stores the input command
"""
key = "yes_no_question_cmdset"
priority = 1
mergetype = "Replace"
no_objs = True
no_exits = True
no_channels = False
[docs]def ask_yes_no(
caller,
prompt="Yes or No {options}?",
yes_action="Yes",
no_action="No",
default=None,
allow_abort=False,
session=None,
*args,
**kwargs,
):
"""
A helper function for asking a simple yes/no question. This will cause
the system to pause and wait for input from the player.
Args:
caller (Object): The entity being asked.
prompt (str): The yes/no question to ask. This takes an optional formatting
marker `{options}` which will be filled with 'Y/N', '[Y]/N' or
'Y/[N]' depending on the setting of `default`. If `allow_abort` is set,
then the 'A(bort)' option will also be available.
yes_action (callable or str): If a callable, this will be called
with `(caller, *args, **kwargs)` when the Yes-choice is made.
If a string, this string will be echoed back to the caller.
no_action (callable or str): If a callable, this will be called
with `(caller, *args, **kwargs)` when the No-choice is made.
If a string, this string will be echoed back to the caller.
default (str optional): This is what the user will get if they just press the
return key without giving any input. One of 'N', 'Y', 'A' or `None`
for no default (an explicit choice must be given). If 'A' (abort)
is given, `allow_abort` kwarg is ignored and assumed set.
allow_abort (bool, optional): If set, the 'A(bort)' option is available
(a third option meaning neither yes or no but just exits the prompt).
session (Session, optional): This allows to specify the
session to send the prompt to. It's usually only needed if `caller`
is an Account in multisession modes greater than 2. The session is
then updated by the command and is available (for example in
callbacks) through `caller.ndb._yes_no_question.session`.
*args: Additional arguments passed on into callables.
**kwargs: Additional keyword args passed on into callables.
Raises:
RuntimeError, FooError: If default and `allow_abort` clashes.
Example:
::
# just returning strings
ask_yes_no(caller, "Are you happy {options}?",
"you answered yes", "you answered no")
# trigger callables
ask_yes_no(caller, "Are you sad {options}?",
_callable_yes, _callable_no, allow_abort=True)
"""
def _callable_yes_txt(caller, *args, **kwargs):
yes_txt = kwargs["yes_txt"]
session = kwargs["caller_session"]
caller.msg(yes_txt, session=session)
def _callable_no_txt(caller, *args, **kwargs):
no_txt = kwargs["no_txt"]
session = kwargs["caller_session"]
caller.msg(no_txt, session=session)
if not callable(yes_action):
kwargs["yes_txt"] = str(yes_action)
yes_action = _callable_yes_txt
if not callable(no_action):
kwargs["no_txt"] = str(no_action)
no_action = _callable_no_txt
# prepare the prompt with options
options = "Y/N"
abort_txt = "/Abort" if allow_abort else ""
if default:
default = default.lower()
if default == "y":
options = "[Y]/N"
elif default == "n":
options = "Y/[N]"
elif default == "a":
allow_abort = True
abort_txt = "/[A]bort"
options += abort_txt
prompt = prompt.format(options=options)
caller.ndb._yes_no_question = _Prompt()
caller.ndb._yes_no_question.prompt = prompt
caller.ndb._yes_no_question.session = session
caller.ndb._yes_no_question.prompt = prompt
caller.ndb._yes_no_question.default = default
caller.ndb._yes_no_question.allow_abort = allow_abort
caller.ndb._yes_no_question.yes_callable = yes_action
caller.ndb._yes_no_question.no_callable = no_action
caller.ndb._yes_no_question.args = args
caller.ndb._yes_no_question.kwargs = kwargs
caller.cmdset.add(YesNoQuestionCmdSet)
caller.msg(prompt, session=session)
# -------------------------------------------------------------
#
# Menu generation from menu template string
#
# -------------------------------------------------------------
_RE_NODE = re.compile(r"##\s*?NODE\s+?(?P<nodename>\S[\S\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]+?)\)|\(\))", 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 = "#"
# Input/option/goto handler functions that allows for dynamically generated
# nodes read from the menu template.
def _process_callable(caller, goto, goto_callables, raw_string, current_nodename, kwargs):
"""
Central helper for parsing a goto-callable (`funcname(**kwargs)`) out of
the right-hand-side of the template options and map this to an actual
callable registered with the template generator. This involves parsing the
func-name and running literal-eval on its kwargs.
"""
match = _RE_CALLABLE.match(goto)
if match:
gotofunc = match.group("funcname")
gotokwargs = match.group("kwargs") or ""
if gotofunc in goto_callables:
for kwarg in gotokwargs.split(","):
if kwarg and "=" in kwarg:
key, value = [part.strip() for part in kwarg.split("=", 1)]
if key in (
"evmenu_goto",
"evmenu_gotomap",
"_current_nodename",
"evmenu_current_nodename",
"evmenu_goto_callables",
):
raise RuntimeError(
f"EvMenu template error: goto-callable '{goto}' uses a "
f"kwarg ({kwarg}) that is reserved for the EvMenu templating "
"system. Rename the kwarg."
)
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}
def _generated_goto_func(caller, raw_string, **kwargs):
"""
This rerouter handles normal direct goto func call matches.
key : ... -> goto_callable(**kwargs)
"""
goto = kwargs["evmenu_goto"]
goto_callables = kwargs["evmenu_goto_callables"]
current_nodename = kwargs["evmenu_current_nodename"]
return _process_callable(caller, goto, goto_callables, raw_string, current_nodename, kwargs)
def _generated_input_goto_func(caller, raw_string, **kwargs):
"""
This goto-func acts as a rerouter for >-type line parsing (by acting as the
_default option). The patterns discovered in the menu maps to different
*actual* goto-funcs. We map to those here.
>pattern: ... -> goto_callable
"""
gotomap = kwargs["evmenu_gotomap"]
goto_callables = kwargs["evmenu_goto_callables"]
current_nodename = kwargs["evmenu_current_nodename"]
raw_string = raw_string.strip("\n") # strip is necessary to catch empty return
# start with glob patterns
for pattern, goto in gotomap.items():
if fnmatch(raw_string.lower(), pattern):
return _process_callable(
caller, goto, goto_callables, raw_string, current_nodename, kwargs
)
# no glob pattern match; try regex
for pattern, goto in gotomap.items():
if pattern and re.match(pattern, raw_string.lower(), flags=re.I + re.M):
return _process_callable(
caller, goto, goto_callables, raw_string, current_nodename, kwargs
)
# no match, show error
raise EvMenuGotoAbortMessage(_HELP_NO_OPTION_MATCH)
def _generated_node(caller, raw_string, **kwargs):
"""
Every node in the templated menu will be this node, but with dynamically
changing text/options. It must be a global function like this because
otherwise we could not make the templated-menu persistent.
"""
text, options = caller.db._evmenu_template_contents[kwargs["_current_nodename"]]
return text, options