Fix merge conflicts

This commit is contained in:
Griatch 2022-06-04 13:26:58 +02:00
commit 1290f648d5
20 changed files with 769 additions and 761 deletions

View file

@ -166,6 +166,12 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10
- New `at_pre_object_receive(obj, source_location)` method on Objects. Called on
destination, mimicking behavior of `at_pre_move` hook - returning False will abort move.
- New `at_pre_object_leave(obj, destination)` method on Objects. Called on
- The db pickle-serializer now checks for methods `__serialize_dbobjs__` and `__deserialize_dbobjs__`
to allow custom packing/unpacking of nested dbobjs, to allow storing in Attribute.
- Optimizations to rpsystem contrib performance. Breaking change: `.get_sdesc()` will
now return `None` instead of `.db.desc` if no sdesc is set; fallback in hook (inspectorCaracal)
- Reworked text2html parser to avoid problems with stateful color tags (inspectorCaracal)
- Simplified `EvMenu.options_formatter` hook to use `EvColumn` and f-strings (inspectorcaracal)
## Evennia 0.9.5

View file

@ -193,7 +193,7 @@ or in the chat.
[pep8]: http://www.python.org/dev/peps/pep-0008
[pep8tool]: https://pypi.python.org/pypi/pep8
[googlestyle]: http://www.sphinx-doc.org/en/stable/ext/example_google.html
[googlestyle]: https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html
[githubmarkdown]: https://help.github.com/articles/github-flavored-markdown/
[markdown-hilight]: https://help.github.com/articles/github-flavored-markdown/#syntax-highlighting
[command-docstrings]: https://github.com/evennia/evennia/wiki/Using%20MUX%20As%20a%20Standard#documentation-policy

View file

@ -152,6 +152,8 @@ mv-local:
@echo "Documentation built (multiversion + autodocs)."
@echo "To see result, open evennia/docs/build/html/<version>/index.html in a browser."
# note - don't run the following manually, the result will clash with the result
# of the github actions!
deploy:
make _multiversion-deploy
@echo "Documentation deployed."

View file

@ -3,7 +3,7 @@
```{code-block}
:caption: In-game
> set obj/myattr = "test"
```
```
```{code-block} python
:caption: In-code, using the .db wrapper
obj.db.foo = [1, 2, 3, "bar"]
@ -16,8 +16,8 @@ value = attributes.get("myattr", category="bar")
```
```{code-block} python
:caption: In-code, using `AttributeProperty` at class level
from evennia import DefaultObject
from evennia import AttributeProperty
from evennia import DefaultObject
from evennia import AttributeProperty
class MyObject(DefaultObject):
foo = AttributeProperty(default=[1, 2, 3, "bar"])
@ -25,20 +25,20 @@ class MyObject(DefaultObject):
```
_Attributes_ allow you to to store arbitrary data on objects and make sure the data survives a server reboot. An Attribute can store pretty much any
Python data structure and data type, like numbers, strings, lists, dicts etc. You can also
_Attributes_ allow you to to store arbitrary data on objects and make sure the data survives a server reboot. An Attribute can store pretty much any
Python data structure and data type, like numbers, strings, lists, dicts etc. You can also
store (references to) database objects like characters and rooms.
- [What can be stored in an Attribute](#what-types-of-data-can-i-save-in-an-attribute) is a must-read to avoid being surprised, also for experienced developers. Attributes can store _almost_ everything
but you need to know the quirks.
- [NAttributes](#in-memory-attributes-nattributes) are the in-memory, non-persistent
- [NAttributes](#in-memory-attributes-nattributes) are the in-memory, non-persistent
siblings of Attributes.
- [Managing Attributes In-game](#managing-attributes-in-game) for in-game builder commands.
## Managing Attributes in Code
## Managing Attributes in Code
Attributes are usually handled in code. All [Typeclassed](./Typeclasses.md) entities
([Accounts](./Accounts.md), [Objects](./Objects.md), [Scripts](./Scripts.md) and
Attributes are usually handled in code. All [Typeclassed](./Typeclasses.md) entities
([Accounts](./Accounts.md), [Objects](./Objects.md), [Scripts](./Scripts.md) and
[Channels](./Channels.md)) can (and usually do) have Attributes associated with them. There
are three ways to manage Attributes, all of which can be mixed.
@ -50,8 +50,8 @@ are three ways to manage Attributes, all of which can be mixed.
The simplest way to get/set Attributes is to use the `.db` shortcut. This allows for setting and getting Attributes that lack a _category_ (having category `None`)
```python
import evennia
```python
import evennia
obj = evennia.create_object(key="Foo")
@ -64,10 +64,10 @@ obj.db.self_reference = obj # stores a reference to the obj
rose = evennia.search_object(key="rose")[0] # returns a list, grab 0th element
rose.db.has_thorns = True
# retrieving
# retrieving
val1 = obj.db.foo1
val2 = obj.db.foo2
weap = obj.db.weapon
weap = obj.db.weapon
myself = obj.db.self_reference # retrieve reference from db, get object back
is_ouch = rose.db.has_thorns
@ -75,25 +75,25 @@ is_ouch = rose.db.has_thorns
# this will return None, not AttributeError!
not_found = obj.db.jiwjpowiwwerw
# returns all Attributes on the object
obj.db.all
# returns all Attributes on the object
obj.db.all
# delete an Attribute
del obj.db.foo2
```
Trying to access a non-existing Attribute will never lead to an `AttributeError`. Instead
you will get `None` back. The special `.db.all` will return a list of all Attributes on
the object. You can replace this with your own Attribute `all` if you want, it will replace the
Trying to access a non-existing Attribute will never lead to an `AttributeError`. Instead
you will get `None` back. The special `.db.all` will return a list of all Attributes on
the object. You can replace this with your own Attribute `all` if you want, it will replace the
default `all` functionality until you delete it again.
### Using .attributes
If you want to group your Attribute in a category, or don't know the name of the Attribute beforehand, you can make use of
the [AttributeHandler](evennia.typeclasses.attributes.AttributeHandler), available as `.attributes` on all typeclassed entities. With no extra keywords, this is identical to using the `.db` shortcut (`.db` is actually using the `AttributeHandler` internally):
If you want to group your Attribute in a category, or don't know the name of the Attribute beforehand, you can make use of
the [AttributeHandler](evennia.typeclasses.attributes.AttributeHandler), available as `.attributes` on all typeclassed entities. With no extra keywords, this is identical to using the `.db` shortcut (`.db` is actually using the `AttributeHandler` internally):
```python
is_ouch = rose.attributes.get("has_thorns")
```python
is_ouch = rose.attributes.get("has_thorns")
obj.attributes.add("helmet", "Knight's helmet")
helmet = obj.attributes.get("helmet")
@ -103,7 +103,7 @@ obj.attributes.add("my game log", "long text about ...")
By using a category you can separate same-named Attributes on the same object to help organization.
```python
```python
# store (let's say we have gold_necklace and ringmail_armor from before)
obj.attributes.add("neck", gold_necklace, category="clothing")
obj.attributes.add("neck", ringmail_armor, category="armor")
@ -113,19 +113,19 @@ neck_clothing = obj.attributes.get("neck", category="clothing")
neck_armor = obj.attributes.get("neck", category="armor")
```
If you don't specify a category, the Attribute's `category` will be `None` and can thus also be found via `.db`. `None` is considered a category of its own, so you won't find `None`-category Attributes mixed with Attributes having categories.
If you don't specify a category, the Attribute's `category` will be `None` and can thus also be found via `.db`. `None` is considered a category of its own, so you won't find `None`-category Attributes mixed with Attributes having categories.
Here are the methods of the `AttributeHandler`. See
Here are the methods of the `AttributeHandler`. See
the [AttributeHandler API](evennia.typeclasses.attributes.AttributeHandler) for more details.
- `has(...)` - this checks if the object has an Attribute with this key. This is equivalent
to doing `obj.db.attrname` except you can also check for a specific `category.
- `get(...)` - this retrieves the given Attribute. You can also provide a `default` value to return
- `get(...)` - this retrieves the given Attribute. You can also provide a `default` value to return
if the Attribute is not defined (instead of None). By supplying an
`accessing_object` to the call one can also make sure to check permissions before modifying
anything. The `raise_exception` kwarg allows you to raise an `AttributeError` instead of returning
`None` when you access a non-existing `Attribute`. The `strattr` kwarg tells the system to store
the Attribute as a raw string rather than to pickle it. While an optimization this should usually
anything. The `raise_exception` kwarg allows you to raise an `AttributeError` instead of returning
`None` when you access a non-existing `Attribute`. The `strattr` kwarg tells the system to store
the Attribute as a raw string rather than to pickle it. While an optimization this should usually
not be used unless the Attribute is used for some particular, limited purpose.
- `add(...)` - this adds a new Attribute to the object. An optional [lockstring](./Locks.md) can be
supplied here to restrict future access and also the call itself may be checked against locks.
@ -135,30 +135,30 @@ the [AttributeHandler API](evennia.typeclasses.attributes.AttributeHandler) for
Examples:
```python
```python
try:
# raise error if Attribute foo does not exist
# raise error if Attribute foo does not exist
val = obj.attributes.get("foo", raise_exception=True):
except AttributeError:
# ...
# return default value if foo2 doesn't exist
val2 = obj.attributes.get("foo2", default=[1, 2, 3, "bar"])
val2 = obj.attributes.get("foo2", default=[1, 2, 3, "bar"])
# delete foo if it exists (will silently fail if unset, unless
# raise_exception is set)
obj.attributes.remove("foo")
# view all clothes on obj
all_clothes = obj.attributes.all(category="clothes")
all_clothes = obj.attributes.all(category="clothes")
```
### Using AttributeProperty
### Using AttributeProperty
The third way to set up an Attribute is to use an `AttributeProperty`. This
The third way to set up an Attribute is to use an `AttributeProperty`. This
is done on the _class level_ of your typeclass and allows you to treat Attributes a bit like Django database Fields. Unlike using `.db` and `.attributes`, an `AttributeProperty` can't be created on the fly, you must assign it in the class code.
```python
```python
# mygame/typeclasses/characters.py
from evennia import DefaultCharacter
@ -173,16 +173,16 @@ class Character(DefaultCharacter):
sleepy = AttributeProperty(False, autocreate=False)
poisoned = AttributeProperty(False, autocreate=False)
def at_object_creation(self):
# ...
```
def at_object_creation(self):
# ...
```
When a new instance of the class is created, new `Attributes` will be created with the value and category given.
With `AttributeProperty`'s set up like this, one can access the underlying `Attribute` like a regular property on the created object:
With `AttributeProperty`'s set up like this, one can access the underlying `Attribute` like a regular property on the created object:
```python
```python
char = create_object(Character)
char.strength # returns 10
@ -195,15 +195,15 @@ char.db.sleepy # returns None because autocreate=False (see below)
```
```{warning}
```{warning}
Be careful to not assign AttributeProperty's to names of properties and methods already existing on the class, like 'key' or 'at_object_creation'. That could lead to very confusing errors.
```
The `autocreate=False` (default is `True`) used for `sleepy` and `poisoned` is worth a closer explanation. When `False`, _no_ Attribute will be auto-created for these AttributProperties unless they are _explicitly_ set.
The advantage of not creating an Attribute is that the default value given to `AttributeProperty` is returned with no database access unless you change it. This also means that if you want to change the default later, all entities previously create will inherit the new default.
The drawback is that without a database precense you can't find the Attribute via `.db` and `.attributes.get` (or by querying for it in other ways in the database):
The `autocreate=False` (default is `True`) used for `sleepy` and `poisoned` is worth a closer explanation. When `False`, _no_ Attribute will be auto-created for these AttributProperties unless they are _explicitly_ set.
The advantage of not creating an Attribute is that the default value given to `AttributeProperty` is returned with no database access unless you change it. This also means that if you want to change the default later, all entities previously create will inherit the new default.
The drawback is that without a database precense you can't find the Attribute via `.db` and `.attributes.get` (or by querying for it in other ways in the database):
```python
```python
char.sleepy # returns False, no db access
char.db.sleepy # returns None - no Attribute exists
@ -217,39 +217,39 @@ char.sleepy # now returns True, involves db access
```
You can e.g. `del char.strength` to set the value back to the default (the value defined
in the `AttributeProperty`).
You can e.g. `del char.strength` to set the value back to the default (the value defined
in the `AttributeProperty`).
See the [AttributeProperty API](evennia.typeclasses.attributes.AttributeProperty) for more details on how to create it with special options, like giving access-restrictions.
## Managing Attributes in-game
Attributes are mainly used by code. But one can also allow the builder to use Attributes to
'turn knobs' in-game. For example a builder could want to manually tweak the "level" Attribute of an
Attributes are mainly used by code. But one can also allow the builder to use Attributes to
'turn knobs' in-game. For example a builder could want to manually tweak the "level" Attribute of an
enemy NPC to lower its difficuly.
When setting Attributes this way, you are severely limited in what can be stored - this is because
When setting Attributes this way, you are severely limited in what can be stored - this is because
giving players (even builders) the ability to store arbitrary Python would be a severe security
problem.
problem.
In game you can set an Attribute like this:
In game you can set an Attribute like this:
set myobj/foo = "bar"
To view, do
To view, do
set myobj/foo
set myobj/foo
or see them together with all object-info with
or see them together with all object-info with
examine myobj
The first `set`-example will store a new Attribute `foo` on the object `myobj` and give it the
The first `set`-example will store a new Attribute `foo` on the object `myobj` and give it the
value "bar".
You can store numbers, booleans, strings, tuples, lists and dicts this way. But if
You can store numbers, booleans, strings, tuples, lists and dicts this way. But if
you store a list/tuple/dict they must be proper Python structures and may _only_ contain strings
or numbers. If you try to insert an unsupported structure, the input will be converted to a
or numbers. If you try to insert an unsupported structure, the input will be converted to a
string.
set myobj/mybool = True
@ -263,8 +263,8 @@ For the last line you'll get a warning and the value instead will be saved as a
## Locking and checking Attributes
While the `set` command is limited to builders, individual Attributes are usually not
locked down. You may want to lock certain sensitive Attributes, in particular for games
While the `set` command is limited to builders, individual Attributes are usually not
locked down. You may want to lock certain sensitive Attributes, in particular for games
where you allow player building. You can add such limitations by adding a [lock string](./Locks.md)
to your Attribute. A NAttribute have no locks.
@ -273,7 +273,7 @@ The relevant lock types are
- `attrread` - limits who may read the value of the Attribute
- `attredit` - limits who may set/change this Attribute
You must use the `AttributeHandler` to assign the lockstring to the Attribute:
You must use the `AttributeHandler` to assign the lockstring to the Attribute:
```python
lockstring = "attread:all();attredit:perm(Admins)"
@ -281,7 +281,7 @@ obj.attributes.add("myattr", "bar", lockstring=lockstring)"
```
If you already have an Attribute and want to add a lock in-place you can do so
by having the `AttributeHandler` return the `Attribute` object itself (rather than
by having the `AttributeHandler` return the `Attribute` object itself (rather than
its value) and then assign the lock to it directly:
```python
@ -293,8 +293,8 @@ Note the `return_obj` keyword which makes sure to return the `Attribute` object
could be accessed.
A lock is no good if nothing checks it -- and by default Evennia does not check locks on Attributes.
To check the `lockstring` you provided, make sure you include `accessing_obj` and set
`default_access=False` as you make a `get` call.
To check the `lockstring` you provided, make sure you include `accessing_obj` and set
`default_access=False` as you make a `get` call.
```python
# in some command code where we want to limit
@ -328,13 +328,13 @@ values into a string representation before storing it to the database. This is d
With a single object, we mean anything that is *not iterable*, like numbers, strings or custom class
instances without the `__iter__` method.
* You can generally store any non-iterable Python entity that can be pickled.
* Single database objects/typeclasses can be stored, despite them normally not being possible
to pickle. Evennia wil convert them to an internal representation using their classname,
database-id and creation-date with a microsecond precision. When retrieving, the object
* You can generally store any non-iterable Python entity that can be _pickled_.
* Single database objects/typeclasses can be stored, despite them normally not being possible
to pickle. Evennia will convert them to an internal representation using theihr classname,
database-id and creation-date with a microsecond precision. When retrieving, the object
instance will be re-fetched from the database using this information.
* To convert the database object, Evennia must know it's there. If you *hide* a database object
inside a non-iterable class, you will run into errors - this is not supported!
* If you 'hide' a db-obj as a property on a custom class, Evennia will not be
able to find it to serialize it. For that you need to help it out (see below).
```{code-block} python
:caption: Valid assignments
@ -345,16 +345,55 @@ obj.db.test1 = False
# a database object (will be stored as an internal representation)
obj.db.test2 = myobj
```
As mentioned, Evennia will not be able to automatically serialize db-objects
'hidden' in arbitrary properties on an object. This will lead to an error
when saving the Attribute.
```{code-block} python
:caption: Invalid, 'hidden' dbobject
# example of an invalid, "hidden" dbobject
# example of storing an invalid, "hidden" dbobject in Attribute
class Container:
def __init__(self, mydbobj):
# no way for Evennia to know this is a database object!
self.mydbobj = mydbobj
# let's assume myobj is a db-object
container = Container(myobj)
obj.db.invalid = container # will cause error!
obj.db.mydata = container # will raise error!
```
By adding two methods `__serialize_dbobjs__` and `__deserialize_dbobjs__` to the
object you want to save, you can pre-serialize and post-deserialize all 'hidden'
objects before Evennia's main serializer gets to work. Inside these methods, use Evennia's
[evennia.utils.dbserialize.dbserialize](api:evennia.utils.dbserialize.dbserialize) and
[dbunserialize](api:evennia.utils.dbserialize.dbunserialize) functions to safely
serialize the db-objects you want to store.
```{code-block} python
:caption: Fixing an invalid 'hidden' dbobj for storing in Attribute
from evennia.utils import dbserialize # important
class Container:
def __init__(self, mydbobj):
# A 'hidden' db-object
self.mydbobj = mydbobj
def __serialize_dbobjs__(self):
"""This is called before serialization and allows
us to custom-handle those 'hidden' dbobjs"""
self.mydbobj = dbserialize.dbserialize(self.mydbobj
def __deserialize_dbobjs__(self):
"""This is called after deserialization and allows you to
restore the 'hidden' dbobjs you serialized before"""
self.mydbobj = dbserialize.dbunserialize(self.mydbobj)
# let's assume myobj is a db-object
container = Container(myobj)
obj.db.mydata = container # will now work fine!
```
### Storing multiple objects
@ -404,6 +443,12 @@ obj.db.test8[2]["test"] = 5
# test8 is now [4,2,{"test":5}]
```
Note that if make some advanced iterable object, and store an db-object on it in
a way such that it is _not_ returned by iterating over it, you have created a
'hidden' db-object. See [the previous section](#storing-single-objects) for how
to tell Evennia how to serialize such hidden objects safely.
### Retrieving Mutable objects
A side effect of the way Evennia stores Attributes is that *mutable* iterables (iterables that can
@ -429,41 +474,41 @@ print(obj.db.mylist) # now also [1, 2, 3, 5]
```
When you extract your mutable Attribute data into a variable like `mylist`, think of it as getting a _snapshot_
of the variable. If you update the snapshot, it will save to the database, but this change _will not propagate to
of the variable. If you update the snapshot, it will save to the database, but this change _will not propagate to
any other snapshots you may have done previously_.
```python
```python
obj.db.mylist = [1, 2, 3, 4]
mylist1 = obj.db.mylist
mylist2 = obj.db.mylist
mylist1[3] = 5
mylist1 = obj.db.mylist
mylist2 = obj.db.mylist
mylist1[3] = 5
print(mylist1) # this is now [1, 2, 3, 5]
print(obj.db.mylist) # also updated to [1, 2, 3, 5]
print(obj.db.mylist) # also updated to [1, 2, 3, 5]
print(mylist2) # still [1, 2, 3, 4] !
print(mylist2) # still [1, 2, 3, 4] !
```
```{sidebar}
Remember, the complexities of this section only relate to *mutable* iterables - things you can update
in-place, like lists and dicts. [Immutable](https://en.wikipedia.org/wiki/Immutable) objects (strings,
in-place, like lists and dicts. [Immutable](https://en.wikipedia.org/wiki/Immutable) objects (strings,
numbers, tuples etc) are already disconnected from the database from the onset.
```
To avoid confusion with mutable Attributes, only work with one variable (snapshot) at a time and save
back the results as needed.
To avoid confusion with mutable Attributes, only work with one variable (snapshot) at a time and save
back the results as needed.
You can also choose to "disconnect" the Attribute entirely from the
database with the help of the `.deserialize()` method:
```python
obj.db.mylist = [1, 2, 3, 4, {1: 2}]
mylist = obj.db.mylist.deserialize()
mylist = obj.db.mylist.deserialize()
```
The result of this operation will be a structure only consisting of normal Python mutables (`list`
instead of `_SaverList`, `dict` instead of `_SaverDict` and so on). If you update it, you need to
instead of `_SaverList`, `dict` instead of `_SaverDict` and so on). If you update it, you need to
explicitly save it back to the Attribute for it to save.
## Properties of Attributes
@ -518,7 +563,7 @@ are **non-persistent** - they will _not_ survive a server reload.
Differences between `Attributes` and `NAttributes`:
- `NAttribute`s are always wiped on a server reload.
- They only exist in memory and never involve the database at all, making them faster to
- They only exist in memory and never involve the database at all, making them faster to
access and edit than `Attribute`s.
- `NAttribute`s can store _any_ Python structure (and database object) without limit.
- They can _not_ be set with the standard `set` command (but they are visible with `examine`)
@ -526,10 +571,10 @@ Differences between `Attributes` and `NAttributes`:
There are some important reasons we recommend using `ndb` to store temporary data rather than
the simple alternative of just storing a variable directly on an object:
- NAttributes are tracked by Evennia and will not be purged in various cache-cleanup operations
the server may do. So using them guarantees that they'll remain available at least as long as
- NAttributes are tracked by Evennia and will not be purged in various cache-cleanup operations
the server may do. So using them guarantees that they'll remain available at least as long as
the server lives.
- It's a consistent style - `.db/.attributes` and `.ndb/.nattributes` makes for clean-looking code
- It's a consistent style - `.db/.attributes` and `.ndb/.nattributes` makes for clean-looking code
where it's clear how long-lived (or not) your data is to be.
### Persistent vs non-persistent
@ -557,4 +602,4 @@ useful in a few situations though.
- `NAttribute`s have no restrictions at all on what they can store, since they
don't need to worry about being saved to the database - they work very well for temporary storage.
- You want to implement a fully or partly *non-persistent world*. Who are we to argue with your
grand vision!
grand vision!

View file

@ -129,10 +129,11 @@ Contains a very useful list of things to think about when starting your new MUD.
Essential reading for the design of any persistent game
world, written by the co-creator of the original game *MUD*. Published in 2003 but it's still as
relevant now as when it came out. Covers everything you need to know and then some.
- Zed A. Shaw *Learn Python the Hard way* ([homepage](https://learnpythonthehardway.org/)) - Despite
the imposing name this book is for the absolute Python/programming beginner. One learns the language
by gradually creating a small text game! It has been used by multiple users before moving on to
Evennia. *Update: This used to be free to read online, this is no longer the case.*
When the rights to Designing Virtual Worlds returned to him, Richard Bartle
made the PDF of his Designing Virtual Worlds freely available through his own
website ([Designing Virtual Worlds](https://mud.co.uk/dvw/)). A direct link to
the PDF can be found [here](https://mud.co.uk/richard/DesigningVirtualWorlds.pdf).
- David M. Beazley *Python Essential Reference (4th ed)*
([amazon page](https://www.amazon.com/Python-Essential-Reference-David-Beazley/dp/0672329786/)) -
Our recommended book on Python; it not only efficiently summarizes the language but is also

View file

@ -2732,7 +2732,7 @@ class CmdExamine(ObjManipCommand):
return
if ndb_attr and ndb_attr[0]:
return "\n " + " \n".join(
return "\n " + "\n ".join(
sorted(self.format_single_attribute(attr) for attr in ndb_attr)
)

View file

@ -328,4 +328,4 @@ class GametimeScript(DefaultScript):
callback()
seconds = real_seconds_until(**self.db.gametime)
self.restart(interval=seconds)
self.start(interval=seconds,force_restart=True)

View file

@ -4,7 +4,6 @@ Roleplaying emotes and language - Griatch, 2015
"""
from .rpsystem import EmoteError, SdescError, RecogError, LanguageError # noqa
from .rpsystem import ordered_permutation_regex, regex_tuple_from_key_alias # noqa
from .rpsystem import parse_language, parse_sdescs_and_recogs, send_emote # noqa
from .rpsystem import SdescHandler, RecogHandler # noqa
from .rpsystem import RPCommand, CmdEmote, CmdSay, CmdSdesc, CmdPose, CmdRecog, CmdMask # noqa

File diff suppressed because it is too large Load diff

View file

@ -96,7 +96,7 @@ recog01 = "Mr Receiver"
recog02 = "Mr Receiver2"
recog10 = "Mr Sender"
emote = 'With a flair, /me looks at /first and /colliding sdesc-guy. She says "This is a test."'
case_emote = "/me looks at /first, then /FIRST, /First and /Colliding twice."
case_emote = "/Me looks at /first. Then, /me looks at /FIRST, /First and /Colliding twice."
class TestRPSystem(BaseEvenniaTest):
@ -113,41 +113,11 @@ class TestRPSystem(BaseEvenniaTest):
rpsystem.ContribRPCharacter, key="Receiver2", location=self.room
)
def test_ordered_permutation_regex(self):
self.assertEqual(
rpsystem.ordered_permutation_regex(sdesc0),
"/[0-9]*-*A\\ nice\\ sender\\ of\\ emotes(?=\\W|$)+|"
"/[0-9]*-*nice\\ sender\\ of\\ emotes(?=\\W|$)+|"
"/[0-9]*-*A\\ nice\\ sender\\ of(?=\\W|$)+|"
"/[0-9]*-*sender\\ of\\ emotes(?=\\W|$)+|"
"/[0-9]*-*nice\\ sender\\ of(?=\\W|$)+|"
"/[0-9]*-*A\\ nice\\ sender(?=\\W|$)+|"
"/[0-9]*-*nice\\ sender(?=\\W|$)+|"
"/[0-9]*-*of\\ emotes(?=\\W|$)+|"
"/[0-9]*-*sender\\ of(?=\\W|$)+|"
"/[0-9]*-*A\\ nice(?=\\W|$)+|"
"/[0-9]*-*emotes(?=\\W|$)+|"
"/[0-9]*-*sender(?=\\W|$)+|"
"/[0-9]*-*nice(?=\\W|$)+|"
"/[0-9]*-*of(?=\\W|$)+|"
"/[0-9]*-*A(?=\\W|$)+",
)
def test_sdesc_handler(self):
self.speaker.sdesc.add(sdesc0)
self.assertEqual(self.speaker.sdesc.get(), sdesc0)
self.speaker.sdesc.add("This is {#324} ignored")
self.assertEqual(self.speaker.sdesc.get(), "This is 324 ignored")
self.speaker.sdesc.add("Testing three words")
self.assertEqual(
self.speaker.sdesc.get_regex_tuple()[0].pattern,
"/[0-9]*-*Testing\ three\ words(?=\W|$)+|"
"/[0-9]*-*Testing\ three(?=\W|$)+|"
"/[0-9]*-*three\ words(?=\W|$)+|"
"/[0-9]*-*Testing(?=\W|$)+|"
"/[0-9]*-*three(?=\W|$)+|"
"/[0-9]*-*words(?=\W|$)+",
)
def test_recog_handler(self):
self.speaker.sdesc.add(sdesc0)
@ -156,12 +126,8 @@ class TestRPSystem(BaseEvenniaTest):
self.speaker.recog.add(self.receiver2, recog02)
self.assertEqual(self.speaker.recog.get(self.receiver1), recog01)
self.assertEqual(self.speaker.recog.get(self.receiver2), recog02)
self.assertEqual(
self.speaker.recog.get_regex_tuple(self.receiver1)[0].pattern,
"/[0-9]*-*Mr\\ Receiver(?=\\W|$)+|/[0-9]*-*Receiver(?=\\W|$)+|/[0-9]*-*Mr(?=\\W|$)+",
)
self.speaker.recog.remove(self.receiver1)
self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1)
self.assertEqual(self.speaker.recog.get(self.receiver1), None)
self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2})
@ -198,6 +164,24 @@ class TestRPSystem(BaseEvenniaTest):
result,
)
def test_get_sdesc(self):
looker = self.speaker # Sender
target = self.receiver1 # Receiver1
looker.sdesc.add(sdesc0) # A nice sender of emotes
target.sdesc.add(sdesc1) # The first receiver of emotes.
# sdesc with no processing
self.assertEqual(looker.get_sdesc(target), "The first receiver of emotes.")
# sdesc with processing
self.assertEqual(looker.get_sdesc(target, process=True), "|bThe first receiver of emotes.|n")
looker.recog.add(target, recog01) # Mr Receiver
# recog with no processing
self.assertEqual(looker.get_sdesc(target), "Mr Receiver")
# recog with processing
self.assertEqual(looker.get_sdesc(target, process=True), "|mMr Receiver|n")
def test_send_emote(self):
speaker = self.speaker
receiver1 = self.receiver1
@ -212,18 +196,18 @@ class TestRPSystem(BaseEvenniaTest):
rpsystem.send_emote(speaker, receivers, emote, case_sensitive=False)
self.assertEqual(
self.out0,
"With a flair, |bSender|n looks at |bThe first receiver of emotes.|n "
"With a flair, |mSender|n looks at |bThe first receiver of emotes.|n "
'and |bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n',
)
self.assertEqual(
self.out1,
"With a flair, |bA nice sender of emotes|n looks at |bReceiver1|n and "
"With a flair, |bA nice sender of emotes|n looks at |mReceiver1|n and "
'|bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n',
)
self.assertEqual(
self.out2,
"With a flair, |bA nice sender of emotes|n looks at |bThe first "
'receiver of emotes.|n and |bReceiver2|n. She says |w"This is a test."|n',
'receiver of emotes.|n and |mReceiver2|n. She says |w"This is a test."|n',
)
def test_send_case_sensitive_emote(self):
@ -241,20 +225,21 @@ class TestRPSystem(BaseEvenniaTest):
rpsystem.send_emote(speaker, receivers, case_emote)
self.assertEqual(
self.out0,
"|bSender|n looks at |bthe first receiver of emotes.|n, then "
"|bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of emotes.|n and "
"|bAnother nice colliding sdesc-guy for tests|n twice.",
"|mSender|n looks at |bthe first receiver of emotes.|n. Then, |mSender|n "
"looks at |bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of emotes.|n "
"and |bAnother nice colliding sdesc-guy for tests|n twice.",
)
self.assertEqual(
self.out1,
"|bA nice sender of emotes|n looks at |bReceiver1|n, then |bReceiver1|n, "
"|bReceiver1|n and |bAnother nice colliding sdesc-guy for tests|n twice.",
"|bA nice sender of emotes|n looks at |mReceiver1|n. Then, "
"|ba nice sender of emotes|n looks at |mReceiver1|n, |mReceiver1|n "
"and |bAnother nice colliding sdesc-guy for tests|n twice."
)
self.assertEqual(
self.out2,
"|bA nice sender of emotes|n looks at |bthe first receiver of emotes.|n, "
"then |bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of "
"emotes.|n and |bReceiver2|n twice.",
"|bA nice sender of emotes|n looks at |bthe first receiver of emotes.|n. "
"Then, |ba nice sender of emotes|n looks at |bTHE FIRST RECEIVER OF EMOTES.|n, "
"|bThe first receiver of emotes.|n and |mReceiver2|n twice.",
)
def test_rpsearch(self):
@ -265,18 +250,6 @@ class TestRPSystem(BaseEvenniaTest):
self.assertEqual(self.speaker.search("receiver of emotes"), self.receiver1)
self.assertEqual(self.speaker.search("colliding"), self.receiver2)
def test_regex_tuple_from_key_alias(self):
self.speaker.aliases.add("foo bar")
self.speaker.aliases.add("this thing is a long thing")
t0 = time.time()
result = rpsystem.regex_tuple_from_key_alias(self.speaker)
t1 = time.time()
result = rpsystem.regex_tuple_from_key_alias(self.speaker)
t2 = time.time()
# print(f"t1: {t1 - t0}, t2: {t2 - t1}")
self.assertLess(t2 - t1, 10**-4)
self.assertEqual(result, (Anything, self.speaker, self.speaker.key))
class TestRPSystemCommands(BaseEvenniaCommandTest):
def setUp(self):
@ -305,7 +278,7 @@ class TestRPSystemCommands(BaseEvenniaCommandTest):
self.call(
rpsystem.CmdRecog(),
"barfoo as friend",
"Char will now remember BarFoo Character as friend.",
"You will now remember BarFoo Character as friend.",
)
self.call(
rpsystem.CmdRecog(),
@ -316,6 +289,6 @@ class TestRPSystemCommands(BaseEvenniaCommandTest):
self.call(
rpsystem.CmdRecog(),
"friend",
"Char will now know them only as 'BarFoo Character'",
"You will now know them only as 'BarFoo Character'",
cmdstring="forget",
)

View file

@ -286,7 +286,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
categories = make_iter(category) if category else []
n_keys = len(keys)
n_categories = len(categories)
unique_categories = sorted(set(categories))
unique_categories = set(categories)
n_unique_categories = len(unique_categories)
dbmodel = self.model.__dbclass__.__name__.lower()

View file

@ -142,6 +142,13 @@ class TestTypedObjectManager(BaseEvenniaTest):
[self.obj1],
)
def test_get_tag_with_any_including_nones(self):
self.obj1.tags.add("tagA", "categoryA")
self.assertEqual(
self._manager("get_by_tag", ["tagA", "tagB"], ["categoryA", "categoryB", None], match="any"),
[self.obj1],
)
def test_get_tag_withnomatch(self):
self.obj1.tags.add("tagC", "categoryC")
self.assertEqual(

View file

@ -12,6 +12,7 @@ evennia.OPTION_CLASSES
from pickle import dumps
from django.db.utils import OperationalError, ProgrammingError
from django.conf import settings
from evennia.utils.utils import class_from_module, callables_from_module
from evennia.utils import logger
@ -167,7 +168,6 @@ class GlobalScriptContainer(Container):
# store a hash representation of the setup
script.attributes.add("_global_script_settings", compare_hash, category="settings_hash")
script.start()
return script
@ -183,9 +183,16 @@ class GlobalScriptContainer(Container):
# populate self.typeclass_storage
self.load_data()
# start registered scripts
# make sure settings-defined scripts are loaded
for key in self.loaded_data:
self._load_script(key)
# start all global scripts
try:
for script in self._get_scripts():
script.start()
except (OperationalError, ProgrammingError):
# this can happen if db is not loaded yet (such as when building docs)
pass
def load_data(self):
"""

View file

@ -239,6 +239,9 @@ class _SaverMutable(object):
def __gt__(self, other):
return self._data > other
def __or__(self, other):
return self._data | other
@_save
def __setitem__(self, key, value):
self._data.__setitem__(key, self._convert_mutables(value))
@ -450,7 +453,9 @@ def deserialize(obj):
elif tname in ("_SaverOrderedDict", "OrderedDict"):
return OrderedDict([(_iter(key), _iter(val)) for key, val in obj.items()])
elif tname in ("_SaverDefaultDict", "defaultdict"):
return defaultdict(obj.default_factory, {_iter(key): _iter(val) for key, val in obj.items()})
return defaultdict(
obj.default_factory, {_iter(key): _iter(val) for key, val in obj.items()}
)
elif tname in _DESERIALIZE_MAPPING:
return _DESERIALIZE_MAPPING[tname](_iter(val) for val in obj)
elif is_iter(obj):
@ -602,7 +607,9 @@ def to_pickle(data):
def process_item(item):
"""Recursive processor and identification of data"""
dtype = type(item)
if dtype in (str, int, float, bool, bytes, SafeString):
return item
elif dtype == tuple:
@ -612,7 +619,10 @@ def to_pickle(data):
elif dtype in (dict, _SaverDict):
return dict((process_item(key), process_item(val)) for key, val in item.items())
elif dtype in (defaultdict, _SaverDefaultDict):
return defaultdict(item.default_factory, ((process_item(key), process_item(val)) for key, val in item.items()))
return defaultdict(
item.default_factory,
((process_item(key), process_item(val)) for key, val in item.items()),
)
elif dtype in (set, _SaverSet):
return set(process_item(val) for val in item)
elif dtype in (OrderedDict, _SaverOrderedDict):
@ -620,7 +630,20 @@ def to_pickle(data):
elif dtype in (deque, _SaverDeque):
return deque(process_item(val) for val in item)
elif hasattr(item, "__iter__"):
# not one of the base types
if hasattr(item, "__serialize_dbobjs__"):
# Allows custom serialization of any dbobjects embedded in
# the item that Evennia will otherwise not found (these would
# otherwise lead to an error). Use the dbserialize helper from
# this method.
try:
item.__serialize_dbobjs__()
except TypeError:
# we catch typerrors so we can handle both classes (requiring
# classmethods) and instances
pass
if hasattr(item, "__iter__"):
# we try to conserve the iterable class, if not convert to list
try:
return item.__class__([process_item(val) for val in item])
@ -678,7 +701,10 @@ def from_pickle(data, db_obj=None):
elif dtype == dict:
return dict((process_item(key), process_item(val)) for key, val in item.items())
elif dtype == defaultdict:
return defaultdict(item.default_factory, ((process_item(key), process_item(val)) for key, val in item.items()))
return defaultdict(
item.default_factory,
((process_item(key), process_item(val)) for key, val in item.items()),
)
elif dtype == set:
return set(process_item(val) for val in item)
elif dtype == OrderedDict:
@ -692,6 +718,18 @@ def from_pickle(data, db_obj=None):
return item.__class__(process_item(val) for val in item)
except (AttributeError, TypeError):
return [process_item(val) for val in item]
if hasattr(item, "__deserialize_dbobjs__"):
# this allows the object to custom-deserialize any embedded dbobjs
# that we previously serialized with __serialize_dbobjs__.
# use the dbunserialize helper in this module.
try:
item.__deserialize_dbobjs__()
except TypeError:
# handle recoveries both of classes (requiring classmethods
# or instances
pass
return item
def process_tree(item, parent):

View file

@ -274,12 +274,13 @@ import inspect
from ast import literal_eval
from fnmatch import fnmatch
from math import ceil
from inspect import isfunction, getargspec
from django.conf import settings
from evennia import Command, CmdSet
from evennia.utils import logger
from evennia.utils.evtable import EvTable
from evennia.utils.evtable import EvTable, EvColumn
from evennia.utils.ansi import strip_ansi
from evennia.utils.utils import mod_import, make_iter, pad, to_str, m_len, is_iter, dedent, crop
from evennia.commands import cmdhandler
@ -1210,7 +1211,6 @@ class EvMenu:
Args:
optionlist (list): List of (key, description) tuples for every
option related to this node.
caller (Object, Account or None, optional): The caller of the node.
Returns:
options (str): The formatted option display.
@ -1229,7 +1229,7 @@ class EvMenu:
table = []
for key, desc in optionlist:
if key or desc:
desc_string = ": %s" % desc if desc else ""
desc_string = f": {desc}" if desc else ""
table_width_max = max(
table_width_max,
max(m_len(p) for p in key.split("\n"))
@ -1239,42 +1239,31 @@ class EvMenu:
raw_key = strip_ansi(key)
if raw_key != key:
# already decorations in key definition
table.append(" |lc%s|lt%s|le%s" % (raw_key, key, desc_string))
table.append(f" |lc{raw_key}|lt{key}|le{desc_string}")
else:
# add a default white color to key
table.append(" |lc%s|lt|w%s|n|le%s" % (raw_key, raw_key, desc_string))
ncols = _MAX_TEXT_WIDTH // table_width_max # number of ncols
table.append(f" |lc{raw_key}|lt|w{key}|n|le{desc_string}")
ncols = _MAX_TEXT_WIDTH // table_width_max # number of columns
if ncols < 0:
# no visible option at all
# no visible options at all
return ""
ncols = ncols + 1 if ncols == 0 else ncols
# get the amount of rows needed (start with 4 rows)
nrows = 4
while nrows * ncols < nlist:
nrows += 1
ncols = nlist // nrows # number of full columns
nlastcol = nlist % nrows # number of elements in last column
ncols = 1 if ncols == 0 else ncols
# get the final column count
ncols = ncols + 1 if nlastcol > 0 else ncols
if ncols > 1:
# only extend if longer than one column
table.extend([" " for i in range(nrows - nlastcol)])
# minimum number of rows in a column
min_rows = 4
# build the actual table grid
table = [table[icol * nrows : (icol * nrows) + nrows] for icol in range(0, ncols)]
# split the items into columns
split = max(min_rows, ceil(len(table)/ncols))
max_end = len(table)
cols_list = []
for icol in range(ncols):
start = icol*split
end = min(start+split,max_end)
cols_list.append(EvColumn(*table[start:end]))
# adjust the width of each column
for icol in range(len(table)):
col_width = (
max(max(m_len(p) for p in part.split("\n")) for part in table[icol]) + colsep
)
table[icol] = [pad(part, width=col_width + colsep, align="l") for part in table[icol]]
# format the table into columns
return str(EvTable(table=table, border="none"))
return str(EvTable(table=cols_list, border="none"))
def node_formatter(self, nodetext, optionstext):
"""

View file

@ -67,7 +67,7 @@ class TimeScript(DefaultScript):
callback(*args, **kwargs)
seconds = real_seconds_until(**self.db.gametime)
self.restart(interval=seconds)
self.start(interval=seconds,force_restart=True)
# Access functions

View file

@ -62,10 +62,12 @@ class TestDbSerialize(TestCase):
self.obj.db.test.sort(key=lambda d: str(d))
self.assertEqual(self.obj.db.test, [{0: 1}, {1: 0}])
def test_dict(self):
def test_saverdict(self):
self.obj.db.test = {"a": True}
self.obj.db.test.update({"b": False})
self.assertEqual(self.obj.db.test, {"a": True, "b": False})
self.obj.db.test |= {"c": 5}
self.assertEqual(self.obj.db.test, {"a": True, "b": False, "c": 5})
@parameterized.expand(
[
@ -88,27 +90,30 @@ class TestDbSerialize(TestCase):
self.assertIsInstance(value, base_type)
self.assertNotIsInstance(value, saver_type)
self.assertEqual(value, default_value)
self.obj.db.test = {'a': True}
self.obj.db.test.update({'b': False})
self.assertEqual(self.obj.db.test, {'a': True, 'b': False})
self.obj.db.test = {"a": True}
self.obj.db.test.update({"b": False})
self.assertEqual(self.obj.db.test, {"a": True, "b": False})
def test_defaultdict(self):
from collections import defaultdict
# baseline behavior for a defaultdict
_dd = defaultdict(list)
_dd['a']
self.assertEqual(_dd, {'a': []})
_dd["a"]
self.assertEqual(_dd, {"a": []})
# behavior after defaultdict is set as attribute
dd = defaultdict(list)
self.obj.db.test = dd
self.obj.db.test['a']
self.assertEqual(self.obj.db.test, {'a': []})
self.obj.db.test["a"]
self.assertEqual(self.obj.db.test, {"a": []})
self.obj.db.test['a'].append(1)
self.assertEqual(self.obj.db.test, {'a': [1]})
self.obj.db.test['a'].append(2)
self.assertEqual(self.obj.db.test, {'a': [1, 2]})
self.obj.db.test['a'].append(3)
self.assertEqual(self.obj.db.test, {'a': [1, 2, 3]})
self.obj.db.test["a"].append(1)
self.assertEqual(self.obj.db.test, {"a": [1]})
self.obj.db.test["a"].append(2)
self.assertEqual(self.obj.db.test, {"a": [1, 2]})
self.obj.db.test["a"].append(3)
self.assertEqual(self.obj.db.test, {"a": [1, 2, 3]})
self.obj.db.test |= {"b": [5, 6]}
self.assertEqual(self.obj.db.test, {"a": [1, 2, 3], "b": [5, 6]})

View file

@ -7,20 +7,20 @@ import mock
class TestText2Html(TestCase):
def test_re_color(self):
def test_format_styles(self):
parser = text2html.HTML_PARSER
self.assertEqual("foo", parser.re_color("foo"))
self.assertEqual("foo", parser.format_styles("foo"))
self.assertEqual(
'<span class="color-001">red</span>foo',
parser.re_color(ansi.ANSI_UNHILITE + ansi.ANSI_RED + "red" + ansi.ANSI_NORMAL + "foo"),
parser.format_styles(ansi.ANSI_UNHILITE + ansi.ANSI_RED + "red" + ansi.ANSI_NORMAL + "foo"),
)
self.assertEqual(
'<span class="bgcolor-001">red</span>foo',
parser.re_color(ansi.ANSI_BACK_RED + "red" + ansi.ANSI_NORMAL + "foo"),
parser.format_styles(ansi.ANSI_BACK_RED + "red" + ansi.ANSI_NORMAL + "foo"),
)
self.assertEqual(
'<span class="bgcolor-001"><span class="color-002">red</span></span>foo',
parser.re_color(
'<span class="bgcolor-001 color-002">red</span>foo',
parser.format_styles(
ansi.ANSI_BACK_RED
+ ansi.ANSI_UNHILITE
+ ansi.ANSI_GREEN
@ -29,63 +29,37 @@ class TestText2Html(TestCase):
+ "foo"
),
)
@unittest.skip("parser issues")
def test_re_bold(self):
parser = text2html.HTML_PARSER
self.assertEqual("foo", parser.re_bold("foo"))
self.assertEqual(
# "a <strong>red</strong>foo", # TODO: why not?
"a <strong>redfoo</strong>",
parser.re_bold("a " + ansi.ANSI_HILITE + "red" + ansi.ANSI_UNHILITE + "foo"),
)
@unittest.skip("parser issues")
def test_re_underline(self):
parser = text2html.HTML_PARSER
self.assertEqual("foo", parser.re_underline("foo"))
self.assertEqual(
'a <span class="underline">red</span>' + ansi.ANSI_NORMAL + "foo",
parser.re_underline(
'a <span class="underline">red</span>foo',
parser.format_styles(
"a "
+ ansi.ANSI_UNDERLINE
+ "red"
+ ansi.ANSI_NORMAL # TODO: why does it keep it?
+ ansi.ANSI_NORMAL
+ "foo"
),
)
@unittest.skip("parser issues")
def test_re_blinking(self):
parser = text2html.HTML_PARSER
self.assertEqual("foo", parser.re_blinking("foo"))
self.assertEqual(
'a <span class="blink">red</span>' + ansi.ANSI_NORMAL + "foo",
parser.re_blinking(
'a <span class="blink">red</span>foo',
parser.format_styles(
"a "
+ ansi.ANSI_BLINK
+ "red"
+ ansi.ANSI_NORMAL # TODO: why does it keep it?
+ ansi.ANSI_NORMAL
+ "foo"
),
)
@unittest.skip("parser issues")
def test_re_inversing(self):
parser = text2html.HTML_PARSER
self.assertEqual("foo", parser.re_inversing("foo"))
self.assertEqual(
'a <span class="inverse">red</span>' + ansi.ANSI_NORMAL + "foo",
parser.re_inversing(
'a <span class="bgcolor-007 color-000">red</span>foo',
parser.format_styles(
"a "
+ ansi.ANSI_INVERSE
+ "red"
+ ansi.ANSI_NORMAL # TODO: why does it keep it?
+ ansi.ANSI_NORMAL
+ "foo"
),
)
@unittest.skip("parser issues")
def test_remove_bells(self):
parser = text2html.HTML_PARSER
self.assertEqual("foo", parser.remove_bells("foo"))
@ -95,7 +69,7 @@ class TestText2Html(TestCase):
"a "
+ ansi.ANSI_BEEP
+ "red"
+ ansi.ANSI_NORMAL # TODO: why does it keep it?
+ ansi.ANSI_NORMAL
+ "foo"
),
)
@ -110,7 +84,6 @@ class TestText2Html(TestCase):
self.assertEqual("foo", parser.convert_linebreaks("foo"))
self.assertEqual("a<br> redfoo<br>", parser.convert_linebreaks("a\n redfoo\n"))
@unittest.skip("parser issues")
def test_convert_urls(self):
parser = text2html.HTML_PARSER
self.assertEqual("foo", parser.convert_urls("foo"))
@ -118,7 +91,6 @@ class TestText2Html(TestCase):
'a <a href="http://redfoo" target="_blank">http://redfoo</a> runs',
parser.convert_urls("a http://redfoo runs"),
)
# TODO: doesn't URL encode correctly
def test_sub_mxp_links(self):
parser = text2html.HTML_PARSER
@ -186,22 +158,22 @@ class TestText2Html(TestCase):
self.assertEqual("foo", text2html.parse_html("foo"))
self.maxDiff = None
self.assertEqual(
# TODO: note that the blink is currently *not* correctly aborted
# with |n here! This is probably not possible to correctly handle
# with regex - a stateful parser may be needed.
# blink back-cyan normal underline red green yellow blue magenta cyan back-green
text2html.parse_html("|^|[CHello|n|u|rW|go|yr|bl|md|c!|[G!"),
'<span class="blink">'
'<span class="bgcolor-006">Hello</span>' # noqa
'<span class="underline">'
'<span class="color-009">W</span>' # noqa
'<span class="color-010">o</span>'
'<span class="color-011">r</span>'
'<span class="color-012">l</span>'
'<span class="color-013">d</span>'
'<span class="color-014">!'
'<span class="bgcolor-002">!</span>' # noqa
"</span>"
"</span>"
"</span>",
'<span class="blink bgcolor-006">'
'Hello'
'</span><span class="underline color-009">'
'W'
'</span><span class="underline color-010">'
'o'
'</span><span class="underline color-011">'
'r'
'</span><span class="underline color-012">'
'l'
'</span><span class="underline color-013">'
'd'
'</span><span class="underline color-014">'
'!'
'</span><span class="underline bgcolor-002 color-014">'
'!'
'</span>',
)

View file

@ -12,11 +12,10 @@ import re
from html import escape as html_escape
from .ansi import *
# All xterm256 RGB equivalents
XTERM256_FG = "\033[38;5;%sm"
XTERM256_BG = "\033[48;5;%sm"
XTERM256_FG = "\033[38;5;{}m"
XTERM256_BG = "\033[48;5;{}m"
class TextToHTMLparser(object):
@ -25,77 +24,65 @@ class TextToHTMLparser(object):
"""
tabstop = 4
# mapping html color name <-> ansi code.
hilite = ANSI_HILITE
unhilite = ANSI_UNHILITE # this will be stripped - there is no css equivalent.
normal = ANSI_NORMAL # "
underline = ANSI_UNDERLINE
blink = ANSI_BLINK
inverse = ANSI_INVERSE # this will produce an outline; no obvious css equivalent?
colorcodes = [
("color-000", unhilite + ANSI_BLACK), # pure black
("color-001", unhilite + ANSI_RED),
("color-002", unhilite + ANSI_GREEN),
("color-003", unhilite + ANSI_YELLOW),
("color-004", unhilite + ANSI_BLUE),
("color-005", unhilite + ANSI_MAGENTA),
("color-006", unhilite + ANSI_CYAN),
("color-007", unhilite + ANSI_WHITE), # light grey
("color-008", hilite + ANSI_BLACK), # dark grey
("color-009", hilite + ANSI_RED),
("color-010", hilite + ANSI_GREEN),
("color-011", hilite + ANSI_YELLOW),
("color-012", hilite + ANSI_BLUE),
("color-013", hilite + ANSI_MAGENTA),
("color-014", hilite + ANSI_CYAN),
("color-015", hilite + ANSI_WHITE), # pure white
] + [("color-%03i" % (i + 16), XTERM256_FG % ("%i" % (i + 16))) for i in range(240)]
colorback = [
("bgcolor-000", ANSI_BACK_BLACK), # pure black
("bgcolor-001", ANSI_BACK_RED),
("bgcolor-002", ANSI_BACK_GREEN),
("bgcolor-003", ANSI_BACK_YELLOW),
("bgcolor-004", ANSI_BACK_BLUE),
("bgcolor-005", ANSI_BACK_MAGENTA),
("bgcolor-006", ANSI_BACK_CYAN),
("bgcolor-007", ANSI_BACK_WHITE), # light grey
("bgcolor-008", hilite + ANSI_BACK_BLACK), # dark grey
("bgcolor-009", hilite + ANSI_BACK_RED),
("bgcolor-010", hilite + ANSI_BACK_GREEN),
("bgcolor-011", hilite + ANSI_BACK_YELLOW),
("bgcolor-012", hilite + ANSI_BACK_BLUE),
("bgcolor-013", hilite + ANSI_BACK_MAGENTA),
("bgcolor-014", hilite + ANSI_BACK_CYAN),
("bgcolor-015", hilite + ANSI_BACK_WHITE), # pure white
] + [("bgcolor-%03i" % (i + 16), XTERM256_BG % ("%i" % (i + 16))) for i in range(240)]
style_codes = [
# non-color style markers
ANSI_NORMAL,
ANSI_UNDERLINE,
ANSI_HILITE,
ANSI_UNHILITE,
ANSI_INVERSE,
ANSI_BLINK,
ANSI_INV_HILITE,
ANSI_BLINK_HILITE,
ANSI_INV_BLINK,
ANSI_INV_BLINK_HILITE,
]
# make sure to escape [
# colorcodes = [(c, code.replace("[", r"\[")) for c, code in colorcodes]
# colorback = [(c, code.replace("[", r"\[")) for c, code in colorback]
fg_colormap = dict((code, clr) for clr, code in colorcodes)
bg_colormap = dict((code, clr) for clr, code in colorback)
ansi_color_codes = [
# Foreground colors
ANSI_BLACK,
ANSI_RED,
ANSI_GREEN,
ANSI_YELLOW,
ANSI_BLUE,
ANSI_MAGENTA,
ANSI_CYAN,
ANSI_WHITE,
]
# create stop markers
fgstop = "(?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m|\033\[0m|$"
bgstop = "(?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m|\033\[0m|$"
bgfgstop = bgstop[:-2] + fgstop
xterm_fg_codes = [XTERM256_FG.format(i + 16) for i in range(240)]
fgstart = "((?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m)"
bgstart = "((?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m)"
bgfgstart = bgstart + r"((?:\033\[1m|\033\[22m){0,1}\033\[[3-4][0-8].*?m){0,1}"
ansi_bg_codes = [
# Background colors
ANSI_BACK_BLACK,
ANSI_BACK_RED,
ANSI_BACK_GREEN,
ANSI_BACK_YELLOW,
ANSI_BACK_BLUE,
ANSI_BACK_MAGENTA,
ANSI_BACK_CYAN,
ANSI_BACK_WHITE,
]
# extract color markers, tagging the start marker and the text marked
re_fgs = re.compile(fgstart + "(.*?)(?=" + fgstop + ")")
re_bgs = re.compile(bgstart + "(.*?)(?=" + bgstop + ")")
re_bgfg = re.compile(bgfgstart + "(.*?)(?=" + bgfgstop + ")")
xterm_bg_codes = [XTERM256_BG.format(i + 16) for i in range(240)]
re_style = re.compile(
r"({})".format(
"|".join(
style_codes + ansi_color_codes + xterm_fg_codes + ansi_bg_codes + xterm_bg_codes
).replace("[", r"\[")
)
)
colorlist = (
[ANSI_UNHILITE + code for code in ansi_color_codes]
+ [ANSI_HILITE + code for code in ansi_color_codes]
+ xterm_fg_codes
)
bglist = ansi_bg_codes + [ANSI_HILITE + code for code in ansi_bg_codes] + xterm_bg_codes
re_normal = re.compile(normal.replace("[", r"\["))
re_hilite = re.compile("(?:%s)(.*)(?=%s|%s)" % (hilite.replace("[", r"\["), fgstop, bgstop))
re_unhilite = re.compile("(?:%s)(.*)(?=%s|%s)" % (unhilite.replace("[", r"\["), fgstop, bgstop))
re_uline = re.compile("(?:%s)(.*?)(?=%s|%s)" % (underline.replace("[", r"\["), fgstop, bgstop))
re_blink = re.compile("(?:%s)(.*?)(?=%s|%s)" % (blink.replace("[", r"\["), fgstop, bgstop))
re_inverse = re.compile("(?:%s)(.*?)(?=%s|%s)" % (inverse.replace("[", r"\["), fgstop, bgstop))
re_string = re.compile(
r"(?P<htmlchars>[<&>])|(?P<tab>[\t]+)|(?P<lineend>\r\n|\r|\n)",
re.S | re.M | re.I,
@ -106,100 +93,6 @@ class TextToHTMLparser(object):
re_mxplink = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL)
re_mxpurl = re.compile(r"\|lu(.*?)\|lt(.*?)\|le", re.DOTALL)
def _sub_bgfg(self, colormatch):
# print("colormatch.groups()", colormatch.groups())
bgcode, fgcode, text = colormatch.groups()
if not fgcode:
ret = r"""<span class="%s">%s</span>""" % (
self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")),
text,
)
else:
ret = r"""<span class="%s"><span class="%s">%s</span></span>""" % (
self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")),
self.fg_colormap.get(fgcode, self.bg_colormap.get(fgcode, "err")),
text,
)
return ret
def _sub_fg(self, colormatch):
code, text = colormatch.groups()
return r"""<span class="%s">%s</span>""" % (self.fg_colormap.get(code, "err"), text)
def _sub_bg(self, colormatch):
code, text = colormatch.groups()
return r"""<span class="%s">%s</span>""" % (self.bg_colormap.get(code, "err"), text)
def re_color(self, text):
"""
Replace ansi colors with html color class names. Let the
client choose how it will display colors, if it wishes to.
Args:
text (str): the string with color to replace.
Returns:
text (str): Re-colored text.
"""
text = self.re_bgfg.sub(self._sub_bgfg, text)
text = self.re_fgs.sub(self._sub_fg, text)
text = self.re_bgs.sub(self._sub_bg, text)
text = self.re_normal.sub("", text)
return text
def re_bold(self, text):
"""
Clean out superfluous hilights rather than set <strong>to make
it match the look of telnet.
Args:
text (str): Text to process.
Returns:
text (str): Processed text.
"""
text = self.re_hilite.sub(r"<strong>\1</strong>", text)
return self.re_unhilite.sub(r"\1", text) # strip unhilite - there is no equivalent in css.
def re_underline(self, text):
"""
Replace ansi underline with html underline class name.
Args:
text (str): Text to process.
Returns:
text (str): Processed text.
"""
return self.re_uline.sub(r'<span class="underline">\1</span>', text)
def re_blinking(self, text):
"""
Replace ansi blink with custom blink css class
Args:
text (str): Text to process.
Returns:
text (str): Processed text.
"""
return self.re_blink.sub(r'<span class="blink">\1</span>', text)
def re_inversing(self, text):
"""
Replace ansi inverse with custom inverse css class
Args:
text (str): Text to process.
Returns:
text (str): Processed text.
"""
return self.re_inverse.sub(r'<span class="inverse">\1</span>', text)
def remove_bells(self, text):
"""
Remove ansi specials
@ -211,7 +104,7 @@ class TextToHTMLparser(object):
text (str): Processed text.
"""
return text.replace("\07", "")
return text.replace(ANSI_BEEP, "")
def remove_backspaces(self, text):
"""
@ -315,6 +208,128 @@ class TextToHTMLparser(object):
return text
return None
def format_styles(self, text):
"""
Takes a string with parsed ANSI codes and replaces them with
HTML spans and CSS classes.
Args:
text (str): The string to process.
Returns:
text (str): Processed text.
"""
# split out the ANSI codes and clean out any empty items
str_list = [substr for substr in self.re_style.split(text) if substr]
# initialize all the flags and classes
classes = []
clean = True
inverse = False
# default color is light grey - unhilite + white
hilight = ANSI_UNHILITE
fg = ANSI_WHITE
# default bg is black
bg = ANSI_BACK_BLACK
for i, substr in enumerate(str_list):
# reset all current styling
if substr == ANSI_NORMAL and not clean:
# replace with close existing tag
str_list[i] = "</span>"
# reset to defaults
classes = []
clean = True
inverse = False
hilight = ANSI_UNHILITE
fg = ANSI_WHITE
bg = ANSI_BACK_BLACK
# change color
elif substr in self.ansi_color_codes + self.xterm_fg_codes:
# erase ANSI code from output
str_list[i] = ""
# set new color
fg = substr
# change bg color
elif substr in self.ansi_bg_codes + self.xterm_bg_codes:
# erase ANSI code from output
str_list[i] = ""
# set new bg
bg = substr
# non-color codes
elif substr in self.style_codes:
# erase ANSI code from output
str_list[i] = ""
# hilight codes
if substr in (ANSI_HILITE, ANSI_UNHILITE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE):
# set new hilight status
hilight = ANSI_UNHILITE if substr == ANSI_UNHILITE else ANSI_HILITE
# inversion codes
if substr in (ANSI_INVERSE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE):
inverse = True
# blink codes
if (
substr in (ANSI_BLINK, ANSI_BLINK_HILITE, ANSI_INV_BLINK_HILITE)
and "blink" not in classes
):
classes.append("blink")
# underline
if substr == ANSI_UNDERLINE and "underline" not in classes:
classes.append("underline")
else:
# normal text, add text back to list
if not str_list[i - 1]:
# prior entry was cleared, which means style change
# get indices for the fg and bg codes
bg_index = self.bglist.index(bg)
try:
color_index = self.colorlist.index(hilight + fg)
except ValueError:
# xterm256 colors don't have the hilight codes
color_index = self.colorlist.index(fg)
if inverse:
# inverse means swap fg and bg indices
bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0"))
color_class = "color-{}".format(str(bg_index).rjust(3, "0"))
else:
# use fg and bg indices for classes
bg_class = "bgcolor-{}".format(str(bg_index).rjust(3, "0"))
color_class = "color-{}".format(str(color_index).rjust(3, "0"))
# black bg is the default, don't explicitly style
if bg_class != "bgcolor-000":
classes.append(bg_class)
# light grey text is the default, don't explicitly style
if color_class != "color-007":
classes.append(color_class)
# define the new style span
prefix = '<span class="{}">'.format(" ".join(classes))
# close any prior span
if not clean:
prefix = "</span>" + prefix
# add span to output
str_list[i - 1] = prefix
# clean out color classes to easily update next time
classes = [cls for cls in classes if "color" not in cls]
# flag as currently being styled
clean = False
# close span if necessary
if not clean:
str_list.append("</span>")
# recombine back into string
return "".join(str_list)
def parse(self, text, strip_ansi=False):
"""
Main access function, converts a text containing ANSI codes
@ -328,19 +343,14 @@ class TextToHTMLparser(object):
text (str): Parsed text.
"""
# print(f"incoming text:\n{text}")
# parse everything to ansi first
text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True)
# convert all ansi to html
result = re.sub(self.re_string, self.sub_text, text)
result = re.sub(self.re_mxplink, self.sub_mxp_links, result)
result = re.sub(self.re_mxpurl, self.sub_mxp_urls, result)
result = self.re_color(result)
result = self.re_bold(result)
result = self.re_underline(result)
result = self.re_blinking(result)
result = self.re_inversing(result)
result = self.remove_bells(result)
result = self.format_styles(result)
result = self.convert_linebreaks(result)
result = self.remove_backspaces(result)
result = self.convert_urls(result)

View file

@ -149,7 +149,7 @@ An "emitter" object must have a function
// kwargs (obj): keyword-args to listener
//
emit: function (cmdname, args, kwargs) {
if (kwargs.cmdid) {
if (kwargs.cmdid && (kwargs.cmdid in cmdmap)) {
cmdmap[kwargs.cmdid].apply(this, [args, kwargs]);
delete cmdmap[kwargs.cmdid];
}