diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 6e9c7e5679..5ecb4b5e7d 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -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() + Returns 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() @@ -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() + 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() + Returns list with one or more Objects searched globally by key, alias or #dbref. + + """ + return _obj_search(return_list=True, *args, **kwargs) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 2e96af99c9..86230354b9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -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): diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index fa7eeca246..36be5f4c6b 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -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 (, 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): diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index 4becfb7b01..f60f9f0d8a 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -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: "", - "stackfull": lambda *args, **kwargs: "\n (not parsed: " - "inlinefunc stack size exceeded.)"} +_DEFAULT_FUNCS = {"nomatch": lambda *args, **kwargs: "", + "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 diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 22d59a165f..3d07a82e9a 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -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): """