408 lines
15 KiB
Python
408 lines
15 KiB
Python
"""
|
|
Generic function parser for functions embedded in a string. The
|
|
|
|
```
|
|
$funcname(*args, **kwargs)
|
|
```
|
|
|
|
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)
|
|
```
|
|
|
|
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'
|
|
function for the parser to accept it. This is usually done in a module with
|
|
regular Python functions on the form:
|
|
|
|
```python
|
|
# 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
|
|
|
|
def funcname(*args, **kwargs):
|
|
# this can be accecssed as $funcname(*args, **kwargs)
|
|
...
|
|
return some_string
|
|
|
|
```
|
|
|
|
"""
|
|
import dataclasses
|
|
import inspect
|
|
import re
|
|
from evennia.utils import logger
|
|
from evennia.utils.utils import make_iter, callables_from_module
|
|
|
|
_MAX_NESTING = 20
|
|
|
|
_ESCAPE_CHAR = "\\"
|
|
_START_CHAR = "$"
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class ParsedFunc:
|
|
"""
|
|
Represents a function parsed from the string
|
|
|
|
"""
|
|
prefix: str = "$"
|
|
funcname: str = ""
|
|
args: list = dataclasses.field(default_factory=list)
|
|
kwargs: dict = dataclasses.field(default_factory=dict)
|
|
|
|
# state storage
|
|
fullstr: str = ""
|
|
infuncstr: str = ""
|
|
single_quoted: bool = False
|
|
double_quoted: bool = False
|
|
current_kwarg: str = ""
|
|
|
|
def get(self):
|
|
return self.funcname, self.args, self.kwargs
|
|
|
|
def __str__(self):
|
|
return self.fullstr + self.infuncstr
|
|
|
|
|
|
class ParsingError(RuntimeError):
|
|
"""
|
|
Failed to parse for some reason.
|
|
"""
|
|
pass
|
|
|
|
|
|
class FuncParser:
|
|
"""
|
|
Sets up a parser for strings containing $funcname(*args, **kwargs) substrings.
|
|
|
|
"""
|
|
|
|
def __init__(self,
|
|
safe_callables,
|
|
start_char=_START_CHAR,
|
|
escape_char=_ESCAPE_CHAR,
|
|
max_nesting=_MAX_NESTING,
|
|
**default_kwargs):
|
|
"""
|
|
Initialize the parser.
|
|
|
|
Args:
|
|
safe_callables (str, module, list or dict): Where to find
|
|
'safe' functions to make available in the parser. All callables
|
|
in provided modules (whose names don't start with an
|
|
underscore) are considered valid functions to access as
|
|
`$funcname(*args, **kwags)` during parsing. If a `str`, this
|
|
should be the path to such a module. A `list` can either be a
|
|
list of paths or module objects. If a `dict`, this should be a
|
|
mapping `{"funcname": callable, ...}` - this will be used
|
|
directly as valid parseable functions.
|
|
start_char (str, optional): A character used to identify the beginning
|
|
of a parseable function. Default is `$`.
|
|
escape_char (str, optional): Prepend characters with this to have
|
|
them not count as a function. Default is `\\`.
|
|
max_nesting (int, optional): How many levels of nested function calls
|
|
are allowed, to avoid exploitation.
|
|
**default_kwargs: These kwargs will be passed into all callables. These
|
|
kwargs can be overridden both by kwargs passed direcetly to `.parse` _and_
|
|
by kwargs given directly in the string `$funcname` call. They are
|
|
suitable for global defaults that is intended to be changed by the
|
|
user. To _guarantee_ a call always gets a particular kwarg, pass it
|
|
into `.parse` as `**reserved_kwargs` instead.
|
|
|
|
"""
|
|
if isinstance(safe_callables, dict):
|
|
callables = {**safe_callables}
|
|
else:
|
|
# load all modules/paths in sequence. Later-added will override
|
|
# earlier same-named callables (allows for overriding evennia defaults)
|
|
callables = {}
|
|
for safe_callable in make_iter(safe_callables):
|
|
# callables_from_module handles both paths and module instances
|
|
callables.update(callables_from_module(safe_callable))
|
|
self.validate_callables(callables)
|
|
self.callables = callables
|
|
self.escape_char = escape_char
|
|
self.start_char = start_char
|
|
self.default_kwargs = default_kwargs
|
|
|
|
def validate_callables(self, callables):
|
|
"""
|
|
Validate the loaded callables. Each callable must support at least
|
|
`funcname(*args, **kwargs)`.
|
|
property.
|
|
|
|
Args:
|
|
callables (dict): A mapping `{"funcname": callable, ...}` to validate
|
|
|
|
Raise:
|
|
AssertionError: If invalid callable was found.
|
|
|
|
Notes:
|
|
This is also a good method to override for individual parsers
|
|
needing to run any particular pre-checks.
|
|
|
|
"""
|
|
for funcname, clble in callables.items():
|
|
mapping = inspect.getfullargspec(clble)
|
|
assert mapping.varargs, f"Parse-func callable '{funcname}' does not support *args."
|
|
assert mapping.varkw, f"Parse-func callable '{funcname}' does not support **kwargs."
|
|
|
|
def execute(self, parsedfunc, raise_errors=False, **reserved_kwargs):
|
|
"""
|
|
Execute a parsed function
|
|
|
|
Args:
|
|
parsedfunc (ParsedFunc): This dataclass holds the parsed details
|
|
of the function.
|
|
raise_errors (bool, optional): Raise errors. Otherwise return the
|
|
string with the function unparsed.
|
|
**reserved_kwargs: These kwargs are _guaranteed_ to always be passed into
|
|
the callable on every call. It will override any default kwargs
|
|
_and_ also a same-named kwarg given manually in the $funcname
|
|
call. This is often used by Evennia to pass required data into
|
|
the callable, for example the current Session for inlinefuncs.
|
|
Returns:
|
|
any: The result of the execution. If this is a nested function, it
|
|
can be anything, otherwise it will be converted to a string later.
|
|
Always a string on un-raised error (the unparsed function string).
|
|
|
|
Raises:
|
|
ParsingError, any: A `ParsingError` if the function could not be
|
|
found, otherwise error from function definition. Only raised if
|
|
`raise_errors` is `True`
|
|
|
|
Notes:
|
|
The kwargs passed into the callable will be a mixture of the
|
|
`default_kwargs` passed into `FuncParser.__init__`, kwargs given
|
|
directly in the `$funcdef` string, and the `reserved_kwargs` this
|
|
function gets from `.parse()`. For colliding keys, funcdef-defined
|
|
kwargs will override default kwargs while reserved kwargs will always
|
|
override the other two.
|
|
|
|
"""
|
|
funcname, args, kwargs = parsedfunc.get()
|
|
func = self.callables.get(funcname)
|
|
|
|
if not func:
|
|
if raise_errors:
|
|
available = ", ".join(f"'{key}'" for key in self.callables)
|
|
raise ParsingError(f"Unknown parsed function '{str(parsedfunc)}' "
|
|
f"(available: {available})")
|
|
return str(parsedfunc)
|
|
|
|
# build kwargs in the proper priority order
|
|
kwargs = {**self.default_kwargs, **kwargs, **reserved_kwargs}
|
|
|
|
try:
|
|
return str(func(*args, **kwargs))
|
|
except Exception:
|
|
logger.log_trace()
|
|
if raise_errors:
|
|
raise
|
|
return str(parsedfunc)
|
|
|
|
def parse(self, string, raise_errors=False, **reserved_kwargs):
|
|
"""
|
|
Use parser to parse a string that may or may not have `$funcname(*args, **kwargs)`
|
|
- style tokens in it. Only the callables used to initiate the parser
|
|
will be eligible for parsing, others will remain un-parsed.
|
|
|
|
Args:
|
|
string (str): The string to parse.
|
|
raise_errors (bool, optional): By default, a failing parse just
|
|
means not parsing the string but leaving it as-is. If this is
|
|
`True`, errors (like not closing brackets) will lead to an
|
|
ParsingError.
|
|
**reserved_kwargs: If given, these are guaranteed to _always_ pass
|
|
as part of each parsed callable's **kwargs. These override
|
|
same-named default options given in `__init__` as well as any
|
|
same-named kwarg given in the string function. This is because
|
|
it is often used by Evennia to pass necessary kwargs into each
|
|
callable (like the current Session object for inlinefuncs).
|
|
|
|
Returns:
|
|
str: The parsed string, or the same string on error (if `raise_errors` is `False`)
|
|
|
|
Raises:
|
|
ParsingError: If a problem is encountered and `raise_errors` is True.
|
|
|
|
"""
|
|
start_char = self.start_char
|
|
escape_char = self.escape_char
|
|
|
|
# replace e.g. $$ with \$ so we only need to handle one escape method
|
|
string = string.replace(start_char + start_char, escape_char + start_char)
|
|
|
|
# parsing state
|
|
callstack = []
|
|
|
|
single_quoted = False
|
|
double_quoted = False
|
|
open_lparens = 0
|
|
escaped = False
|
|
current_kwarg = ""
|
|
|
|
curr_func = None
|
|
fullstr = '' # final string
|
|
infuncstr = '' # string parts inside the current level of $funcdef (including $)
|
|
|
|
for char in string:
|
|
|
|
if escaped:
|
|
# always store escaped characters verbatim
|
|
if curr_func:
|
|
infuncstr += char
|
|
else:
|
|
fullstr += char
|
|
escaped = False
|
|
continue
|
|
|
|
if char == escape_char:
|
|
# don't store the escape-char itself
|
|
escaped = True
|
|
continue
|
|
|
|
if char == start_char:
|
|
# start a new function definition (not escaped as $$)
|
|
|
|
if curr_func:
|
|
# we are starting a nested funcdef
|
|
if len(callstack) > _MAX_NESTING:
|
|
# stack full - ignore this function
|
|
if raise_errors:
|
|
raise ParsingError("Only allows for parsing nesting function defs "
|
|
f"to a max depth of {_MAX_NESTING}.")
|
|
infuncstr += char
|
|
continue
|
|
else:
|
|
# 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
|
|
current_kwarg = ""
|
|
infuncstr = ""
|
|
open_lparens = 0
|
|
single_quoted = False
|
|
double_quoted = False
|
|
callstack.append(curr_func)
|
|
|
|
# start a new func
|
|
curr_func = ParsedFunc(prefix=char, fullstr=char)
|
|
continue
|
|
|
|
if not curr_func:
|
|
# a normal piece of string
|
|
fullstr += char
|
|
continue
|
|
|
|
# in a function def (can be nested)
|
|
|
|
if char == "'": # note that this is the same as "\'"
|
|
# a single quote - flip status
|
|
single_quoted = not single_quoted
|
|
infuncstr += char
|
|
continue
|
|
|
|
if char == '"': # note that this is the same as '\"'
|
|
# a double quote = flip status
|
|
double_quoted = not double_quoted
|
|
infuncstr += char
|
|
continue
|
|
|
|
if double_quoted or single_quoted:
|
|
# inside a string escape
|
|
infuncstr += char
|
|
continue
|
|
|
|
# special characters detected inside function def
|
|
if char == '(':
|
|
if not curr_func.funcname:
|
|
# end of a funcdef name
|
|
curr_func.funcname = infuncstr
|
|
curr_func.fullstr += infuncstr + char
|
|
infuncstr = ''
|
|
else:
|
|
# just a random left-parenthesis
|
|
infuncstr += char
|
|
# track the open left-parenthesis
|
|
open_lparens += 1
|
|
continue
|
|
|
|
if char == '=':
|
|
# beginning of a keyword argument
|
|
current_kwarg = infuncstr.strip()
|
|
curr_func.kwargs[current_kwarg] = ""
|
|
curr_func.fullstr += infuncstr + char
|
|
infuncstr = ''
|
|
continue
|
|
|
|
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
|
|
infuncstr += char
|
|
open_lparens -= 1 if char == ')' else 0
|
|
continue
|
|
|
|
# end current arg/kwarg one way or another
|
|
if current_kwarg:
|
|
curr_func.kwargs[current_kwarg] = infuncstr.strip()
|
|
current_kwarg = ""
|
|
elif infuncstr.strip():
|
|
curr_func.args.append(infuncstr.strip())
|
|
|
|
# we need to store the full string so we can print it 'raw' in
|
|
# case this funcdef turns out to e.g. lack an ending paranthesis
|
|
curr_func.fullstr += infuncstr + char
|
|
infuncstr = ''
|
|
|
|
if char == ')':
|
|
# closing the function list - this means we have a
|
|
# ready function-def to run.
|
|
open_lparens = 0
|
|
infuncstr = self.execute(
|
|
curr_func, raise_errors=raise_errors, **reserved_kwargs)
|
|
|
|
curr_func = None
|
|
if callstack:
|
|
# unnest the higher-level funcdef from stack
|
|
# and continue where we were
|
|
curr_func = callstack.pop()
|
|
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
|
|
else:
|
|
# back to the top-level string
|
|
curr_func = None
|
|
fullstr += infuncstr
|
|
infuncstr = ''
|
|
continue
|
|
|
|
# no special char
|
|
infuncstr += char
|
|
|
|
if curr_func:
|
|
# if there is a still open funcdef or defs remaining in callstack,
|
|
# these are malformed (no closing bracket) and we should get their
|
|
# strings as-is.
|
|
callstack.append(curr_func)
|
|
for _ in range(len(callstack)):
|
|
infuncstr = str(callstack.pop()) + infuncstr
|
|
|
|
# add the last bit to the finished string and return
|
|
fullstr += infuncstr
|
|
return fullstr
|