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: 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. - 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. - 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 - 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 set, the protfunc should take care not to perform any persistent actions, such as operate on
objects or add things to the database. 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 ast import literal_eval
from random import randint as base_randint, random as base_random from random import randint as base_randint, random as base_random
from django.conf import settings from evennia.utils import search
from evennia.utils import inlinefuncs from evennia.utils.utils import justify as base_justify, is_iter, to_str
from evennia.utils.utils import callables_from_module
from evennia.utils.utils import justify as base_justify, is_iter
_PROTLIB = None _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 # default protfuncs
@ -180,7 +122,7 @@ def protkey(*args, **kwargs):
""" """
if args: if args:
prototype = kwargs['prototype'] prototype = kwargs['prototype']
return prototype[args[0]] return prototype[args[0].strip()]
def add(*args, **kwargs): def add(*args, **kwargs):
@ -193,7 +135,16 @@ def add(*args, **kwargs):
""" """
if len(args) > 1: if len(args) > 1:
val1, val2 = args[0], 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.") raise ValueError("$add requires two arguments.")
@ -207,11 +158,20 @@ def sub(*args, **kwargs):
""" """
if len(args) > 1: if len(args) > 1:
val1, val2 = args[0], 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.") raise ValueError("$sub requires two arguments.")
def mul(*args, **kwargs): def mult(*args, **kwargs):
""" """
Usage: $mul(val1, val2) Usage: $mul(val1, val2)
Returns the value of val1 * val2. The values must be Returns the value of val1 * val2. The values must be
@ -221,7 +181,16 @@ def mul(*args, **kwargs):
""" """
if len(args) > 1: if len(args) > 1:
val1, val2 = args[0], 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.") raise ValueError("$mul requires two arguments.")
@ -234,10 +203,33 @@ def div(*args, **kwargs):
""" """
if len(args) > 1: if len(args) > 1:
val1, val2 = args[0], 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.") 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): def eval(*args, **kwargs):
""" """
Usage $eval(<expression>) Usage $eval(<expression>)
@ -247,16 +239,79 @@ def eval(*args, **kwargs):
- those will then be evaluated *after* $eval. - 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) 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): 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): if is_iter(val):
stype = type(val) stype = type(val)
if stype == dict: if stype == dict:
return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()} return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()}
return stype((_recursive_parse(v) for v in val)) return stype((_recursive_parse(v) for v in val))
return protfunc_parser(val) return _PROTLIB.protfunc_parser(val)
return _recursive_parse(struct) 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 django.conf import settings
from evennia.scripts.scripts import DefaultScript from evennia.scripts.scripts import DefaultScript
from evennia.objects.models import ObjectDB from evennia.objects.models import ObjectDB
from evennia.utils.create import create_script from evennia.utils.create import create_script
from evennia.utils.utils import ( 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.locks.lockhandler import validate_lockstring, check_lockstring
from evennia.utils import logger from evennia.utils import logger
from evennia.utils import inlinefuncs
from evennia.utils.evtable import EvTable from evennia.utils.evtable import EvTable
from evennia.prototypes.protfuncs import protfunc_parser
_MODULE_PROTOTYPE_MODULES = {} _MODULE_PROTOTYPE_MODULES = {}
@ -23,6 +23,7 @@ _MODULE_PROTOTYPES = {}
_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks")
_PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_CATEGORY = "from_prototype"
_PROTOTYPE_TAG_META_CATEGORY = "db_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
_PROT_FUNCS = {}
class PermissionError(RuntimeError): class PermissionError(RuntimeError):
@ -36,6 +37,68 @@ class ValidationError(RuntimeError):
pass 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 # helper functions
def value_to_obj(value, force=True): def value_to_obj(value, force=True):

View file

@ -167,7 +167,7 @@ class TestProtLib(EvenniaTest):
pass pass
@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs']) @override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs'], CLIENT_DEFAULT_WIDTH=20)
class TestProtFuncs(EvenniaTest): class TestProtFuncs(EvenniaTest):
def setUp(self): def setUp(self):
@ -176,11 +176,55 @@ class TestProtFuncs(EvenniaTest):
"prototype_desc": "testing prot", "prototype_desc": "testing prot",
"key": "ExampleObj"} "key": "ExampleObj"}
@mock.patch("random.random", new=mock.MagicMock(return_value=0.5)) @mock.patch("evennia.prototypes.protfuncs.base_random", new=mock.MagicMock(return_value=0.5))
@mock.patch("random.randint", new=mock.MagicMock(return_value=5)) @mock.patch("evennia.prototypes.protfuncs.base_randint", new=mock.MagicMock(return_value=5))
def test_protfuncs(self): def test_protfuncs(self):
self.assertEqual(protfuncs.protfunc_parser("$random()", 0.5)) self.assertEqual(protlib.protfunc_parser("$random()"), 0.5)
self.assertEqual(protfuncs.protfunc_parser("$randint(1, 10)", 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): class TestPrototypeStorage(EvenniaTest):

View file

@ -161,13 +161,15 @@ def clr(*args, **kwargs):
def null(*args, **kwargs): def null(*args, **kwargs):
return args[0] if args else '' return args[0] if args else ''
_INLINE_FUNCS = {}
# we specify a default nomatch function to use if no matching func was # we specify a default nomatch function to use if no matching func was
# found. This will be overloaded by any nomatch function defined in # found. This will be overloaded by any nomatch function defined in
# the imported modules. # the imported modules.
_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "<UKNOWN>", _DEFAULT_FUNCS = {"nomatch": lambda *args, **kwargs: "<UNKNOWN>",
"stackfull": lambda *args, **kwargs: "\n (not parsed: " "stackfull": lambda *args, **kwargs: "\n (not parsed: "}
"inlinefunc stack size exceeded.)"}
_INLINE_FUNCS.update(_DEFAULT_FUNCS)
# load custom inline func modules. # load custom inline func modules.
for module in utils.make_iter(settings.INLINEFUNC_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: if not available_funcs:
available_funcs = _INLINE_FUNCS available_funcs = _INLINE_FUNCS
usecache = True 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: if usecache and string in _PARSING_CACHE:
# stack is already cached # stack is already cached
@ -299,9 +306,14 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs):
# process string on stack # process string on stack
ncallable = 0 ncallable = 0
nlparens = 0 nlparens = 0
# print("STRING: {} =>".format(string))
for match in _RE_TOKEN.finditer(string): for match in _RE_TOKEN.finditer(string):
gdict = match.groupdict() 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"]: if gdict["singlequote"]:
stack.append(gdict["singlequote"]) stack.append(gdict["singlequote"])
elif gdict["doublequote"]: elif gdict["doublequote"]:
@ -386,10 +398,10 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs):
kwargs["inlinefunc_stack_depth"] = depth kwargs["inlinefunc_stack_depth"] = depth
retval = "" if strip else func(*args, **kwargs) retval = "" if strip else func(*args, **kwargs)
return utils.to_str(retval, force_string=True) return utils.to_str(retval, force_string=True)
retval = "".join(_run_stack(item) for item in stack)
# print("STACK:\n{}".format(stack)) # print("STACK: \n{} => {}\n".format(stack, retval))
# execute the stack # execute the stack
return "".join(_run_stack(item) for item in stack) return retval
# #
# Nick templating # Nick templating

View file

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