From 030887dd21eb89d032c0e99bfea191ddc1c9e7c1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 21 May 2023 21:02:38 +0200 Subject: [PATCH] Add TagCategoryProperty. --- CHANGELOG.md | 2 + docs/source/Coding/Changelog.md | 12 +- docs/source/Components/Tags.md | 45 +++-- ...trib.game_systems.containers.containers.md | 10 ++ ...evennia.contrib.game_systems.containers.md | 18 ++ ...a.contrib.game_systems.containers.tests.md | 10 ++ .../api/evennia.contrib.game_systems.md | 1 + evennia/__init__.py | 17 +- evennia/objects/tests.py | 33 +++- evennia/typeclasses/models.py | 7 +- evennia/typeclasses/tags.py | 161 ++++++++++++++++-- 11 files changed, 251 insertions(+), 65 deletions(-) create mode 100644 docs/source/api/evennia.contrib.game_systems.containers.containers.md create mode 100644 docs/source/api/evennia.contrib.game_systems.containers.md create mode 100644 docs/source/api/evennia.contrib.game_systems.containers.tests.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 46df2955b4..3ba3f4a613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index 962c25b7f8..3ba3f4a613 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -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). diff --git a/docs/source/Components/Tags.md b/docs/source/Components/Tags.md index f11e9d44b2..9aeecee161 100644 --- a/docs/source/Components/Tags.md +++ b/docs/source/Components/Tags.md @@ -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. diff --git a/docs/source/api/evennia.contrib.game_systems.containers.containers.md b/docs/source/api/evennia.contrib.game_systems.containers.containers.md new file mode 100644 index 0000000000..b4cdcfd0a2 --- /dev/null +++ b/docs/source/api/evennia.contrib.game_systems.containers.containers.md @@ -0,0 +1,10 @@ +```{eval-rst} +evennia.contrib.game\_systems.containers.containers +========================================================== + +.. automodule:: evennia.contrib.game_systems.containers.containers + :members: + :undoc-members: + :show-inheritance: + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.game_systems.containers.md b/docs/source/api/evennia.contrib.game_systems.containers.md new file mode 100644 index 0000000000..69d3c7a5c5 --- /dev/null +++ b/docs/source/api/evennia.contrib.game_systems.containers.md @@ -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 + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.game_systems.containers.tests.md b/docs/source/api/evennia.contrib.game_systems.containers.tests.md new file mode 100644 index 0000000000..318a3d9669 --- /dev/null +++ b/docs/source/api/evennia.contrib.game_systems.containers.tests.md @@ -0,0 +1,10 @@ +```{eval-rst} +evennia.contrib.game\_systems.containers.tests +===================================================== + +.. automodule:: evennia.contrib.game_systems.containers.tests + :members: + :undoc-members: + :show-inheritance: + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.game_systems.md b/docs/source/api/evennia.contrib.game_systems.md index b321dd8abc..72bbb06be7 100644 --- a/docs/source/api/evennia.contrib.game_systems.md +++ b/docs/source/api/evennia.contrib.game_systems.md @@ -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 diff --git a/evennia/__init__.py b/evennia/__init__.py index 0aa5ae7af0..bcf39069b8 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -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): diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index c2ef53b498..41d5ce1f6c 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -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 diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 8d1c76e7eb..5fe225dabe 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -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 diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index 3cb06f935f..20b76621aa 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -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.