diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 49d3f550cc..305b841956 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -32,7 +32,7 @@ from evennia.server.signals import ( SIGNAL_OBJECT_POST_PUPPET, SIGNAL_OBJECT_POST_UNPUPPET, ) -from evennia.typeclasses.attributes import NickHandler +from evennia.typeclasses.attributes import NickHandler, ModelAttributeBackend from evennia.scripts.scripthandler import ScriptHandler from evennia.commands.cmdsethandler import CmdSetHandler from evennia.utils.optionhandler import OptionHandler @@ -199,7 +199,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): @lazy_property def nicks(self): - return NickHandler(self) + return NickHandler(self, ModelAttributeBackend) @lazy_property def sessions(self): diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 8ee0d5bfa4..e95481773d 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -12,7 +12,7 @@ from collections import defaultdict from django.conf import settings from evennia.typeclasses.models import TypeclassBase -from evennia.typeclasses.attributes import NickHandler +from evennia.typeclasses.attributes import NickHandler, ModelAttributeBackend from evennia.objects.manager import ObjectManager from evennia.objects.models import ObjectDB from evennia.scripts.scripthandler import ScriptHandler @@ -225,7 +225,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): @lazy_property def nicks(self): - return NickHandler(self) + return NickHandler(self, ModelAttributeBackend) @lazy_property def sessions(self): diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 2443758b12..15ffa7a42b 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -6,7 +6,6 @@ connection actually happens (so it's the same for telnet, web, ssh etc). It is stored on the Server side (as opposed to protocol-specific sessions which are stored on the Portal side) """ -import weakref import time from django.utils import timezone from django.conf import settings @@ -16,6 +15,7 @@ from evennia.utils.utils import make_iter, lazy_property from evennia.commands.cmdsethandler import CmdSetHandler from evennia.server.session import Session from evennia.scripts.monitorhandler import MONITOR_HANDLER +from evennia.typeclasses.attributes import AttributeHandler, InMemoryAttributeBackend, DbHolder _GA = object.__getattribute__ _SA = object.__setattr__ @@ -25,124 +25,6 @@ _ANSI = None # i18n from django.utils.translation import gettext as _ -# Handlers for Session.db/ndb operation - - -class NDbHolder(object): - """Holder for allowing property access of attributes""" - - def __init__(self, obj, name, manager_name="attributes"): - _SA(self, name, _GA(obj, manager_name)) - _SA(self, "name", name) - - def __getattribute__(self, attrname): - if attrname == "all": - # we allow to overload our default .all - attr = _GA(self, _GA(self, "name")).get("all") - return attr if attr else _GA(self, "all") - return _GA(self, _GA(self, "name")).get(attrname) - - def __setattr__(self, attrname, value): - _GA(self, _GA(self, "name")).add(attrname, value) - - def __delattr__(self, attrname): - _GA(self, _GA(self, "name")).remove(attrname) - - def get_all(self): - return _GA(self, _GA(self, "name")).all() - - all = property(get_all) - - -class NAttributeHandler(object): - """ - NAttributeHandler version without recache protection. - This stand-alone handler manages non-database saving. - It is similar to `AttributeHandler` and is used - by the `.ndb` handler in the same way as `.db` does - for the `AttributeHandler`. - """ - - def __init__(self, obj): - """ - Initialized on the object - """ - self._store = {} - self.obj = weakref.proxy(obj) - - def has(self, key): - """ - Check if object has this attribute or not. - - Args: - key (str): The Nattribute key to check. - - Returns: - has_nattribute (bool): If Nattribute is set or not. - - """ - return key in self._store - - def get(self, key, default=None): - """ - Get the named key value. - - Args: - key (str): The Nattribute key to get. - - Returns: - the value of the Nattribute. - - """ - return self._store.get(key, default) - - def add(self, key, value): - """ - Add new key and value. - - Args: - key (str): The name of Nattribute to add. - value (any): The value to store. - - """ - self._store[key] = value - - def remove(self, key): - """ - Remove Nattribute from storage. - - Args: - key (str): The name of the Nattribute to remove. - - """ - if key in self._store: - del self._store[key] - - def clear(self): - """ - Remove all NAttributes from handler. - - """ - self._store = {} - - def all(self, return_tuples=False): - """ - List the contents of the handler. - - Args: - return_tuples (bool, optional): Defines if the Nattributes - are returns as a list of keys or as a list of `(key, value)`. - - Returns: - nattributes (list): A list of keys `[key, key, ...]` or a - list of tuples `[(key, value), ...]` depending on the - setting of `return_tuples`. - - """ - if return_tuples: - return [(key, value) for (key, value) in self._store.items() if not key.startswith("_")] - return [key for key in self._store if not key.startswith("_")] - # ------------------------------------------------------------- # Server Session @@ -175,6 +57,10 @@ class ServerSession(Session): cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set) + @property + def id(self): + return self.sessid + def at_sync(self): """ This is called whenever a session has been resynced with the @@ -490,7 +376,7 @@ class ServerSession(Session): @lazy_property def nattributes(self): - return NAttributeHandler(self) + return AttributeHandler(self, InMemoryAttributeBackend) @lazy_property def attributes(self): @@ -508,7 +394,7 @@ class ServerSession(Session): try: return self._ndb_holder except AttributeError: - self._ndb_holder = NDbHolder(self, "nattrhandler", manager_name="nattributes") + self._ndb_holder = DbHolder(self, "nattrhandler", manager_name="nattributes") return self._ndb_holder # @ndb.setter diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 6fb4870dae..001cee929d 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -12,6 +12,8 @@ import re import fnmatch import weakref +from collections import defaultdict + from django.db import models from django.conf import settings from django.utils.encoding import smart_str @@ -31,7 +33,7 @@ _TYPECLASS_AGGRESSIVE_CACHE = settings.TYPECLASS_AGGRESSIVE_CACHE # ------------------------------------------------------------- -class Attribute(SharedMemoryModel): +class IAttribute: """ Attributes are things that are specific to different types of objects. For example, a drink container needs to store its fill level, whereas an exit @@ -53,8 +55,113 @@ class Attribute(SharedMemoryModel): - category (str): Optional character string for grouping the Attribute. + This class is an API/Interface/Abstract base class; do not instantiate it directly. """ + @lazy_property + def locks(self): + return LockHandler(self) + + key = property(lambda self: self.db_key) + strvalue = property(lambda self: self.db_strvalue) + category = property(lambda self: self.db_category) + model = property(lambda self: self.db_model) + attrtype = property(lambda self: self.db_attrtype) + date_created = property(lambda self: self.db_date_created) + + def __lock_storage_get(self): + return self.db_lock_storage + + def __lock_storage_set(self, value): + self.db_lock_storage = value + + def __lock_storage_del(self): + self.db_lock_storage = "" + + lock_storage = property(__lock_storage_get, __lock_storage_set, __lock_storage_del) + + def access(self, accessing_obj, access_type="read", default=False, **kwargs): + """ + Determines if another object has permission to access. + + Args: + accessing_obj (object): Entity trying to access this one. + access_type (str, optional): Type of access sought, see + the lock documentation. + default (bool, optional): What result to return if no lock + of access_type was found. The default, `False`, means a lockdown + policy, only allowing explicit access. + kwargs (any, optional): Not used; here to make the API consistent with + other access calls. + + Returns: + result (bool): If the lock was passed or not. + + """ + result = self.locks.check(accessing_obj, access_type=access_type, default=default) + return result + + # + # + # Attribute methods + # + # + + def __str__(self): + return smart_str("%s(%s)" % (self.db_key, self.id)) + + def __repr__(self): + return "%s(%s)" % (self.db_key, self.id) + + +class InMemoryAttribute(IAttribute): + """ + This Attribute is used purely for NAttributes/NAttributeHandler. It has no database backend. + """ + + # Primary Key has no meaning for an InMemoryAttribute. This merely serves to satisfy other code. + + def __init__(self, pk, **kwargs): + """ + Create an Attribute that exists only in Memory. + + Args: + pk (int): This is a fake 'primary key' / id-field. It doesn't actually have to be unique, but is fed an + incrementing number from the InMemoryBackend by default. This is needed only so Attributes can be + sorted. Some parts of the API also see the lack of a .pk field as a sign that the Attribute was + deleted. + **kwargs: Other keyword arguments are used to construct the actual Attribute. + """ + self.id = pk + self.pk = pk + + # Copy all kwargs to local properties. We use db_ for compatability here. + for key, value in kwargs.items(): + # Value and locks are special. We must call the wrappers. + if key == "value": + self.value = value + elif key == "lock_storage": + self.lock_storage = value + else: + setattr(self, f"db_{key}", value) + + # value property (wraps db_value) + def __value_get(self): + return self.db_value + + def __value_set(self, new_value): + self.db_value = new_value + + def __value_del(self): + pass + + value = property(__value_get, __value_set, __value_del) + + +class Attribute(IAttribute, SharedMemoryModel): + """ + This attribute is stored via Django. Most Attributes will be using this class. + """ # # Attribute Database Model setup # @@ -109,35 +216,10 @@ class Attribute(SharedMemoryModel): # Database manager # objects = managers.AttributeManager() - @lazy_property - def locks(self): - return LockHandler(self) - class Meta(object): "Define Django meta options" verbose_name = "Evennia Attribute" - # read-only wrappers - key = property(lambda self: self.db_key) - strvalue = property(lambda self: self.db_strvalue) - category = property(lambda self: self.db_category) - model = property(lambda self: self.db_model) - attrtype = property(lambda self: self.db_attrtype) - date_created = property(lambda self: self.db_date_created) - - def __lock_storage_get(self): - return self.db_lock_storage - - def __lock_storage_set(self, value): - self.db_lock_storage = value - self.save(update_fields=["db_lock_storage"]) - - def __lock_storage_del(self): - self.db_lock_storage = "" - self.save(update_fields=["db_lock_storage"]) - - lock_storage = property(__lock_storage_get, __lock_storage_set, __lock_storage_del) - # Wrapper properties to easily set database fields. These are # @property decorators that allows to access these fields using # normal python operations (without having to remember to save() @@ -146,6 +228,20 @@ class Attribute(SharedMemoryModel): # value = self.attr and del self.attr respectively (where self # is the object in question). + # lock_storage wrapper. Overloaded for saving to database. + def __lock_storage_get(self): + return self.db_lock_storage + + def __lock_storage_set(self, value): + super().__lock_storage_set(value) + self.save(update_fields=["db_lock_storage"]) + + def __lock_storage_del(self): + super().__lock_storage_del() + self.save(update_fields=["db_lock_storage"]) + + lock_storage = property(__lock_storage_get, __lock_storage_set, __lock_storage_del) + # value property (wraps db_value) # @property def __value_get(self): @@ -164,7 +260,6 @@ class Attribute(SharedMemoryModel): see self.__value_get. """ self.db_value = to_pickle(new_value) - # print("value_set, self.db_value:", repr(self.db_value)) # DEBUG self.save(update_fields=["db_value"]) # @value.deleter @@ -174,98 +269,147 @@ class Attribute(SharedMemoryModel): value = property(__value_get, __value_set, __value_del) - # - # - # Attribute methods - # - # - - def __str__(self): - return smart_str("%s(%s)" % (self.db_key, self.id)) - - def __repr__(self): - return "%s(%s)" % (self.db_key, self.id) - - def access(self, accessing_obj, access_type="read", default=False, **kwargs): - """ - Determines if another object has permission to access. - - Args: - accessing_obj (object): Entity trying to access this one. - access_type (str, optional): Type of access sought, see - the lock documentation. - default (bool, optional): What result to return if no lock - of access_type was found. The default, `False`, means a lockdown - policy, only allowing explicit access. - kwargs (any, optional): Not used; here to make the API consistent with - other access calls. - - Returns: - result (bool): If the lock was passed or not. - - """ - result = self.locks.check(accessing_obj, access_type=access_type, default=default) - return result - - # # Handlers making use of the Attribute model # - -class AttributeHandler(object): +class IAttributeBackend: """ - Handler for adding Attributes to the object. + Abstract interface for the backends used by the Attribute Handler. + + All Backends must implement this base class. """ - _m2m_fieldname = "db_attributes" _attrcreate = "attrcreate" _attredit = "attredit" _attrread = "attrread" - _attrtype = None + _attrclass = None - def __init__(self, obj): - """Initialize handler.""" - self.obj = obj - self._objid = obj.id - self._model = to_str(obj.__dbclass__.__name__.lower()) + def __init__(self, handler, attrtype): + self.handler = handler + self.obj = handler.obj + self._attrtype = attrtype + self._objid = handler.obj.id self._cache = {} # store category names fully cached self._catcache = {} # full cache was run on all attributes self._cache_complete = False - def _query_all(self): - "Fetch all Attributes on this object" - query = { - "%s__id" % self._model: self._objid, - "attribute__db_model__iexact": self._model, - "attribute__db_attrtype": self._attrtype, - } - return [ - conn.attribute - for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) - ] + def query_all(self): + """ + Fetch all Attributes from this object. - def _fullcache(self): + Returns: + attrlist (list): A list of Attribute objects. + """ + raise NotImplementedError() + + def query_key(self, key, category): + """ + + Args: + key (str): The key of the Attribute being searched for. + category (str or None): The category of the desired Attribute. + + Returns: + attribute (IAttribute): A single Attribute. + """ + raise NotImplementedError() + + def query_category(self, category): + """ + Returns every matching Attribute as a list, given a category. + + This method calls up whatever storage the backend uses. + + Args: + category (str or None): The category to query. + + Returns: + attrs (list): The discovered Attributes. + """ + raise NotImplementedError() + + def _full_cache(self): """Cache all attributes of this object""" if not _TYPECLASS_AGGRESSIVE_CACHE: return - attrs = self._query_all() - self._cache = dict( - ( - "%s-%s" - % ( - to_str(attr.db_key).lower(), - attr.db_category.lower() if attr.db_category else None, - ), - attr, - ) - for attr in attrs - ) + attrs = self.query_all() + self._cache = {f"{to_str(attr.key).lower()}-{attr.category.lower() if attr.category else None}": attr + for attr in attrs} self._cache_complete = True - def _getcache(self, key=None, category=None): + def _get_cache_key(self, key, category): + """ + + + Args: + key (str): The key of the Attribute being searched for. + category (str or None): The category of the desired Attribute. + + Returns: + attribute (IAttribute): A single Attribute. + """ + cachekey = "%s-%s" % (key, category) + cachefound = False + try: + attr = _TYPECLASS_AGGRESSIVE_CACHE and self._cache[cachekey] + cachefound = True + except KeyError: + attr = None + + if attr and (not hasattr(attr, "pk") and attr.pk is None): + # clear out Attributes deleted from elsewhere. We must search this anew. + attr = None + cachefound = False + del self._cache[cachekey] + if cachefound and _TYPECLASS_AGGRESSIVE_CACHE: + if attr: + return [attr] # return cached entity + else: + return [] # no such attribute: return an empty list + else: + conn = self.query_key(key, category) + if conn: + attr = conn[0].attribute + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = attr + return [attr] if attr.pk else [] + else: + # There is no such attribute. We will explicitly save that + # in our cache to avoid firing another query if we try to + # retrieve that (non-existent) attribute again. + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = None + return [] + + def _get_cache_category(self, category): + """ + Retrieves Attribute list (by category) from cache. + + Args: + category (str or None): The category to query. + + Returns: + attrs (list): The discovered Attributes. + """ + catkey = "-%s" % category + if _TYPECLASS_AGGRESSIVE_CACHE and catkey in self._catcache: + return [attr for key, attr in self._cache.items() if key.endswith(catkey) and attr] + else: + # we have to query to make this category up-date in the cache + attrs = self.query_category(category) + if _TYPECLASS_AGGRESSIVE_CACHE: + for attr in attrs: + if attr.pk: + cachekey = "%s-%s" % (attr.key, category) + self._cache[cachekey] = attr + # mark category cache as up-to-date + self._catcache[catkey] = True + return attrs + + def _get_cache(self, key=None, category=None): """ Retrieve from cache or database (always caches) @@ -291,85 +435,31 @@ class AttributeHandler(object): key = key.strip().lower() if key else None category = category.strip().lower() if category else None if key: - cachekey = "%s-%s" % (key, category) - cachefound = False - try: - attr = _TYPECLASS_AGGRESSIVE_CACHE and self._cache[cachekey] - cachefound = True - except KeyError: - attr = None + return self._get_cache_key(key, category) + return self._get_cache_category(category) - if attr and (not hasattr(attr, "pk") and attr.pk is None): - # clear out Attributes deleted from elsewhere. We must search this anew. - attr = None - cachefound = False - del self._cache[cachekey] - if cachefound and _TYPECLASS_AGGRESSIVE_CACHE: - if attr: - return [attr] # return cached entity - else: - return [] # no such attribute: return an empty list - else: - query = { - "%s__id" % self._model: self._objid, - "attribute__db_model__iexact": self._model, - "attribute__db_attrtype": self._attrtype, - "attribute__db_key__iexact": key.lower(), - "attribute__db_category__iexact": category.lower() if category else None, - } - if not self.obj.pk: - return [] - conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) - if conn: - attr = conn[0].attribute - if _TYPECLASS_AGGRESSIVE_CACHE: - self._cache[cachekey] = attr - return [attr] if attr.pk else [] - else: - # There is no such attribute. We will explicitly save that - # in our cache to avoid firing another query if we try to - # retrieve that (non-existent) attribute again. - if _TYPECLASS_AGGRESSIVE_CACHE: - self._cache[cachekey] = None - return [] - else: - # only category given (even if it's None) - we can't - # assume the cache to be complete unless we have queried - # for this category before - catkey = "-%s" % category - if _TYPECLASS_AGGRESSIVE_CACHE and catkey in self._catcache: - return [attr for key, attr in self._cache.items() if key.endswith(catkey) and attr] - else: - # we have to query to make this category up-date in the cache - query = { - "%s__id" % self._model: self._objid, - "attribute__db_model__iexact": self._model, - "attribute__db_attrtype": self._attrtype, - "attribute__db_category__iexact": category.lower() if category else None, - } - attrs = [ - conn.attribute - for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter( - **query - ) - ] - if _TYPECLASS_AGGRESSIVE_CACHE: - for attr in attrs: - if attr.pk: - cachekey = "%s-%s" % (attr.db_key, category) - self._cache[cachekey] = attr - # mark category cache as up-to-date - self._catcache[catkey] = True - return attrs + def get(self, key=None, category=None): + """ + Frontend for .get_cache. Retrieves Attribute(s). - def _setcache(self, key, category, attr_obj): + Args: + key (str, optional): Attribute key to query for + category (str, optional): Attribiute category + + Returns: + args (list): Returns a list of zero or more matches + found from cache or database. + """ + return self._get_cache(key, category) + + def _set_cache(self, key, category, attr_obj): """ Update cache. Args: key (str): A cleaned key string category (str or None): A cleaned category name - attr_obj (Attribute): The newly saved attribute + attr_obj (IAttribute): The newly saved attribute """ if not _TYPECLASS_AGGRESSIVE_CACHE: @@ -383,7 +473,7 @@ class AttributeHandler(object): self._catcache.pop(catkey, None) self._cache_complete = False - def _delcache(self, key, category): + def _delete_cache(self, key, category): """ Remove attribute from cache @@ -414,6 +504,419 @@ class AttributeHandler(object): self._cache = {} self._catcache = {} + def do_create_attribute(self, key, category, lockstring, value, strvalue): + """ + Does the hard work of actually creating Attributes, whatever is needed. + + Args: + key (str): The Attribute's key. + category (str or None): The Attribute's category, or None + lockstring (str): Any locks for the Attribute. + value (obj): The Value of the Attribute. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + + Returns: + attr (IAttribute): The new Attribute. + """ + raise NotImplementedError() + + def create_attribute(self, key, category, lockstring, value, strvalue=False, cache=True): + """ + Creates Attribute (using the class specified for the backend), (optionally) caches it, and returns it. + + This MUST actively save the Attribute to whatever database backend is used, AND + call self.set_cache(key, category, new_attrobj) + + Args: + key (str): The Attribute's key. + category (str or None): The Attribute's category, or None + lockstring (str): Any locks for the Attribute. + value (obj): The Value of the Attribute. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + cache (bool): Whether to cache the new Attribute + + Returns: + attr (IAttribute): The new Attribute. + """ + attr = self.do_create_attribute(key, category, lockstring, value, strvalue) + if cache: + self._set_cache(key, category, attr) + return attr + + def do_update_attribute(self, attr, value): + """ + Simply sets a new Value to an Attribute. + + Args: + attr (IAttribute): The Attribute being changed. + value (obj): The Value for the Attribute. + + """ + raise NotImplementedError() + + def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + """ + Called opnly by batch add. For the database backend, this is a method + of updating that can alter category and lock-storage. + + Args: + attr_obj (IAttribute): The Attribute being altered. + category (str or None): The attribute's (new) category. + lock_storage (str): The attribute's new locks. + new_value (obj): The Attribute's new value. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + """ + raise NotImplementedError() + + def do_batch_finish(self, attr_objs): + """ + Called only by batch_add. Used for handling database operations and/or caching complications. + + Args: + attr_objs (list of IAttribute): The Attributes created/updated thus far. + """ + raise NotImplementedError() + + def batch_add(self, *args, **kwargs): + """ + Batch-version of `add()`. This is more efficient than + repeat-calling add when having many Attributes to add. + + Args: + indata (list): List of tuples of varying length representing the + Attribute to add to this object. Supported tuples are + - `(key, value)` + - `(key, value, category)` + - `(key, value, category, lockstring)` + - `(key, value, category, lockstring, default_access)` + + Raises: + RuntimeError: If trying to pass a non-iterable as argument. + + Notes: + The indata tuple order matters, so if you want a lockstring + but no category, set the category to `None`. This method + does not have the ability to check editing permissions like + normal .add does, and is mainly used internally. It does not + use the normal self.add but apply the Attributes directly + to the database. + + """ + new_attrobjs = [] + strattr = kwargs.get("strattr", False) + for tup in args: + if not is_iter(tup) or len(tup) < 2: + raise RuntimeError("batch_add requires iterables as arguments (got %r)." % tup) + ntup = len(tup) + keystr = str(tup[0]).strip().lower() + new_value = tup[1] + category = str(tup[2]).strip().lower() if ntup > 2 and tup[2] is not None else None + lockstring = tup[3] if ntup > 3 else "" + + attr_objs = self._get_cache(keystr, category) + + if attr_objs: + attr_obj = attr_objs[0] + # update an existing attribute object + self.do_batch_update_attribute(attr_obj, category, lockstring, new_value, strattr) + else: + new_attr = self.do_create_attribute(keystr, category, lockstring, new_value, strvalue=strattr) + new_attrobjs.append(new_attr) + if new_attrobjs: + self.do_batch_finish(new_attrobjs) + + def do_delete_attribute(self, attr): + """ + Does the hard work of actually deleting things. + + Args: + attr (IAttribute): The attribute to delete. + """ + raise NotImplementedError() + + def delete_attribute(self, attr): + """ + Given an Attribute, deletes it. Also remove it from cache. + + Args: + attr (IAttribute): The attribute to delete. + """ + if not attr: + return + self._delete_cache(attr.key, attr.category) + self.do_delete_attribute(attr) + + def update_attribute(self, attr, value): + """ + Simply updates an Attribute. + + Args: + attr (IAttribute): The attribute to delete. + value (obj): The new value. + """ + self.do_update_attribute(attr, value) + + def do_batch_delete(self, attribute_list): + """ + Given a list of attributes, deletes them all. + The default implementation is fine, but this is overridable since some databases may allow + for a better method. + + Args: + attribute_list (list of IAttribute): + """ + for attribute in attribute_list: + self.delete_attribute(attribute) + + def clear_attributes(self, category, accessing_obj, default_access): + """ + Remove all Attributes on this object. + + Args: + category (str, optional): If given, clear only Attributes + of this category. + accessing_obj (object, optional): If given, check the + `attredit` lock on each Attribute before continuing. + default_access (bool, optional): Use this permission as + fallback if `access_obj` is given but there is no lock of + type `attredit` on the Attribute in question. + + """ + category = category.strip().lower() if category is not None else None + + if not self._cache_complete: + self._full_cache() + + if category is not None: + attrs = [attr for attr in self._cache.values() if attr.category == category] + else: + attrs = self._cache.values() + + if accessing_obj: + self.do_batch_delete([attr for attr in attrs if attr.access(accessing_obj, self._attredit, + default=default_access)]) + else: + # have to cast the results to a list or we'll get a RuntimeError for removing from the dict we're iterating + self.do_batch_delete(list(attrs)) + self.reset_cache() + + def get_all_attributes(self): + """ + Simply returns all Attributes of this object, sorted by their IDs. + + Returns: + attributes (list of IAttribute) + """ + if _TYPECLASS_AGGRESSIVE_CACHE: + if not self._cache_complete: + self._full_cache() + return sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id) + else: + return sorted([attr for attr in self.query_all() if attr], key=lambda o: o.id) + + +class InMemoryAttributeBackend(IAttributeBackend): + """ + This Backend for Attributes stores NOTHING in the database. Everything is kept in memory, and normally lost + on a crash, reload, shared memory flush, etc. It generates IDs for the Attributes it manages, but these are + of little importance beyond sorting and satisfying the caching logic to know an Attribute hasn't been + deleted out from under the cache's nose. + + """ + + _attrclass = InMemoryAttribute + + def __init__(self, handler, attrtype): + super().__init__(handler, attrtype) + self._storage = dict() + self._category_storage = defaultdict(list) + self._id_counter = 0 + + def _next_id(self): + """ + Increments the internal ID counter and returns the new value. + + Returns: + next_id (int): A simple integer. + """ + self._id_counter += 1 + return self._id_counter + + def query_all(self): + return self._storage.values() + + def query_key(self, key, category): + found = self._storage.get((key, category), None) + if found: + return [found] + return [] + + def query_category(self, category): + if category is None: + return self._storage.values() + return self._category_storage.get(category, []) + + def do_create_attribute(self, key, category, lockstring, value, strvalue): + """ + See parent class. + + strvalue has no meaning for InMemory attributes. + """ + new_attr = self._attrclass(pk=self._next_id(), key=key, category=category, lock_storage=lockstring, value=value) + self._storage[(key, category)] = new_attr + self._category_storage[category].append(new_attr) + return new_attr + + def do_update_attribute(self, attr, value): + attr.value = value + + def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + """ + No need to bother saving anything. Just set some values. + """ + attr_obj.db_category = category + attr_obj.db_lock_storage = lock_storage if lock_storage else "" + attr_obj.value = new_value + + def do_batch_finish(self, attr_objs): + """ + Nothing to do here for In-Memory. + + Args: + attr_objs (list of IAttribute): The Attributes created/updated thus far. + """ + pass + + def do_delete_attribute(self, attr): + """ + Removes the Attribute from local storage. Once it's out of the cache, garbage collection will handle the rest. + + Args: + attr (IAttribute): The attribute to delete. + """ + del self._storage[(attr.key, attr.category)] + self._category_storage[attr.category].remove(attr) + + +class ModelAttributeBackend(IAttributeBackend): + """ + Uses Django models for storing Attributes. + """ + _attrclass = Attribute + _m2m_fieldname = "db_attributes" + + def __init__(self, handler, attrtype): + super().__init__(handler, attrtype) + self._model = to_str(handler.obj.__dbclass__.__name__.lower()) + + def query_all(self): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + } + return [ + conn.attribute + for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) + ] + + def query_key(self, key, category): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + "attribute__db_key__iexact": key.lower(), + "attribute__db_category__iexact": category.lower() if category else None, + } + if not self.obj.pk: + return [] + return getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) + + def query_category(self, category): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + "attribute__db_category__iexact": category.lower() if category else None, + } + return [ + conn.attribute + for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter( + **query + ) + ] + + def do_create_attribute(self, key, category, lockstring, value, strvalue): + kwargs = { + "db_key": key, + "db_category": category, + "db_model": self._model, + "db_lock_storage": lockstring if lockstring else "", + "db_attrtype": self._attrtype + } + if strvalue: + kwargs["db_value"] = None + kwargs["db_strvalue"] = value + else: + kwargs["db_value"] = to_pickle(value) + kwargs["db_strvalue"] = None + new_attr = self._attrclass(**kwargs) + new_attr.save() + getattr(self.obj, self._m2m_fieldname).add(new_attr) + self._set_cache(key, category, new_attr) + return new_attr + + def do_update_attribute(self, attr, value): + attr.value = value + + def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + attr_obj.db_category = category + attr_obj.db_lock_storage = lock_storage if lock_storage else "" + if strvalue: + # store as a simple string (will not notify OOB handlers) + attr_obj.db_strvalue = new_value + attr_obj.value = None + else: + # store normally (this will also notify OOB handlers) + attr_obj.value = new_value + attr_obj.db_strvalue = None + attr_obj.save(update_fields=["db_strvalue", "db_value", "db_category", "db_lock_storage"]) + + def do_batch_finish(self, attr_objs): + # Add new objects to m2m field all at once + getattr(self.obj, self._m2m_fieldname).add(*attr_objs) + + def do_delete_attribute(self, attr): + try: + attr.delete() + except AssertionError: + # This could happen if the Attribute has already been deleted. + pass + + +class AttributeHandler: + """ + Handler for adding Attributes to the object. + """ + _attrcreate = "attrcreate" + _attredit = "attredit" + _attrread = "attrread" + _attrtype = None + + def __init__(self, obj, backend_class): + """ + Setup the AttributeHandler. + + Args: + obj (TypedObject): An Account, Object, Channel, ServerSession (not technically a typed object), etc. + backend_class (IAttributeBackend class): The class of the backend to use. + """ + self.obj = obj + self.backend = backend_class(self, self._attrtype) + def has(self, key=None, category=None): """ Checks if the given Attribute (or list of Attributes) exists on @@ -435,7 +938,7 @@ class AttributeHandler(object): category = category.strip().lower() if category is not None else None for keystr in make_iter(key): keystr = key.strip().lower() - ret.extend(bool(attr) for attr in self._getcache(keystr, category)) + ret.extend(bool(attr) for attr in self.backend.get(keystr, category)) return ret[0] if len(ret) == 1 else ret def get( @@ -493,7 +996,7 @@ class AttributeHandler(object): ret = [] for keystr in make_iter(key): # it's okay to send a None key - attr_objs = self._getcache(keystr, category) + attr_objs = self.backend.get(keystr, category) if attr_objs: ret.extend(attr_objs) elif raise_exception: @@ -559,33 +1062,15 @@ class AttributeHandler(object): category = category.strip().lower() if category is not None else None keystr = key.strip().lower() - attr_obj = self._getcache(key, category) + attr_obj = self.backend.get(key, category) if attr_obj: # update an existing attribute object attr_obj = attr_obj[0] - if strattr: - # store as a simple string (will not notify OOB handlers) - attr_obj.db_strvalue = value - attr_obj.save(update_fields=["db_strvalue"]) - else: - # store normally (this will also notify OOB handlers) - attr_obj.value = value + self.backend.update_attribute(attr_obj, value) else: # create a new Attribute (no OOB handlers can be notified) - kwargs = { - "db_key": keystr, - "db_category": category, - "db_model": self._model, - "db_attrtype": self._attrtype, - "db_value": None if strattr else to_pickle(value), - "db_strvalue": value if strattr else None, - } - new_attr = Attribute(**kwargs) - new_attr.save() - getattr(self.obj, self._m2m_fieldname).add(new_attr) - # update cache - self._setcache(keystr, category, new_attr) + self.backend.create_attribute(keystr, category, lockstring, value, strattr) def batch_add(self, *args, **kwargs): """ @@ -618,50 +1103,7 @@ class AttributeHandler(object): to the database. """ - new_attrobjs = [] - strattr = kwargs.get("strattr", False) - for tup in args: - if not is_iter(tup) or len(tup) < 2: - raise RuntimeError("batch_add requires iterables as arguments (got %r)." % tup) - ntup = len(tup) - keystr = str(tup[0]).strip().lower() - new_value = tup[1] - category = str(tup[2]).strip().lower() if ntup > 2 and tup[2] is not None else None - lockstring = tup[3] if ntup > 3 else "" - - attr_objs = self._getcache(keystr, category) - - if attr_objs: - attr_obj = attr_objs[0] - # update an existing attribute object - attr_obj.db_category = category - attr_obj.db_lock_storage = lockstring or "" - attr_obj.save(update_fields=["db_category", "db_lock_storage"]) - if strattr: - # store as a simple string (will not notify OOB handlers) - attr_obj.db_strvalue = new_value - attr_obj.save(update_fields=["db_strvalue"]) - else: - # store normally (this will also notify OOB handlers) - attr_obj.value = new_value - else: - # create a new Attribute (no OOB handlers can be notified) - kwargs = { - "db_key": keystr, - "db_category": category, - "db_model": self._model, - "db_attrtype": self._attrtype, - "db_value": None if strattr else to_pickle(new_value), - "db_strvalue": new_value if strattr else None, - "db_lock_storage": lockstring or "", - } - new_attr = Attribute(**kwargs) - new_attr.save() - new_attrobjs.append(new_attr) - self._setcache(keystr, category, new_attr) - if new_attrobjs: - # Add new objects to m2m field all at once - getattr(self.obj, self._m2m_fieldname).add(*new_attrobjs) + self.backend.batch_add(*args, **kwargs) def remove( self, @@ -710,20 +1152,13 @@ class AttributeHandler(object): for keystr in make_iter(key): keystr = keystr.lower() - attr_objs = self._getcache(keystr, category) + attr_objs = self.backend.get(keystr, category) for attr_obj in attr_objs: if not ( accessing_obj and not attr_obj.access(accessing_obj, self._attredit, default=default_access) ): - try: - attr_obj.delete() - except AssertionError: - print("Assertionerror for attr.delete()") - # this happens if the attr was already deleted - pass - finally: - self._delcache(keystr, category) + self.backend.delete_attribute(attr_obj) if not attr_objs and raise_exception: raise AttributeError @@ -741,27 +1176,7 @@ class AttributeHandler(object): type `attredit` on the Attribute in question. """ - category = category.strip().lower() if category is not None else None - - if not self._cache_complete: - self._fullcache() - - if category is not None: - attrs = [attr for attr in self._cache.values() if attr.category == category] - else: - attrs = self._cache.values() - - if accessing_obj: - [ - attr.delete() - for attr in attrs - if attr and attr.access(accessing_obj, self._attredit, default=default_access) - ] - else: - [attr.delete() for attr in attrs if attr and attr.pk] - self._cache = {} - self._catcache = {} - self._cache_complete = False + self.backend.clear_attributes(category, accessing_obj, default_access) def all(self, accessing_obj=None, default_access=True): """ @@ -780,12 +1195,7 @@ class AttributeHandler(object): their values!) in the handler. """ - if _TYPECLASS_AGGRESSIVE_CACHE: - if not self._cache_complete: - self._fullcache() - attrs = sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id) - else: - attrs = sorted([attr for attr in self._query_all() if attr], key=lambda o: o.id) + attrs = self.backend.get_all_attributes() if accessing_obj: return [ @@ -796,6 +1206,41 @@ class AttributeHandler(object): else: return attrs + def reset_cache(self): + self.backend.reset_cache() + + +# DbHolders for .db and .ndb properties on Typeclasses. + +_GA = object.__getattribute__ +_SA = object.__setattr__ + + +class DbHolder(object): + "Holder for allowing property access of attributes" + + def __init__(self, obj, name, manager_name="attributes"): + _SA(self, name, _GA(obj, manager_name)) + _SA(self, "name", name) + + def __getattribute__(self, attrname): + if attrname == "all": + # we allow to overload our default .all + attr = _GA(self, _GA(self, "name")).get("all") + return attr if attr else _GA(self, "all") + return _GA(self, _GA(self, "name")).get(attrname) + + def __setattr__(self, attrname, value): + _GA(self, _GA(self, "name")).add(attrname, value) + + def __delattr__(self, attrname): + _GA(self, _GA(self, "name")).remove(attrname) + + def get_all(self): + return _GA(self, _GA(self, "name")).get_all_attributes() + + all = property(get_all) + # Nick templating # @@ -1037,92 +1482,3 @@ class NickHandler(AttributeHandler): if is_match: break return raw_string - - -class NAttributeHandler(object): - """ - This stand-alone handler manages non-database saving. - It is similar to `AttributeHandler` and is used - by the `.ndb` handler in the same way as `.db` does - for the `AttributeHandler`. - """ - - def __init__(self, obj): - """ - Initialized on the object - """ - self._store = {} - self.obj = weakref.proxy(obj) - - def has(self, key): - """ - Check if object has this attribute or not. - - Args: - key (str): The Nattribute key to check. - - Returns: - has_nattribute (bool): If Nattribute is set or not. - - """ - return key in self._store - - def get(self, key): - """ - Get the named key value. - - Args: - key (str): The Nattribute key to get. - - Returns: - the value of the Nattribute. - - """ - return self._store.get(key, None) - - def add(self, key, value): - """ - Add new key and value. - - Args: - key (str): The name of Nattribute to add. - value (any): The value to store. - - """ - self._store[key] = value - - def remove(self, key): - """ - Remove Nattribute from storage. - - Args: - key (str): The name of the Nattribute to remove. - - """ - if key in self._store: - del self._store[key] - - def clear(self): - """ - Remove all NAttributes from handler. - - """ - self._store = {} - - def all(self, return_tuples=False): - """ - List the contents of the handler. - - Args: - return_tuples (bool, optional): Defines if the Nattributes - are returns as a list of keys or as a list of `(key, value)`. - - Returns: - nattributes (list): A list of keys `[key, key, ...]` or a - list of tuples `[(key, value), ...]` depending on the - setting of `return_tuples`. - - """ - if return_tuples: - return [(key, value) for (key, value) in self._store.items() if not key.startswith("_")] - return [key for key in self._store if not key.startswith("_")] diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index c6869b339f..84ec1dd408 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -36,7 +36,8 @@ from django.urls import reverse from django.utils.encoding import smart_str from django.utils.text import slugify -from evennia.typeclasses.attributes import Attribute, AttributeHandler, NAttributeHandler +from evennia.typeclasses.attributes import Attribute, AttributeHandler, ModelAttributeBackend, InMemoryAttributeBackend +from evennia.typeclasses.attributes import DbHolder from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler from evennia.utils.idmapper.models import SharedMemoryModel, SharedMemoryModelBase @@ -121,33 +122,6 @@ class TypeclassBase(SharedMemoryModelBase): signals.pre_delete.connect(remove_attributes_on_delete, sender=new_class) return new_class - -class DbHolder(object): - "Holder for allowing property access of attributes" - - def __init__(self, obj, name, manager_name="attributes"): - _SA(self, name, _GA(obj, manager_name)) - _SA(self, "name", name) - - def __getattribute__(self, attrname): - if attrname == "all": - # we allow to overload our default .all - attr = _GA(self, _GA(self, "name")).get("all") - return attr if attr else _GA(self, "all") - return _GA(self, _GA(self, "name")).get(attrname) - - def __setattr__(self, attrname, value): - _GA(self, _GA(self, "name")).add(attrname, value) - - def __delattr__(self, attrname): - _GA(self, _GA(self, "name")).remove(attrname) - - def get_all(self): - return _GA(self, _GA(self, "name")).all() - - all = property(get_all) - - # # Main TypedObject abstraction # @@ -301,7 +275,7 @@ class TypedObject(SharedMemoryModel): # initialize all handlers in a lazy fashion @lazy_property def attributes(self): - return AttributeHandler(self) + return AttributeHandler(self, ModelAttributeBackend) @lazy_property def locks(self): @@ -321,7 +295,7 @@ class TypedObject(SharedMemoryModel): @lazy_property def nattributes(self): - return NAttributeHandler(self) + return AttributeHandler(self, InMemoryAttributeBackend) class Meta(object): """ diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py index 8632db5fc6..eb2f8e45e1 100644 --- a/evennia/typeclasses/tests.py +++ b/evennia/typeclasses/tests.py @@ -26,12 +26,12 @@ class TestAttributes(EvenniaTest): key = "testattr" value = "test attr value " self.obj1.attributes.add(key, value) - self.assertFalse(self.obj1.attributes._cache) + self.assertFalse(self.obj1.attributes.backend._cache) self.assertEqual(self.obj1.attributes.get(key), value) self.obj1.db.testattr = value self.assertEqual(self.obj1.db.testattr, value) - self.assertFalse(self.obj1.attributes._cache) + self.assertFalse(self.obj1.attributes.backend._cache) def test_weird_text_save(self): "test 'weird' text type (different in py2 vs py3)"