Added a functioning version of nested inline funcs. Still poor error handling and some edge cases are not handled.

This commit is contained in:
Griatch 2015-11-13 21:29:22 +01:00
parent 5d3df584bf
commit 641846e855
3 changed files with 279 additions and 1 deletions

View file

@ -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:

View file

@ -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 `<UNKNOWN: funcname(args)>` 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: "<UKNOWN: %s>" % (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+)\{") # unescaped $funcname( (start of function call)
_RE_TOKEN = re.compile(r"""(?<!\\)''(?P<singlequote>.*?)(?<!\\)\''| # unescaped '' (escapes all inside them)
(?<!\\)\"\"\"(?P<triplequote>.*?)(?<!\\)\"\"\"| # unescaped triple quote (escapes all inside them)
(?P<comma>(?<!\\)\,)| # unescaped , (argument lists) - this is thrown away
(?P<end>(?<!\\)\})| # unescaped ) (end of function call)
(?P<start>(?<!\\)\$\w+\{)| # unescaped $funcname( (start of function call)
(?P<escaped>\\'|\\"|\\\)|\\$\w+\{)| # escaped tokens should appear in text
(?P<rest>[\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])

View file

@ -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()