Merge branch 'GulliblePsychologist-argparser' into develop

This commit is contained in:
Griatch 2021-03-27 23:45:28 +01:00
commit cc352f7723
38 changed files with 12246 additions and 1728 deletions

View file

@ -40,6 +40,10 @@
code style and paradigms instead of relying on `Scripts` for everything.
- Expand `CommandTest` with ability to check multipler msg-receivers; inspired by PR by
user davewiththenicehat. Also add new doc string.
- Add central `FuncParser` as a much more powerful replacement for the old `parse_inlinefunc`
function.
- Add `evennia/utils/verb_conjugation` for automatic verb conjugation (English only). This
is useful for implementing actor-stance emoting for sending a string to different targets.
### Evennia 0.9.5 (2019-2020)

View file

@ -0,0 +1,382 @@
# The Inline Function Parser
The [FuncParser](api:evennia.utils.funcparser#evennia.utils.funcparser.FuncParser) extracts and executes
'inline functions'
embedded in a string on the form `$funcname(args, kwargs)`. Under the hood, this will
lead to a call to a Python function you control. The inline function call will be replaced by
the return from the function.
```python
from evennia.utils.funcparser import FuncParser
def _power_callable(*args, **kwargs):
"""This will be callable as $square(number, power=<num>) in string"""
pow = int(kwargs.get('power', 2))
return float(args[0]) ** pow
parser = FuncParser({"pow": _power_callable})
```
Next, just pass a string into the parser, optionally containing `$func(...)` markers:
```python
parser.parse("We have that 4 x 4 x 4 is $pow(4, power=3).")
"We have that 4 x 4 x 4 is 64."
```
Normally the return is always converted to a string but you can also get the actual data type from the call:
```python
parser.parse_to_any("$pow(4)")
16
```
To show a `$func()` verbatim in your code without parsing it, escape it as either `$$func()` or `\$func()`:
```python
parser.parse("This is an escaped $$pow(4) and so is this \$pow(3)")
"This is an escaped $pow(4) and so is this $pow(3)"
```
## Uses in default Evennia
The FuncParser can be applied to any string. Out of the box it's applied in a few situations:
- _Outgoing messages_. All messages sent from the server is processed through FuncParser and every
callable is provided the [Session](./Sessions) of the object receiving the message. This potentially
allows a message to be modified on the fly to look different for different recipients.
- _Prototype values_. A [Prototype](./Prototypes) dict's values are run through the parser such that every
callable gets a reference to the rest of the prototype. In the Prototype ORM, this would allow builders
to safely call functions to set non-string values to prototype values, get random values, reference
other fields of the prototype, and more.
- _Actor-stance in messages to others_. In the
[Object.msg_contents](api:evennia.objects.objects#DefaultObject.msg_contents) method,
the outgoing string is parsed for special `$You()` and `$conj()` callables to decide if a given recipient
should see "You" or the character's name.
```important::
The inline-function parser is not intended as a 'softcode' programming language. It does not
have things like loops and conditionals, for example. While you could in principle extend it to
do very advanced things and allow builders a lot of power, all-out coding is something
Evennia expects you to do in a proper text editor, outside of the game, not from inside it.
```
## Using the FuncParser
You can apply inline function parsing to any string. The
[FuncParser](api:evennia.utils.funcparser.FuncParser) is found in `evennia.utils.funcparser.py`.
```python
from evennia.utils import funcparser
parser = FuncParser(callables, **default_kwargs)
parsed_string = parser.parser(input_string, raise_errors=False,
escape=False, strip=False,
return_str=True, **reserved_kwargs)
# callables can also be passed as paths to modules
parser = FuncParser(["game.myfuncparser_callables", "game.more_funcparser_callables"])
```
Here, `callables` points to a collection of normal Python functions (see next section) for you to make
available to the parser as you parse strings with it. It can either be
- A `dict` of `{"functionname": callable, ...}`. This allows you do pick and choose exactly which callables
to include and how they should be named. Do you want a callable to be available under more than one name?
Just add it multiple times to the dict, with a different key.
- A `module` or (more commonly) a `python-path` to a module. This module can define a dict
`FUNCPARSER_CALLABLES = {"funcname": callable, ...}` - this will be imported and used like ther `dict` above.
If no such variable is defined, _every_ top-level function in the module (whose name doesn't start with
an underscore `_`) will be considered a suitable callable. The name of the function will be the `$funcname`
by which it can be called.
- A `list` of modules/paths. This allows you to pull in modules from many sources for your parsing.
The other arguments to the parser:
- `raise_errors` - By default, any errors from a callable will be quietly ignored and the result
will be that the failing function call will show verbatim. If `raise_errors` is set,
then parsing will stop and whatever exception happened will be raised. It'd be up to you to handle
this properly.
- `escape` - Returns a string where every `$func(...)` has been escaped as `\$func()`.
- `strip` - Remove all `$func(...)` calls from string (as if each returned `''`).
- `return_str` - When `True` (default), `parser` always returns a string. If `False`, it may return
the return value of a single function call in the string. This is the same as using the `.parse_to_any`
method.
- The `**default/reserved_keywords` are optional and allow you to pass custom data into _every_ function
call. This is great for including things like the current session or config options. Defaults can be
replaced if the user gives the same-named kwarg in the string's function call. Reserved kwargs are always passed,
ignoring defaults or what the user passed. In addition, the `funcparser` and `raise_errors`
reserved kwargs are always passed - the first is a back-reference to the `FuncParser` instance and the second
is the `raise_errors` boolean passed into `FuncParser.parse`.
Here's an example of using the default/reserved keywords:
```python
def _test(*args, **kwargs):
# do stuff
return something
parser = funcparser.FuncParser({"test": _test}, mydefault=2)
result = parser.parse("$test(foo, bar=4)", myreserved=[1, 2, 3])
```
Here the callable will be called as
```python
_test('foo', bar='4', mydefault=2, myreserved=[1, 2, 3],
funcparser=<FuncParser>, raise_errrors=False)
```
The `mydefault=2` kwarg could be overwritten if we made the call as `$test(mydefault=...)`
but `myreserved=[1, 2, 3]` will _always_ be sent as-is and will override a call `$test(myreserved=...)`.
The `funcparser`/`raise_errors` kwargs are also always included as reserved kwargs.
## Defining custom callables
All callables made available to the parser must have the following signature:
```python
def funcname(*args, **kwargs):
# ...
return something
```
> The `*args` and `**kwargs` must always be included. If you are unsure how `*args` and `**kwargs` work in Python,
> [read about them here](https://www.digitalocean.com/community/tutorials/how-to-use-args-and-kwargs-in-python-3).
The input from the innermost `$funcname(...)` call in your callable will always be a `str`. Here's
an example of an `$toint` function; it converts numbers to integers.
"There's a $toint(22.0)% chance of survival."
What will enter the `$toint` callable (as `args[0]`) is the _string_ `"22.0"`. The function is responsible
for converting this to a number so that we can convert it to an integer. We must also properly handle invalid
inputs (like non-numbers).
If you want to mark an error, raise `evennia.utils.funcparser.ParsingError`. This stops the entire parsing
of the string and may or may not raise the exception depending on what you set `raise_errors` to when you
created the parser.
However, if you _nest_ functions, the return of the innermost function may be something other than
a string. Let's introduce the `$eval` function, which evaluates simple expressions using
Python's `literal_eval` and/or `simple_eval`.
"There's a $toint($eval(10 * 2.2))% chance of survival."
Since the `$eval` is the innermost call, it will get a string as input - the string `"10 * 2.2"`.
It evaluates this and returns the `float` `22.0`. This time the outermost `$toint` will be called with
this `float` instead of with a string.
> It's important to safely validate your inputs since users may end up nesting your callables in any order.
> See the next section for useful tools to help with this.
In these examples, the result will be embedded in the larger string, so the result of the entire parsing
will be a string:
```python
parser.parse(above_string)
"There's a 22% chance of survival."
```
However, if you use the `parse_to_any` (or `parse(..., return_str=True)`) and _don't add any extra string around the outermost function call_,
you'll get the return type of the outermost callable back:
```python
parser.parse_to_any("$toint($eval(10 * 2.2)%")
"22%"
parser.parse_to_any("$toint($eval(10 * 2.2)")
22
```
### Safe convertion of inputs
Since you don't know in which order users may use your callables, they should always check the types
of its inputs and convert to the type the callable needs. Note also that when converting from strings,
there are limits what inputs you can support. This is because FunctionParser strings are often used by
non-developer players/builders and some things (such as complex classes/callables etc) are just not
safe/possible to convert from string representation.
In `evennia.utils.utils` is a helper called
[safe_convert_to_types](api:evennia.utils.utils#evennia.utils.utils.safe_convert_to_types). This function
automates the conversion of simple data types in a safe way:
```python
from evennia.utils.utils import safe_convert_to_types
def _process_callable(*args, **kwargs):
"""
A callable with a lot of custom options
$process(expression, local, extra=34, extra2=foo)
"""
args, kwargs = safe_convert_to_type(
(('py', 'py'), {'extra1': int, 'extra2': str}),
*args, **kwargs)
# args/kwargs should be correct types now
```
In other words,
```python
args, kwargs = safe_convert_to_type(
(tuple_of_arg_converters, dict_of_kwarg_converters), *args, **kwargs)
```
Each converter should be a callable taking one argument - this will be the arg/kwarg-value to convert. The
special converter `"py"` will try to convert a string argument to a Python structure with the help of the
following tools (which you may also find useful to experiment with on your own):
- [ast.literal_eval](https://docs.python.org/3.8/library/ast.html#ast.literal_eval) is an in-built Python
function. It
_only_ supports strings, bytes, numbers, tuples, lists, dicts, sets, booleans and `None`. That's
it - no arithmetic or modifications of data is allowed. This is good for converting individual values and
lists/dicts from the input line to real Python objects.
- [simpleeval](https://pypi.org/project/simpleeval/) is a third-party tool included with Evennia. This
allows for evaluation of simple (and thus safe) expressions. One can operate on numbers and strings
with +-/* as well as do simple comparisons like `4 > 3` and more. It does _not_ accept more complex
containers like lists/dicts etc, so this and `literal_eval` are complementary to each other.
```warning::
It may be tempting to run use Python's in-built ``eval()`` or ``exec()`` functions as converters since
these are able to convert any valid Python source code to Python. NEVER DO THIS unless you really, really
know that ONLY developers will ever modify the string going into the callable. The parser is intended
for untrusted users (if you were trusted you'd have access to Python already). Letting untrusted users
pass strings to ``eval``/``exec`` is a MAJOR security risk. It allows the caller to run arbitrary
Python code on your server. This is the path to maliciously deleted hard drives. Just don't do it and
sleep better at night.
```
## Default callables
These are some example callables you can import and add your parser. They are divided into
global-level dicts in `evennia.utils.funcparser`. Just import the dict(s) and merge/add one or
more to them when you create your `FuncParser` instance to have those callables be available.
### `evennia.utils.funcparser.FUNCPARSER_CALLABLES`
These are the 'base' callables.
- `$eval(expression)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_eval)) -
this uses `literal_eval` and `simple_eval` (see previous section) attemt to convert a string expression
to a python object. This handles e.g. lists of literals `[1, 2, 3]` and simple expressions like `"1 + 2"`.
- `$toint(number)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_toint)) -
always converts an output to an integer, if possible.
- `$add/sub/mult/div(obj1, obj2)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_add)) -
this adds/subtracts/multiplies and divides to elements together. While simple addition could be done with
`$eval`, this could for example be used also to add two lists together, which is not possible with `eval`;
for example `$add($eval([1,2,3]), $eval([4,5,6])) -> [1, 2, 3, 4, 5, 6]`.
- `$round(float, significant)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_round)) -
rounds an input float into the number of provided significant digits. For example `$round(3.54343, 3) -> 3.543`.
- `$random([start, [end]])` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_random)) -
this works like the Python `random()` function, but will randomize to an integer value if both start/end are
integers. Without argument, will return a float between 0 and 1.
- `$randint([start, [end]])` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_randint)) -
works like the `randint()` python function and always returns an integer.
- `$choice(list)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_choice)) -
the input will automatically be parsed the same way as `$eval` and is expected to be an iterable. A random
element of this list will be returned.
- `$pad(text[, width, align, fillchar])` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_pad)) -
this will pad content. `$pad("Hello", 30, c, -)` will lead to a text centered in a 30-wide block surrounded by `-`
characters.
- `$crop(text, width=78, suffix='[...]')` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_crop)) -
this will crop a text longer than the width, by default ending it with a `[...]`-suffix that also fits within
the width. If no width is given, the client width or `settings.DEFAULT_CLIENT_WIDTH` will be used.
- `$space(num)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_space)) -
this will insert `num` spaces.
- `$just(string, width=40, align=c, indent=2)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_justify)) -
justifies the text to a given width, aligning it left/right/center or 'f' for full (spread text across width).
- `$ljust` - shortcut to justify-left. Takes all other kwarg of `$just`.
- `$rjust` - shortcut to right justify.
- `$cjust` - shortcut to center justify.
- `$clr(startcolor, text[, endcolor])` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_clr)) -
color text. The color is given with one or two characters without the preceeding `|`. If no endcolor is
given, the string will go back to neutral, so `$clr(r, Hello)` is equivalent to `|rHello|n`.
### `evennia.utils.funcparser.SEARCHING_CALLABLES`
These are callables that requires access-checks in order to search for objects. So they require some
extra reserved kwargs to be passed when running the parser:
```python
parser.parse_to_any(string, caller=<object or account>, access="control", ...)`
```
The `caller` is required, it's the the object to do the access-check for. The `access` kwarg is the
[lock type](./Locks) to check, default being `"control"`.
- `$search(query,type=account|script,return_list=False)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_search)) -
this will look up and try to match an object by key or alias. Use the `type` kwarg to
search for `account` or `script` instead. By default this will return nothing if there are more than one
match; if `return_list` is `True` a list of 0, 1 or more matches will be returned instead.
- `$obj(query)`, `$dbref(query)` - legacy aliases for `$search`.
- `$objlist(query)` - legacy alias for `$search`, always returning a list.
### `evennia.utils.funcparser.ACTOR_STANCE_CALLABLES`
These are used to implement actor-stance emoting. They are used by the
[DefaultObject.msg_contents](api:evennia.objects.objects#evennia.objects.objects.DefaultObject.msg_contents) method
by default.
These all require extra kwargs be passed into the parser:
```python
parser.parse(string, caller=<obj>, receiver=<obj>, mapping={'key': <obj>, ...})
```
Here the `caller` is the one sending the message and `receiver` the one to see it. The `mapping` contains
references to other objects accessible via these callables.
- `$you([key])` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_you)) -
if no `key` is given, this represents the `caller`, otherwise an object from `mapping`
will be used. As this message is sent to different recipients, the `receiver` will change and this will
be replaced either with the string `you` (if you and the receiver is the same entity) or with the
result of `you_obj.get_display_name(looker=receiver)`. This allows for a single string to echo differently
depending on who sees it, and also to reference other people in the same way.
- `$You([key])` - same as `$you` but always capitalized.
- `$conj(verb)` ([code](api:evennia.utils.funcparser#evennia.utils.funcparser.funcparser_callable_conjugate)) -- conjugates a verb between 4nd person presens to 3rd person presence depending on who
sees the string. For example `"$You() $conj(smiles)".` will show as "You smile." and "Tom smiles." depending
on who sees it. This makes use of the tools in [evennia.utils.verb_conjugation](api:evennia.utils.verb_conjugation)
to do this, and only works for English verbs.
### Example
Here's an example of including the default callables together with two custom ones.
```python
from evennia.utils import funcparser
from evennia.utils import gametime
def _dashline(*args, **kwargs):
if args:
return f"\n-------- {args[0]} --------"
return ''
def _uptime(*args, **kwargs):
return gametime.uptime()
callables = {
"dashline": _dashline,
"uptime": _uptime,
**funcparser.FUNCPARSER_CALLABLES,
**funcparser.ACTOR_STANCE_CALLABLES,
**funcparser.SEARCHING_CALLABLES
}
parser = funcparser.FuncParser(callables)
string = "This is the current uptime:$dashline($toint($uptime()) seconds)"
result = parser.parse(string)
```
Above we define two callables `_dashline` and `_uptime` and map them to names `"dashline"` and `"uptime"`,
which is what we then can call as `$header` and `$uptime` in the string. We also have access to
all the defaults (like `$toint()`).
The parsed result of the above would be something like this:
This is the current uptime:
------- 343 seconds -------

View file

@ -3,7 +3,7 @@
Whether due to abuse, blatant breaking of your rules, or some other reason, you will eventually find
no other recourse but to kick out a particularly troublesome player. The default command set has
admin tools to handle this, primarily `@ban`, `@unban`, and `@boot`.
admin tools to handle this, primarily `ban`, `unban`, and `boot`.
## Creating a ban
@ -16,7 +16,7 @@ have tried to be nice. Now you just want this troll gone.
The easiest recourse is to block the account YouSuck from ever connecting again.
@ban YouSuck
ban YouSuck
This will lock the name YouSuck (as well as 'yousuck' and any other capitalization combination), and
next time they try to log in with this name the server will not let them!
@ -24,12 +24,12 @@ next time they try to log in with this name the server will not let them!
You can also give a reason so you remember later why this was a good thing (the banned account will
never see this)
@ban YouSuck:This is just a troll.
ban YouSuck:This is just a troll.
If you are sure this is just a spam account, you might even consider deleting the player account
outright:
@delaccount YouSuck
accounts/delete YouSuck
Generally, banning the name is the easier and safer way to stop the use of an account -- if you
change your mind you can always remove the block later whereas a deletion is permanent.
@ -49,7 +49,7 @@ the `who` command, which will show you something like this:
The "Host" bit is the IP address from which the account is connecting. Use this to define the ban
instead of the name:
@ban 237.333.0.223
ban 237.333.0.223
This will stop YouSuckMore connecting from their computer. Note however that IP address might change
easily - either due to how the player's Internet Service Provider operates or by the user simply
@ -58,7 +58,7 @@ groups of three digits in the address. So if you figure out that !YouSuckMore ma
237.333.0.223, 237.333.0.225, and 237.333.0.256 (only changes in their subnet), it might be an idea
to put down a ban like this to include any number in that subnet:
@ban 237.333.0.*
ban 237.333.0.*
You should combine the IP ban with a name-ban too of course, so the account YouSuckMore is truly
locked regardless of where they connect from.
@ -71,16 +71,16 @@ blocking out innocent players who just happen to connect from the same subnet as
YouSuck is not really noticing all this banning yet though - and won't until having logged out and
trying to log back in again. Let's help the troll along.
@boot YouSuck
boot YouSuck
Good riddance. You can give a reason for booting too (to be echoed to the player before getting
kicked out).
@boot YouSuck:Go troll somewhere else.
boot YouSuck:Go troll somewhere else.
### Lifting a ban
Use the `@unban` (or `@ban`) command without any arguments and you will see a list of all currently
Use the `unban` (or `ban`) command without any arguments and you will see a list of all currently
active bans:
Active bans
@ -90,7 +90,7 @@ active bans:
Use the `id` from this list to find out which ban to lift.
@unban 2
unban 2
Cleared ban 2: 237.333.0.*
@ -132,7 +132,7 @@ case) the lock to fail.
- **type thomas = FlowerPot** -- Turn an annoying player into a flower pot (assuming you have a
`FlowerPot` typeclass ready)
- **userpassword thomas = fooBarFoo** -- Change a user's password
- **delaccount thomas** -- Delete a player account (not recommended, use **ban** instead)
- **accounts/delete thomas** -- Delete a player account (not recommended, use **ban** instead)
- **server** -- Show server statistics, such as CPU load, memory usage, and how many objects are
cached
@ -141,4 +141,4 @@ cached
- **reset** -- Restarts the server, kicking all connections
- **shutdown** -- Stops the server cold without it auto-starting again
- **py** -- Executes raw Python code, allows for direct inspection of the database and account
objects on the fly. For advanced users.
objects on the fly. For advanced users.

View file

@ -0,0 +1,21 @@
## Clickable links
Evennia supports clickable links for clients that supports it. This marks certain text so it can be
clicked by a mouse and trigger a given Evennia command. To support clickable links, Evennia requires
the webclient or an third-party telnet client with [MXP](http://www.zuggsoft.com/zmud/mxp.htm)
support (*Note: Evennia only supports clickable links, no other MXP features*).
- `|lc` to start the link, by defining the command to execute.
- `|lt` to continue with the text to show to the user (the link text).
- `|le` to end the link text and the link definition.
All elements must appear in exactly this order to make a valid link. For example,
```
"If you go |lcnorth|ltto the north|le you will find a cottage."
```
This will display as "If you go __to the north__ you will find a cottage." where clicking the link
will execute the command `north`. If the client does not support clickable links, only the link text
will be shown.

View file

@ -0,0 +1,183 @@
# Colors
*Note that the Documentation does not display colour the way it would look on the screen.*
Color can be a very useful tool for your game. It can be used to increase readability and make your
game more appealing visually.
Remember however that, with the exception of the webclient, you generally don't control the client
used to connect to the game. There is, for example, one special tag meaning "yellow". But exactly
*which* hue of yellow is actually displayed on the user's screen depends on the settings of their
particular mud client. They could even swap the colours around or turn them off altogether if so
desired. Some clients don't even support color - text games are also played with special reading
equipment by people who are blind or have otherwise diminished eyesight.
So a good rule of thumb is to use colour to enhance your game but don't *rely* on it to display
critical information. If you are coding the game, you can add functionality to let users disable
colours as they please, as described [here](../Howto/Manually-Configuring-Color).
To see which colours your client support, use the default `@color` command. This will list all
available colours for ANSI and Xterm256 along with the codes you use for them. You can find a list
of all the parsed `ANSI`-colour codes in `evennia/utils/ansi.py`.
### ANSI colours
Evennia supports the `ANSI` standard for text. This is by far the most supported MUD-color standard,
available in all but the most ancient mud clients. The ANSI colours are **r**ed, **g**reen,
**y**ellow, **b**lue, **m**agenta, **c**yan, **w**hite and black. They are abbreviated by their
first letter except for black which is abbreviated with the letter **x**. In ANSI there are "bright"
and "normal" (darker) versions of each color, adding up to a total of 16 colours to use for
foreground text. There are also 8 "background" colours. These have no bright alternative in ANSI
(but Evennia uses the [Xterm256](./TextTags#xterm256-colours) extension behind the scenes to offer
them anyway).
To colour your text you put special tags in it. Evennia will parse these and convert them to the
correct markup for the client used. If the user's client/console/display supports ANSI colour, they
will see the text in the specified colour, otherwise the tags will be stripped (uncolored text).
This works also for non-terminal clients, such as the webclient. For the webclient, Evennia will
translate the codes to HTML RGB colors.
Here is an example of the tags in action:
|rThis text is bright red.|n This is normal text.
|RThis is a dark red text.|n This is normal text.
|[rThis text has red background.|n This is normal text.
|b|[yThis is bright blue text on yellow background.|n This is normal text.
- `|n` - this tag will turn off all color formatting, including background colors.
- `|#`- markup marks the start of foreground color. The case defines if the text is "bright" or
"normal". So `|g` is a bright green and `|G` is "normal" (darker) green.
- `|[#` is used to add a background colour to the text. The case again specifies if it is "bright"
or "normal", so `|[c` starts a bright cyan background and `|[C` a darker cyan background.
- `|!#` is used to add foreground color without any enforced brightness/normal information.
These are normal-intensity and are thus always given as uppercase, such as
`|!R` for red. The difference between e.g. `|!R` and `|R` is that
`|!R` will "inherit" the brightness setting from previously set color tags, whereas `|R` will
always reset to the normal-intensity red. The `|#` format contains an implicit `|h`/`|H` tag in it:
disabling highlighting when switching to a normal color, and enabling it for bright ones. So `|btest
|!Rtest2` will result in a bright red `test2` since the brightness setting from `|b` "bleeds over".
You could use this to for example quickly switch the intensity of a multitude of color tags. There
is no background-color equivalent to `|!` style tags.
- `|h` is used to make any following foreground ANSI colors bright (it has no effect on Xterm
colors). This is only relevant to use with `|!` type tags and will be valid until the next `|n`,
`|H` or normal (upper-case) `|#` tag. This tag will never affect background colors, those have to be
set bright/normal explicitly. Technically, `|h|!G` is identical to `|g`.
- `|H` negates the effects `|h` and returns all ANSI foreground colors (`|!` and `|` types) to
'normal' intensity. It has no effect on background and Xterm colors.
> Note: The ANSI standard does not actually support bright backgrounds like `|[r` - the standard
only supports "normal" intensity backgrounds. To get around this Evennia instead implements these
as [Xterm256 colours](./TextTags#xterm256-colours) behind the scenes. If the client does not support
Xterm256 the ANSI colors will be used instead and there will be no visible difference between using
upper- and lower-case background tags.
If you want to display an ANSI marker as output text (without having any effect), you need to escape
it by preceding its `|` with another `|`:
```
say The ||r ANSI marker changes text color to bright red.
```
This will output the raw `|r` without any color change. This can also be necessary if you are doing
ansi art that uses `|` with a letter directly following it.
Use the command
@color ansi
to get a list of all supported ANSI colours and the tags used to produce them.
A few additional ANSI codes are supported:
- `|/` A line break. You cannot put the normal Python `\n` line breaks in text entered inside the
game (Evennia will filter this for security reasons). This is what you use instead: use the `|/`
marker to format text with line breaks from the game command line.
- `` This will translate into a `TAB` character. This will not always show (or show differently) to
the client since it depends on their local settings. It's often better to use multiple spaces.
- `|_` This is a space. You can usually use the normal space character, but if the space is *at the
end of the line*, Evennia will likely crop it. This tag will not be cropped but always result in a
space.
- `|*` This will invert the current text/background colours. Can be useful to mark things (but see
below).
##### Caveats of `|*`
The `|*` tag (inverse video) is an old ANSI standard and should usually not be used for more than to
mark short snippets of text. If combined with other tags it comes with a series of potentially
confusing behaviors:
* The `|*` tag will only work once in a row:, ie: after using it once it won't have an effect again
until you declare another tag. This is an example:
```
Normal text, |*reversed text|*, still reversed text.
```
that is, it will not reverse to normal at the second `|*`. You need to reset it manually:
```
Normal text, |*reversed text|n, normal again.
```
* The `|*` tag does not take "bright" colors into account:
```
|RNormal red, |hnow brightened. |*BG is normal red.
```
So `|*` only considers the 'true' foreground color, ignoring any highlighting. Think of the bright
state (`|h`) as something like like `<strong>` in HTML: it modifies the _appearance_ of a normal
foreground color to match its bright counterpart, without changing its normal color.
* Finally, after a `|*`, if the previous background was set to a dark color (via `|[`), `|!#`) will
actually change the background color instead of the foreground:
```
|*reversed text |!R now BG is red.
```
For a detailed explanation of these caveats, see the [Understanding Color Tags](Understanding-Color-
Tags) tutorial. But most of the time you might be better off to simply avoid `|*` and mark your text
manually instead.
### Xterm256 Colours
The _Xterm256_ standard is a colour scheme that supports 256 colours for text and/or background.
While this offers many more possibilities than traditional ANSI colours, be wary that too many text
colors will be confusing to the eye. Also, not all clients support Xterm256 - these will instead see
the closest equivalent ANSI color. You can mix Xterm256 tags with ANSI tags as you please.
|555 This is pure white text.|n This is normal text.
|230 This is olive green text.
|[300 This text has a dark red background.
|005|[054 This is dark blue text on a bright cyan background.
|=a This is a greyscale value, equal to black.
|=m This is a greyscale value, midway between white and black.
|=z This is a greyscale value, equal to white.
|[=m This is a background greyscale value.
- `|###` - markup consists of three digits, each an integer from 0 to 5. The three digits describe
the amount of **r**ed, **g**reen and **b**lue (RGB) components used in the colour. So `|500` means
maximum red and none of the other colours - the result is a bright red. `|520` is red with a touch
of green - the result is orange. As opposed to ANSI colors, Xterm256 syntax does not worry about
bright/normal intensity, a brighter (lighter) color is just achieved by upping all RGB values with
the same amount.
- `|[###` - this works the same way but produces a coloured background.
- `|=#` - markup produces the xterm256 gray scale tones, where `#` is a letter from `a` (black) to
`z` (white). This offers many more nuances of gray than the normal `|###` markup (which only has
four gray tones between solid black and white (`|000`, `|111`, `|222`, `|333` and `|444`)).
- `|[=#` - this works in the same way but produces background gray scale tones.
If you have a client that supports Xterm256, you can use
@color xterm256
to get a table of all the 256 colours and the codes that produce them. If the table looks broken up
into a few blocks of colors, it means Xterm256 is not supported and ANSI are used as a replacement.
You can use the `@options` command to see if xterm256 is active for you. This depends on if your
client told Evennia what it supports - if not, and you know what your client supports, you may have
to activate some features manually.
## More reading
There is an [Understanding Color Tags](../Howto/Understanding-Color-Tags) tutorial which expands on the
use of ANSI color tags and the pitfalls of mixing ANSI and Xterms256 color tags in the same context.

View file

@ -1,340 +1,19 @@
# TextTags
This documentation details the various text tags supported by Evennia, namely *colours*, *command
links* and *inline functions*.
There is also an [Understanding Color Tags](../Howto/Understanding-Color-Tags) tutorial which expands on the
use of ANSI color tags and the pitfalls of mixing ANSI and Xterms256 color tags in the same context.
## Coloured text
*Note that the Documentation does not display colour the way it would look on the screen.*
Color can be a very useful tool for your game. It can be used to increase readability and make your
game more appealing visually.
Remember however that, with the exception of the webclient, you generally don't control the client
used to connect to the game. There is, for example, one special tag meaning "yellow". But exactly
*which* hue of yellow is actually displayed on the user's screen depends on the settings of their
particular mud client. They could even swap the colours around or turn them off altogether if so
desired. Some clients don't even support color - text games are also played with special reading
equipment by people who are blind or have otherwise diminished eyesight.
So a good rule of thumb is to use colour to enhance your game but don't *rely* on it to display
critical information. If you are coding the game, you can add functionality to let users disable
colours as they please, as described [here](../Howto/Manually-Configuring-Color).
To see which colours your client support, use the default `@color` command. This will list all
available colours for ANSI and Xterm256 along with the codes you use for them. You can find a list
of all the parsed `ANSI`-colour codes in `evennia/utils/ansi.py`.
### ANSI colours
Evennia supports the `ANSI` standard for text. This is by far the most supported MUD-color standard,
available in all but the most ancient mud clients. The ANSI colours are **r**ed, **g**reen,
**y**ellow, **b**lue, **m**agenta, **c**yan, **w**hite and black. They are abbreviated by their
first letter except for black which is abbreviated with the letter **x**. In ANSI there are "bright"
and "normal" (darker) versions of each color, adding up to a total of 16 colours to use for
foreground text. There are also 8 "background" colours. These have no bright alternative in ANSI
(but Evennia uses the [Xterm256](./TextTags#xterm256-colours) extension behind the scenes to offer
them anyway).
To colour your text you put special tags in it. Evennia will parse these and convert them to the
correct markup for the client used. If the user's client/console/display supports ANSI colour, they
will see the text in the specified colour, otherwise the tags will be stripped (uncolored text).
This works also for non-terminal clients, such as the webclient. For the webclient, Evennia will
translate the codes to HTML RGB colors.
Here is an example of the tags in action:
|rThis text is bright red.|n This is normal text.
|RThis is a dark red text.|n This is normal text.
|[rThis text has red background.|n This is normal text.
|b|[yThis is bright blue text on yellow background.|n This is normal text.
- `|n` - this tag will turn off all color formatting, including background colors.
- `|#`- markup marks the start of foreground color. The case defines if the text is "bright" or
"normal". So `|g` is a bright green and `|G` is "normal" (darker) green.
- `|[#` is used to add a background colour to the text. The case again specifies if it is "bright"
or "normal", so `|[c` starts a bright cyan background and `|[C` a darker cyan background.
- `|!#` is used to add foreground color without any enforced brightness/normal information.
These are normal-intensity and are thus always given as uppercase, such as
`|!R` for red. The difference between e.g. `|!R` and `|R` is that
`|!R` will "inherit" the brightness setting from previously set color tags, whereas `|R` will
always reset to the normal-intensity red. The `|#` format contains an implicit `|h`/`|H` tag in it:
disabling highlighting when switching to a normal color, and enabling it for bright ones. So `|btest
|!Rtest2` will result in a bright red `test2` since the brightness setting from `|b` "bleeds over".
You could use this to for example quickly switch the intensity of a multitude of color tags. There
is no background-color equivalent to `|!` style tags.
- `|h` is used to make any following foreground ANSI colors bright (it has no effect on Xterm
colors). This is only relevant to use with `|!` type tags and will be valid until the next `|n`,
`|H` or normal (upper-case) `|#` tag. This tag will never affect background colors, those have to be
set bright/normal explicitly. Technically, `|h|!G` is identical to `|g`.
- `|H` negates the effects `|h` and returns all ANSI foreground colors (`|!` and `|` types) to
'normal' intensity. It has no effect on background and Xterm colors.
> Note: The ANSI standard does not actually support bright backgrounds like `|[r` - the standard
only supports "normal" intensity backgrounds. To get around this Evennia instead implements these
as [Xterm256 colours](./TextTags#xterm256-colours) behind the scenes. If the client does not support
Xterm256 the ANSI colors will be used instead and there will be no visible difference between using
upper- and lower-case background tags.
If you want to display an ANSI marker as output text (without having any effect), you need to escape
it by preceding its `|` with another `|`:
```
say The ||r ANSI marker changes text color to bright red.
```
This will output the raw `|r` without any color change. This can also be necessary if you are doing
ansi art that uses `|` with a letter directly following it.
Use the command
@color ansi
to get a list of all supported ANSI colours and the tags used to produce them.
A few additional ANSI codes are supported:
- `|/` A line break. You cannot put the normal Python `\n` line breaks in text entered inside the
game (Evennia will filter this for security reasons). This is what you use instead: use the `|/`
marker to format text with line breaks from the game command line.
- `` This will translate into a `TAB` character. This will not always show (or show differently) to
the client since it depends on their local settings. It's often better to use multiple spaces.
- `|_` This is a space. You can usually use the normal space character, but if the space is *at the
end of the line*, Evennia will likely crop it. This tag will not be cropped but always result in a
space.
- `|*` This will invert the current text/background colours. Can be useful to mark things (but see
below).
##### Caveats of `|*`
The `|*` tag (inverse video) is an old ANSI standard and should usually not be used for more than to
mark short snippets of text. If combined with other tags it comes with a series of potentially
confusing behaviors:
* The `|*` tag will only work once in a row:, ie: after using it once it won't have an effect again
until you declare another tag. This is an example:
```
Normal text, |*reversed text|*, still reversed text.
```
that is, it will not reverse to normal at the second `|*`. You need to reset it manually:
```
Normal text, |*reversed text|n, normal again.
```
* The `|*` tag does not take "bright" colors into account:
```
|RNormal red, |hnow brightened. |*BG is normal red.
```
So `|*` only considers the 'true' foreground color, ignoring any highlighting. Think of the bright
state (`|h`) as something like like `<strong>` in HTML: it modifies the _appearance_ of a normal
foreground color to match its bright counterpart, without changing its normal color.
* Finally, after a `|*`, if the previous background was set to a dark color (via `|[`), `|!#`) will
actually change the background color instead of the foreground:
```
|*reversed text |!R now BG is red.
```
For a detailed explanation of these caveats, see the [Understanding Color Tags](Understanding-Color-
Tags) tutorial. But most of the time you might be better off to simply avoid `|*` and mark your text
manually instead.
### Xterm256 Colours
The _Xterm256_ standard is a colour scheme that supports 256 colours for text and/or background.
While this offers many more possibilities than traditional ANSI colours, be wary that too many text
colors will be confusing to the eye. Also, not all clients support Xterm256 - these will instead see
the closest equivalent ANSI color. You can mix Xterm256 tags with ANSI tags as you please.
|555 This is pure white text.|n This is normal text.
|230 This is olive green text.
|[300 This text has a dark red background.
|005|[054 This is dark blue text on a bright cyan background.
|=a This is a greyscale value, equal to black.
|=m This is a greyscale value, midway between white and black.
|=z This is a greyscale value, equal to white.
|[=m This is a background greyscale value.
- `|###` - markup consists of three digits, each an integer from 0 to 5. The three digits describe
the amount of **r**ed, **g**reen and **b**lue (RGB) components used in the colour. So `|500` means
maximum red and none of the other colours - the result is a bright red. `|520` is red with a touch
of green - the result is orange. As opposed to ANSI colors, Xterm256 syntax does not worry about
bright/normal intensity, a brighter (lighter) color is just achieved by upping all RGB values with
the same amount.
- `|[###` - this works the same way but produces a coloured background.
- `|=#` - markup produces the xterm256 gray scale tones, where `#` is a letter from `a` (black) to
`z` (white). This offers many more nuances of gray than the normal `|###` markup (which only has
four gray tones between solid black and white (`|000`, `|111`, `|222`, `|333` and `|444`)).
- `|[=#` - this works in the same way but produces background gray scale tones.
If you have a client that supports Xterm256, you can use
@color xterm256
to get a table of all the 256 colours and the codes that produce them. If the table looks broken up
into a few blocks of colors, it means Xterm256 is not supported and ANSI are used as a replacement.
You can use the `@options` command to see if xterm256 is active for you. This depends on if your
client told Evennia what it supports - if not, and you know what your client supports, you may have
to activate some features manually.
## Clickable links
Evennia supports clickable links for clients that supports it. This marks certain text so it can be
clicked by a mouse and trigger a given Evennia command. To support clickable links, Evennia requires
the webclient or an third-party telnet client with [MXP](http://www.zuggsoft.com/zmud/mxp.htm)
support (*Note: Evennia only supports clickable links, no other MXP features*).
- `|lc` to start the link, by defining the command to execute.
- `|lt` to continue with the text to show to the user (the link text).
- `|le` to end the link text and the link definition.
All elements must appear in exactly this order to make a valid link. For example,
```
"If you go |lcnorth|ltto the north|le you will find a cottage."
```
This will display as "If you go __to the north__ you will find a cottage." where clicking the link
will execute the command `north`. If the client does not support clickable links, only the link text
will be shown.
## Inline functions
> Note: Inlinefuncs are **not** activated by default. To use them you need to add
`INLINEFUNC_ENABLED=True` to your settings file.
Evennia has its own inline text formatting language, known as *inlinefuncs*. It allows the builder
to include special function calls in code. They are executed dynamically by each session that
receives them.
To add an inlinefunc, you embed it in a text string like this:
```
"A normal string with $funcname(arg, arg, ...) embedded inside it."
```
When this string is sent to a session (with the `msg()` method), these embedded inlinefuncs will be
parsed. Their return value (which always is a string) replace their call location in the finalized
string. The interesting thing with this is that the function called will have access to which
session is seeing the string, meaning the string can end up looking different depending on who is
looking. It could of course also vary depending on other factors like game time.
Any number of comma-separated arguments can be given (or none). No keywords are supported. You can
also nest inlinefuncs by letting an argument itself also be another `$funcname(arg, arg, ...)` call
(down to any depth of nesting). Function call resolution happens as in all programming languages
inside-out, with the nested calls replacing the argument with their return strings before calling he
parent.
```
> say "This is $pad(a center-padded text, 30,c,-) of width 30."
You say, "This is ---- a center-padded text----- of width 30."
```
A special case happens if wanting to use an inlinefunc argument that itself includes a comma - this
would be parsed as an argument separator. To escape commas you can either escape each comma manually
with a backslash `\,`, or you can embed the entire string in python triple-quotes `"""` or `'''` -
this will escape the entire argument, including commas and any nested inlinefunc calls within.
Only certain functions are available to use as inlinefuncs and the game developer may add their own
functions as needed.
### New inlinefuncs
To add new inlinefuncs, edit the file `mygame/server/conf/inlinefuncs.py`.
*All globally defined functions in this module* are considered inline functions by the system. The
only exception is functions whose name starts with an underscore `_`. An inlinefunc must be of the
following form:
```python
def funcname(*args, **kwargs):
# ...
return modified_text
```
where `*args` denotes all the arguments this function will accept as an `$inlinefunc`. The inline
function is expected to clean arguments and check that they are valid. If needed arguments are not
given, default values should be used. The function should always return a string (even if it's
empty). An inlinefunc should never cause a traceback regardless of the input (but it could log
errors if desired).
Note that whereas the function should accept `**kwargs`, keyword inputs are *not* usable in the call
to the inlinefunction. The `kwargs` part is instead intended for Evennia to be able to supply extra
information. Currently Evennia sends a single keyword to every inline function and that is
`session`, which holds the [serversession](../Components/Sessions) this text is targeted at. Through the session
object, a lot of dynamic possibilities are opened up for your inline functions.
The `settings.INLINEFUNC_MODULES` configuration option is a list that decides which modules should
be parsed for inline function definitions. This will include `mygame/server/conf/inlinefuncs.py` but
more could be added. The list is read from left to right so if you want to overload default
functions you just have to put your custom module-paths later in the list and name your functions
the same as default ones.
Here is an example, the `crop` default inlinefunction:
```python
from evennia.utils import utils
def crop(*args, **kwargs):
"""
Inlinefunc. Crops ingoing text to given widths.
Args:
text (str, optional): Text to crop.
width (str, optional): Will be converted to an integer. Width of
crop in characters.
suffix (str, optional): End string to mark the fact that a part
of the string was cropped. Defaults to `[...]`.
Kwargs:
session (Session): Session performing the crop.
Example:
`$crop(text, 50, [...])`
"""
text, width, suffix = "", 78, "[...]"
nargs = len(args)
if nargs > 0:
text = args[0]
if nargs > 1:
width = int(args[1]) if args[1].strip().isdigit() else 78
if nargs > 2:
suffix = args[2]
return utils.crop(text, width=width, suffix=suffix)
```
Another example, making use of the Session:
```python
def charactername(*args, **kwargs):
"""
Inserts the character name of whomever sees the string
(so everyone will see their own name). Uses the account
name for OOC communications.
Example:
say "This means YOU, $charactername()!"
"""
session = kwargs["session"]
if session.puppet:
return kwargs["session"].puppet.key
else:
return session.account.key
```
Evennia itself offers the following default inline functions (mostly as examples):
* `crop(text, width, suffix)` - See above.
* `pad(text, width, align, fillchar)` - this pads the text to `width` (default 78), alignment ("c",
"l" or "r", defaulting to "c") and fill-in character (defaults to space). Example: `$pad(40,l,-)`
* `clr(startclr, text, endclr)` - A programmatic way to enter colored text for those who don't want
to use the normal `|c` type color markers for some reason. The `color` argument is the same as the
color markers except without the actual pre-marker, so `|r` would be just `r`. If `endclr` is not
given, it defaults to resetting the color (`n`). Example: `$clr(b, A blue text)`
* `space(number)` - Inserts the given number of spaces. If no argument is given, use 4 spaces.
# In-text tags parsed by Evennia
Evennia understands various extra information embedded in text:
- [Colors](./Colors) - Using `|r`, `|n` etc can be used to mark parts of text with a color. The color will
become ANSI/XTerm256 color tags for Telnet connections and CSS information for the webclient.
- [Clickable links](./Clickable-Links) - This allows you to provide a text the user can click to execute an
in-game command. This is on the form `|lc command |lt text |le`.
- [FuncParser callables](../Components/FuncParser) - These are full-fledged function calls on the form `$funcname(args, kwargs)`
that lead to calls to Python functions. The parser can be run with different available callables in different
circumstances. The parser is run on all outgoing messages if `settings.FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED=True`
(disabled by default).
```toctree::
Colors.md
Clickable-Links.md
../Components/FuncParser.md
```

View file

@ -77,6 +77,7 @@ The flat API is defined in `__init__.py` [viewable here](github:evennia/__init__
- [evennia.EvForm](api:evennia.utils.evform#evennia.utils.evform.EvForm) - text form creator
- Evennia.EvMore - text paginator
- [evennia.EvEditor](api:evennia.utils.eveditor#evennia.utils.eveditor.EvEditor) - in game text line editor ([docs](Components/EvEditor))
- [evennia.utils.funcparser.Funcparser](api:evennia.utils.funcparser) - inline parsing of functions ([docs](Components/FuncParser))
### Global singleton handlers

View file

@ -0,0 +1,7 @@
evennia.utils.funcparser
===============================
.. automodule:: evennia.utils.funcparser
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,7 +0,0 @@
evennia.utils.inlinefuncs
================================
.. automodule:: evennia.utils.inlinefuncs
:members:
:undoc-members:
:show-inheritance:

View file

@ -21,8 +21,8 @@ evennia.utils
evennia.utils.evmenu
evennia.utils.evmore
evennia.utils.evtable
evennia.utils.funcparser
evennia.utils.gametime
evennia.utils.inlinefuncs
evennia.utils.logger
evennia.utils.optionclasses
evennia.utils.optionhandler
@ -38,3 +38,4 @@ evennia.utils
:maxdepth: 6
evennia.utils.idmapper
evennia.utils.verb_conjugation

View file

@ -0,0 +1,7 @@
evennia.utils.verb\_conjugation.conjugate
================================================
.. automodule:: evennia.utils.verb_conjugation.conjugate
:members:
:undoc-members:
:show-inheritance:

View file

@ -0,0 +1,15 @@
evennia.utils.verb\_conjugation
=======================================
.. automodule:: evennia.utils.verb_conjugation
:members:
:undoc-members:
:show-inheritance:
.. toctree::
:maxdepth: 6
evennia.utils.verb_conjugation.conjugate
evennia.utils.verb_conjugation.tests

View file

@ -0,0 +1,7 @@
evennia.utils.verb\_conjugation.tests
============================================
.. automodule:: evennia.utils.verb_conjugation.tests
:members:
:undoc-members:
:show-inheritance:

View file

@ -1,5 +1,5 @@
# Toc
- [API root](api/evennia-api.rst)
- [Coding/Coding Introduction](Coding/Coding-Introduction)
- [Coding/Coding Overview](Coding/Coding-Overview)
- [Coding/Continuous Integration](Coding/Continuous-Integration)
@ -29,6 +29,7 @@
- [Components/EvEditor](Components/EvEditor)
- [Components/EvMenu](Components/EvMenu)
- [Components/EvMore](Components/EvMore)
- [Components/FuncParser](Components/FuncParser)
- [Components/Help System](Components/Help-System)
- [Components/Inputfuncs](Components/Inputfuncs)
- [Components/Locks](Components/Locks)
@ -52,6 +53,8 @@
- [Concepts/Banning](Concepts/Banning)
- [Concepts/Bootstrap & Evennia](Concepts/Bootstrap-&-Evennia)
- [Concepts/Building Permissions](Concepts/Building-Permissions)
- [Concepts/Clickable Links](Concepts/Clickable-Links)
- [Concepts/Colors](Concepts/Colors)
- [Concepts/Concepts Overview](Concepts/Concepts-Overview)
- [Concepts/Custom Protocols](Concepts/Custom-Protocols)
- [Concepts/Guest Logins](Concepts/Guest-Logins)

View file

@ -7,7 +7,7 @@ from django.db.models import Q, Min, Max
from evennia.objects.models import ObjectDB
from evennia.locks.lockhandler import LockException
from evennia.commands.cmdhandler import get_and_merge_cmdsets
from evennia.utils import create, utils, search, logger
from evennia.utils import create, utils, search, logger, funcparser
from evennia.utils.utils import (
inherits_from,
class_from_module,
@ -22,10 +22,11 @@ from evennia.utils.eveditor import EvEditor
from evennia.utils.evmore import EvMore
from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
from evennia.utils.ansi import raw as ansi_raw
from evennia.utils.inlinefuncs import raw as inlinefunc_raw
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
_FUNCPARSER = None
# limit symbol import for API
__all__ = (
"ObjManipCommand",
@ -2122,7 +2123,8 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
)
if "prototype" in self.switches:
modified = spawner.batch_update_objects_with_prototype(prototype, objects=[obj])
modified = spawner.batch_update_objects_with_prototype(
prototype, objects=[obj], caller=self.caller)
prototype_success = modified > 0
if not prototype_success:
caller.msg("Prototype %s failed to apply." % prototype["key"])
@ -2380,12 +2382,16 @@ class CmdExamine(ObjManipCommand):
value (any): Attribute value.
Returns:
"""
global _FUNCPARSER
if not _FUNCPARSER:
_FUNCPARSER = funcparser.FuncParser(settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES)
if attr is None:
return "No such attribute was found."
value = utils.to_str(value)
if crop:
value = utils.crop(value)
value = inlinefunc_raw(ansi_raw(value))
value = _FUNCPARSER.parse(ansi_raw(value), escape=True)
if category:
return f"{attr}[{category}] = {value}"
else:
@ -3458,7 +3464,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
"Python structures are allowed. \nMake sure to use correct "
"Python syntax. Remember especially to put quotes around all "
"strings inside lists and dicts.|n For more advanced uses, embed "
"inlinefuncs in the strings."
"funcparser callables ($funcs) in the strings."
)
else:
string = "Expected {}, got {}.".format(expect, type(prototype))
@ -3554,7 +3560,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
return
try:
n_updated = spawner.batch_update_objects_with_prototype(
prototype, objects=existing_objects
prototype, objects=existing_objects, caller=caller,
)
except Exception:
logger.log_trace()
@ -3806,7 +3812,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
# proceed to spawning
try:
for obj in spawner.spawn(prototype):
for obj in spawner.spawn(prototype, caller=self.caller):
self.caller.msg("Spawned %s." % obj.get_display_name(self.caller))
if not prototype.get("location") and not noloc:
# we don't hardcode the location in the prototype (unless the user

View file

@ -695,27 +695,27 @@ If you want there is also some |wextra|n info for where to go beyond that.
After playing through the tutorial-world quest, if you aim to make a game with
Evennia you are wise to take a look at the |wEvennia documentation|n at
|yhttps://github.com/evennia/evennia/wiki|n
|yhttps://www.evennia.com/docs/latest|n
- You can start by trying to build some stuff by following the |wBuilder quick-start|n:
|yhttps://github.com/evennia/evennia/wiki/Building-Quickstart|n
|yhttps://www.evennia.com/docs/latest/Building-Quickstart|n
- The tutorial-world may or may not be your cup of tea, but it does show off
several |wuseful tools|n of Evennia. You may want to check out how it works:
|yhttps://github.com/evennia/evennia/wiki/Tutorial-World-Introduction|n
|yhttps://www.evennia.com/docs/latest/Tutorial-World-Introduction|n
- You can then continue looking through the |wTutorials|n and pick one that
fits your level of understanding.
|yhttps://github.com/evennia/evennia/wiki/Tutorials|n
|yhttps://www.evennia.com/docs/latest/Tutorials|n
- Make sure to |wjoin our forum|n and connect to our |wsupport chat|n! The
Evennia community is very active and friendly and no question is too simple.
You will often quickly get help. You can everything you need linked from
|yhttp://www.evennia.com|n
|yhttps://www.evennia.com|n
# ---------------------------------------------------------------------------------

View file

@ -1,17 +1,17 @@
"""
Inlinefunc
Outgoing callables to apply with the FuncParser on outgoing messages.
Inline functions allow for direct conversion of text users mark in a
special way. Inlinefuncs are deactivated by default. To activate, add
The functions in this module will become available as $funcname(args, kwargs)
in all outgoing strings if you add
INLINEFUNC_ENABLED = True
FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED = True
to your settings file. The default inlinefuncs are found in
evennia.utils.inlinefunc.
to your settings file. The default inlinefuncs are found at the bottom of
`evennia.utils.funcparser`.
In text, usage is straightforward:
$funcname([arg1,[arg2,...]])
$funcname(arg1, arg2, ..., key=val, key2=val2, ...)
Example 1 (using the "pad" inlinefunc):
say This is $pad("a center-padded text", 50,c,-) of width 50.
@ -26,26 +26,14 @@ Example 2 (using nested "pad" and "time" inlinefuncs):
To add more inline functions, add them to this module, using
the following call signature:
def funcname(text, *args, **kwargs)
where `text` is always the part between {funcname(args) and
{/funcname and the *args are taken from the appropriate part of the
call. If no {/funcname is given, `text` will be the empty string.
It is important that the inline function properly clean the
incoming `args`, checking their type and replacing them with sane
defaults if needed. If impossible to resolve, the unmodified text
should be returned. The inlinefunc should never cause a traceback.
While the inline function should accept **kwargs, the keyword is
never accepted as a valid call - this is only intended to be used
internally by Evennia, notably to send the `session` keyword to
the function; this is the session of the object viewing the string
and can be used to customize it to each session.
def funcname(*args, **kwargs)
...
"""
# def capitalize(text, *args, **kwargs):
# "Silly capitalize example. Used as {capitalize() ... {/capitalize"
# def capitalize(*args, **kwargs):
# "Silly capitalize example. Used as $capitalize
# if not args:
# return ''
# session = kwargs.get("session")
# return text.capitalize()
# return args[0].capitalize()

View file

@ -20,6 +20,7 @@ from evennia.objects.models import ObjectDB
from evennia.scripts.scripthandler import ScriptHandler
from evennia.commands import cmdset, command
from evennia.commands.cmdsethandler import CmdSetHandler
from evennia.utils import funcparser
from evennia.utils import create
from evennia.utils import search
from evennia.utils import logger
@ -47,6 +48,12 @@ _COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
# the sessid_max is based on the length of the db_sessid csv field (excluding commas)
_SESSID_MAX = 16 if _MULTISESSION_MODE in (1, 3) else 1
_MSG_CONTENTS_PARSER = funcparser.FuncParser(
{"you": funcparser.funcparser_callable_you,
"You": funcparser.funcparser_callable_You,
"conj": funcparser.funcparser_callable_conjugate
})
class ObjectSessionHandler(object):
"""
@ -717,64 +724,94 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
text (str or tuple): Message to send. If a tuple, this should be
on the valid OOB outmessage form `(message, {kwargs})`,
where kwargs are optional data passed to the `text`
outputfunc.
outputfunc. The message will be parsed for `{key}` formatting and
`$You/$you()/$You(key)` and `$conj(verb)` inline function callables.
The `key` is taken from the `mapping` kwarg {"key": object, ...}`.
The `mapping[key].get_display_name(looker=recipient)` will be called
for that key for every recipient of the string.
exclude (list, optional): A list of objects not to send to.
from_obj (Object, optional): An object designated as the
"sender" of the message. See `DefaultObject.msg()` for
more info.
mapping (dict, optional): A mapping of formatting keys
`{"key":<object>, "key2":<object2>,...}. The keys
must match `{key}` markers in the `text` if this is a string or
in the internal `message` if `text` is a tuple. These
formatting statements will be
replaced by the return of `<object>.get_display_name(looker)`
for every looker in contents that receives the
message. This allows for every object to potentially
get its own customized string.
Keyword Args:
Keyword arguments will be passed on to `obj.msg()` for all
messaged objects.
`{"key":<object>, "key2":<object2>,...}.
The keys must either match `{key}` or `$You(key)/$you(key)` markers
in the `text` string. If `<object>` doesn't have a `get_display_name`
method, it will be returned as a string. If not set, a key `you` will
be auto-added to point to `from_obj` if given, otherwise to `self`.
**kwargs: Keyword arguments will be passed on to `obj.msg()` for all
messaged objects.
Notes:
The `mapping` argument is required if `message` contains
{}-style format syntax. The keys of `mapping` should match
named format tokens, and its values will have their
`get_display_name()` function called for each object in
the room before substitution. If an item in the mapping does
not have `get_display_name()`, its string value will be used.
For 'actor-stance' reporting (You say/Name says), use the
`$You()/$you()/$You(key)` and `$conj(verb)` (verb-conjugation)
inline callables. This will use the respective `get_display_name()`
for all onlookers except for `from_obj or self`, which will become
'You/you'. If you use `$You/you(key)`, the key must be in `mapping`.
Example:
Say Char is a Character object and Npc is an NPC object:
For 'director-stance' reporting (Name says/Name says), use {key}
syntax directly. For both `{key}` and `You/you(key)`,
`mapping[key].get_display_name(looker=recipient)` may be called
depending on who the recipient is.
char.location.msg_contents(
"{attacker} kicks {defender}",
mapping=dict(attacker=char, defender=npc), exclude=(char, npc))
Examples:
This will result in everyone in the room seeing 'Char kicks NPC'
where everyone may potentially see different results for Char and Npc
depending on the results of `char.get_display_name(looker)` and
`npc.get_display_name(looker)` for each particular onlooker
Let's assume
- `player1.key -> "Player1"`,
`player1.get_display_name(looker=player2) -> "The First girl"`
- `player2.key -> "Player2"`,
`player2.get_display_name(looker=player1) -> "The Second girl"`
Actor-stance:
::
char.location.msg_contents(
"$You() $conj(attack) $you(defender).",
mapping={"defender": player2})
- player1 will see `You attack The Second girl.`
- player2 will see 'The First girl attacks you.'
Director-stance:
::
char.location.msg_contents(
"{attacker} attacks {defender}.",
mapping={"attacker:player1, "defender":player2})
- player1 will see: 'Player1 attacks The Second girl.'
- player2 will see: 'The First girl attacks Player2'
"""
# we also accept an outcommand on the form (message, {kwargs})
is_outcmd = text and is_iter(text)
inmessage = text[0] if is_outcmd else text
outkwargs = text[1] if is_outcmd and len(text) > 1 else {}
mapping = mapping or {}
you = from_obj or self
if 'you' not in mapping:
mapping[you] = you
contents = self.contents
if exclude:
exclude = make_iter(exclude)
contents = [obj for obj in contents if obj not in exclude]
for obj in contents:
if mapping:
substitutions = {
t: sub.get_display_name(obj) if hasattr(sub, "get_display_name") else str(sub)
for t, sub in mapping.items()
}
outmessage = inmessage.format(**substitutions)
else:
outmessage = inmessage
obj.msg(text=(outmessage, outkwargs), from_obj=from_obj, **kwargs)
for receiver in contents:
# actor-stance replacements
inmessage = _MSG_CONTENTS_PARSER.parse(
inmessage, raise_errors=True, return_string=True,
caller=you, receiver=receiver, mapping=mapping)
# director-stance replacements
outmessage = inmessage.format(
**{key: obj.get_display_name(looker=receiver)
if hasattr(obj, "get_display_name") else str(obj)
for key, obj in mapping.items()})
receiver.msg(text=(outmessage, outkwargs), from_obj=from_obj, **kwargs)
def move_to(
self,

View file

@ -177,9 +177,7 @@ def _set_property(caller, raw_string, **kwargs):
if kwargs.get("test_parse", True):
out.append(" Simulating prototype-func parsing ...")
err, parsed_value = protlib.protfunc_parser(value, testing=True)
if err:
out.append(" |yPython `literal_eval` warning: {}|n".format(err))
parsed_value = protlib.protfunc_parser(value, testing=True, prototype=prototype)
if parsed_value != value:
out.append(
" |g(Example-)value when parsed ({}):|n {}".format(type(parsed_value), parsed_value)
@ -264,7 +262,7 @@ def _validate_prototype(prototype):
def _format_protfuncs():
out = []
sorted_funcs = [
(key, func) for key, func in sorted(protlib.PROT_FUNCS.items(), key=lambda tup: tup[0])
(key, func) for key, func in sorted(protlib.FUNC_PARSER.callables.items(), key=lambda tup: tup[0])
]
for protfunc_name, protfunc in sorted_funcs:
out.append(
@ -2115,7 +2113,8 @@ def _apply_diff(caller, **kwargs):
objects = kwargs["objects"]
back_node = kwargs["back_node"]
diff = kwargs.get("diff", None)
num_changed = spawner.batch_update_objects_with_prototype(prototype, diff=diff, objects=objects)
num_changed = spawner.batch_update_objects_with_prototype(prototype, diff=diff, objects=objects,
caller=caller)
caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed))
return back_node
@ -2483,7 +2482,7 @@ def _spawn(caller, **kwargs):
if not prototype.get("location"):
prototype["location"] = caller
obj = spawner.spawn(prototype)
obj = spawner.spawn(prototype, caller=caller)
if obj:
obj = obj[0]
text = "|gNew instance|n {key} ({dbref}) |gspawned at location |n{loc}|n|g.|n".format(

View file

@ -1,33 +1,28 @@
"""
Protfuncs are function-strings embedded in a prototype and allows for a builder to create a
prototype with custom logics without having access to Python. The Protfunc is parsed using the
inlinefunc parser but is fired at the moment the spawning happens, using the creating object's
session as input.
Protfuncs are FuncParser-callables that can be embedded in a prototype to
provide custom logic without having access to Python. The protfunc is parsed at
the time of spawning, using the creating object's session as input. If the
protfunc returns a non-string, this is what will be added to the prototype.
In the prototype dict, the protfunc is specified as a string inside the prototype, e.g.:
{ ...
"key": "$funcname(arg1, arg2, ...)"
"key": "$funcname(args, kwargs)"
... }
and multiple functions can be nested (no keyword args are supported). The result will be used as the
value for that prototype key for that individual spawn.
Available protfuncs are callables in one of the modules of `settings.PROT_FUNC_MODULES`. They
are specified as functions
Available protfuncs are either all callables in one of the modules of `settings.PROT_FUNC_MODULES`
or all callables added to a dict FUNCPARSER_CALLABLES in such a module.
def funcname (*args, **kwargs)
where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia:
At spawn-time the spawner passes the following extra kwargs into each callable (in addition to
what is added in the call itself):
- session (Session): The Session of the entity spawning using this prototype.
- 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.
Any traceback raised by this function will be handled at the time of spawning and abort the spawn
before any object is created/updated. It must otherwise return the value to store for the specified
@ -35,312 +30,32 @@ 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, choice as base_choice
import re
from evennia.utils import search
from evennia.utils.utils import justify as base_justify, is_iter, to_str
_PROTLIB = None
_RE_DBREF = re.compile(r"\#[0-9]+")
from evennia.utils import funcparser
# default protfuncs
def random(*args, **kwargs):
def protfunc_callable_protkey(*args, **kwargs):
"""
Usage: $random()
Returns a random value in the interval [0, 1)
"""
return base_random()
def randint(*args, **kwargs):
"""
Usage: $randint(start, end)
Returns random integer in interval [start, end]
"""
if len(args) != 2:
raise TypeError("$randint needs two arguments - start and end.")
start, end = int(args[0]), int(args[1])
return base_randint(start, end)
def left_justify(*args, **kwargs):
"""
Usage: $left_justify(<text>)
Returns <text> left-justified.
"""
if args:
return base_justify(args[0], align="l")
return ""
def right_justify(*args, **kwargs):
"""
Usage: $right_justify(<text>)
Returns <text> right-justified across screen width.
"""
if args:
return base_justify(args[0], align="r")
return ""
def center_justify(*args, **kwargs):
"""
Usage: $center_justify(<text>)
Returns <text> centered in screen width.
"""
if args:
return base_justify(args[0], align="c")
return ""
def choice(*args, **kwargs):
"""
Usage: $choice(val, val, val, ...)
Returns one of the values randomly
"""
if args:
return base_choice(args)
return ""
def full_justify(*args, **kwargs):
"""
Usage: $full_justify(<text>)
Returns <text> filling up screen width by adding extra space.
"""
if args:
return base_justify(args[0], align="f")
return ""
def protkey(*args, **kwargs):
"""
Usage: $protkey(<key>)
Usage: $protkey(keyname)
Returns the value of another key in this prototoype. Will raise an error if
the key is not found in this prototype.
"""
if args:
prototype = kwargs["prototype"]
return prototype[args[0].strip()]
if not args:
return ""
prototype = kwargs.get("prototype", {})
prot_value = prototype[args[0]]
try:
return funcparser.funcparser_callable_eval(prot_value, **kwargs)
except funcparser.ParsingError:
return prot_value
def add(*args, **kwargs):
"""
Usage: $add(val1, val2)
Returns the result of val1 + val2. Values must be
valid simple Python structures possible to add,
such as numbers, lists etc.
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
# 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.")
def sub(*args, **kwargs):
"""
Usage: $del(val1, val2)
Returns the value of val1 - val2. Values must be
valid simple Python structures possible to
subtract.
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
# 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 mult(*args, **kwargs):
"""
Usage: $mul(val1, val2)
Returns the value of val1 * val2. The values must be
valid simple Python structures possible to
multiply, like strings and/or numbers.
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
# 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.")
def div(*args, **kwargs):
"""
Usage: $div(val1, val2)
Returns the value of val1 / val2. Values must be numbers and
the result is always a float.
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
# 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>)
Returns evaluation of a simple Python expression. The string may *only* consist of the following
Python literal structures: strings, numbers, tuples, lists, dicts, booleans,
and None. The strings can also contain #dbrefs. Escape embedded protfuncs as $$protfunc(..)
- those will then be evaluated *after* $eval.
"""
global _PROTLIB
if not _PROTLIB:
from evennia.prototypes import prototypes as _PROTLIB
string = ",".join(args)
struct = literal_eval(string)
if isinstance(struct, str):
# we must shield the string, otherwise it will be merged as a string and future
# literal_evas will pick up e.g. '2' as something that should be converted to a number
struct = '"{}"'.format(struct)
# convert any #dbrefs to objects (also in nested structures)
struct = _PROTLIB.value_to_obj_or_any(struct)
return struct
def _obj_search(*args, **kwargs):
"Helper function to search for an object"
query = "".join(args)
session = kwargs.get("session", None)
return_list = kwargs.pop("return_list", False)
account = None
if session:
account = session.account
targets = search.search_object(query)
if return_list:
retlist = []
if account:
for target in targets:
if target.access(account, target, "control"):
retlist.append(target)
else:
retlist = targets
return retlist
else:
# single-match
if not targets:
raise ValueError("$obj: Query '{}' gave no matches.".format(query))
if len(targets) > 1:
raise ValueError(
"$obj: Query '{query}' gave {nmatches} matches. Limit your "
"query or use $objlist instead.".format(query=query, nmatches=len(targets))
)
target = targets[0]
if account:
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.
"""
obj = _obj_search(return_list=False, *args, **kwargs)
if obj:
return "#{}".format(obj.id)
return "".join(args)
def objlist(*args, **kwargs):
"""
Usage $objlist(<query>)
Returns list with one or more Objects searched globally by key, alias or #dbref.
"""
return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)]
def dbref(*args, **kwargs):
"""
Usage $dbref(<#dbref>)
Validate that a #dbref input is valid.
"""
if not args or len(args) < 1 or _RE_DBREF.match(args[0]) is None:
raise ValueError("$dbref requires a valid #dbref argument.")
return obj(args[0])
# this is picked up by FuncParser
FUNCPARSER_CALLABLES = {
"protkey": protfunc_callable_protkey,
**funcparser.FUNCPARSER_CALLABLES,
**funcparser.SEARCHING_CALLABLES,
}

View file

@ -30,7 +30,8 @@ from evennia.utils.utils import (
)
from evennia.locks.lockhandler import validate_lockstring, check_lockstring
from evennia.utils import logger
from evennia.utils import inlinefuncs, dbserialize
from evennia.utils.funcparser import FuncParser
from evennia.utils import dbserialize
from evennia.utils.evtable import EvTable
@ -58,11 +59,14 @@ _PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + (
)
PROTOTYPE_TAG_CATEGORY = "from_prototype"
_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
PROT_FUNCS = {}
_PROTOTYPE_FALLBACK_LOCK = "spawn:all();edit:all()"
# the protfunc parser
FUNC_PARSER = FuncParser(settings.PROT_FUNC_MODULES)
class PermissionError(RuntimeError):
pass
@ -709,18 +713,7 @@ def validate_prototype(
prototype["prototype_locks"] = prototype_locks
# Protfunc parsing (in-prototype functions)
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, stacktrace=False, **kwargs):
def protfunc_parser(value, available_functions=None, testing=False, stacktrace=False, caller=None, **kwargs):
"""
Parse a prototype value string for a protfunc and process it.
@ -732,45 +725,27 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F
protfuncs, all other types are returned as-is.
available_functions (dict, optional): Mapping of name:protfunction to use for this parsing.
If not set, use default sources.
testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may
behave differently.
stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser.
Keyword Args:
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.
caller (Object or Account): This is necessary for certain protfuncs that perform object
searches and have to check permissions.
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.
any: A structure to replace the string on the prototype leve. Note
that FunctionParser functions $funcname(*args, **kwargs) can return any
data type to insert into the prototype.
"""
if not isinstance(value, str):
return value
available_functions = PROT_FUNCS if available_functions is None else available_functions
result = FUNC_PARSER.parse(value, raise_errors=True, return_str=False, caller=caller, **kwargs)
result = inlinefuncs.parse_inlinefunc(
value, available_funcs=available_functions, stacktrace=stacktrace, testing=testing, **kwargs
)
err = None
try:
result = literal_eval(result)
except ValueError:
pass
except Exception as exc:
err = str(exc)
if testing:
return err, result
return result
@ -785,7 +760,7 @@ def format_available_protfuncs():
clr (str, optional): What coloration tag to use.
"""
out = []
for protfunc_name, protfunc in PROT_FUNCS.items():
for protfunc_name, protfunc in FUNC_PARSER.callables.items():
out.append(
"- |c${name}|n - |W{docs}".format(
name=protfunc_name, docs=protfunc.__doc__.strip().replace("\n", "")
@ -910,7 +885,7 @@ def check_permission(prototype_key, action, default=True):
return default
def init_spawn_value(value, validator=None):
def init_spawn_value(value, validator=None, caller=None):
"""
Analyze the prototype value and produce a value useful at the point of spawning.
@ -921,6 +896,8 @@ def init_spawn_value(value, validator=None):
other - will be assigned depending on the variable type
validator (callable, optional): If given, this will be called with the value to
check and guarantee the outcome is of a given type.
caller (Object or Account): This is necessary for certain protfuncs that perform object
searches and have to check permissions.
Returns:
any (any): The (potentially pre-processed value to use for this prototype key)
@ -935,7 +912,7 @@ def init_spawn_value(value, validator=None):
value = validator(value[0](*make_iter(args)))
else:
value = validator(value)
result = protfunc_parser(value)
result = protfunc_parser(value, caller=caller)
if result != value:
return validator(result)
return result

View file

@ -607,7 +607,8 @@ def format_diff(diff, minimal=True):
return "\n ".join(line for line in texts if line)
def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exact=False):
def batch_update_objects_with_prototype(prototype, diff=None, objects=None,
exact=False, caller=None):
"""
Update existing objects with the latest version of the prototype.
@ -624,6 +625,7 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exac
if it's not set in the prototype. With `exact=True`, all un-specified properties of the
objects will be removed if they exist. This will lead to a more accurate 1:1 correlation
between the object and the prototype but is usually impractical.
caller (Object or Account, optional): This may be used by protfuncs to do permission checks.
Returns:
changed (int): The number of objects that had changes applied to them.
@ -675,33 +677,33 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exac
do_save = True
if key == "key":
obj.db_key = init_spawn_value(val, str)
obj.db_key = init_spawn_value(val, str, caller=caller)
elif key == "typeclass":
obj.db_typeclass_path = init_spawn_value(val, str)
obj.db_typeclass_path = init_spawn_value(val, str, caller=caller)
elif key == "location":
obj.db_location = init_spawn_value(val, value_to_obj)
obj.db_location = init_spawn_value(val, value_to_obj, caller=caller)
elif key == "home":
obj.db_home = init_spawn_value(val, value_to_obj)
obj.db_home = init_spawn_value(val, value_to_obj, caller=caller)
elif key == "destination":
obj.db_destination = init_spawn_value(val, value_to_obj)
obj.db_destination = init_spawn_value(val, value_to_obj, caller=caller)
elif key == "locks":
if directive == "REPLACE":
obj.locks.clear()
obj.locks.add(init_spawn_value(val, str))
obj.locks.add(init_spawn_value(val, str, caller=caller))
elif key == "permissions":
if directive == "REPLACE":
obj.permissions.clear()
obj.permissions.batch_add(*(init_spawn_value(perm, str) for perm in val))
obj.permissions.batch_add(*(init_spawn_value(perm, str, caller=caller) for perm in val))
elif key == "aliases":
if directive == "REPLACE":
obj.aliases.clear()
obj.aliases.batch_add(*(init_spawn_value(alias, str) for alias in val))
obj.aliases.batch_add(*(init_spawn_value(alias, str, caller=caller) for alias in val))
elif key == "tags":
if directive == "REPLACE":
obj.tags.clear()
obj.tags.batch_add(
*(
(init_spawn_value(ttag, str), tcategory, tdata)
(init_spawn_value(ttag, str, caller=caller), tcategory, tdata)
for ttag, tcategory, tdata in val
)
)
@ -711,8 +713,8 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exac
obj.attributes.batch_add(
*(
(
init_spawn_value(akey, str),
init_spawn_value(aval, value_to_obj),
init_spawn_value(akey, str, caller=caller),
init_spawn_value(aval, value_to_obj, caller=caller),
acategory,
alocks,
)
@ -723,7 +725,7 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exac
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.add(key, init_spawn_value(val, value_to_obj))
obj.attributes.add(key, init_spawn_value(val, value_to_obj, caller=caller))
elif directive == "REMOVE":
do_save = True
if key == "key":
@ -836,7 +838,7 @@ def batch_create_object(*objparams):
# Spawner mechanism
def spawn(*prototypes, **kwargs):
def spawn(*prototypes, caller=None, **kwargs):
"""
Spawn a number of prototyped objects.
@ -845,6 +847,7 @@ def spawn(*prototypes, **kwargs):
prototype_key (will be used to find the prototype) or a full prototype
dictionary. These will be batched-spawned as one object each.
Keyword Args:
caller (Object or Account, optional): This may be used by protfuncs to do access checks.
prototype_modules (str or list): A python-path to a prototype
module, or a list of such paths. These will be used to build
the global protparents dictionary accessible by the input
@ -910,39 +913,39 @@ def spawn(*prototypes, **kwargs):
"key",
"Spawned-{}".format(hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:6]),
)
create_kwargs["db_key"] = init_spawn_value(val, str)
create_kwargs["db_key"] = init_spawn_value(val, str, caller=caller)
val = prot.pop("location", None)
create_kwargs["db_location"] = init_spawn_value(val, value_to_obj)
create_kwargs["db_location"] = init_spawn_value(val, value_to_obj, caller=caller)
val = prot.pop("home", None)
if val:
create_kwargs["db_home"] = init_spawn_value(val, value_to_obj)
create_kwargs["db_home"] = init_spawn_value(val, value_to_obj, caller=caller)
else:
try:
create_kwargs["db_home"] = init_spawn_value(settings.DEFAULT_HOME, value_to_obj)
create_kwargs["db_home"] = init_spawn_value(settings.DEFAULT_HOME, value_to_obj, caller=caller)
except ObjectDB.DoesNotExist:
# settings.DEFAULT_HOME not existing is common for unittests
pass
val = prot.pop("destination", None)
create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj)
create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj, caller=caller)
val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS)
create_kwargs["db_typeclass_path"] = init_spawn_value(val, str)
create_kwargs["db_typeclass_path"] = init_spawn_value(val, str, caller=caller)
# extract calls to handlers
val = prot.pop("permissions", [])
permission_string = init_spawn_value(val, make_iter)
permission_string = init_spawn_value(val, make_iter, caller=caller)
val = prot.pop("locks", "")
lock_string = init_spawn_value(val, str)
lock_string = init_spawn_value(val, str, caller=caller)
val = prot.pop("aliases", [])
alias_string = init_spawn_value(val, make_iter)
alias_string = init_spawn_value(val, make_iter, caller=caller)
val = prot.pop("tags", [])
tags = []
for (tag, category, *data) in val:
tags.append((init_spawn_value(tag, str), category, data[0] if data else None))
tags.append((init_spawn_value(tag, str, caller=caller), category, data[0] if data else None))
prototype_key = prototype.get("prototype_key", None)
if prototype_key:
@ -950,11 +953,11 @@ def spawn(*prototypes, **kwargs):
tags.append((prototype_key, PROTOTYPE_TAG_CATEGORY))
val = prot.pop("exec", "")
execs = init_spawn_value(val, make_iter)
execs = init_spawn_value(val, make_iter, caller=caller)
# extract ndb assignments
nattributes = dict(
(key.split("_", 1)[1], init_spawn_value(val, value_to_obj))
(key.split("_", 1)[1], init_spawn_value(val, value_to_obj, caller=caller))
for key, val in prot.items()
if key.startswith("ndb_")
)
@ -963,7 +966,7 @@ def spawn(*prototypes, **kwargs):
val = make_iter(prot.pop("attrs", []))
attributes = []
for (attrname, value, *rest) in val:
attributes.append((attrname, init_spawn_value(value),
attributes.append((attrname, init_spawn_value(value, caller=caller),
rest[0] if rest else None, rest[1] if len(rest) > 1 else None))
simple_attributes = []
@ -975,7 +978,7 @@ def spawn(*prototypes, **kwargs):
continue
else:
simple_attributes.append(
(key, init_spawn_value(value, value_to_obj_or_any), None, None)
(key, init_spawn_value(value, value_to_obj_or_any, caller=caller), None, None)
)
attributes = attributes + simple_attributes

View file

@ -338,223 +338,19 @@ class TestProtLib(EvenniaTest):
self.assertEqual(match, [self.prot])
@override_settings(PROT_FUNC_MODULES=["evennia.prototypes.protfuncs"], CLIENT_DEFAULT_WIDTH=20)
class TestProtFuncs(EvenniaTest):
def setUp(self):
super(TestProtFuncs, self).setUp()
self.prot = {
"prototype_key": "test_prototype",
"prototype_desc": "testing prot",
"key": "ExampleObj",
}
@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(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"),
)
@override_settings(PROT_FUNC_MODULES=["evennia.prototypes.protfuncs"])
def test_protkey_protfunc(self):
test_prot = {"key1": "value1", "key2": 2}
self.assertEqual(
protlib.protfunc_parser("$protkey(key1)", testing=True, prototype=test_prot),
(None, "value1"),
"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},
)
# no object search
odbref = self.obj1.dbref
with mock.patch(
"evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search
) as mocked__obj_search:
self.assertEqual(
protlib.protfunc_parser("obj({})".format(odbref), session=self.session),
"obj({})".format(odbref),
)
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("dbref({})".format(odbref), session=self.session),
"dbref({})".format(odbref),
)
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("stone(#12345)", session=self.session), "stone(#12345)"
)
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(odbref, session=self.session), odbref)
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("#12345", session=self.session), "#12345")
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("nothing({})".format(odbref), session=self.session),
"nothing({})".format(odbref),
)
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("(#12345)", session=self.session), "(#12345)")
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("obj(Char)", session=self.session), "obj(Char)"
)
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("objlist({})".format(odbref), session=self.session),
"objlist({})".format(odbref),
)
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("dbref(Char)", session=self.session), "dbref(Char)"
)
mocked__obj_search.assert_not_called()
# obj search happens
with mock.patch(
"evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search
) as mocked__obj_search:
self.assertEqual(
protlib.protfunc_parser("$objlist({})".format(odbref), session=self.session),
[odbref],
)
mocked__obj_search.assert_called_once()
assert (odbref,) == mocked__obj_search.call_args[0]
with mock.patch(
"evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search
) as mocked__obj_search:
self.assertEqual(
protlib.protfunc_parser("$obj({})".format(odbref), session=self.session), odbref
)
mocked__obj_search.assert_called_once()
assert (odbref,) == mocked__obj_search.call_args[0]
with mock.patch(
"evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search
) as mocked__obj_search:
self.assertEqual(
protlib.protfunc_parser("$dbref({})".format(odbref), session=self.session), odbref
)
mocked__obj_search.assert_called_once()
assert (odbref,) == mocked__obj_search.call_args[0]
cdbref = self.char1.dbref
with mock.patch(
"evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search
) as mocked__obj_search:
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), cdbref)
mocked__obj_search.assert_called_once()
assert ("Char",) == mocked__obj_search.call_args[0]
# 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), "<UNKNOWN>"
)
mocked__obj_search.assert_not_called()
with mock.patch(
"evennia.prototypes.protfuncs._obj_search", wraps=protofuncs._obj_search
) as mocked__obj_search:
self.assertRaises(ValueError, protlib.protfunc_parser, "$dbref(Char)")
mocked__obj_search.assert_not_called()
self.assertEqual(
protlib.value_to_obj(protlib.protfunc_parser(cdbref, session=self.session)), self.char1
)
self.assertEqual(
protlib.value_to_obj_or_any(protlib.protfunc_parser(cdbref, session=self.session)),
self.char1,
)
self.assertEqual(
protlib.value_to_obj_or_any(
protlib.protfunc_parser("[1,2,3,'{}',5]".format(cdbref), session=self.session)
),
[1, 2, 3, self.char1, 5],
protlib.protfunc_parser("$protkey(key2)", testing=True, prototype=test_prot),
2
)

View file

@ -59,6 +59,21 @@ def check_errors(settings):
"Update your settings file (see evennia/settings_default.py "
"for more info)."
)
depstring = (
"settings.{} was renamed to {}. Update your settings file (the FuncParser "
"replaces and generalizes that which inlinefuncs used to do).")
if hasattr(settings, "INLINEFUNC_ENABLED"):
raise DeprecationWarning(depstring.format(
"settings.INLINEFUNC_ENABLED", "FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLE"))
if hasattr(settings, "INLINEFUNC_STACK_MAXSIZE"):
raise DeprecationWarning(depstring.format(
"settings.INLINEFUNC_STACK_MAXSIZE", "FUNCPARSER_MAX_NESTING"))
if hasattr(settings, "INLINEFUNC_MODULES"):
raise DeprecationWarning(depstring.format(
"settings.INLINEFUNC_MODULES", "FUNCPARSER_OUTGOING_MESSAGES_MODULES"))
if hasattr(settings, "PROTFUNC_MODULES"):
raise DeprecationWarning(depstring.format(
"settings.PROTFUNC_MODULES", "FUNCPARSER_PROTOTYPE_VALUE_MODULES"))
gametime_deprecation = (
"The settings TIME_SEC_PER_MIN, TIME_MIN_PER_HOUR,"

View file

@ -28,10 +28,9 @@ from evennia.utils.utils import (
from evennia.server.portal import amp
from evennia.server.signals import SIGNAL_ACCOUNT_POST_LOGIN, SIGNAL_ACCOUNT_POST_LOGOUT
from evennia.server.signals import SIGNAL_ACCOUNT_POST_FIRST_LOGIN, SIGNAL_ACCOUNT_POST_LAST_LOGOUT
from evennia.utils.inlinefuncs import parse_inlinefunc
from codecs import decode as codecs_decode
_INLINEFUNC_ENABLED = settings.INLINEFUNC_ENABLED
_FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED = settings.FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED
# delayed imports
_AccountDB = None
@ -59,6 +58,9 @@ _DELAY_CMD_LOGINSTART = settings.DELAY_CMD_LOGINSTART
_MAX_SERVER_COMMANDS_PER_SECOND = 100.0
_MAX_SESSION_COMMANDS_PER_SECOND = 5.0
_MODEL_MAP = None
_FUNCPARSER = None
# input handlers
@ -151,7 +153,8 @@ class SessionHandler(dict):
def clean_senddata(self, session, kwargs):
"""
Clean up data for sending across the AMP wire. Also apply INLINEFUNCS.
Clean up data for sending across the AMP wire. Also apply the
FuncParser using callables from `settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES`.
Args:
session (Session): The relevant session instance.
@ -167,10 +170,15 @@ class SessionHandler(dict):
Returns:
kwargs (dict): A cleaned dictionary of cmdname:[[args],{kwargs}] pairs,
where the keys, args and kwargs have all been converted to
send-safe entities (strings or numbers), and inlinefuncs have been
send-safe entities (strings or numbers), and funcparser parsing has been
applied.
"""
global _FUNCPARSER
if not _FUNCPARSER:
from evennia.utils.funcparser import FuncParser
_FUNCPARSER = FuncParser(settings.FUNCPARSER_OUTGOING_MESSAGES_MODULE, raise_errors=True)
options = kwargs.pop("options", None) or {}
raw = options.get("raw", False)
strip_inlinefunc = options.get("strip_inlinefunc", False)
@ -202,9 +210,10 @@ class SessionHandler(dict):
elif isinstance(data, (str, bytes)):
data = _utf8(data)
if _INLINEFUNC_ENABLED and not raw and isinstance(self, ServerSessionHandler):
# only parse inlinefuncs on the outgoing path (sessionhandler->)
data = parse_inlinefunc(data, strip=strip_inlinefunc, session=session)
if _FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED and not raw and isinstance(self, ServerSessionHandler):
# only apply funcparser on the outgoing path (sessionhandler->)
# data = parse_inlinefunc(data, strip=strip_inlinefunc, session=session)
data = _FUNCPARSER.parse(data, strip=strip_inlinefunc, session=session)
return str(data)
elif (

View file

@ -598,23 +598,31 @@ TIME_GAME_EPOCH = None
TIME_IGNORE_DOWNTIMES = False
######################################################################
# Inlinefunc, PrototypeFuncs
# FuncParser
#
# Strings parsed with the FuncParser can contain 'callables' on the
# form $funcname(args,kwargs), which will lead to actual Python functions
# being executed.
######################################################################
# Evennia supports inline function preprocessing. This allows users
# to supply inline calls on the form $func(arg, arg, ...) to do
# session-aware text formatting and manipulation on the fly. If
# disabled, such inline functions will not be parsed.
INLINEFUNC_ENABLED = False
# This defined how deeply nested inlinefuncs can be. Set to <=0 to
# disable (not recommended, this is a safeguard against infinite loops).
INLINEFUNC_STACK_MAXSIZE = 20
# This changes the start-symbol for the funcparser callable. Note that
# this will make a lot of documentation invalid and there may also be
# other unexpected side effects, so change with caution.
FUNCPARSER_START_CHAR = '$'
# The symbol to use to escape Func
FUNCPARSER_ESCAPE_CHAR = '\\'
# This is the global max nesting-level for nesting functions in
# the funcparser. This protects against infinite loops.
FUNCPARSER_MAX_NESTING = 20
# Activate funcparser for all outgoing strings. The current Session
# will be passed into the parser (used to be called inlinefuncs)
FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED = False
# Only functions defined globally (and not starting with '_') in
# these modules will be considered valid inlinefuncs. The list
# is loaded from left-to-right, same-named functions will overload
INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs", "server.conf.inlinefuncs"]
# Module holding handlers for ProtFuncs. These allow for embedding
# functional code in prototypes and has the same syntax as inlinefuncs.
PROTOTYPEFUNC_MODULES = ["evennia.utils.prototypefuncs", "server.conf.prototypefuncs"]
FUNCPARSER_OUTGOING_MESSAGES_MODULES = ["evennia.utils.funcparser", "server.conf.inlinefuncs"]
# Prototype values are also parsed with FuncParser. These modules
# define which $func callables are available to use in prototypes.
FUNCPARSER_PROTOTYPE_PARSING_MODULES = ["evennia.prototypes.protfuncs", "server.conf.prototypefuncs"]
######################################################################
# Global Scripts

1182
evennia/utils/funcparser.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,625 +0,0 @@
"""
Inline functions (nested form).
This parser accepts nested inlinefunctions on the form
```python
$funcname(arg, arg, ...)
```
embedded in any text where any arg can be another ``$funcname()`` call.
This functionality is turned off by default - to activate,
`settings.INLINEFUNC_ENABLED` must be set to `True`.
Each token starts with `$funcname(` where there must be no space
between the `$funcname` and `"("`. The inlinefunc ends with a matched ending parentesis.
`")"`.
Inside the inlinefunc definition, one can use `\` to escape. This is
mainly needed for escaping commas in flowing text (which would
otherwise be interpreted as an argument separator), or to escape `)`
when not intended to close the function block. Enclosing text in
matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will
also escape *everything* within without needing to escape individual
characters.
The available inlinefuncs are defined as global-level functions in
modules defined by `settings.INLINEFUNC_MODULES`. They are identified
by their function name (and ignored if this name starts with `_`). They
should be on the following form:
```python
def funcname (*args, **kwargs):
# ...
```
Here, the arguments given to `$funcname(arg1,arg2)` will appear as the
`*args` tuple. This will be populated by the arguments given to the
inlinefunc in-game - the only part that will be available from
in-game. `**kwargs` are not supported from in-game but are only used
internally by Evennia to make details about the caller available to
the function. The kwarg passed to all functions is `session`, the
Sessionobject for the object seeing the string. This may be `None` if
the string is sent to a non-puppetable object. The inlinefunc should
never raise an exception.
There are two reserved function names:
- "nomatch": This is called if the user uses a functionname that is
not registered. The nomatch function will get the name of the
not-found function as its first argument followed by the normal
arguments to the given function. If not defined the default effect is
to print `<UNKNOWN>` to replace the unknown function.
- "stackfull": This is called when the maximum nested function stack is reached.
When this happens, the original parsed string is returned and the result of
the `stackfull` inlinefunc is appended to the end. By default this is an
error message.
Syntax errors, notably failing to completely closing all inlinefunc
blocks, will lead to the entire string remaining unparsed. Inlineparsing should
never traceback.
----
"""
import re
import fnmatch
import random as base_random
from django.conf import settings
from evennia.utils import utils, logger
# The stack size is a security measure. Set to <=0 to disable.
_STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE
# example/testing inline functions
def random(*args, **kwargs):
"""
Inlinefunc. Returns a random number between
0 and 1, from 0 to a maximum value, or within a given range (inclusive).
Args:
minval (str, optional): Minimum value. If not given, assumed 0.
maxval (str, optional): Maximum value.
Keyword argumuents:
session (Session): Session getting the string.
Notes:
If either of the min/maxvalue has a '.' in it, a floating-point random
value will be returned. Otherwise it will be an integer value in the
given range.
Example:
`$random()`
`$random(5)`
`$random(5, 10)`
"""
nargs = len(args)
if nargs == 1:
# only maxval given
minval, maxval = "0", args[0]
elif nargs > 1:
minval, maxval = args[:2]
else:
minval, maxval = ("0", "1")
if "." in minval or "." in maxval:
# float mode
try:
minval, maxval = float(minval), float(maxval)
except ValueError:
minval, maxval = 0, 1
return "{:.2f}".format(minval + maxval * base_random.random())
else:
# int mode
try:
minval, maxval = int(minval), int(maxval)
except ValueError:
minval, maxval = 0, 1
return str(base_random.randint(minval, maxval))
def pad(*args, **kwargs):
"""
Inlinefunc. Pads text to given width.
Args:
text (str, optional): Text to pad.
width (str, optional): Will be converted to integer. Width
of padding.
align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'.
fillchar (str, optional): Character used for padding. Defaults to a
space.
Keyword Args:
session (Session): Session performing the pad.
Example:
`$pad(text, width, align, fillchar)`
"""
text, width, align, fillchar = "", 78, "c", " "
nargs = len(args)
if nargs > 0:
text = args[0]
if nargs > 1:
width = int(args[1]) if args[1].strip().isdigit() else 78
if nargs > 2:
align = args[2] if args[2] in ("c", "l", "r") else "c"
if nargs > 3:
fillchar = args[3]
return utils.pad(text, width=width, align=align, fillchar=fillchar)
def crop(*args, **kwargs):
"""
Inlinefunc. Crops ingoing text to given widths.
Args:
text (str, optional): Text to crop.
width (str, optional): Will be converted to an integer. Width of
crop in characters.
suffix (str, optional): End string to mark the fact that a part
of the string was cropped. Defaults to `[...]`.
Keyword Args:
session (Session): Session performing the crop.
Example:
`$crop(text, width=78, suffix='[...]')`
"""
text, width, suffix = "", 78, "[...]"
nargs = len(args)
if nargs > 0:
text = args[0]
if nargs > 1:
width = int(args[1]) if args[1].strip().isdigit() else 78
if nargs > 2:
suffix = args[2]
return utils.crop(text, width=width, suffix=suffix)
def space(*args, **kwargs):
"""
Inlinefunc. Inserts an arbitrary number of spaces. Defaults to 4 spaces.
Args:
spaces (int, optional): The number of spaces to insert.
Keyword Args:
session (Session): Session performing the crop.
Example:
`$space(20)`
"""
width = 4
if args:
width = abs(int(args[0])) if args[0].strip().isdigit() else 4
return " " * width
def clr(*args, **kwargs):
"""
Inlinefunc. Colorizes nested text.
Args:
startclr (str, optional): An ANSI color abbreviation without the
prefix `|`, such as `r` (red foreground) or `[r` (red background).
text (str, optional): Text
endclr (str, optional): The color to use at the end of the string. Defaults
to `|n` (reset-color).
Keyword Args:
session (Session): Session object triggering inlinefunc.
Example:
`$clr(startclr, text, endclr)`
"""
text = ""
nargs = len(args)
if nargs > 0:
color = args[0].strip()
if nargs > 1:
text = args[1]
text = "|" + color + text
if nargs > 2:
text += "|" + args[2].strip()
else:
text += "|n"
return text
def null(*args, **kwargs):
return args[0] if args else ""
def nomatch(name, *args, **kwargs):
"""
Default implementation of nomatch returns the function as-is as a string.
"""
kwargs.pop("inlinefunc_stack_depth", None)
kwargs.pop("session")
return "${name}({args}{kwargs})".format(
name=name,
args=",".join(args),
kwargs=",".join("{}={}".format(key, val) for key, val in kwargs.items()),
)
_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.
_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):
try:
_INLINE_FUNCS.update(utils.callables_from_module(module))
except ImportError as err:
if module == "server.conf.inlinefuncs":
# a temporary warning since the default module changed name
raise ImportError(
"Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should "
"be renamed to mygame/server/conf/inlinefuncs.py (note "
"the S at the end)." % err
)
else:
raise
# regex definitions
_RE_STARTTOKEN = re.compile(r"(?<!\\)\$(\w+)\(") # unescaped $funcname( (start of function call)
# note: this regex can be experimented with at https://regex101.com/r/kGR3vE/2
_RE_TOKEN = re.compile(
r"""
(?<!\\)\'\'\'(?P<singlequote>.*?)(?<!\\)\'\'\'| # single-triplets escape all inside
(?<!\\)\"\"\"(?P<doublequote>.*?)(?<!\\)\"\"\"| # double-triplets escape all inside
(?P<comma>(?<!\\)\,)| # , (argument sep)
(?P<end>(?<!\\)\))| # ) (possible end of func call)
(?P<leftparens>(?<!\\)\()| # ( (lone left-parens)
(?P<start>(?<!\\)\$\w+\()| # $funcname (start of func call)
(?P<escaped> # escaped tokens to re-insert sans backslash
\\\'|\\\"|\\\)|\\\$\w+\(|\\\()|
(?P<rest> # everything else to re-insert verbatim
\$(?!\w+\()|\'|\"|\\|[^),$\'\"\\\(]+)""",
re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL,
)
# Cache for function lookups.
_PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000)
class ParseStack(list):
"""
Custom stack that always concatenates strings together when the
strings are added next to one another. Tuples are stored
separately and None is used to mark that a string should be broken
up into a new chunk. Below is the resulting stack after separately
appending 3 strings, None, 2 strings, a tuple and finally 2
strings:
[string + string + string,
None
string + string,
tuple,
string + string]
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# always start stack with the empty string
list.append(self, "")
# indicates if the top of the stack is a string or not
self._string_last = True
def __eq__(self, other):
return (
super().__eq__(other)
and hasattr(other, "_string_last")
and self._string_last == other._string_last
)
def __ne__(self, other):
return not self.__eq__(other)
def append(self, item):
"""
The stack will merge strings, add other things as normal
"""
if isinstance(item, str):
if self._string_last:
self[-1] += item
else:
list.append(self, item)
self._string_last = True
else:
# everything else is added as normal
list.append(self, item)
self._string_last = False
class InlinefuncError(RuntimeError):
pass
def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False, **kwargs):
"""
Parse the incoming string.
Args:
string (str): The incoming string to parse.
strip (bool, optional): Whether to strip function calls rather than
execute them.
available_funcs (dict, optional): Define an alternative source of functions to parse for.
If unset, use the functions found through `settings.INLINEFUNC_MODULES`.
stacktrace (bool, optional): If set, print the stacktrace to log.
Keyword Args:
session (Session): This is sent to this function by Evennia when triggering
it. It is passed to the inlinefunc.
kwargs (any): All other kwargs are also passed on to the inlinefunc.
"""
global _PARSING_CACHE
usecache = False
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
stack = _PARSING_CACHE[string]
elif not _RE_STARTTOKEN.search(string):
# if there are no unescaped start tokens at all, return immediately.
return string
else:
# no cached stack; build a new stack and continue
stack = ParseStack()
# process string on stack
ncallable = 0
nlparens = 0
nvalid = 0
if stacktrace:
out = "STRING: {} =>".format(string)
print(out)
logger.log_info(out)
for match in _RE_TOKEN.finditer(string):
gdict = match.groupdict()
if stacktrace:
out = " MATCH: {}".format({key: val for key, val in gdict.items() if val})
print(out)
logger.log_info(out)
if gdict["singlequote"]:
stack.append(gdict["singlequote"])
elif gdict["doublequote"]:
stack.append(gdict["doublequote"])
elif gdict["leftparens"]:
# we have a left-parens inside a callable
if ncallable:
nlparens += 1
stack.append("(")
elif gdict["end"]:
if nlparens > 0:
nlparens -= 1
stack.append(")")
continue
if ncallable <= 0:
stack.append(")")
continue
args = []
while stack:
operation = stack.pop()
if callable(operation):
if not strip:
stack.append((operation, [arg for arg in reversed(args)]))
ncallable -= 1
break
else:
args.append(operation)
elif gdict["start"]:
funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1)
try:
# try to fetch the matching inlinefunc from storage
stack.append(available_funcs[funcname])
nvalid += 1
except KeyError:
stack.append(available_funcs["nomatch"])
stack.append(funcname)
stack.append(None)
ncallable += 1
elif gdict["escaped"]:
# escaped tokens
token = gdict["escaped"].lstrip("\\")
stack.append(token)
elif gdict["comma"]:
if ncallable > 0:
# commas outside strings and inside a callable are
# used to mark argument separation - we use None
# in the stack to indicate such a separation.
stack.append(None)
else:
# no callable active - just a string
stack.append(",")
else:
# the rest
stack.append(gdict["rest"])
if ncallable > 0:
# this means not all inlinefuncs were complete
return string
if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < nvalid:
# if stack is larger than limit, throw away parsing
return string + available_funcs["stackfull"](*args, **kwargs)
elif usecache:
# cache the stack - we do this also if we don't check the cache above
_PARSING_CACHE[string] = stack
# run the stack recursively
def _run_stack(item, depth=0):
retval = item
if isinstance(item, tuple):
if strip:
return ""
else:
func, arglist = item
args = [""]
for arg in arglist:
if arg is None:
# an argument-separating comma - start a new arg
args.append("")
else:
# all other args should merge into one string
args[-1] += _run_stack(arg, depth=depth + 1)
# execute the inlinefunc at this point or strip it.
kwargs["inlinefunc_stack_depth"] = depth
retval = "" if strip else func(*args, **kwargs)
return utils.to_str(retval)
retval = "".join(_run_stack(item) for item in stack)
if stacktrace:
out = "STACK: \n{} => {}\n".format(stack, retval)
print(out)
logger.log_info(out)
# execute the stack
return retval
def raw(string):
"""
Escape all inlinefuncs in a string so they won't get parsed.
Args:
string (str): String with inlinefuncs to escape.
"""
def _escape(match):
return "\\" + match.group(0)
return _RE_STARTTOKEN.sub(_escape, string)
#
# Nick templating
#
"""
This supports the use of replacement templates in nicks:
This happens in two steps:
1) The user supplies a template that is converted to a regex according
to the unix-like templating language.
2) This regex is tested against nicks depending on which nick replacement
strategy is considered (most commonly inputline).
3) If there is a template match and there are templating markers,
these are replaced with the arguments actually given.
@desc $1 $2 $3
This will be converted to the following regex:
\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+)
Supported template markers (through fnmatch)
* matches anything (non-greedy) -> .*?
? matches any single character ->
[seq] matches any entry in sequence
[!seq] matches entries not in sequence
Custom arg markers
$N argument position (1-99)
"""
_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)")
_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)")
_RE_NICK_SPACE = re.compile(r"\\ ")
class NickTemplateInvalid(ValueError):
pass
def initialize_nick_templates(in_template, out_template):
"""
Initialize the nick templates for matching and remapping a string.
Args:
in_template (str): The template to be used for nick recognition.
out_template (str): The template to be used to replace the string
matched by the in_template.
Returns:
regex (regex): Regex to match against strings
template (str): Template with markers {arg1}, {arg2}, etc for
replacement using the standard .format method.
Raises:
evennia.utils.inlinefuncs.NickTemplateInvalid: If the in/out template
does not have a matching number of $args.
"""
# create the regex for in_template
regex_string = fnmatch.translate(in_template)
n_inargs = len(_RE_NICK_ARG.findall(regex_string))
regex_string = _RE_NICK_SPACE.sub("\s+", regex_string)
regex_string = _RE_NICK_ARG.sub(lambda m: "(?P<arg%s>.+?)" % m.group(2), regex_string)
# create the out_template
template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template)
# validate the tempaltes - they should at least have the same number of args
n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template))
if n_inargs != n_outargs:
raise NickTemplateInvalid
return re.compile(regex_string), template_string
def parse_nick_template(string, template_regex, outtemplate):
"""
Parse a text using a template and map it to another template
Args:
string (str): The input string to processj
template_regex (regex): A template regex created with
initialize_nick_template.
outtemplate (str): The template to which to map the matches
produced by the template_regex. This should have $1, $2,
etc to match the regex.
"""
match = template_regex.match(string)
if match:
return outtemplate.format(**match.groupdict())
return string

View file

@ -0,0 +1,466 @@
"""
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
from evennia.utils import funcparser, test_resources
def _test_callable(*args, **kwargs):
kwargs.pop('funcparser', None)
kwargs.pop('raise_errors', None)
argstr = ", ".join(args)
kwargstr = ""
if kwargs:
kwargstr = (", " if args else "") + (
", ".join(f"{key}={val}" for key, val in kwargs.items()))
return f"_test({argstr}{kwargstr})"
def _repl_callable(*args, **kwargs):
if args:
return f"r{args[0]}r"
return "rr"
def _double_callable(*args, **kwargs):
if args:
try:
return int(args[0]) * 2
except ValueError:
pass
return 'N/A'
def _eval_callable(*args, **kwargs):
if args:
return simple_eval(args[0])
return ''
def _clr_callable(*args, **kwargs):
clr, string, *rest = args
return f"|{clr}{string}|n"
def _typ_callable(*args, **kwargs):
try:
if isinstance(args[0], str):
return type(literal_eval(args[0]))
else:
return type(args[0])
except (SyntaxError, ValueError):
return type("")
def _add_callable(*args, **kwargs):
if len(args) > 1:
return literal_eval(args[0]) + literal_eval(args[1])
return ''
def _lit_callable(*args, **kwargs):
return literal_eval(args[0])
def _lsum_callable(*args, **kwargs):
if isinstance(args[0], (list, tuple)):
return sum(val for val in args[0])
return ''
_test_callables = {
"foo": _test_callable,
"bar": _test_callable,
"with spaces": _test_callable,
"repl": _repl_callable,
"double": _double_callable,
"eval": _eval_callable,
"clr": _clr_callable,
"typ": _typ_callable,
"add": _add_callable,
"lit": _lit_callable,
"sum": _lsum_callable,
}
class TestFuncParser(TestCase):
"""
Test the FuncParser class
"""
def setUp(self):
self.parser = funcparser.FuncParser(
_test_callables
)
@parameterized.expand([
("Test normal string", "Test normal string"),
("Test noargs1 $foo()", "Test noargs1 _test()"),
("Test noargs2 $bar() etc.", "Test noargs2 _test() etc."),
("Test noargs3 $with spaces() etc.", "Test noargs3 _test() etc."),
("Test noargs4 $foo(), $bar() and $foo", "Test noargs4 _test(), _test() and $foo"),
("$foo() Test noargs5", "_test() Test noargs5"),
("Test args1 $foo(a,b,c)", "Test args1 _test(a, b, c)"),
("Test args2 $bar(foo, bar, too)", "Test args2 _test(foo, bar, too)"),
("Test args3 $bar(foo, bar, ' too')", "Test args3 _test(foo, bar, ' too')"),
("Test args4 $foo('')", "Test args4 _test('')"),
("Test args4 $foo(\"\")", "Test args4 _test(\"\")"),
("Test args5 $foo(\(\))", "Test args5 _test(())"),
("Test args6 $foo(\()", "Test args6 _test(()"),
("Test args7 $foo(())", "Test args7 _test(())"),
("Test args8 $foo())", "Test args8 _test())"),
("Test args9 $foo(=)", "Test args9 _test(=)"),
("Test args10 $foo(\,)", "Test args10 _test(,)"),
("Test args10 $foo(',')", "Test args10 _test(',')"),
("Test args11 $foo(()", "Test args11 $foo(()"), # invalid syntax
("Test kwarg1 $bar(foo=1, bar='foo', too=ere)",
"Test kwarg1 _test(foo=1, bar='foo', too=ere)"),
("Test kwarg2 $bar(foo,bar,too=ere)",
"Test kwarg2 _test(foo, bar, too=ere)"),
("test kwarg3 $foo(foo = bar, bar = ere )",
"test kwarg3 _test(foo=bar, bar=ere)"),
("test kwarg4 $foo(foo =' bar ',\" bar \"= ere )",
"test kwarg4 _test(foo=' bar ', \" bar \"=ere)"),
("Test nest1 $foo($bar(foo,bar,too=ere))",
"Test nest1 _test(_test(foo, bar, too=ere))"),
("Test nest2 $foo(bar,$repl(a),$repl()=$repl(),a=b) etc",
"Test nest2 _test(bar, rar, rr=rr, a=b) etc"),
("Test nest3 $foo(bar,$repl($repl($repl(c))))",
"Test nest3 _test(bar, rrrcrrr)"),
("Test nest4 $foo($bar(a,b),$bar(a,$repl()),$bar())",
"Test nest4 _test(_test(a, b), _test(a, rr), _test())"),
("Test escape1 \\$repl(foo)", "Test escape1 $repl(foo)"),
("Test escape2 \"This is $foo() and $bar($bar())\", $repl()",
"Test escape2 \"This is _test() and _test(_test())\", rr"),
("Test escape3 'This is $foo() and $bar($bar())', $repl()",
"Test escape3 'This is _test() and _test(_test())', rr"),
("Test escape4 $$foo() and $$bar(a,b), $repl()",
"Test escape4 $foo() and $bar(a,b), rr"),
("Test with color |r$foo(a,b)|n is ok",
"Test with color |r_test(a, b)|n is ok"),
("Test malformed1 This is $foo( and $bar(",
"Test malformed1 This is $foo( and $bar("),
("Test malformed2 This is $foo( and $bar()",
"Test malformed2 This is $foo( and _test()"),
("Test malformed3 $", "Test malformed3 $"),
("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"),
("Test eval1 $eval(21 + 21 - 10)", "Test eval1 32"),
("Test eval2 $eval((21 + 21) / 2)", "Test eval2 21.0"),
("Test eval3 $eval('21' + 'foo' + 'bar')", "Test eval3 21foobar"),
("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 <class 'list'>"),
("Test type2 $typ((1,2,3,4))", "Test type2 <class 'tuple'>"),
("Test type3 $typ({1,2,3,4})", "Test type3 <class 'set'>"),
("Test type4 $typ({1:2,3:4})", "Test type4 <class 'dict'>"),
("Test type5 $typ(1), $typ(1.0)", "Test type5 <class 'int'>, <class 'float'>"),
("Test type6 $typ('1'), $typ(\"1.0\")", "Test type6 <class 'str'>, <class 'str'>"),
("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]"),
("Test literal1 $sum($lit([1,2,3,4,5,6]))", "Test literal1 21"),
("Test literal2 $typ($lit(1))", "Test literal2 <class 'int'>"),
("Test literal3 $typ($lit(1)aaa)", "Test literal3 <class 'str'>"),
("Test literal4 $typ(aaa$lit(1))", "Test literal4 <class 'str'>"),
])
def test_parse(self, string, expected):
"""
Test parsing of string.
"""
# t0 = time.time()
# from evennia import set_trace;set_trace()
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):
"""
Make sure error is raised if told to do so.
"""
string = "Test malformed This is $dummy(a, b) and $bar("
with self.assertRaises(funcparser.ParsingError):
self.parser.parse(string, raise_errors=True)
def test_parse_strip(self):
"""
Test the parser's strip functionality.
"""
string = "Test $foo(a,b, $bar()) and $repl($eval(3+2)) things"
ret = self.parser.parse(string, strip=True)
self.assertEqual("Test and things", ret)
def test_parse_escape(self):
"""
Test the parser's escape functionality.
"""
string = "Test $foo(a) and $bar() and $rep(c) things"
ret = self.parser.parse(string, escape=True)
self.assertEqual("Test \$foo(a) and \$bar() and \$rep(c) things", ret)
def test_parse_lit(self):
"""
Get non-strings back from parsing.
"""
string = "$lit(123)"
# normal parse
ret = self.parser.parse(string)
self.assertEqual('123', ret)
self.assertTrue(isinstance(ret, str))
# parse lit
ret = self.parser.parse_to_any(string)
self.assertEqual(123, ret)
self.assertTrue(isinstance(ret, int))
ret = self.parser.parse_to_any("$lit([1,2,3,4])")
self.assertEqual([1, 2, 3, 4], ret)
self.assertTrue(isinstance(ret, list))
ret = self.parser.parse_to_any("$lit('')")
self.assertEqual("", ret)
self.assertTrue(isinstance(ret, str))
# mixing a literal with other chars always make a string
ret = self.parser.parse_to_any(string + "aa")
self.assertEqual('123aa', ret)
self.assertTrue(isinstance(ret, str))
ret = self.parser.parse_to_any("test")
self.assertEqual('test', ret)
self.assertTrue(isinstance(ret, str))
def test_kwargs_overrides(self):
"""
Test so default kwargs are added and overridden properly
"""
# default kwargs passed on initializations
parser = funcparser.FuncParser(
_test_callables,
test='foo'
)
ret = parser.parse("This is a $foo() string")
self.assertEqual("This is a _test(test=foo) string", ret)
# override in the string itself
ret = parser.parse("This is a $foo(test=bar,foo=moo) string")
self.assertEqual("This is a _test(test=bar, foo=moo) string", ret)
# parser kwargs override the other types
ret = parser.parse("This is a $foo(test=bar,foo=moo) string", test="override", foo="bar")
self.assertEqual("This is a _test(test=override, foo=bar) string", ret)
# non-overridden kwargs shine through
ret = parser.parse("This is a $foo(foo=moo) string", foo="bar")
self.assertEqual("This is a _test(test=foo, foo=bar) string", ret)
class _DummyObj:
def __init__(self, name):
self.name = name
def get_display_name(self, looker=None):
return self.name
class TestDefaultCallables(TestCase):
"""
Test default callables.
"""
def setUp(self):
from django.conf import settings
self.parser = funcparser.FuncParser({**funcparser.FUNCPARSER_CALLABLES,
**funcparser.ACTOR_STANCE_CALLABLES})
self.obj1 = _DummyObj("Char1")
self.obj2 = _DummyObj("Char2")
@parameterized.expand([
("Test py1 $eval('')", "Test py1 "),
])
def test_callable(self, string, expected):
"""
Test callables with various input strings
"""
ret = self.parser.parse(string, raise_errors=True)
self.assertEqual(expected, ret)
@parameterized.expand([
("$You() $conj(smile) at him.", "You smile at him.", "Char1 smiles at him."),
("$You() $conj(smile) at $You(char1).", "You smile at You.", "Char1 smiles at Char1."),
("$You() $conj(smile) at $You(char2).", "You smile at Char2.", "Char1 smiles at You."),
("$You(char2) $conj(smile) at $you(char1).", "Char2 smile at you.", "You smiles at Char1."),
])
def test_conjugate(self, string, expected_you, expected_them):
"""
Test callables with various input strings
"""
mapping = {"char1": self.obj1, "char2": self.obj2}
ret = self.parser.parse(string, caller=self.obj1, receiver=self.obj1, mapping=mapping,
raise_errors=True)
self.assertEqual(expected_you, ret)
ret = self.parser.parse(string, caller=self.obj1, receiver=self.obj2, mapping=mapping,
raise_errors=True)
self.assertEqual(expected_them, ret)
@parameterized.expand([
("Test $pad(Hello, 20, c, -) there", "Test -------Hello-------- there"),
("Test $pad(Hello, width=20, align=c, fillchar=-) there",
"Test -------Hello-------- there"),
("Test $crop(This is a long test, 12)", "Test This is[...]"),
("Some $space(10) here", "Some here"),
("Some $clr(b, blue color) now", "Some |bblue color|n now"),
("Some $add(1, 2) things", "Some 3 things"),
("Some $sub(10, 2) things", "Some 8 things"),
("Some $mult(3, 2) things", "Some 6 things"),
("Some $div(6, 2) things", "Some 3.0 things"),
("Some $toint(6) things", "Some 6 things"),
("Some $ljust(Hello, 30)", "Some Hello "),
("Some $rjust(Hello, 30)", "Some Hello"),
("Some $rjust(Hello, width=30)", "Some Hello"),
("Some $cjust(Hello, 30)", "Some Hello "),
("Some $eval('-'*20)Hello", "Some --------------------Hello"),
])
def test_other_callables(self, string, expected):
"""
Test default callables.
"""
ret = self.parser.parse(string, raise_errors=True)
self.assertEqual(expected, ret)
def test_random(self):
string = "$random(1,10)"
for i in range(100):
ret = self.parser.parse_to_any(string, raise_errors=True)
self.assertTrue(1 <= ret <= 10)
string = "$random()"
for i in range(100):
ret = self.parser.parse_to_any(string, raise_errors=True)
self.assertTrue(0 <= ret <= 1)
string = "$random(1.0, 3.0)"
for i in range(100):
ret = self.parser.parse_to_any(string, raise_errors=True)
self.assertTrue(isinstance(ret, float))
self.assertTrue(1.0 <= ret <= 3.0)
def test_randint(self):
string = "$randint(1.0, 3.0)"
ret = self.parser.parse_to_any(string, raise_errors=True)
self.assertTrue(isinstance(ret, int))
self.assertTrue(1.0 <= ret <= 3.0)
def test_nofunc(self):
self.assertEqual(
self.parser.parse("as$382ewrw w we w werw,|44943}"),
"as$382ewrw w we w werw,|44943}",
)
def test_incomplete(self):
self.assertEqual(
self.parser.parse("testing $blah{without an ending."),
"testing $blah{without an ending.",
)
def test_single_func(self):
self.assertEqual(
self.parser.parse("this is a test with $pad(centered, 20) text in it."),
"this is a test with centered text in it.",
)
def test_nested(self):
self.assertEqual(
self.parser.parse(
"this $crop(is a test with $pad(padded, 20) text in $pad(pad2, 10) a crop, 80)"
),
"this is a test with padded text in pad2 a crop",
)
def test_escaped(self):
self.assertEqual(
self.parser.parse(
"this should be $pad('''escaped,''' and '''instead,''' cropped $crop(with a long,5) text., 80)"
),
"this should be '''escaped,''' and '''instead,''' cropped with text. ",
)
def test_escaped2(self):
self.assertEqual(
self.parser.parse(
'this should be $pad("""escaped,""" and """instead,""" cropped $crop(with a long,5) text., 80)'
),
"this should be \"\"\"escaped,\"\"\" and \"\"\"instead,\"\"\" cropped with text. ",
)
class TestCallableSearch(test_resources.EvenniaTest):
"""
Test the $search(query) callable
"""
def setUp(self):
super().setUp()
self.parser = funcparser.FuncParser(funcparser.SEARCHING_CALLABLES)
def test_search_obj(self):
"""
Test searching for an object
"""
string = "$search(Char)"
expected = self.char1
ret = self.parser.parse(string, caller=self.char1, return_str=False, raise_errors=True)
self.assertEqual(expected, ret)
def test_search_account(self):
"""
Test searching for an account
"""
string = "$search(TestAccount, type=account)"
expected = self.account
ret = self.parser.parse(string, caller=self.char1, return_str=False, raise_errors=True)
self.assertEqual(expected, ret)
def test_search_script(self):
"""
Test searching for a script
"""
string = "$search(Script, type=script)"
expected = self.script
ret = self.parser.parse(string, caller=self.char1, return_str=False, raise_errors=True)
self.assertEqual(expected, ret)
def test_search_obj_embedded(self):
"""
Test searching for an object - embedded in str
"""
string = "This is $search(Char) the guy."
expected = "This is " + str(self.char1) + " the guy."
ret = self.parser.parse(string, caller=self.char1, return_str=False, raise_errors=True)
self.assertEqual(expected, ret)

View file

@ -3,10 +3,10 @@ Unit tests for all sorts of inline text-tag parsing, like ANSI, html conversion,
"""
import re
from django.test import TestCase
from django.test import TestCase, override_settings
from evennia.utils.ansi import ANSIString
from evennia.utils.text2html import TextToHTMLparser
from evennia.utils import inlinefuncs
from evennia.utils import funcparser
class ANSIStringTestCase(TestCase):
@ -347,49 +347,3 @@ class TestTextToHTMLparser(TestCase):
'</span><a href="http://example.com/" target="_blank">'
'http://example.com/</a><span class="red">',
)
class TestInlineFuncs(TestCase):
"""Test the nested inlinefunc module"""
def test_nofunc(self):
self.assertEqual(
inlinefuncs.parse_inlinefunc("as$382ewrw w we w werw,|44943}"),
"as$382ewrw w we w werw,|44943}",
)
def test_incomplete(self):
self.assertEqual(
inlinefuncs.parse_inlinefunc("testing $blah{without an ending."),
"testing $blah{without an ending.",
)
def test_single_func(self):
self.assertEqual(
inlinefuncs.parse_inlinefunc("this is a test with $pad(centered, 20) text in it."),
"this is a test with centered text in it.",
)
def test_nested(self):
self.assertEqual(
inlinefuncs.parse_inlinefunc(
"this $crop(is a test with $pad(padded, 20) text in $pad(pad2, 10) a crop, 80)"
),
"this is a test with padded text in pad2 a crop",
)
def test_escaped(self):
self.assertEqual(
inlinefuncs.parse_inlinefunc(
"this should be $pad('''escaped,''' and '''instead,''' cropped $crop(with a long,5) text., 80)"
),
"this should be escaped, and instead, cropped with text. ",
)
def test_escaped2(self):
self.assertEqual(
inlinefuncs.parse_inlinefunc(
'this should be $pad("""escaped,""" and """instead,""" cropped $crop(with a long,5) text., 80)'
),
"this should be escaped, and instead, cropped with text. ",
)

View file

@ -8,6 +8,7 @@ TODO: Not nearly all utilities are covered yet.
import os.path
import random
from parameterized import parameterized
import mock
from django.test import TestCase
from datetime import datetime
@ -385,3 +386,43 @@ class TestPercent(TestCase):
self.assertEqual(utils.percent(3, 1, 1), "0.0%")
self.assertEqual(utils.percent(3, 0, 1), "100.0%")
self.assertEqual(utils.percent(-3, 0, 1), "0.0%")
class TestSafeConvert(TestCase):
"""
Test evennia.utils.utils.safe_convert_to_types
"""
@parameterized.expand([
(('1', '2', 3, 4, '5'), {'a': 1, 'b': '2', 'c': 3},
((int, float, str, int), {'a': int, 'b': float}), # "
(1, 2.0, '3', 4, '5'), {'a': 1, 'b': 2.0, 'c': 3}),
(('1 + 2', '[1, 2, 3]', [3, 4, 5]), {'a': '3 + 4', 'b': 5},
(('py', 'py', 'py'), {'a': 'py', 'b': 'py'}),
(3, [1, 2, 3], [3, 4, 5]), {'a': 7, 'b': 5}),
])
def test_conversion(self, args, kwargs, converters, expected_args, expected_kwargs):
"""
Test the converter with different inputs
"""
result_args, result_kwargs = utils.safe_convert_to_types(
converters, *args, raise_errors=True, **kwargs)
self.assertEqual(expected_args, result_args)
self.assertEqual(expected_kwargs, result_kwargs)
def test_conversion__fail(self):
"""
Test failing conversion
"""
from evennia.utils.funcparser import ParsingError
with self.assertRaises(ValueError):
utils.safe_convert_to_types(
(int, ), *('foo', ), raise_errors=True)
with self.assertRaises(ParsingError) as err:
utils.safe_convert_to_types(
('py', {}), *('foo', ), raise_errors=True)

View file

@ -20,6 +20,8 @@ import traceback
import importlib
import importlib.util
import importlib.machinery
from ast import literal_eval
from simpleeval import simple_eval
from unicodedata import east_asian_width
from twisted.internet.task import deferLater
from twisted.internet.defer import returnValue # noqa - used as import target
@ -1078,7 +1080,8 @@ def repeat(interval, callback, persistent=True, idstring="", stop=False,
store_key (tuple, optional): This is only used in combination with `stop` and
should be the return given from the original `repeat` call. If this
is given, all other args except `stop` are ignored.
*args, **kwargs: Used as arguments to `callback`.
*args: Used as arguments to `callback`.
**kwargs: Keyword-arguments to pass to `callback`.
Returns:
tuple or None: The tuple is the `store_key` - the identifier for the
@ -1112,8 +1115,8 @@ def unrepeat(store_key):
Args:
store_key (tuple): This is the return from `repeat`, used to uniquely
identify the ticker to stop. Without the store_key, the ticker
must be stopped by passing its parameters to `TICKER_HANDLER.remove`
identify the ticker to stop. Without the store_key, the ticker
must be stopped by passing its parameters to `TICKER_HANDLER.remove`
directly.
Returns:
@ -2390,3 +2393,104 @@ def interactive(func):
return ret
return decorator
def safe_convert_to_types(converters, *args, raise_errors=True, **kwargs):
"""
Helper function to safely convert inputs to expected data types.
Args:
converters (tuple): A tuple `((converter, converter,...), {kwarg: converter, ...})` to
match a converter to each element in `*args` and `**kwargs`.
Each converter will will be called with the arg/kwarg-value as the only argument.
If there are too few converters given, the others will simply not be converter. If the
converter is given as the string 'py', it attempts to run
`safe_eval`/`literal_eval` on the input arg or kwarg value. It's possible to
skip the arg/kwarg part of the tuple, an empty tuple/dict will then be assumed.
*args: The arguments to convert with `argtypes`.
raise_errors (bool, optional): If set, raise any errors. This will
abort the conversion at that arg/kwarg. Otherwise, just skip the
conversion of the failing arg/kwarg. This will be set by the FuncParser if
this is used as a part of a FuncParser callable.
**kwargs: The kwargs to convert with `kwargtypes`
Returns:
tuple: `(args, kwargs)` in converted form.
Raises:
utils.funcparser.ParsingError: If parsing failed in the `'py'`
converter. This also makes this compatible with the FuncParser
interface.
any: Any other exception raised from other converters, if raise_errors is True.
Notes:
This function is often used to validate/convert input from untrusted sources. For
security, the "py"-converter is deliberately limited and uses `safe_eval`/`literal_eval`
which only supports simple expressions or simple containers with literals. NEVER
use the python `eval` or `exec` methods as a converter for any untrusted input! Allowing
untrusted sources to execute arbitrary python on your server is a severe security risk,
Example:
::
$funcname(1, 2, 3.0, c=[1,2,3])
def _funcname(*args, **kwargs):
args, kwargs = safe_convert_input(((int, int, float), {'c': 'py'}), *args, **kwargs)
# ...
"""
def _safe_eval(inp):
if not inp:
return ''
if not isinstance(inp, str):
# already converted
return inp
try:
return literal_eval(inp)
except Exception as err:
literal_err = f"{err.__class__.__name__}: {err}"
try:
return simple_eval(inp)
except Exception as err:
simple_err = f"{str(err.__class__.__name__)}: {err}"
pass
if raise_errors:
from evennia.utils.funcparser import ParsingError
err = (f"Errors converting '{inp}' to python:\n"
f"literal_eval raised {literal_err}\n"
f"simple_eval raised {simple_err}")
raise ParsingError(err)
# handle an incomplete/mixed set of input converters
if not converters:
return args, kwargs
arg_converters, *kwarg_converters = converters
arg_converters = make_iter(arg_converters)
kwarg_converters = kwarg_converters[0] if kwarg_converters else {}
# apply the converters
if args and arg_converters:
args = list(args)
arg_converters = make_iter(arg_converters)
for iarg, arg in enumerate(args[:len(arg_converters)]):
converter = arg_converters[iarg]
converter = _safe_eval if converter in ('py', 'python') else converter
try:
args[iarg] = converter(arg)
except Exception:
if raise_errors:
raise
args = tuple(args)
if kwarg_converters and isinstance(kwarg_converters, dict):
for key, converter in kwarg_converters.items():
converter = _safe_eval if converter in ('py', 'python') else converter
if key in {**kwargs}:
try:
kwargs[key] = converter(kwargs[key])
except Exception:
if raise_errors:
raise
return args, kwargs

View file

@ -0,0 +1,340 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.

View file

@ -0,0 +1,387 @@
"""
English verb conjugation
Original Author: Tom De Smedt <tomdesmedt@organisms.be> of Nodebox
Refactored by Griatch 2021, for Evennia.
This is distributed under the GPL2 license. See ./LICENSE.txt for details.
The verb.txt morphology was adopted from the XTAG morph_englis.flat:
http://www.cis.upenn.edu/~xtag/
"""
import os
_VERBS_FILE = "verbs.txt"
# Each verb and its tenses is a list in verbs.txt,
# indexed according to the following keys:
# the negated forms (for supported verbs) are ind+11.
verb_tenses_keys = {
"infinitive": 0,
"1st singular present": 1,
"2nd singular present": 2,
"3rd singular present": 3,
"present plural": 4,
"present participle": 5,
"1st singular past": 6,
"2nd singular past": 7,
"3rd singular past": 8,
"past plural": 9,
"past": 10,
"past participle": 11,
}
# allow to specify tenses with a shorter notation
verb_tenses_aliases = {
"inf": "infinitive",
"1sgpres": "1st singular present",
"2sgpres": "2nd singular present",
"3sgpres": "3rd singular present",
"pl": "present plural",
"prog": "present participle",
"1sgpast": "1st singular past",
"2sgpast": "2nd singular past",
"3sgpast": "3rd singular past",
"pastpl": "past plural",
"ppart": "past participle",
}
# Each verb has morphs for infinitve,
# 3rd singular present, present participle,
# past and past participle.
# Verbs like "be" have other morphs as well
# (i.e. I am, you are, she is, they aren't)
# Additionally, the following verbs can be negated:
# be, can, do, will, must, have, may, need, dare, ought.
# load the conjugation forms from ./verbs.txt
verb_tenses = {}
path = os.path.join(os.path.dirname(__file__), _VERBS_FILE)
with open(path) as fil:
for line in fil.readlines():
wordlist = [part.strip() for part in line.split(",")]
verb_tenses[wordlist[0]] = wordlist
# Each verb can be lemmatised:
# inflected morphs of the verb point
# to its infinitive in this dictionary.
verb_lemmas = {}
for infinitive in verb_tenses:
for tense in verb_tenses[infinitive]:
if tense:
verb_lemmas[tense] = infinitive
def verb_infinitive(verb):
"""
Returns the uninflected form of the verb, like 'are' -> 'be'
Args:
verb (str): The verb to get the uninflected form of.
Returns:
str: The uninflected verb form of `verb`.
"""
return verb_lemmas.get(verb, '')
def verb_conjugate(verb, tense="infinitive", negate=False):
"""
Inflects the verb to the given tense.
Args:
verb (str): The single verb to conjugate.
tense (str): The tense to convert to. This can be given either as a long or short form
- "infinitive" ("inf") - be
- "1st/2nd/3rd singular present" ("1/2/3sgpres") - am/are/is
- "present plural" ("pl") - are
- "present participle" ("prog") - being
- "1st/2nd/3rd singular past" ("1/2/3sgpast") - was/were/was
- "past plural" ("pastpl") - were
- "past" - were
- "past participle" ("ppart") - been
negate (bool): Negates the verb. This only supported
for a limited number of verbs: be, can, do, will, must, have, may,
need, dare, ought.
Returns:
str: The conjugated verb. If conjugation fails, the original verb is returned.
Examples:
The verb 'be':
- present: I am, you are, she is,
- present participle: being,
- past: I was, you were, he was,
- past participle: been,
- negated present: I am not, you aren't, it isn't.
"""
tense = verb_tenses_aliases.get(tense, tense)
verb = verb_infinitive(verb)
ind = verb_tenses_keys[tense]
if negate:
ind += len(verb_tenses_keys)
try:
return verb_tenses[verb][ind]
except IndexError:
# TODO implement simple algorithm here with +s for certain tenses?
return verb
def verb_present(verb, person="", negate=False):
"""
Inflects the verb in the present tense.
Args:
person (str or int): This can be 1, 2, 3, "1st", "2nd", "3rd", "plural" or "*".
negate (bool): Some verbs like be, have, must, can be negated.
Returns:
str: The present tense verb.
Example:
had -> have
"""
person = str(person).replace("pl", "*").strip("stndrgural")
mapping = {
"1": "1st singular present",
"2": "2nd singular present",
"3": "3rd singular present",
"*": "present plural",
}
if person in mapping and verb_conjugate(verb, mapping[person], negate) != "":
return verb_conjugate(verb, mapping[person], negate)
return verb_conjugate(verb, "infinitive", negate)
def verb_present_participle(verb):
"""
Inflects the verb in the present participle.
Args:
verb (str): The verb to inflect.
Returns:
str: The inflected verb.
Examples:
give -> giving, be -> being, swim -> swimming
"""
return verb_conjugate(verb, "present participle")
def verb_past(verb, person="", negate=False):
"""
Inflects the verb in the past tense.
Args:
verb (str): The verb to inflect.
person (str, optional): The person can be specified with 1, 2, 3,
"1st", "2nd", "3rd", "plural", "*".
negate (bool, optional): Some verbs like be, have, must, can be negated.
Returns:
str: The inflected verb.
Examples:
give -> gave, be -> was, swim -> swam
"""
person = str(person).replace("pl", "*").strip("stndrgural")
mapping = {
"1": "1st singular past",
"2": "2nd singular past",
"3": "3rd singular past",
"*": "past plural",
}
if person in mapping and verb_conjugate(verb, mapping[person], negate) != "":
return verb_conjugate(verb, mapping[person], negate)
return verb_conjugate(verb, "past", negate=negate)
def verb_past_participle(verb):
"""
Inflects the verb in the present participle.
Args:
verb (str): The verb to inflect.
Returns:
str: The inflected verb.
Examples:
give -> given, be -> been, swim -> swum
"""
return verb_conjugate(verb, "past participle")
def verb_all_tenses():
"""
Get all all possible verb tenses.
Returns:
list: A list if string names.
"""
return list(verb_tenses_keys.keys())
def verb_tense(verb):
"""
Returns a string from verb_tenses_keys representing the verb's tense.
Args:
verb (str): The verb to check the tense of.
Returns:
str: The tense.
Example:
given -> "past participle"
"""
infinitive = verb_infinitive(verb)
data = verb_tenses[infinitive]
for tense in verb_tenses_keys:
if data[verb_tenses_keys[tense]] == verb:
return tense
if data[verb_tenses_keys[tense] + len(verb_tenses_keys)] == verb:
return tense
def verb_is_tense(verb, tense):
"""
Checks whether the verb is in the given tense.
Args:
verb (str): The verb to check.
tense (str): The tense to check.
Return:
bool: If verb matches given tense.
"""
tense = verb_tenses_aliases.get(tense, tense)
return verb_tense(verb) == tense
def verb_is_present(verb, person="", negated=False):
"""
Checks whether the verb is in the present tense.
Args:
verb (str): The verb to check.
person (str): Check which person.
negated (bool): Check if verb was negated.
Returns:
bool: If verb was in present tense.
"""
person = str(person).replace("*", "plural")
tense = verb_tense(verb)
if tense is not None:
if "present" in tense and person in tense:
if not negated:
return True
elif "n't" in verb or " not" in verb:
return True
return False
def verb_is_present_participle(verb):
"""
Checks whether the verb is in present participle.
Args:
verb (str): The verb to check.
Returns:
bool: Result of check.
"""
tense = verb_tense(verb)
return tense == "present participle"
def verb_is_past(verb, person="", negated=False):
"""
Checks whether the verb is in the past tense.
Args:
verb (str): The verb to check.
person (str): The person to check.
negated (bool): Check if verb is negated.
Returns:
bool: Result of check.
"""
person = str(person).replace("*", "plural")
tense = verb_tense(verb)
if tense is not None:
if "past" in tense and person in tense:
if not negated:
return True
elif "n't" in verb or " not" in verb:
return True
return False
def verb_is_past_participle(verb):
"""
Checks whether the verb is in past participle.
Args:
verb (str): The verb to check.
Returns:
bool: The result of the check.
"""
tense = verb_tense(verb)
return tense == "past participle"
def verb_actor_stance_components(verb):
"""
Figure out actor stance components of a verb.
Args:
verb (str): The verb to analyze
Returns:
tuple: The 2nd person (you) and 3rd person forms of the verb,
in the same tense as the ingoing verb.
"""
tense = verb_tense(verb)
if "participle" in tense or "plural" in tense:
return (verb, verb)
if tense == "infinitive" or "present" in tense:
you_str = verb_present(verb, person="2") or verb
them_str = verb_present(verb, person="3") or verb + "s"
else:
you_str = verb_past(verb, person="2") or verb
them_str = verb_past(verb, person="3") or verb + "s"
return (you_str, them_str)

View file

@ -0,0 +1,241 @@
"""
Unit tests for verb conjugation.
"""
from parameterized import parameterized
from django.test import TestCase
from . import conjugate
class TestVerbConjugate(TestCase):
"""
Test the conjugation.
"""
@parameterized.expand([
("have", "have"),
("swim", "swim"),
("give", "give"),
("given", "give"),
("am", "be"),
("doing", "do"),
("are", "be"),
])
def test_verb_infinitive(self, verb, expected):
"""
Test the infinite-getter.
"""
self.assertEqual(expected, conjugate.verb_infinitive(verb))
@parameterized.expand([
("inf", "have", "have"),
("inf", "swim", "swim"),
("inf", "give", "give"),
("inf", "given", "give"),
("inf", "am", "be"),
("inf", "doing", "do"),
("inf", "are", "be"),
("2sgpres", "am", "are"),
("3sgpres", "am", "is"),
])
def test_verb_conjugate(self, tense, verb, expected):
"""
Test conjugation for different tenses.
"""
self.assertEqual(expected, conjugate.verb_conjugate(verb, tense=tense))
@parameterized.expand([
("1st", "have", "have"),
("1st", "swim", "swim"),
("1st", "give", "give"),
("1st", "given", "give"),
("1st", "am", "am"),
("1st", "doing", "do"),
("1st", "are", "am"),
("2nd", "were", "are"),
("3rd", "am", "is"),
])
def test_verb_present(self, person, verb, expected):
"""
Test the present.
"""
self.assertEqual(expected, conjugate.verb_present(verb, person=person))
@parameterized.expand([
("have", "having"),
("swim", "swimming"),
("give", "giving"),
("given", "giving"),
("am", "being"),
("doing", "doing"),
("are", "being"),
])
def test_verb_present_participle(self, verb, expected):
"""
Test the present_participle
"""
self.assertEqual(expected, conjugate.verb_present_participle(verb))
@parameterized.expand([
("1st", "have", "had"),
("1st", "swim", "swam"),
("1st", "give", "gave"),
("1st", "given", "gave"),
("1st", "am", "was"),
("1st", "doing", "did"),
("1st", "are", "was"),
("2nd", "were", "were"),
])
def test_verb_past(self, person, verb, expected):
"""
Test the past getter.
"""
self.assertEqual(expected, conjugate.verb_past(verb, person=person))
@parameterized.expand([
("have", "had"),
("swim", "swum"),
("give", "given"),
("given", "given"),
("am", "been"),
("doing", "done"),
("are", "been"),
])
def test_verb_past_participle(self, verb, expected):
"""
Test the past participle.
"""
self.assertEqual(expected, conjugate.verb_past_participle(verb))
def test_verb_get_all_tenses(self):
"""
Test getting all tenses.
"""
self.assertEqual(list(conjugate.verb_tenses_keys.keys()), conjugate.verb_all_tenses())
@parameterized.expand([
("have", "infinitive"),
("swim", "infinitive"),
("give", "infinitive"),
("given", "past participle"),
("am", "1st singular present"),
("doing", "present participle"),
("are", "2nd singular present"),
])
def test_verb_tense(self, verb, expected):
"""
Test the tense retriever.
"""
self.assertEqual(expected, conjugate.verb_tense(verb))
@parameterized.expand([
("inf", "have", True),
("inf", "swim", True),
("inf", "give", True),
("inf", "given", False),
("inf", "am", False),
("inf", "doing", False),
("inf", "are", False),
])
def test_verb_is_tense(self, tense, verb, expected):
"""
Test the tense-checker
"""
self.assertEqual(expected, conjugate.verb_is_tense(verb, tense))
@parameterized.expand([
("1st", "have", False),
("1st", "swim", False),
("1st", "give", False),
("1st", "given", False),
("1st", "am", True),
("1st", "doing", False),
("1st", "are", False),
("1st", "had", False),
])
def test_verb_is_present(self, person, verb, expected):
"""
Test the tense-checker
"""
self.assertEqual(expected, conjugate.verb_is_present(verb, person=person))
@parameterized.expand([
("have", False),
("swim", False),
("give", False),
("given", False),
("am", False),
("doing", True),
("are", False),
])
def test_verb_is_present_participle(self, verb, expected):
"""
Test the tense-checker
"""
self.assertEqual(expected, conjugate.verb_is_present_participle(verb))
@parameterized.expand([
("1st", "have", False),
("1st", "swim", False),
("1st", "give", False),
("1st", "given", False),
("1st", "am", False),
("1st", "doing", False),
("1st", "are", False),
("2nd", "were", True),
])
def test_verb_is_past(self, person, verb, expected):
"""
Test the tense-checker
"""
self.assertEqual(expected, conjugate.verb_is_past(verb, person=person))
@parameterized.expand([
("have", False),
("swimming", False),
("give", False),
("given", True),
("am", False),
("doing", False),
("are", False),
("had", False),
])
def test_verb_is_past_participle(self, verb, expected):
"""
Test the tense-checker
"""
self.assertEqual(expected, conjugate.verb_is_past_participle(verb))
@parameterized.expand([
("have", ("have", "has")),
("swimming", ("swimming", "swimming")),
("give", ("give", "gives")),
("given", ("given", "given")),
("am", ("are", "is")),
("doing", ("doing", "doing")),
("are", ("are", "is")),
("had", ("had", "had")),
("grin", ("grin", "grins")),
("smile", ("smile", "smiles")),
("vex", ("vex", "vexes")),
("thrust", ("thrust", "thrusts")),
])
def test_verb_actor_stance_components(self, verb, expected):
"""
Test the tense-checker
"""
self.assertEqual(expected, conjugate.verb_actor_stance_components(verb))

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,11 @@ django-sekizai
inflect >= 5.2.0
autobahn >= 17.9.3
lunr == 0.5.6
simpleeval <= 1.0
# conjugation library, py3 version
git+https://github.com/markrogersjr/nodebox_linguistics_extended.git
# try to resolve dependency issue in py3.7
attrs >= 19.2.0