Merge with develop and fix merge conflicts

This commit is contained in:
Griatch 2018-10-01 20:58:16 +02:00
commit 72f4fedcbe
148 changed files with 20005 additions and 2718 deletions

View file

@ -43,13 +43,18 @@ command definition too) with function definitions:
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.
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._menutree`. This makes it a convenient place to store
@ -82,12 +87,14 @@ menu is immediately exited and the default "look" command is called.
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.
(possibly modified) kwarg to pass into the next node. If the callable returns
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
its number 1..N.
@ -158,16 +165,16 @@ evennia.utils.evmenu`.
"""
import random
import inspect
from builtins import object, range
from textwrap import dedent
from inspect import isfunction, getargspec
from django.conf import settings
from evennia import Command, CmdSet
from evennia.utils import logger
from evennia.utils.evtable import EvTable
from evennia.utils.ansi import strip_ansi
from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter
from evennia.utils.utils import mod_import, make_iter, pad, to_str, m_len, is_iter, dedent, crop
from evennia.commands import cmdhandler
# read from protocol NAWS later?
@ -182,7 +189,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
# i18n
from django.utils.translation import ugettext as _
_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is not implemented. Make another choice.")
_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is either not implemented or "
"caused an error. Make another choice.")
_ERR_GENERAL = _("Error in menu node '{nodename}'.")
_ERR_NO_OPTION_DESC = _("No description.")
_HELP_FULL = _("Commands: <menu option>, help, quit")
@ -315,7 +323,7 @@ class EvMenu(object):
auto_quit=True, auto_look=True, auto_help=True,
cmd_on_exit="look",
persistent=False, startnode_input="", session=None,
**kwargs):
debug=False, **kwargs):
"""
Initialize the menu tree and start the caller onto the first node.
@ -368,7 +376,8 @@ class EvMenu(object):
*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.
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,
@ -378,6 +387,10 @@ class EvMenu(object):
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:
any (any): All kwargs will become initialization variables on `caller.ndb._menutree`,
@ -401,7 +414,7 @@ class EvMenu(object):
"""
self._startnode = startnode
self._menutree = self._parse_menudata(menudata)
self._persistent = persistent
self._persistent = persistent if not debug else False
self._quitting = False
if startnode not in self._menutree:
@ -415,6 +428,7 @@ class EvMenu(object):
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
@ -573,6 +587,7 @@ class EvMenu(object):
except EvMenuError:
errmsg = _ERR_GENERAL.format(nodename=callback)
self.caller.msg(errmsg, self._session)
logger.log_trace()
raise
return ret
@ -606,9 +621,11 @@ class EvMenu(object):
nodetext, options = ret, None
except KeyError:
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
logger.log_trace()
raise EvMenuError
except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session)
logger.log_trace()
raise
# store options to make them easier to test
@ -665,45 +682,65 @@ class EvMenu(object):
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.
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.
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, execute = option_dict.get("goto", None), option_dict.get("exec", 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]
if execute and isinstance(execute, (tuple, list)):
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):
"""
Run a node by name, optionally dynamically generating that name first.
Args:
nodename (str or callable): Name of node or a callable
to be called as `function(caller, raw_string)` or `function(caller)`
to return the actual goto string.
to be called as `function(caller, raw_string, **kwargs)` or
`function(caller, **kwargs)` to return the actual goto string or
a ("nodename", kwargs) tuple.
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
Kwargs:
any: Extra arguments to goto callables.
"""
def _extract_goto_exec(option_dict):
"Helper: Get callables and their eventual kwargs"
goto_kwargs, exec_kwargs = {}, {}
goto, execute = option_dict.get("goto", None), option_dict.get("exec", 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]
if execute and isinstance(execute, (tuple, list)):
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
if callable(nodename):
# run the "goto" callable, if possible
@ -714,6 +751,9 @@ class EvMenu(object):
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
try:
# execute the found node, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
@ -746,12 +786,12 @@ class EvMenu(object):
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 = _extract_goto_exec(dic)
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 = _extract_goto_exec(dic)
goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
if keys:
display_options.append((keys[0], desc))
for key in keys:
@ -765,7 +805,7 @@ class EvMenu(object):
# handle the helptext
if helptext:
self.helptext = helptext
self.helptext = self.helptext_formatter(helptext)
elif options:
self.helptext = _HELP_FULL if self.auto_quit else _HELP_NO_QUIT
else:
@ -814,6 +854,51 @@ class EvMenu(object):
if self.cmd_on_exit is not None:
self.cmd_on_exit(self.caller, self)
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.caller.msg(debugtxt)
def parse_input(self, raw_string):
"""
Parses the incoming string from the menu user.
@ -840,16 +925,14 @@ class EvMenu(object):
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, goto_kwargs, execfunc, exec_kwargs = self.default
self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs)
else:
self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session)
if not (self.options or self.default):
# no options - we are at the end of the menu.
self.close_menu()
def display_nodetext(self):
self.caller.msg(self.nodetext, session=self._session)
@ -869,7 +952,20 @@ class EvMenu(object):
nodetext (str): The formatted node text.
"""
return dedent(nodetext).strip()
return dedent(nodetext.strip('\n'), baseline_index=0).rstrip()
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()
def options_formatter(self, optionlist):
"""
@ -949,14 +1045,188 @@ class EvMenu(object):
node (str): The formatted node to display.
"""
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 = max(options_width_max, nodetext_width_max)
total_width = min(screen_width, max(options_width_max, nodetext_width_max))
separator1 = "_" * total_width + "\n\n" if nodetext_width_max else ""
separator2 = "\n" + "_" * 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)
#
# -----------------------------------------------------------
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) where
menuchoice is the chosen option as a string. Should return 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:
@list_node(['foo', 'bar'], select)
def node_index(caller):
text = "describing the list"
return text, []
Notes:
All normal `goto` or `exec` 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.get("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(getargspec(select).keywords):
return select(caller, selection, available_choices=available_choices)
else:
return select(caller, selection)
except Exception:
logger.log_trace()
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)
# this means the previous node will be re-run with these same kwargs
return None
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})} 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(getargspec(func).keywords)
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 ("exec" in eopt and "exec") 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