Many more tests, debugging of protfuncs/inlinefuncs

This commit is contained in:
Griatch 2018-06-17 23:42:53 +02:00
parent 646b73e872
commit 721cdb5ae0
5 changed files with 267 additions and 92 deletions

View file

@ -23,8 +23,8 @@ are specified as functions
where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia:
- session (Session): The Session of the entity spawning using this prototype.
- prototype_key (str): The currently spawning prototype-key.
- prototype (dict): The dict this protfunc is a part of.
- current_key (str): The active key this value belongs to in the prototype.
- testing (bool): This is set if this function is called as part of the prototype validation; if
set, the protfunc should take care not to perform any persistent actions, such as operate on
objects or add things to the database.
@ -38,68 +38,10 @@ prototype key (this value must be possible to serialize in an Attribute).
from ast import literal_eval
from random import randint as base_randint, random as base_random
from django.conf import settings
from evennia.utils import inlinefuncs
from evennia.utils.utils import callables_from_module
from evennia.utils.utils import justify as base_justify, is_iter
from evennia.utils import search
from evennia.utils.utils import justify as base_justify, is_iter, to_str
_PROTLIB = None
_PROT_FUNCS = {}
for mod in settings.PROT_FUNC_MODULES:
try:
callables = callables_from_module(mod)
if mod == __name__:
callables.pop("protfunc_parser", None)
_PROT_FUNCS.update(callables)
except ImportError:
pass
def protfunc_parser(value, available_functions=None, **kwargs):
"""
Parse a prototype value string for a protfunc and process it.
Available protfuncs are specified as callables in one of the modules of
`settings.PROTFUNC_MODULES`, or specified on the command line.
Args:
value (any): The value to test for a parseable protfunc. Only strings will be parsed for
protfuncs, all other types are returned as-is.
available_functions (dict, optional): Mapping of name:protfunction to use for this parsing.
Kwargs:
any (any): Passed on to the inlinefunc.
Returns:
any (any): A structure to replace the string on the prototype level. If this is a
callable or a (callable, (args,)) structure, it will be executed as if one had supplied
it to the prototype directly. This structure is also passed through literal_eval so one
can get actual Python primitives out of it (not just strings). It will also identify
eventual object #dbrefs in the output from the protfunc.
"""
global _PROTLIB
if not _PROTLIB:
from evennia.prototypes import prototypes as _PROTLIB
if not isinstance(value, basestring):
return value
available_functions = _PROT_FUNCS if available_functions is None else available_functions
result = inlinefuncs.parse_inlinefunc(value, available_funcs=available_functions, **kwargs)
# at this point we have a string where all procfuncs were parsed
try:
result = literal_eval(result)
except ValueError:
# this is due to the string not being valid for literal_eval - keep it a string
pass
result = _PROTLIB.value_to_obj_or_any(result)
try:
return literal_eval(result)
except ValueError:
return result
# default protfuncs
@ -180,7 +122,7 @@ def protkey(*args, **kwargs):
"""
if args:
prototype = kwargs['prototype']
return prototype[args[0]]
return prototype[args[0].strip()]
def add(*args, **kwargs):
@ -193,7 +135,16 @@ def add(*args, **kwargs):
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
return literal_eval(val1) + literal_eval(val2)
# try to convert to python structures, otherwise, keep as strings
try:
val1 = literal_eval(val1.strip())
except Exception:
pass
try:
val2 = literal_eval(val2.strip())
except Exception:
pass
return val1 + val2
raise ValueError("$add requires two arguments.")
@ -207,11 +158,20 @@ def sub(*args, **kwargs):
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
return literal_eval(val1) - literal_eval(val2)
# try to convert to python structures, otherwise, keep as strings
try:
val1 = literal_eval(val1.strip())
except Exception:
pass
try:
val2 = literal_eval(val2.strip())
except Exception:
pass
return val1 - val2
raise ValueError("$sub requires two arguments.")
def mul(*args, **kwargs):
def mult(*args, **kwargs):
"""
Usage: $mul(val1, val2)
Returns the value of val1 * val2. The values must be
@ -221,7 +181,16 @@ def mul(*args, **kwargs):
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
return literal_eval(val1) * literal_eval(val2)
# try to convert to python structures, otherwise, keep as strings
try:
val1 = literal_eval(val1.strip())
except Exception:
pass
try:
val2 = literal_eval(val2.strip())
except Exception:
pass
return val1 * val2
raise ValueError("$mul requires two arguments.")
@ -234,10 +203,33 @@ def div(*args, **kwargs):
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
return literal_eval(val1) / float(literal_eval(val2))
# try to convert to python structures, otherwise, keep as strings
try:
val1 = literal_eval(val1.strip())
except Exception:
pass
try:
val2 = literal_eval(val2.strip())
except Exception:
pass
return val1 / float(val2)
raise ValueError("$mult requires two arguments.")
def toint(*args, **kwargs):
"""
Usage: $toint(<number>)
Returns <number> as an integer.
"""
if args:
val = args[0]
try:
return int(literal_eval(val.strip()))
except ValueError:
return val
raise ValueError("$toint requires one argument.")
def eval(*args, **kwargs):
"""
Usage $eval(<expression>)
@ -247,16 +239,79 @@ def eval(*args, **kwargs):
- those will then be evaluated *after* $eval.
"""
string = args[0] if args else ''
global _PROTLIB
if not _PROTLIB:
from evennia.prototypes import prototypes as _PROTLIB
string = ",".join(args)
struct = literal_eval(string)
if isinstance(struct, basestring):
# we must shield the string, otherwise it will be merged as a string and future
# literal_evals will pick up e.g. '2' as something that should be converted to a number
struct = '"{}"'.format(struct)
def _recursive_parse(val):
# an extra round of recursive parsing, to catch any escaped $$profuncs
# an extra round of recursive parsing after literal_eval, to catch any
# escaped $$profuncs. This is commonly useful for object references.
if is_iter(val):
stype = type(val)
if stype == dict:
return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()}
return stype((_recursive_parse(v) for v in val))
return protfunc_parser(val)
return _PROTLIB.protfunc_parser(val)
return _recursive_parse(struct)
def _obj_search(return_list=False, *args, **kwargs):
"Helper function to search for an object"
query = "".join(args)
session = kwargs.get("session", None)
if not session:
raise ValueError("$obj called by Evennia without Session. This is not supported.")
account = session.account
if not account:
raise ValueError("$obj requires a logged-in account session.")
targets = search.search_object(query)
if return_list:
retlist = []
for target in targets:
if target.access(account, target, 'control'):
retlist.append(target)
return retlist
else:
# single-match
if not targets:
raise ValueError("$obj: Query '{}' gave no matches.".format(query))
if targets.count() > 1:
raise ValueError("$obj: Query '{query}' gave {nmatches} matches. Limit your "
"query or use $objlist instead.".format(
query=query, nmatches=targets.count()))
target = target[0]
if not target.access(account, target, 'control'):
raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - "
"Account {account} does not have 'control' access.".format(
target=target.key, dbref=target.id, account=account))
return target
def obj(*args, **kwargs):
"""
Usage $obj(<query>)
Returns one Object searched globally by key, alias or #dbref. Error if more than one.
"""
return _obj_search(*args, **kwargs)
def objlist(*args, **kwargs):
"""
Usage $objlist(<query>)
Returns list with one or more Objects searched globally by key, alias or #dbref.
"""
return _obj_search(return_list=True, *args, **kwargs)

View file

@ -5,17 +5,17 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos
"""
from ast import literal_eval
from django.conf import settings
from evennia.scripts.scripts import DefaultScript
from evennia.objects.models import ObjectDB
from evennia.utils.create import create_script
from evennia.utils.utils import (
all_from_module, make_iter, is_iter, dbid_to_obj)
all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module)
from evennia.locks.lockhandler import validate_lockstring, check_lockstring
from evennia.utils import logger
from evennia.utils import inlinefuncs
from evennia.utils.evtable import EvTable
from evennia.prototypes.protfuncs import protfunc_parser
_MODULE_PROTOTYPE_MODULES = {}
@ -23,6 +23,7 @@ _MODULE_PROTOTYPES = {}
_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks")
_PROTOTYPE_TAG_CATEGORY = "from_prototype"
_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
_PROT_FUNCS = {}
class PermissionError(RuntimeError):
@ -36,6 +37,68 @@ class ValidationError(RuntimeError):
pass
# Protfunc parsing
for mod in settings.PROT_FUNC_MODULES:
try:
callables = callables_from_module(mod)
_PROT_FUNCS.update(callables)
except ImportError:
logger.log_trace()
raise
def protfunc_parser(value, available_functions=None, testing=False, **kwargs):
"""
Parse a prototype value string for a protfunc and process it.
Available protfuncs are specified as callables in one of the modules of
`settings.PROTFUNC_MODULES`, or specified on the command line.
Args:
value (any): The value to test for a parseable protfunc. Only strings will be parsed for
protfuncs, all other types are returned as-is.
available_functions (dict, optional): Mapping of name:protfunction to use for this parsing.
testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may
behave differently.
Kwargs:
session (Session): Passed to protfunc. Session of the entity spawning the prototype.
protototype (dict): Passed to protfunc. The dict this protfunc is a part of.
current_key(str): Passed to protfunc. The key in the prototype that will hold this value.
any (any): Passed on to the protfunc.
Returns:
testresult (tuple): If `testing` is set, returns a tuple (error, result) where error is
either None or a string detailing the error from protfunc_parser or seen when trying to
run `literal_eval` on the parsed string.
any (any): A structure to replace the string on the prototype level. If this is a
callable or a (callable, (args,)) structure, it will be executed as if one had supplied
it to the prototype directly. This structure is also passed through literal_eval so one
can get actual Python primitives out of it (not just strings). It will also identify
eventual object #dbrefs in the output from the protfunc.
"""
if not isinstance(value, basestring):
return value
available_functions = _PROT_FUNCS if available_functions is None else available_functions
result = inlinefuncs.parse_inlinefunc(
value, available_funcs=available_functions, testing=testing, **kwargs)
# at this point we have a string where all procfuncs were parsed
# print("parse_inlinefuncs(\"{}\", available_funcs={}) => {}".format(value, available_functions, result))
result = value_to_obj_or_any(result)
err = None
try:
result = literal_eval(result)
except ValueError:
pass
except Exception as err:
err = str(err)
if testing:
return err, result
return result
# helper functions
def value_to_obj(value, force=True):

View file

@ -167,7 +167,7 @@ class TestProtLib(EvenniaTest):
pass
@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs'])
@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs'], CLIENT_DEFAULT_WIDTH=20)
class TestProtFuncs(EvenniaTest):
def setUp(self):
@ -176,11 +176,55 @@ class TestProtFuncs(EvenniaTest):
"prototype_desc": "testing prot",
"key": "ExampleObj"}
@mock.patch("random.random", new=mock.MagicMock(return_value=0.5))
@mock.patch("random.randint", new=mock.MagicMock(return_value=5))
@mock.patch("evennia.prototypes.protfuncs.base_random", new=mock.MagicMock(return_value=0.5))
@mock.patch("evennia.prototypes.protfuncs.base_randint", new=mock.MagicMock(return_value=5))
def test_protfuncs(self):
self.assertEqual(protfuncs.protfunc_parser("$random()", 0.5))
self.assertEqual(protfuncs.protfunc_parser("$randint(1, 10)", 5))
self.assertEqual(protlib.protfunc_parser("$random()"), 0.5)
self.assertEqual(protlib.protfunc_parser("$randint(1, 10)"), 5)
self.assertEqual(protlib.protfunc_parser("$left_justify( foo )"), "foo ")
self.assertEqual(protlib.protfunc_parser("$right_justify( foo )"), " foo")
self.assertEqual(protlib.protfunc_parser("$center_justify(foo )"), " foo ")
self.assertEqual(protlib.protfunc_parser(
"$full_justify(foo bar moo too)"), 'foo bar moo too')
self.assertEqual(
protlib.protfunc_parser("$right_justify( foo )", testing=True),
('unexpected indent (<unknown>, line 1)', ' foo'))
test_prot = {"key1": "value1",
"key2": 2}
self.assertEqual(protlib.protfunc_parser(
"$protkey(key1)", testing=True, prototype=test_prot), (None, "value1"))
self.assertEqual(protlib.protfunc_parser(
"$protkey(key2)", testing=True, prototype=test_prot), (None, 2))
self.assertEqual(protlib.protfunc_parser("$add(1, 2)"), 3)
self.assertEqual(protlib.protfunc_parser("$add(10, 25)"), 35)
self.assertEqual(protlib.protfunc_parser(
"$add('''[1,2,3]''', '''[4,5,6]''')"), [1, 2, 3, 4, 5, 6])
self.assertEqual(protlib.protfunc_parser("$add(foo, bar)"), "foo bar")
self.assertEqual(protlib.protfunc_parser("$sub(5, 2)"), 3)
self.assertRaises(TypeError, protlib.protfunc_parser, "$sub(5, test)")
self.assertEqual(protlib.protfunc_parser("$mult(5, 2)"), 10)
self.assertEqual(protlib.protfunc_parser("$mult( 5 , 10)"), 50)
self.assertEqual(protlib.protfunc_parser("$mult('foo',3)"), "foofoofoo")
self.assertEqual(protlib.protfunc_parser("$mult(foo,3)"), "foofoofoo")
self.assertRaises(TypeError, protlib.protfunc_parser, "$mult(foo, foo)")
self.assertEqual(protlib.protfunc_parser("$toint(5.3)"), 5)
self.assertEqual(protlib.protfunc_parser("$div(5, 2)"), 2.5)
self.assertEqual(protlib.protfunc_parser("$toint($div(5, 2))"), 2)
self.assertEqual(protlib.protfunc_parser("$sub($add(5, 3), $add(10, 2))"), -4)
self.assertEqual(protlib.protfunc_parser("$eval('2')"), '2')
self.assertEqual(protlib.protfunc_parser(
"$eval(['test', 1, '2', 3.5, \"foo\"])"), ['test', 1, '2', 3.5, 'foo'])
self.assertEqual(protlib.protfunc_parser(
"$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3})
class TestPrototypeStorage(EvenniaTest):

View file

@ -161,13 +161,15 @@ def clr(*args, **kwargs):
def null(*args, **kwargs):
return args[0] if args else ''
_INLINE_FUNCS = {}
# 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>",
"stackfull": lambda *args, **kwargs: "\n (not parsed: "
"inlinefunc stack size exceeded.)"}
_DEFAULT_FUNCS = {"nomatch": lambda *args, **kwargs: "<UNKNOWN>",
"stackfull": lambda *args, **kwargs: "\n (not parsed: "}
_INLINE_FUNCS.update(_DEFAULT_FUNCS)
# load custom inline func modules.
for module in utils.make_iter(settings.INLINEFUNC_MODULES):
@ -285,6 +287,11 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs):
if not available_funcs:
available_funcs = _INLINE_FUNCS
usecache = True
else:
# make sure the default keys are available, but also allow overriding
tmp = _DEFAULT_FUNCS.copy()
tmp.update(available_funcs)
available_funcs = tmp
if usecache and string in _PARSING_CACHE:
# stack is already cached
@ -299,9 +306,14 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs):
# process string on stack
ncallable = 0
nlparens = 0
# print("STRING: {} =>".format(string))
for match in _RE_TOKEN.finditer(string):
gdict = match.groupdict()
# print("match: {}".format({key: val for key, val in gdict.items() if val}))
# print(" MATCH: {}".format({key: val for key, val in gdict.items() if val}))
if gdict["singlequote"]:
stack.append(gdict["singlequote"])
elif gdict["doublequote"]:
@ -386,10 +398,10 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs):
kwargs["inlinefunc_stack_depth"] = depth
retval = "" if strip else func(*args, **kwargs)
return utils.to_str(retval, force_string=True)
# print("STACK:\n{}".format(stack))
retval = "".join(_run_stack(item) for item in stack)
# print("STACK: \n{} => {}\n".format(stack, retval))
# execute the stack
return "".join(_run_stack(item) for item in stack)
return retval
#
# Nick templating

View file

@ -43,8 +43,6 @@ _GA = object.__getattribute__
_SA = object.__setattr__
_DA = object.__delattr__
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
def is_iter(iterable):
"""
@ -80,7 +78,7 @@ def make_iter(obj):
return not hasattr(obj, '__iter__') and [obj] or obj
def wrap(text, width=_DEFAULT_WIDTH, indent=0):
def wrap(text, width=None, indent=0):
"""
Safely wrap text to a certain number of characters.
@ -93,6 +91,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0):
text (str): Properly wrapped text.
"""
width = width if width else settings.CLIENT_DEFAULT_WIDTH
if not text:
return ""
text = to_unicode(text)
@ -104,7 +103,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0):
fill = wrap
def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "):
def pad(text, width=None, align="c", fillchar=" "):
"""
Pads to a given width.
@ -119,6 +118,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "):
text (str): The padded text.
"""
width = width if width else settings.CLIENT_DEFAULT_WIDTH
align = align if align in ('c', 'l', 'r') else 'c'
fillchar = fillchar[0] if fillchar else " "
if align == 'l':
@ -129,7 +129,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "):
return text.center(width, fillchar)
def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"):
def crop(text, width=None, suffix="[...]"):
"""
Crop text to a certain width, throwing away text from too-long
lines.
@ -147,7 +147,7 @@ def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"):
text (str): The cropped text.
"""
width = width if width else settings.CLIENT_DEFAULT_WIDTH
utext = to_unicode(text)
ltext = len(utext)
if ltext <= width:
@ -179,7 +179,7 @@ def dedent(text):
return textwrap.dedent(text)
def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0):
def justify(text, width=None, align="f", indent=0):
"""
Fully justify a text so that it fits inside `width`. When using
full justification (default) this will be done by padding between
@ -198,6 +198,7 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0):
justified (str): The justified and indented block of text.
"""
width = width if width else settings.CLIENT_DEFAULT_WIDTH
def _process_line(line):
"""