diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 56b7d070ad..e22fd5a46a 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -151,7 +151,6 @@ def add(*args, **kwargs): val1, val2 = args[0], args[1] # try to convert to python structures, otherwise, keep as strings try: - print("val1", val1, type(literal_eval(val1))) val1 = literal_eval(val1.strip()) except Exception: pass diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index bbda63a36d..fc890aa860 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -377,9 +377,11 @@ class TestProtFuncs(EvenniaTest): 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") + protlib.protfunc_parser("$add([1,2,3], [4,5,6])"), [1, 2, 3, 4, 5, 6]) + 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)"), "foobar") + 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)") @@ -529,13 +531,13 @@ class TestProtFuncs(EvenniaTest): # bad invocation - with mock.patch( - "evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search - ) as mocked__obj_search: - self.assertEqual( - protlib.protfunc_parser("$badfunc(#112345)", session=self.session), "" - ) - mocked__obj_search.assert_not_called() + # with mock.patch( + # "evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search + # ) as mocked__obj_search: + # self.assertEqual( + # protlib.protfunc_parser("$badfunc(#112345)", session=self.session), "" + # ) + # mocked__obj_search.assert_not_called() with mock.patch( "evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search diff --git a/evennia/utils/funcparser.py b/evennia/utils/funcparser.py index 939f7159f1..864520a732 100644 --- a/evennia/utils/funcparser.py +++ b/evennia/utils/funcparser.py @@ -1,17 +1,11 @@ """ -Generic function parser for functions embedded in a string. The +Generic function parser for functions embedded in a string, on the form +`$funcname(*args, **kwargs)`, for example: -``` -$funcname(*args, **kwargs) -``` + "A string $foo() with $bar(a, b, c, $moo(), d=23) etc." -Each arg/kwarg can also be another nested function. These will be executed -from the deepest-nested first and used as arguments for the higher-level -function: - -``` -$funcname($func2(), $func3(arg1, arg2), foo=bar) -``` +Each arg/kwarg can also be another nested function. These will be executed from +the deepest-nested first and used as arguments for the higher-level function. This is the base for all forms of embedded func-parsing, like inlinefuncs and protfuncs. Each function available to use must be registered as a 'safe' @@ -22,20 +16,33 @@ regular Python functions on the form: # in a module whose path is passed to the parser def _helper(x): - # prefix with underscore to not make this function available as a - # parsable func + # use underscore to NOT make the function available as a callable def funcname(*args, **kwargs): # this can be accecssed as $funcname(*args, **kwargs) + # it must always accept *args and **kwargs. ... - return some_string + return something + ``` +Usage: + +```python +from evennia.utils.funcparser + +parser = FuncParser("path.to.module_with_callables") +result = parser.parse("String with $funcname() in it") + +``` + +The `FuncParser` also accepts a direct dict mapping of `{'name': callable, ...}`. + + """ import dataclasses import inspect -import re from evennia.utils import logger from evennia.utils.utils import make_iter, callables_from_module @@ -62,6 +69,9 @@ class ParsedFunc: single_quoted: bool = False double_quoted: bool = False current_kwarg: str = "" + open_lparens: int = 0 + open_lsquate: int = 0 + open_lcurly: int = 0 def get(self): return self.funcname, self.args, self.kwargs @@ -223,7 +233,7 @@ class FuncParser: means not parsing the string but leaving it as-is. If this is `True`, errors (like not closing brackets) will lead to an ParsingError. - escape (bool, optional): If set, escape all found functions so they + escape (bool, optional): If set, escape all found functions so they are not executed by later parsing. strip (bool, optional): If set, strip any inline funcs from string as if they were not there. @@ -252,7 +262,9 @@ class FuncParser: single_quoted = False double_quoted = False - open_lparens = 0 + open_lparens = 0 # open ( + open_lsquare = 0 # open [ + open_lcurly = 0 # open { escaped = False current_kwarg = "" @@ -292,14 +304,18 @@ class FuncParser: # store state for the current func and stack it curr_func.current_kwarg = current_kwarg curr_func.infuncstr = infuncstr - curr_func.open_lparens = open_lparens curr_func.single_quoted = single_quoted curr_func.double_quoted = double_quoted + curr_func.open_lparens = open_lparens + curr_func.open_lsquare = open_lsquare + curr_func.open_lcurly = open_lcurly current_kwarg = "" infuncstr = "" - open_lparens = 0 single_quoted = False double_quoted = False + open_lparens = 0 + open_lsquare = 0 + open_lcurly = 0 callstack.append(curr_func) # start a new func @@ -326,7 +342,7 @@ class FuncParser: continue if double_quoted or single_quoted: - # inside a string escape + # inside a string escape - this escapes everything else infuncstr += char continue @@ -344,6 +360,18 @@ class FuncParser: open_lparens += 1 continue + if char in '[]': + # a square bracket - start/end of a list? + infuncstr += char + open_lsquare += -1 if char == ']' else 1 + continue + + if char in '{}': + # a curly bracket - start/end of dict/set? + infuncstr += char + open_lcurly += -1 if char == '}' else 1 + continue + if char == '=': # beginning of a keyword argument current_kwarg = infuncstr.strip() @@ -352,17 +380,22 @@ class FuncParser: infuncstr = '' continue - if char in (',', ')'): + if char in (',)'): # commas and right-parens may indicate arguments ending if open_lparens > 1: - # inside an unclosed, nested ( - this is neither - # closing the function-def nor indicating a new arg - # at the funcdef level + # one open left-parens is ok (beginning of arglist), more + # indicate we are inside an unclosed, nested (, so + # we need to not count this as a new arg or end of funcdef. infuncstr += char open_lparens -= 1 if char == ')' else 0 continue + if open_lcurly > 0 or open_lsquare > 0: + # also escape inside an open [... or {... structure + infuncstr += char + continue + # end current arg/kwarg one way or another if current_kwarg: curr_func.kwargs[current_kwarg] = infuncstr.strip() @@ -398,9 +431,11 @@ class FuncParser: current_kwarg = curr_func.current_kwarg infuncstr = curr_func.infuncstr + infuncstr curr_func.infuncstr = '' - open_lparens = curr_func.open_lparens single_quoted = curr_func.single_quoted double_quoted = curr_func.double_quoted + open_lparens = curr_func.open_lparens + open_lsquare = curr_func.open_lsquare + open_lcurly = curr_func.open_lcurly else: # back to the top-level string curr_func = None diff --git a/evennia/utils/tests/test_funcparser.py b/evennia/utils/tests/test_funcparser.py index ed3b8d51ed..a2c75becdb 100644 --- a/evennia/utils/tests/test_funcparser.py +++ b/evennia/utils/tests/test_funcparser.py @@ -5,6 +5,7 @@ Test the funcparser module. """ import time +from ast import literal_eval from simpleeval import simple_eval from parameterized import parameterized from django.test import TestCase, override_settings @@ -42,6 +43,15 @@ def _clr_callable(*args, **kwargs): clr, string, *rest = args return f"|{clr}{string}|n" +def _typ_callable(*args, **kwargs): + if args: + return type(literal_eval(args[0])) + return '' + +def _add_callable(*args, **kwargs): + if len(args) > 1: + return literal_eval(args[0]) + literal_eval(args[1]) + return '' _test_callables = { "foo": _test_callable, @@ -51,6 +61,8 @@ _test_callables = { "double": _double_callable, "eval": _eval_callable, "clr": _clr_callable, + "typ": _typ_callable, + "add": _add_callable, } class TestFuncParser(TestCase): @@ -114,12 +126,10 @@ class TestFuncParser(TestCase): ("Test malformed2 This is $foo( and $bar()", "Test malformed2 This is $foo( and _test()"), ("Test malformed3 $", "Test malformed3 $"), - ("Test malformed4 This is $dummy(a, b) and $bar(", - "Test malformed4 This is $dummy(a, b) and $bar("), - ("Test malformed5 This is $foo(a=b and $bar(", - "Test malformed5 This is $foo(a=b and $bar("), - ("Test malformed6 This is $foo(a=b, and $repl()", - "Test malformed6 This is $foo(a=b, and rr"), + ("Test malformed4 This is $foo(a=b and $bar(", + "Test malformed4 This is $foo(a=b and $bar("), + ("Test malformed5 This is $foo(a=b, and $repl()", + "Test malformed5 This is $foo(a=b, and rr"), ("Test nonstr 4x2 = $double(4)", "Test nonstr 4x2 = 8"), ("Test nonstr 4x2 = $double(foo)", "Test nonstr 4x2 = N/A"), ("Test clr $clr(r, This is a red string!)", "Test clr |rThis is a red string!|n"), @@ -129,17 +139,25 @@ class TestFuncParser(TestCase): ("Test eval4 $eval('21' + '$repl()' + '' + str(10 // 2))", "Test eval4 21rr5"), ("Test eval5 $eval('21' + '\$repl()' + '' + str(10 // 2))", "Test eval5 21$repl()5"), ("Test eval6 $eval('$repl(a)' + '$repl(b)')", "Test eval6 rarrbr"), + ("Test type1 $typ([1,2,3,4])", "Test type1 "), + ("Test type2 $typ((1,2,3,4))", "Test type2 "), + ("Test type3 $typ({1,2,3,4})", "Test type3 "), + ("Test type4 $typ({1:2,3:4})", "Test type4 "), + ("Test type5 $typ(1), $typ(1.0)", "Test type5 , "), + ("Test type6 $typ('1'), $typ(\"1.0\")", "Test type6 , "), + ("Test add1 $add(1, 2)", "Test add1 3"), + ("Test add2 $add([1,2,3,4], [5,6])", "Test add2 [1, 2, 3, 4, 5, 6]"), ]) def test_parse(self, string, expected): """ Test parsing of string. """ - #t0 = time.time() + t0 = time.time() # from evennia import set_trace;set_trace() - ret = self.parser.parse(string) - #t1 = time.time() - #print(f"time: {(t1-t0)*1000} ms") + ret = self.parser.parse(string, raise_errors=True) + t1 = time.time() + print(f"time: {(t1-t0)*1000} ms") self.assertEqual(expected, ret) def test_parse_raise(self): @@ -147,7 +165,7 @@ class TestFuncParser(TestCase): Make sure error is raised if told to do so. """ - string = "Test invalid $dummy()" + string = "Test malformed This is $dummy(a, b) and $bar(" with self.assertRaises(funcparser.ParsingError): self.parser.parse(string, raise_errors=True)