Fix EvMenu failure to stop if returning options as an empty dict. Resolve #2981

This commit is contained in:
Griatch 2022-11-13 19:48:50 +01:00
parent 804e44c3f0
commit 7c08f77aa0
4 changed files with 123 additions and 202 deletions

View file

@ -268,7 +268,7 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS):
else: else:
caller.msg( caller.msg(
"Running Batch-command processor - Automatic mode " "Running Batch-command processor - Automatic mode "
f"for {python_path} (this might take some time) ..." f"for {python_path} (this might take some time) ..."
) )
# run in-process (might block) # run in-process (might block)

View file

@ -21,14 +21,13 @@ of the screen is done by the unlogged-in "look" command.
""" """
from django.conf import settings from django.conf import settings
from evennia import utils from evennia import utils
CONNECTION_SCREEN = """ CONNECTION_SCREEN = """
|b==============================================================|n |b==============================================================|n
Welcome to |g{}|n, version {}! Welcome to |g{}|n, version {}!
Enter |wh|nelp for more info. |wlook|n will re-show this screen. Enter |wh|nelp for more info.
|b==============================================================|n""".format( |b==============================================================|n""".format(
settings.SERVERNAME, utils.get_evennia_version("short") settings.SERVERNAME, utils.get_evennia_version("short")
) )

View file

@ -10,6 +10,7 @@ To install, add this line to the settings file (`mygame/server/conf/settings.py`
CMDSET_UNLOGGEDIN = "evennia.contrib.base_systems.menu_login.UnloggedinCmdSet" CMDSET_UNLOGGEDIN = "evennia.contrib.base_systems.menu_login.UnloggedinCmdSet"
Reload the server and the new connection method will be active. Note that you must Reload the server and the new connection method will be active. Note that you must
independently change the connection screen to match this login style, by editing independently change the connection screen to match this login style, by editing
`mygame/server/conf/connection_screens.py`. `mygame/server/conf/connection_screens.py`.
@ -20,7 +21,6 @@ called automatically when a new user connects.
""" """
from django.conf import settings from django.conf import settings
from evennia import CmdSet, Command, syscmdkeys from evennia import CmdSet, Command, syscmdkeys
from evennia.utils.evmenu import EvMenu from evennia.utils.evmenu import EvMenu
from evennia.utils.utils import callables_from_module, class_from_module, random_string_from_module from evennia.utils.utils import callables_from_module, class_from_module, random_string_from_module
@ -57,9 +57,9 @@ def node_enter_username(caller, raw_text, **kwargs):
""" """
'Goto-callable', set up to be called from the _default option below. 'Goto-callable', set up to be called from the _default option below.
Called when user enters a username string. Check if this username already exists and set the flag Called when user enters a username string. Check if this username already exists and set the
'new_user' if not. Will also directly login if the username is 'guest' flag 'new_user' if not. Will also directly login if the username is 'guest' and
and GUEST_ENABLED is True. GUEST_ENABLED is True.
The return from this goto-callable determines which node we go to next The return from this goto-callable determines which node we go to next
and what kwarg it will be called with. and what kwarg it will be called with.
@ -167,7 +167,7 @@ def node_enter_password(caller, raw_string, **kwargs):
# Attempting to fix password # Attempting to fix password
text = "Enter a new password:" text = "Enter a new password:"
else: else:
text = "Creating a new account |c{}|n. " "Enter a password (empty to abort):".format( text = "Creating a new account |c{}|n. Enter a password (empty to abort):".format(
username username
) )
else: else:
@ -199,15 +199,24 @@ def node_quit_or_login(caller, raw_text, **kwargs):
# EvMenu helper function # EvMenu helper function
def _node_formatter(nodetext, optionstext, caller=None): class MenuLoginEvMenu(EvMenu):
"""Do not display the options, only the text. """
Version of EvMenu that does not display any of its options.
This function is used by EvMenu to format the text of nodes. The menu login
is just a series of prompts so we disable all automatic display decoration
and let the nodes handle everything on their own.
""" """
return nodetext
def node_formatter(self, nodetext, optionstext):
return nodetext
def options_formatter(self, optionlist):
"""Do not display the options, only the text.
This function is used by EvMenu to format the text of nodes. The menu login
is just a series of prompts so we disable all automatic display decoration
and let the nodes handle everything on their own.
"""
return ""
# Commands and CmdSets # Commands and CmdSets
@ -240,12 +249,17 @@ class CmdUnloggedinLook(Command):
Run the menu using the nodes in this module. Run the menu using the nodes in this module.
""" """
EvMenu( menu_nodes = {
"node_enter_username": node_enter_username,
"node_enter_password": node_enter_password,
"node_quit_or_login": node_quit_or_login,
}
MenuLoginEvMenu(
self.caller, self.caller,
"evennia.contrib.base_systems.menu_login.menu_login", menu_nodes,
startnode="node_enter_username", startnode="node_enter_username",
auto_look=False, auto_look=False,
auto_quit=False, auto_quit=False,
cmd_on_exit=None, cmd_on_exit=None,
node_formatter=_node_formatter,
) )

View file

@ -89,12 +89,6 @@ menu is immediately exited and the default "look" command is called.
string is the name of the next node to go to and the dict is the new, 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 (possibly modified) kwarg to pass into the next node. If the callable returns
None or the empty string, the current node will be revisited. None or the empty string, the current node will be revisited.
- `exec` (str, callable or tuple, optional): This takes the same input as `goto` above
and runs before it. If given a node name, the node will be executed but will not
be considered the next node. If node/callback returns str or (str, dict), these will
replace the `goto` step (`goto` callbacks will not fire), with the string being the
next node name and the optional dict acting as the kwargs-input for the next node.
If an exec callable returns the empty string (only), the current node is re-run.
If `key` is not given, the option will automatically be identified by If `key` is not given, the option will automatically be identified by
its number 1..N. its number 1..N.
@ -111,7 +105,6 @@ Example:
options = ({"key": "testing", options = ({"key": "testing",
"desc": "Select this to go to node 2", "desc": "Select this to go to node 2",
"goto": ("node2", {"foo": "bar"}), "goto": ("node2", {"foo": "bar"}),
"exec": "callback1"},
{"desc": "Go to node 3.", {"desc": "Go to node 3.",
"goto": "node3"}) "goto": "node3"})
return text, options return text, options
@ -280,7 +273,6 @@ from django.conf import settings
# i18n # i18n
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from evennia import CmdSet, Command from evennia import CmdSet, Command
from evennia.commands import cmdhandler from evennia.commands import cmdhandler
from evennia.utils import logger from evennia.utils import logger
@ -357,7 +349,8 @@ class EvMenuGotoAbortMessage(RuntimeError):
class CmdEvMenuNode(Command): class CmdEvMenuNode(Command):
""" """
Menu options. Command to handle all user input targeted at the menu while the menu is active.
""" """
key = _CMD_NOINPUT key = _CMD_NOINPUT
@ -638,7 +631,7 @@ class EvMenu:
# store ourself on the object # store ourself on the object
self.caller.ndb._evmenu = self self.caller.ndb._evmenu = self
# DEPRECATED - for backwards-compatibility # DEPRECATED - for backwards-compatibility. Use `.ndb._evmenu` instead
self.caller.ndb._menutree = self self.caller.ndb._menutree = self
if persistent: if persistent:
@ -778,7 +771,7 @@ class EvMenu:
def _execute_node(self, nodename, raw_string, **kwargs): def _execute_node(self, nodename, raw_string, **kwargs):
""" """
Execute a node. Execute a node (-function) and get its returns.
Args: Args:
nodename (str): Name of node. nodename (str): Name of node.
@ -814,87 +807,12 @@ class EvMenu:
raise raise
# store options to make them easier to test # store options to make them easier to test
self.test_options = options
self.test_nodetext = nodetext self.test_nodetext = nodetext
self.test_options = options
return nodetext, options return nodetext, options
def msg(self, txt): def _extract_goto(self, nodename, option_dict):
"""
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)
def run_exec(self, nodename, raw_string, **kwargs):
"""
NOTE: This is deprecated. Use `goto` directly instead.
Run a function or node as a callback (with the 'exec' option key).
Args:
nodename (callable or str): A callable to run as
`callable(caller, raw_string)`, or the Name of an existing
node to run as a callable. This may or may not return
a string.
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
kwargs (any): These are optional kwargs passed into goto
Returns:
new_goto (str or None): A replacement goto location string or
None (no replacement).
Notes:
Relying on exec callbacks to set the goto location is
very powerful but will easily lead to spaghetti structure and
hard-to-trace paths through the menu logic. So be careful with
relying on this.
"""
try:
if callable(nodename):
# this is a direct callable - execute it directly
ret = self._safe_call(nodename, raw_string, **kwargs)
if isinstance(ret, (tuple, list)):
if not len(ret) > 1 or not isinstance(ret[1], dict):
raise EvMenuError(
"exec callable must return either None, str or (str, dict)"
)
ret, kwargs = ret[:2]
else:
# nodename is a string; lookup as node and run as node in-place (don't goto it)
# execute the node
ret = self._execute_node(nodename, raw_string, **kwargs)
if isinstance(ret, (tuple, list)):
if not len(ret) > 1 and ret[1] and not isinstance(ret[1], dict):
raise EvMenuError("exec node must return either None, str or (str, dict)")
ret, kwargs = ret[:2]
except EvMenuError as err:
errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string.rstrip(), err)
self.msg("|r%s|n" % errmsg)
logger.log_trace(errmsg)
return
if isinstance(ret, str):
# only return a value if a string (a goto target), ignore all other returns
if not ret:
# an empty string - rerun the same node
return self.nodename
return ret, kwargs
return None
def extract_goto_exec(self, nodename, option_dict):
""" """
Helper: Get callables and their eventual kwargs. Helper: Get callables and their eventual kwargs.
@ -905,12 +823,10 @@ class EvMenu:
Returns: Returns:
goto (str, callable or None): The goto directive in the option. 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 (dict): Kwargs for `goto` if the former is callable, otherwise empty.
execute (callable or None): Executable given by the `exec` directive.
exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty.
""" """
goto_kwargs, exec_kwargs = {}, {} goto_kwargs = {}
goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) goto = option_dict.get("goto", None)
if goto and isinstance(goto, (tuple, list)): if goto and isinstance(goto, (tuple, list)):
if len(goto) > 1: if len(goto) > 1:
goto, goto_kwargs = goto[:2] # ignore any extra arguments goto, goto_kwargs = goto[:2] # ignore any extra arguments
@ -923,29 +839,17 @@ class EvMenu:
) )
else: else:
goto = goto[0] goto = goto[0]
if execute and isinstance(execute, (tuple, list)): return goto, goto_kwargs
if len(execute) > 1:
execute, exec_kwargs = execute[:2] # ignore any extra arguments
if not hasattr(exec_kwargs, "__getitem__"):
# not a dict-like structure
raise EvMenuError(
"EvMenu node {}: exec kwargs is not a dict: {}".format(
nodename, goto_kwargs
)
)
else:
execute = execute[0]
return goto, goto_kwargs, execute, exec_kwargs
def goto(self, nodename, raw_string, **kwargs): def goto(self, nodename_or_callable, raw_string, **kwargs):
""" """
Run a node by name, optionally dynamically generating that name first. Run a node by name, optionally dynamically generating that name first.
Args: Args:
nodename (str or callable): Name of node or a callable nodename_or_callable (str or callable): Name of node or a callable
to be called as `function(caller, raw_string, **kwargs)` or to be called as `function(caller, raw_string, **kwargs)` or
`function(caller, **kwargs)` to return the actual goto string or `function(caller, **kwargs)`. This callable must return the node-name (str)
a ("nodename", kwargs) tuple. pointing to the next node.
raw_string (str): The raw default string entered on the raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an previous node (only used if the node accepts it as an
argument) argument)
@ -953,10 +857,10 @@ class EvMenu:
""" """
if callable(nodename): inp_nodename = nodename_or_callable
# run the "goto" callable, if possible if callable(nodename_or_callable):
inp_nodename = nodename # run the "goto" callable to get the next node to go to
nodename = self._safe_call(nodename, raw_string, **kwargs) nodename = self._safe_call(nodename_or_callable, raw_string, **kwargs)
if isinstance(nodename, (tuple, list)): if isinstance(nodename, (tuple, list)):
if not len(nodename) > 1 or not isinstance(nodename[1], dict): if not len(nodename) > 1 or not isinstance(nodename[1], dict):
raise EvMenuError( raise EvMenuError(
@ -966,8 +870,14 @@ class EvMenu:
if not nodename: if not nodename:
# no nodename return. Re-run current node # no nodename return. Re-run current node
nodename = self.nodename 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: try:
# execute the found node, make use of the returns. # execute the found nodename, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string, **kwargs) nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
except EvMenuError: except EvMenuError:
return return
@ -978,47 +888,13 @@ class EvMenu:
) )
# validation of the node return values # validation of the node return values
# if the nodetext is a list/tuple, the second set is the help text.
helptext = "" helptext = ""
if is_iter(nodetext): if is_iter(nodetext):
if len(nodetext) > 1: nodetext, *helptext = nodetext
nodetext, helptext = nodetext[:2] helptext = helptext[0] if helptext else ""
else:
nodetext = nodetext[0]
nodetext = "" if nodetext is None else str(nodetext) nodetext = "" if nodetext is None else str(nodetext)
options = [options] if isinstance(options, dict) else options
# this will be displayed in the given order
display_options = []
# this is used for lookup
self.options = {}
self.default = None
if options:
for inum, dic in enumerate(options):
# fix up the option dicts
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, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
self.default = (goto, goto_kwargs, execute, exec_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, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
if keys:
display_options.append((keys[0], desc))
for key in keys:
if goto or execute:
self.options[strip_ansi(key).strip().lower()] = (
goto,
goto_kwargs,
execute,
exec_kwargs,
)
self.nodetext = self._format_node(nodetext, display_options)
self.node_kwargs = kwargs
self.nodename = nodename
# handle the helptext # handle the helptext
if helptext: if helptext:
@ -1028,33 +904,46 @@ class EvMenu:
else: else:
self.helptext = _HELP_NO_OPTIONS if self.auto_quit else _HELP_NO_OPTIONS_NO_QUIT 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() self.display_nodetext()
# close menu if we have no more options to process
if not options: if not options:
self.close_menu() self.close_menu()
def run_exec_then_goto(self, runexec, goto, raw_string, runexec_kwargs=None, goto_kwargs=None):
"""
Call 'exec' callback and goto (which may also be a callable) in sequence.
Args:
runexec (callable or str): Callback to run before goto. If
the callback returns a string, this is used to replace
the `goto` string/callable before being passed into the goto handler.
goto (str): The target node to go to next (may be replaced
by `runexec`)..
raw_string (str): The original user input.
runexec_kwargs (dict, optional): Optional kwargs for runexec.
goto_kwargs (dict, optional): Optional kwargs for goto.
"""
if runexec:
# replace goto only if callback returns
goto, goto_kwargs = self.run_exec(
runexec, raw_string, **(runexec_kwargs if runexec_kwargs else {})
) or (goto, goto_kwargs)
if goto:
self.goto(goto, raw_string, **(goto_kwargs if goto_kwargs else {}))
def close_menu(self): def close_menu(self):
""" """
Shutdown menu; occurs when reaching the end node or using the quit command. Shutdown menu; occurs when reaching the end node or using the quit command.
@ -1131,9 +1020,27 @@ class EvMenu:
) )
self.msg(debugtxt) self.msg(debugtxt)
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)
def parse_input(self, raw_string): def parse_input(self, raw_string):
""" """
Parses the incoming string from the menu user. Parses the incoming string from the menu user. This is the entry-point for all input
into the menu.
Args: Args:
raw_string (str): The incoming, unmodified string raw_string (str): The incoming, unmodified string
@ -1144,14 +1051,15 @@ class EvMenu:
should also report errors directly to the user. should also report errors directly to the user.
""" """
# this is the input cmd given to the menu
cmd = strip_ansi(raw_string.strip().lower()) cmd = strip_ansi(raw_string.strip().lower())
try: try:
if self.options and cmd in self.options: if self.options and cmd in self.options:
# this will take precedence over the default commands # we chose one of the available options; this
# below # will take precedence over the default commands
goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd] goto_node, goto_kwargs = self.options[cmd]
self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs) self.goto(goto_node, raw_string, **(goto_kwargs or {}))
elif self.auto_look and cmd in ("look", "l"): elif self.auto_look and cmd in ("look", "l"):
self.display_nodetext() self.display_nodetext()
elif self.auto_help and cmd in ("help", "h"): elif self.auto_help and cmd in ("help", "h"):
@ -1161,8 +1069,8 @@ class EvMenu:
elif self.debug_mode and cmd.startswith("menudebug"): elif self.debug_mode and cmd.startswith("menudebug"):
self.print_debug_info(cmd[9:].strip()) self.print_debug_info(cmd[9:].strip())
elif self.default: elif self.default:
goto, goto_kwargs, execfunc, exec_kwargs = self.default goto_node, goto_kwargs = self.default
self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs) self.goto(goto_node, raw_string, **(goto_kwargs or {}))
else: else:
self.msg(_HELP_NO_OPTION_MATCH) self.msg(_HELP_NO_OPTION_MATCH)
except EvMenuGotoAbortMessage as err: except EvMenuGotoAbortMessage as err:
@ -1354,7 +1262,7 @@ def list_node(option_generator, select=None, pagesize=10):
``` ```
Notes: Notes:
All normal `goto` or `exec` callables returned from the decorated nodes All normal `goto` callables returned from the decorated nodes
will, if they accept `**kwargs`, get a new kwarg 'available_choices' will, if they accept `**kwargs`, get a new kwarg 'available_choices'
injected. These are the ordered list of named options (descs) visible injected. These are the ordered list of named options (descs) visible
on the current node page. on the current node page.
@ -1486,7 +1394,7 @@ def list_node(option_generator, select=None, pagesize=10):
if isinstance(decorated_options, dict): if isinstance(decorated_options, dict):
decorated_options = [decorated_options] decorated_options = [decorated_options]
for eopt in decorated_options: for eopt in decorated_options:
cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None cback = ("goto" in eopt and "goto") or None
if cback: if cback:
signature = eopt[cback] signature = eopt[cback]
if callable(signature): if callable(signature):