From 641846e8554d06004b91c959a4bfd21e2f16526a Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 13 Nov 2015 21:29:22 +0100 Subject: [PATCH] Added a functioning version of nested inline funcs. Still poor error handling and some edge cases are not handled. --- evennia/server/serversession.py | 2 + evennia/utils/nested_inlinefuncs.py | 237 ++++++++++++++++++++++++++++ evennia/utils/utils.py | 41 ++++- 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 evennia/utils/nested_inlinefuncs.py diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 73dbbd17da..ac89722d11 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -16,6 +16,7 @@ from django.conf import settings from evennia.comms.models import ChannelDB from evennia.utils import logger from evennia.utils.inlinefunc import parse_inlinefunc +from evennia.utils.nested_inlinefuncs import parse_inlinefunc as parse_nested_inlinefunc from evennia.utils.utils import make_iter, lazy_property from evennia.commands.cmdhandler import cmdhandler from evennia.commands.cmdsethandler import CmdSetHandler @@ -389,6 +390,7 @@ class ServerSession(Session): text = text if text else "" if _INLINEFUNC_ENABLED and not "raw" in kwargs: text = parse_inlinefunc(text, strip="strip_inlinefunc" in kwargs, session=self) + text = parse_nested_inlinefunc(text, strip="strip_inlinefunc" in kwargs, session=self) if self.screenreader: global _ANSI if not _ANSI: diff --git a/evennia/utils/nested_inlinefuncs.py b/evennia/utils/nested_inlinefuncs.py new file mode 100644 index 0000000000..5cda53c973 --- /dev/null +++ b/evennia/utils/nested_inlinefuncs.py @@ -0,0 +1,237 @@ +""" +Inline functions (nested form). + +This parser accepts nested inlinefunctions on the form + +``` +$funcname(arg, arg, ...) +``` +where any arg can be another $funcname() call. + +Each token starts with "$funcname(" where there must be no space +between the $funcname and (. It ends with a matched ending parentesis. +")". + +Inside the inlinefunc definition, one can use `\` to escape. Enclosing +text in `"` or `'` will also escape them - use this to include the +right parenthesis or commas in the argument, for example. + +The inlinefuncs, defined as global-level functions in modules defined +by `settings.INLINEFUNC_MODULES`. They are identified by their +function name (and ignored if this name starts with `_`. They should +be on the following form: + +```python +def funcname (*args, **kwargs): + # ... +``` + +Here, the arguments given to `$funcname(arg1,arg2)` will appear as the +`*args` tuple. The `**kwargs` is used only by Evennia to make details +about the caller available to the function. The kwarg passed to all +functions is `session`, the Sessionobject for the object seeing the +string. This may be `None` if the string is sent to a non-puppetable +object. + +There are two reserved function names: +- "nomatch": This is called if the user uses a functionname that is + not registered. The nomatch function will get the name of the + not-found function as its first argument followed by the normal + arguments to the given function. If not defined the default effect is + to print `` to replace the unknown function. +- "stackfull": This is called when the maximum nested function stack is reached. + When this happens, the original parsed string is returned and the result of + the `stackfull` inlinefunc is appended to the end. By default this is an + error message. + +""" + +import re +from django.conf import settings +from evennia.utils import utils + +# we specify a default nomatch function to use if no matching func was +# found. This will be overloaded by any nomatch function defined in +# the imported modules. +_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "" % (args[0]), + "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"} + + +# load custom inline func modules. +for module in utils.make_iter(settings.INLINEFUNC_MODULES): + _INLINE_FUNCS.update(utils.all_from_module(module)) +# remove +_INLINE_FUNCS.pop("inline_func_parse", None) + + +# The stack size is a security measure. Set to <=0 to disable. +try: + _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE +except AttributeError: + _STACK_MAXSIZE = 20 + +# regex definitions + +_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\{)| # escaped tokens should appear in text + (?P[\w\s.-\/#!%\^&\*;:=\-_`~()\[\]]+) # everything else should also be included + """, + re.UNICODE + re.IGNORECASE + re.VERBOSE + re.DOTALL) + + +# Cache for function lookups. +_PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000) + +class ParseStack(list): + """ + Custom stack that always concatenates strings together when the + strings are added next to one another. Tuples are stored + separately and None is used to mark that a string should be broken + up into a new chunk. Below is the resulting stack after separately + appending 3 strings, None, 2 strings, a tuple and finally 2 + strings: + + [string + string + string, + None + string + string, + tuple, + string + string] + + """ + def __init__(self, *args, **kwargs): + super(ParseStack, self).__init__(*args, **kwargs) + # always start stack with the empty string + list.append(self, "") + # indicates if the top of the stack is a string or not + self._string_last = True + + def append(self, item): + """ + The stack will merge strings, add other things as normal + """ + if isinstance(item, basestring): + if self._string_last: + self[-1] += item + else: + list.append(self, item) + self._string_last = True + else: + # everything else is added as normal + list.append(self, item) + self._string_last = False + + +class InlinefuncError(RuntimeError): + pass + +def parse_inlinefunc(string, strip=False, **kwargs): + """ + Parse the incoming string. + + Args: + string (str): The incoming string to parse. + strip (bool, optional): Whether to strip function calls rather than + execute them. + kwargs (any, optional): This will be passed on to all found inlinefuncs as + part of their call signature. This is to be called by Evennia or the + coder and is used to make various game states available, such as + the session of the user triggering the parse, character etc. + + """ + global _PARSING_CACHE + if string in _PARSING_CACHE: + # stack is already cached + stack = _PARSING_CACHE[string] + else: + # not a cached string. + if not _RE_STARTTOKEN.search(string): + # if there are no unescaped start tokens at all, return immediately. + return string + + # build a new cache entry + stack = ParseStack() + ncallable = 0 + for match in _RE_TOKEN.finditer(string): + gdict = match.groupdict() + print "gdict:", gdict + if gdict["singlequote"]: + stack.append(gdict["singlequote"]) + elif gdict["triplequote"]: + stack.append(gdict["triplequote"]) + elif gdict["end"]: + if ncallable <= 0: + stack.append(")") + continue + args = [] + while stack: + operation = stack.pop() + if callable(operation): + if not strip: + stack.append((operation, [arg for arg in reversed(args)])) + ncallable -= 1 + break + else: + args.append(operation) + elif gdict["start"]: + funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1) + try: + # try to fetch the matching inlinefunc from storage + stack.append(_INLINE_FUNCS[funcname]) + except KeyError: + stack.append(_INLINE_FUNCS["nomatch"]) + stack.append(funcname) + ncallable += 1 + elif gdict["escaped"]: + # escaped tokens + token = gdict["escaped"].lstrip("\\") + stack.append(token) + elif gdict["comma"]: + if ncallable > 0: + # commas outside strings and inside a callable are + # used to mark argument separation - we use None + # in the stack to indicate such a separation. + stack.append(None) + else: + # no callable active - just a string + stack.append(",") + else: + # the rest + stack.append(gdict["rest"]) + + if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack): + # if stack is larger than limit, throw away parsing + return string + gdict["stackfull"](*args, **kwargs) + else: + # cache the result + _PARSING_CACHE[string] = stack + + # run the stack recursively + def _run_stack(item): + if isinstance(item, tuple): + if strip: + return "" + else: + func, arglist = item + args = [""] + for arg in arglist: + if arg is None: + # an argument-separating comma - start a new arg + args.append("") + else: + # all other args should merge into one string + args[-1] += _run_stack(arg) + # execute the inlinefunc at this point or strip it. + return "" if strip else utils.to_str(func(*args, **kwargs)) + else: + return item + + # execute the stack from the cache + print "_PARSING_CACHE[string]:", _PARSING_CACHE[string] + return "".join(_run_stack(item) for item in _PARSING_CACHE[string]) + diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 4fc10195aa..39776786cd 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -19,7 +19,7 @@ import textwrap import random from importlib import import_module from inspect import ismodule, trace -from collections import defaultdict +from collections import defaultdict, OrderedDict from twisted.internet import threads, defer, reactor from django.conf import settings from django.utils import timezone @@ -1537,3 +1537,42 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): if error and not quiet: caller.msg(error.strip()) return matches + + +class LimitedSizeOrderedDict(OrderedDict): + """ + This dictionary subclass is both ordered and limited to a maximum + number of elements. Its main use is to hold a cache that can never + grow out of bounds. + + """ + def __init__(self, *args, **kwargs): + """ + Limited-size ordered dict. + + Kwargs: + size_limit (int): Use this to limit the number of elements + alloweds to be in this list. By default the overshooting elements + will be removed in FIFO order. + fifo (bool, optional): Defaults to `True`. Remove overshooting elements + in FIFO order. If `False`, remove in FILO order. + + """ + super(LimitedSizeOrderedDict, self).__init__() + self.size_limit = kwargs.get("size_limit", None) + self.filo = not kwargs.get("fifo", True) # FIFO inverse of FILO + self._check_size() + + def _check_size(self): + filo = self.filo + if self.size_limit is not None: + while self.size_limit < len(self): + self.popitem(last=filo) + + def __setitem__(self, key, value): + super(LimitedSizeOrderedDict, self).__setitem__(key, value) + self._check_size() + + def update(self, *args, **kwargs): + super(LimitedSizeOrderedDict, self).update(*args, **kwargs) + self._check_size()