Add TagCategoryProperty.

This commit is contained in:
Griatch 2023-05-21 21:02:38 +02:00
parent 85a8cd613b
commit 030887dd21
11 changed files with 251 additions and 65 deletions

View file

@ -4,6 +4,8 @@
- New Contrib: `Container` typeclass with new commands for storing and retrieving
things inside them (InspectorCaracal)
- Feature: Add `TagCategoryProperty` for setting categories with multiple tags
as properties directly on objects. Complements `TagProperty`.
- Feature: Attribute-support for saving/loading `deques` with `maxlen=` set.
- Feature: Refactor to provide `evennia.SESSION_HANDLER` for easier overloading
and less risks of circular import problems (Volund)

View file

@ -2,11 +2,19 @@
## Main branch
- Feature: Attribute-support for saving/loading `deques` with `maxlen=` set.
- Contrib: Container typeclass with new commands for storing and retrieving
- New Contrib: `Container` typeclass with new commands for storing and retrieving
things inside them (InspectorCaracal)
- Feature: Add `TagCategoryProperty` for setting categories with multiple tags
as properties directly on objects. Complements `TagProperty`.
- Feature: Attribute-support for saving/loading `deques` with `maxlen=` set.
- Feature: Refactor to provide `evennia.SESSION_HANDLER` for easier overloading
and less risks of circular import problems (Volund)
- Fix: Allow webclient's goldenlayout UI (default) to understand `msg`
`cls` kwarg for customizing the CSS class for every resulting `div` (friarzen)
- Fix: The `AttributeHandler.all()` now actually accepts `category=` as
keyword arg, like our docs already claimed it should (Volund)
- Fix: `TickerHandler` store key updating was refactored, fixing an issue with
updating intervals (InspectorCaracal)
- Docs: New Beginner-Tutorial lessons for NPCs, Base-Combat Twitch-Combat and
Turnbased-combat (note that the Beginner tutorial is still WIP).

View file

@ -12,18 +12,26 @@ obj.tags.get("mytag", category="foo")
```
```{code-block} python
:caption: In code, using TagProperty (auto-assign tag to all instances of the class)
:caption: In code, using TagProperty or TagCategoryProperty
from evennia import DefaultObject
from evennia import TagProperty
from evennia import TagProperty, TagCategoryProperty
class Sword(DefaultObject):
# name of property is the tagkey, category as argument
can_be_wielded = TagProperty(category='combat')
has_sharp_edge = TagProperty(category='combat')
# name of property is the category, tag-keys are arguments
damage_type = TagCategoryProperty("piercing", "slashing")
crafting_element = TagCategory("blade", "hilt", "pommel")
```
_Tags_ are short text lables one can 'hang' on objects in order to organize, group and quickly find out their properties. An Evennia entity can be tagged by any number of tags. They are more efficient than [Attributes](./Attributes.md) since on the database-side, Tags are _shared_ between all objects with that particular tag. A tag does not carry a value in itself; it either sits on the entity
You manage Tags using the `TagHandler` (`.tags`) on typeclassed entities. You can also assign Tags on the class level through the `TagProperty` (one tag, one category per line) or the `TagCategoryProperty` (one category, multiple tags per line). Both of these use the `TagHandler` under the hood, they are just convenient ways to add tags already when you define your class.
Above, the tags inform us that the `Sword` is both sharp and can be wielded. If that's all they do, they could just be a normal Python flag. When tags become important is if there are a lot of objects with different combinations of tags. Maybe you have a magical spell that dulls _all_ sharp-edged objects in the castle - whether sword, dagger, spear or kitchen knife! You can then just grab all objects with the `has_sharp_edge` tag.
Another example would be a weather script affecting all rooms tagged as `outdoors` or finding all characters tagged with `belongs_to_fighter_guild`.
@ -35,34 +43,24 @@ In Evennia, Tags are technically also used to implement `Aliases` (alternative n
Tags are *unique*. This means that there is only ever one Tag object with a given key and category.
> Not specifying a category (default) gives the tag a category of `None`, which is also considered a
unique key + category combination.
```{important}
Not specifying a category (default) gives the tag a category of `None`, which is also considered a unique key + category combination. You cannot use `TagCategoryProperty` to set Tags with `None` categories, since the property name may not be `None`. Use the `TagHandler` (or `TagProperty`) for this.
When Tags are assigned to game entities, these entities are actually sharing the same Tag. This
means that Tags are not suitable for storing information about a single object - use an
```
When Tags are assigned to game entities, these entities are actually sharing the same Tag. This means that Tags are not suitable for storing information about a single object - use an
[Attribute](./Attributes.md) for this instead. Tags are a lot more limited than Attributes but this also
makes them very quick to lookup in the database - this is the whole point.
Tags have the following properties, stored in the database:
- **key** - the name of the Tag. This is the main property to search for when looking up a Tag.
- **category** - this category allows for retrieving only specific subsets of tags used for
different purposes. You could have one category of tags for "zones", another for "outdoor
locations", for example. If not given, the category will be `None`, which is also considered a
separate, default, category.
- **data** - this is an optional text field with information about the tag. Remember that Tags are
shared between entities, so this field cannot hold any object-specific information. Usually it would
be used to hold info about the group of entities the Tag is tagging - possibly used for contextual
help like a tool tip. It is not used by default.
- **category** - this category allows for retrieving only specific subsets of tags used for different purposes. You could have one category of tags for "zones", another for "outdoor locations", for example. If not given, the category will be `None`, which is also considered a separate, default, category.
- **data** - this is an optional text field with information about the tag. Remember that Tags are shared between entities, so this field cannot hold any object-specific information. Usually it would be used to hold info about the group of entities the Tag is tagging - possibly used for contextual help like a tool tip. It is not used by default.
There are also two special properties. These should usually not need to be changed or set, it is
used internally by Evennia to implement various other uses it makes of the `Tag` object:
- **model** - this holds a *natural-key* description of the model object that this tag deals with,
on the form *application.modelclass*, for example `objects.objectdb`. It used by the TagHandler of
each entity type for correctly storing the data behind the scenes.
- **tagtype** - this is a "top-level category" of sorts for the inbuilt children of Tags, namely
*Aliases* and *Permissions*. The Taghandlers using this special field are especially intended to
free up the *category* property for any use you desire.
There are also two special properties. These should usually not need to be changed or set, it is used internally by Evennia to implement various other uses it makes of the `Tag` object:
- **model** - this holds a *natural-key* description of the model object that this tag deals with, on the form *application.modelclass*, for example `objects.objectdb`. It used by the TagHandler of each entity type for correctly storing the data behind the scenes.
- **tagtype** - this is a "top-level category" of sorts for the inbuilt children of Tags, namely *Aliases* and *Permissions*. The Taghandlers using this special field are especially intended to free up the *category* property for any use you desire.
### Adding/Removing Tags
@ -157,6 +155,5 @@ used in the same way as Tags above:
all_aliases = boy.aliases.all()
```
and so on. Similarly to how `@tag` works in-game, there is also the `@perm` command for assigning
permissions and `@alias` command for aliases.
and so on. Similarly to how `tag` works in-game, there is also the `perm` command for assigning permissions and `@alias` command for aliases.

View file

@ -0,0 +1,10 @@
```{eval-rst}
evennia.contrib.game\_systems.containers.containers
==========================================================
.. automodule:: evennia.contrib.game_systems.containers.containers
:members:
:undoc-members:
:show-inheritance:
```

View file

@ -0,0 +1,18 @@
```{eval-rst}
evennia.contrib.game\_systems.containers
================================================
.. automodule:: evennia.contrib.game_systems.containers
:members:
:undoc-members:
:show-inheritance:
.. toctree::
:maxdepth: 6
evennia.contrib.game_systems.containers.containers
evennia.contrib.game_systems.containers.tests
```

View file

@ -0,0 +1,10 @@
```{eval-rst}
evennia.contrib.game\_systems.containers.tests
=====================================================
.. automodule:: evennia.contrib.game_systems.containers.tests
:members:
:undoc-members:
:show-inheritance:
```

View file

@ -13,6 +13,7 @@ evennia.contrib.game\_systems
evennia.contrib.game_systems.barter
evennia.contrib.game_systems.clothing
evennia.contrib.game_systems.containers
evennia.contrib.game_systems.cooldowns
evennia.contrib.game_systems.crafting
evennia.contrib.game_systems.gendersub

View file

@ -51,6 +51,7 @@ Msg = None
# Properties
AttributeProperty = None
TagProperty = None
TagCategoryProperty = None
# commands
Command = None
@ -157,7 +158,7 @@ def _init(portal_mode=False):
global GLOBAL_SCRIPTS, OPTION_CLASSES
global EvMenu, EvTable, EvForm, EvMore, EvEditor
global ANSIString
global AttributeProperty, TagProperty
global AttributeProperty, TagProperty, TagCategoryProperty
# Parent typeclasses
# utilities
@ -172,12 +173,7 @@ def _init(portal_mode=False):
from .comms.models import ChannelDB, Msg
from .locks import lockfuncs
from .objects.models import ObjectDB
from .objects.objects import (
DefaultCharacter,
DefaultExit,
DefaultObject,
DefaultRoom,
)
from .objects.objects import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
from .prototypes.spawner import spawn
from .scripts.models import ScriptDB
from .scripts.monitorhandler import MONITOR_HANDLER
@ -186,7 +182,7 @@ def _init(portal_mode=False):
from .scripts.tickerhandler import TICKER_HANDLER
from .server import signals
from .typeclasses.attributes import AttributeProperty
from .typeclasses.tags import TagProperty
from .typeclasses.tags import TagCategoryProperty, TagProperty
from .utils import ansi, gametime, logger
from .utils.ansi import ANSIString
@ -218,22 +214,23 @@ def _init(portal_mode=False):
search_script,
search_tag,
)
from .utils.utils import class_from_module
if portal_mode:
# Set up the PortalSessionHandler
from evennia.server.portal import portalsessionhandler
portal_sess_handler_class = class_from_module(settings.PORTAL_SESSION_HANDLER_CLASS)
portalsessionhandler.PORTAL_SESSIONS = portal_sess_handler_class()
else:
# Create the ServerSesssionHandler
from evennia.server import sessionhandler
sess_handler_class = class_from_module(settings.SERVER_SESSION_HANDLER_CLASS)
sessionhandler.SESSIONS = sess_handler_class()
sessionhandler.SESSION_HANDLER = sessionhandler.SESSIONS
SESSION_HANDLER = sessionhandler.SESSIONS
# API containers
class _EvContainer(object):

View file

@ -1,14 +1,17 @@
from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
from evennia.objects.models import ObjectDB
from evennia.objects.objects import DefaultObject
from evennia.typeclasses.attributes import AttributeProperty
from evennia.typeclasses.tags import AliasProperty, PermissionProperty, TagProperty
from evennia.typeclasses.tags import (
AliasProperty,
PermissionProperty,
TagCategoryProperty,
TagProperty,
)
from evennia.utils import create
from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTestCase
class DefaultObjectTest(BaseEvenniaTest):
ip = "212.216.139.14"
def test_object_create(self):
@ -60,7 +63,10 @@ class DefaultObjectTest(BaseEvenniaTest):
self.assertEqual(obj.db.creator_ip, self.ip)
def test_exit_create(self):
description = "The steaming depths of the dumpster, ripe with refuse in various states of decomposition."
description = (
"The steaming depths of the dumpster, ripe with refuse in various states of"
" decomposition."
)
obj, errors = DefaultExit.create(
"in", self.room1, self.room2, account=self.account, description=description, ip=self.ip
)
@ -279,6 +285,8 @@ class TestObjectPropertiesClass(DefaultObject):
testperm = PermissionProperty()
awaretest = 5
settest = 0
tagcategory1 = TagCategoryProperty("category_tag1")
tagcategory2 = TagCategoryProperty("category_tag1", "category_tag2", "category_tag3")
@property
def base_property(self):
@ -299,10 +307,7 @@ class TestProperties(EvenniaTestCase):
def tearDown(self):
self.obj.delete()
def test_properties(self):
"""
Test all properties assigned at class level.
"""
def test_attribute_properties(self):
obj = self.obj
self.assertEqual(obj.db.attr1, "attr1")
@ -326,6 +331,9 @@ class TestProperties(EvenniaTestCase):
self.assertEqual(obj.db.attr3, "attr3b")
self.assertTrue(obj.attributes.has("attr3"))
def test_tag_properties(self):
obj = self.obj
self.assertTrue(obj.tags.has("tag1"))
self.assertTrue(obj.tags.has("tag2", category="tagcategory"))
self.assertTrue(obj.tags.has("tag3"))
@ -337,6 +345,15 @@ class TestProperties(EvenniaTestCase):
# only Attribute or TagProperties.
self.assertFalse(hasattr(obj, "property_initialized"))
def test_tag_category_properties(self):
obj = self.obj
self.assertFalse(obj.tags.has("category_tag1")) # no category
self.assertTrue(obj.tags.has("category_tag1", category="tagcategory1"))
self.assertTrue(obj.tags.has("category_tag1", category="tagcategory2"))
self.assertTrue(obj.tags.has("category_tag2", category="tagcategory2"))
self.assertTrue(obj.tags.has("category_tag3", category="tagcategory2"))
def test_object_awareness(self):
"""Test the "object-awareness" of customized AttributeProperty getter/setters"""
obj = self.obj

View file

@ -34,7 +34,6 @@ from django.db.models.base import ModelBase
from django.urls import reverse
from django.utils.encoding import smart_str
from django.utils.text import slugify
from evennia.locks.lockhandler import LockHandler
from evennia.server.signals import SIGNAL_TYPED_OBJECT_POST_RENAME
from evennia.typeclasses import managers
@ -50,6 +49,7 @@ from evennia.typeclasses.tags import (
AliasHandler,
PermissionHandler,
Tag,
TagCategoryProperty,
TagHandler,
TagProperty,
)
@ -343,7 +343,7 @@ class TypedObject(SharedMemoryModel):
by fetching them once.
"""
for propkey, prop in self.__class__.__dict__.items():
if isinstance(prop, (AttributeProperty, TagProperty)):
if isinstance(prop, (AttributeProperty, TagProperty, TagCategoryProperty)):
try:
getattr(self, propkey)
except Exception:
@ -626,7 +626,8 @@ class TypedObject(SharedMemoryModel):
raise RuntimeError(
"Cannot use swap_typeclass on time-dependent "
"Script '%s'.\nStop and start a new Script of the "
"right type instead." % self.key
"right type instead."
% self.key
)
self.typeclass_path = new_typeclass.path

View file

@ -13,7 +13,6 @@ from collections import defaultdict
from django.conf import settings
from django.db import models
from evennia.locks.lockfuncs import perm as perm_lockfunc
from evennia.utils.utils import make_iter, to_str
@ -99,28 +98,31 @@ class Tag(models.Model):
class TagProperty:
"""
Tag property descriptor. Allows for setting tags on an object as Django-like 'fields'
on the class level. Since Tags are almost always used for querying, Tags are always
created/assigned along with the object. Make sure the property/tagname does not collide
with an existing method/property on the class. If it does, you must use tags.add()
instead.
Note that while you _can_ check e.g. `obj.tagname,this will give an AttributeError
if the Tag is not set. Most often you want to use `obj.tags.get("tagname")` to check
if a tag is set on an object.
Example:
::
class Character(DefaultCharacter):
mytag = TagProperty() # category=None
mytag2 = TagProperty(category="tagcategory")
Tag Property.
"""
taghandler_name = "tags"
def __init__(self, category=None, data=None):
"""
Tag property descriptor. Allows for setting tags on an object as Django-like 'fields'
on the class level. Since Tags are almost always used for querying, Tags are always
created/assigned along with the object. Make sure the property/tagname does not collide
with an existing method/property on the class. If it does, you must use tags.add()
instead.
Note that while you _can_ check e.g. `obj.tagname,this will give an AttributeError
if the Tag is not set. Most often you want to use `obj.tags.get("tagname")` to check
if a tag is set on an object.
Example:
::
class Character(DefaultCharacter):
mytag = TagProperty() # category=None
mytag2 = TagProperty(category="tagcategory")
"""
self._category = category
self._data = data
self._key = ""
@ -167,6 +169,129 @@ class TagProperty:
getattr(instance, self.taghandler_name).remove(key=self._key, category=self._category)
class TagCategoryProperty:
"""
Tag Category Property.
"""
taghandler_name = "tags"
def __init__(self, *args):
"""
Assign a property for a Tag Category, with any number of Tag keys.
This is often more useful than the `TagProperty` since it's common to want to check which
tags of a particular category the object is a member of.
Args:
*args (str or callable): Tag keys to assign to this property, using the category given
by the name of the property. If a callable, it will be called without arguments
to return the tag key. It is not possible to set tag `data` this way (use the
Taghandler directly for that). Tag keys are not case sensitive.
Raises:
ValueError: If the input is not a valid tag key or tuple.
Notes:
It is not possible to set Tags with a `None` category using a `TagCategoryProperty` -
use `obj.tags.add()` instead.
Example:
::
class RogueCharacter(DefaultCharacter):
guild = TagProperty("thieves_guild", "merchant_guild")
"""
self._category = ""
self._tags = self._parse_tag_input(*args)
def _parse_tag_input(self, *args):
"""
Parse input to the property.
Args:
*args (str or callable): Tags, either as strings or `callable`, which should return
the tag key when called without arguments. Keys are not case sensitive.
Returns:
list: A list of tag keys.
"""
tags = []
for tagkey in args:
if callable(tagkey):
tagkey = tagkey()
tags.append((str(tagkey).lower()))
return tags
def __set_name__(self, cls, name):
"""
Called when descriptor is first assigned to the class (not the instance!).
It is called with the name of the field.
"""
self._category = name
def __get__(self, instance, owner):
"""
Called when accessing the tag as a property on the instance. Returns a list
of tags under the given category.
"""
taghandler = getattr(instance, self.taghandler_name)
tags = []
add_new = []
for tagkey in self._tags:
try:
tag = taghandler.get(
key=tagkey, category=self._category, return_list=False, raise_exception=True
)
except AttributeError:
add_new.append(tagkey)
else:
tags.append(tag)
if add_new:
for new_tag in add_new:
# we must remove this from the internal store or system will think it already
# existed when determining the sets in __set__
self._tags.remove(new_tag)
self.__set__(instance, *add_new)
return tags
def __set__(self, instance, *args):
"""
Assign a new set of tags to the category. This replaces the previous set of tags.
"""
taghandler = getattr(instance, self.taghandler_name)
old_tags = set(self._tags)
new_tags = set(self._parse_tag_input(*args))
# new_tags could be a sub/superset of old tags
removed_tags = old_tags - new_tags
added_tags = new_tags - old_tags
# remove tags
for tag in removed_tags:
taghandler.remove(key=tag, category=self._category)
# add new tags (won't re-add if obj already had it)
taghandler.batch_add(*[(tag, self._category) for tag in added_tags])
def __delete__(self, instance):
"""
Called when running `del` on the property. Will remove all tags of this
category from the object. Note that the tags will be readded on next fetch
unless the TagCategoryProperty is also removed in code!
"""
for tagkey in self.tags:
getattr(instance, self.taghandler_name).remove(key=self.tagkey, category=self._category)
class TagHandler(object):
"""
Generic tag-handler. Accessed via TypedObject.tags.