From bb030868efc319b60c97b9583550c37231cde4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Sun, 24 Nov 2019 22:55:14 -0500 Subject: [PATCH 1/8] Added entity caching. --- .../packages/source-python/entities/_base.py | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/addons/source-python/packages/source-python/entities/_base.py b/addons/source-python/packages/source-python/entities/_base.py index 1b65d91b9..6d1b5ecad 100644 --- a/addons/source-python/packages/source-python/entities/_base.py +++ b/addons/source-python/packages/source-python/entities/_base.py @@ -10,6 +10,10 @@ from collections import defaultdict # Contextlib from contextlib import suppress +# FuncTools +from functools import update_wrapper +# WeakRef +from weakref import WeakKeyDictionary # Source.Python Imports # Core @@ -70,7 +74,62 @@ # ============================================================================= # >> CLASSES # ============================================================================= -class Entity(BaseEntity): +class EntityCaching(BaseEntity.__class__): + """Metaclass used to cache entity instances.""" + + # Internal cache dictionary + # Release the cached instances if the stored classes are garbage collected + # E.g. Subclasses from plugins that are unloaded, etc. + cache = WeakKeyDictionary() + + def __new__(metaclass, classname, bases, attributes, caching=True): + """Creates a new entity class that will cache its instances.""" + cls = super().__new__( + metaclass, classname, bases, attributes + ) + + # New instances of this class will be cached in that dictionary + if caching: + metaclass.cache[cls] = {} + + def __init__(self, index): + # Does nothing, so we don't re-initialize cached instances, etc. + return + update_wrapper(__init__, cls.__init__) + cls.__init__ = __init__ + + def __new__(self, index): + # Let's first lookup for a cached instance + caching = self in metaclass.cache + if caching: + cache = metaclass.cache[self] + if index in cache: + return cache[index] + + # Nothing in cache, let's create a new instance + obj = cls.__base__.__new__(self, index) + + # Successively initialize the base classes + # This is required, because Boost's construction happens there + for base in (self,) + bases: + getattr( + base.__init__, '__wrapped__', base.__init__)( + obj, index + ) + + # Let's cache the new instance we just created + if caching: + cache[index] = obj + + # We are done, let's return the instance + return obj + update_wrapper(__new__, cls.__new__) + cls.__new__ = __new__ + + return cls + + +class Entity(BaseEntity, metaclass=EntityCaching): """Class used to interact directly with entities. Beside the standard way of doing stuff via methods and properties this @@ -1031,6 +1090,10 @@ def _on_entity_deleted(base_entity): # Get the index of the entity... index = base_entity.index + # Cleanup the internal cache + for objects in EntityCaching.cache.values(): + objects.pop(index, None) + # Was no delay registered for this entity? if index not in _entity_delays: return From d8cef55653f52e9500faa18d39ec0c8d8c20b471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Sun, 24 Nov 2019 23:37:24 -0500 Subject: [PATCH 2/8] Made the cache lookup slightly faster. --- .../packages/source-python/entities/_base.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/addons/source-python/packages/source-python/entities/_base.py b/addons/source-python/packages/source-python/entities/_base.py index 6d1b5ecad..581867e80 100644 --- a/addons/source-python/packages/source-python/entities/_base.py +++ b/addons/source-python/packages/source-python/entities/_base.py @@ -100,11 +100,11 @@ def __init__(self, index): def __new__(self, index): # Let's first lookup for a cached instance - caching = self in metaclass.cache - if caching: - cache = metaclass.cache[self] - if index in cache: - return cache[index] + cache = metaclass.cache.get(self, None) + if cache is not None: + obj = cache.get(index, None) + if obj is not None: + return obj # Nothing in cache, let's create a new instance obj = cls.__base__.__new__(self, index) @@ -118,7 +118,7 @@ def __new__(self, index): ) # Let's cache the new instance we just created - if caching: + if cache is not None: cache[index] = obj # We are done, let's return the instance From 285287f9998cdd0ba5e5c517a37748c1bf0ebc6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Sun, 24 Nov 2019 23:52:53 -0500 Subject: [PATCH 3/8] Fixed a rare case where a base class initialisation would be skipped. --- addons/source-python/packages/source-python/entities/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/source-python/packages/source-python/entities/_base.py b/addons/source-python/packages/source-python/entities/_base.py index 581867e80..ecfdf80f6 100644 --- a/addons/source-python/packages/source-python/entities/_base.py +++ b/addons/source-python/packages/source-python/entities/_base.py @@ -111,7 +111,7 @@ def __new__(self, index): # Successively initialize the base classes # This is required, because Boost's construction happens there - for base in (self,) + bases: + for base in dict.fromkeys((self,) + bases + self.__bases__): getattr( base.__init__, '__wrapped__', base.__init__)( obj, index From 2aab39bad1457c94dfabec8a22e64df30f0e0104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Mon, 25 Nov 2019 00:04:31 -0500 Subject: [PATCH 4/8] No need to wrap the classes that are not using the cache. --- .../source-python/packages/source-python/entities/_base.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/addons/source-python/packages/source-python/entities/_base.py b/addons/source-python/packages/source-python/entities/_base.py index ecfdf80f6..4ab97d5dc 100644 --- a/addons/source-python/packages/source-python/entities/_base.py +++ b/addons/source-python/packages/source-python/entities/_base.py @@ -87,10 +87,13 @@ def __new__(metaclass, classname, bases, attributes, caching=True): cls = super().__new__( metaclass, classname, bases, attributes ) + # No need to override the instance creation/initialisation if we + # are not caching them + if not caching: + return cls # New instances of this class will be cached in that dictionary - if caching: - metaclass.cache[cls] = {} + metaclass.cache[cls] = {} def __init__(self, index): # Does nothing, so we don't re-initialize cached instances, etc. From 58fec31bcd445f1d42e39efa9912ff42cec38739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Mon, 25 Nov 2019 07:17:20 -0500 Subject: [PATCH 5/8] Greatly improved the caching implementation. --- .../packages/source-python/entities/_base.py | 93 +++++++++---------- 1 file changed, 42 insertions(+), 51 deletions(-) diff --git a/addons/source-python/packages/source-python/entities/_base.py b/addons/source-python/packages/source-python/entities/_base.py index 4ab97d5dc..f04d8efd9 100644 --- a/addons/source-python/packages/source-python/entities/_base.py +++ b/addons/source-python/packages/source-python/entities/_base.py @@ -10,10 +10,8 @@ from collections import defaultdict # Contextlib from contextlib import suppress -# FuncTools -from functools import update_wrapper # WeakRef -from weakref import WeakKeyDictionary +from weakref import finalize # Source.Python Imports # Core @@ -49,6 +47,7 @@ from filters.weapons import WeaponClassIter # Listeners from listeners import OnEntityDeleted +from listeners import on_entity_deleted_listener_manager from listeners.tick import Delay # Mathlib from mathlib import NULL_VECTOR @@ -77,59 +76,55 @@ class EntityCaching(BaseEntity.__class__): """Metaclass used to cache entity instances.""" - # Internal cache dictionary - # Release the cached instances if the stored classes are garbage collected - # E.g. Subclasses from plugins that are unloaded, etc. - cache = WeakKeyDictionary() - - def __new__(metaclass, classname, bases, attributes, caching=True): - """Creates a new entity class that will cache its instances.""" - cls = super().__new__( - metaclass, classname, bases, attributes + def __init__(cls, classname, bases, attributes): + """Initializes the class.""" + # New instances of this class will be cached in that dictionary + cls._cache = {} + on_entity_deleted_listener_manager.register_listener( + cls.on_entity_deleted + ) + # Unregister the listener when the class is being garbage collected + finalize( + cls, + on_entity_deleted_listener_manager.unregister_listener, + cls.on_entity_deleted ) - # No need to override the instance creation/initialisation if we - # are not caching them - if not caching: - return cls - # New instances of this class will be cached in that dictionary - metaclass.cache[cls] = {} + def __call__(cls, index, caching=True): + """Called when a new instance of this class is requested. - def __init__(self, index): - # Does nothing, so we don't re-initialize cached instances, etc. - return - update_wrapper(__init__, cls.__init__) - cls.__init__ = __init__ + :param int index: + The index of the entity instance requested. + :param bool caching: + Whether to lookup the cache for an existing instance or not. + """ + # Let's first lookup for a cached instance + if caching: + obj = cls.cache.get(index, None) + if obj is not None: + return obj - def __new__(self, index): - # Let's first lookup for a cached instance - cache = metaclass.cache.get(self, None) - if cache is not None: - obj = cache.get(index, None) - if obj is not None: - return obj + # Nothing in cache, let's create a new instance + obj = super().__call__(index) - # Nothing in cache, let's create a new instance - obj = cls.__base__.__new__(self, index) + # Let's cache the new instance we just created + if caching: + cls.cache[index] = obj - # Successively initialize the base classes - # This is required, because Boost's construction happens there - for base in dict.fromkeys((self,) + bases + self.__bases__): - getattr( - base.__init__, '__wrapped__', base.__init__)( - obj, index - ) + # We are done, let's return the instance + return obj - # Let's cache the new instance we just created - if cache is not None: - cache[index] = obj + @property + def cache(self): + return self._cache - # We are done, let's return the instance - return obj - update_wrapper(__new__, cls.__new__) - cls.__new__ = __new__ + def on_entity_deleted(cls, base_entity): + """Called when an entity is deleted.""" + if not base_entity.is_networked(): + return - return cls + # Cleanup the cache + cls.cache.pop(base_entity.index, None) class Entity(BaseEntity, metaclass=EntityCaching): @@ -1093,10 +1088,6 @@ def _on_entity_deleted(base_entity): # Get the index of the entity... index = base_entity.index - # Cleanup the internal cache - for objects in EntityCaching.cache.values(): - objects.pop(index, None) - # Was no delay registered for this entity? if index not in _entity_delays: return From d7cc213280b6520c40ed6ff2bbbf1752fe5ce7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Mon, 25 Nov 2019 07:43:38 -0500 Subject: [PATCH 6/8] Updated docstrings, etc. --- .../packages/source-python/entities/_base.py | 17 +++++++++++------ .../packages/source-python/players/_base.py | 4 +++- .../packages/source-python/weapons/_base.py | 4 +++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/addons/source-python/packages/source-python/entities/_base.py b/addons/source-python/packages/source-python/entities/_base.py index f04d8efd9..7996f5278 100644 --- a/addons/source-python/packages/source-python/entities/_base.py +++ b/addons/source-python/packages/source-python/entities/_base.py @@ -73,21 +73,24 @@ # ============================================================================= # >> CLASSES # ============================================================================= -class EntityCaching(BaseEntity.__class__): +class _EntityCaching(BaseEntity.__class__): """Metaclass used to cache entity instances.""" def __init__(cls, classname, bases, attributes): """Initializes the class.""" # New instances of this class will be cached in that dictionary cls._cache = {} + + # Listen for entity deletions for cleanup purposes on_entity_deleted_listener_manager.register_listener( - cls.on_entity_deleted + cls._on_entity_deleted ) + # Unregister the listener when the class is being garbage collected finalize( cls, on_entity_deleted_listener_manager.unregister_listener, - cls.on_entity_deleted + cls._on_entity_deleted ) def __call__(cls, index, caching=True): @@ -118,7 +121,7 @@ def __call__(cls, index, caching=True): def cache(self): return self._cache - def on_entity_deleted(cls, base_entity): + def _on_entity_deleted(cls, base_entity): """Called when an entity is deleted.""" if not base_entity.is_networked(): return @@ -127,7 +130,7 @@ def on_entity_deleted(cls, base_entity): cls.cache.pop(base_entity.index, None) -class Entity(BaseEntity, metaclass=EntityCaching): +class Entity(BaseEntity, metaclass=_EntityCaching): """Class used to interact directly with entities. Beside the standard way of doing stuff via methods and properties this @@ -141,11 +144,13 @@ class also provides dynamic attributes that depend on the entity that is 4. :attr:`keyvalues` """ - def __init__(self, index): + def __init__(self, index, caching=True): """Initialize the Entity instance. :param int index: The entity index to wrap. + :param bool caching: + Whether to lookup the cache for an existing instance or not. """ # Initialize the object super().__init__(index) diff --git a/addons/source-python/packages/source-python/players/_base.py b/addons/source-python/packages/source-python/players/_base.py index 7a13dba46..3ef264893 100644 --- a/addons/source-python/packages/source-python/players/_base.py +++ b/addons/source-python/packages/source-python/players/_base.py @@ -77,11 +77,13 @@ class Player(PlayerMixin, Entity): """Class used to interact directly with players.""" - def __init__(self, index): + def __init__(self, index, caching=True): """Initialize the object. :param int index: A valid player index. + :param bool caching: + Whether to lookup the cache for an existing instance or not. :raise ValueError: Raised if the index is invalid. """ diff --git a/addons/source-python/packages/source-python/weapons/_base.py b/addons/source-python/packages/source-python/weapons/_base.py index bced33209..443939f10 100644 --- a/addons/source-python/packages/source-python/weapons/_base.py +++ b/addons/source-python/packages/source-python/weapons/_base.py @@ -27,11 +27,13 @@ class Weapon(WeaponMixin, Entity): """Allows easy usage of the weapon's attributes.""" - def __init__(self, index): + def __init__(self, index, caching=True): """Initialize the object. :param int index: A valid weapon index. + :param bool caching: + Whether to lookup the cache for an existing instance or not. :raise ValueError: Raised if the index is invalid. """ From d9f87dc30356ab5fda3ecc9a863ddd6d90e41411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Mon, 25 Nov 2019 08:00:49 -0500 Subject: [PATCH 7/8] Moved the "cache" property to the Entity class so that it gets added to the wiki. --- .../packages/source-python/entities/_base.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/addons/source-python/packages/source-python/entities/_base.py b/addons/source-python/packages/source-python/entities/_base.py index 7996f5278..6ebf85ae7 100644 --- a/addons/source-python/packages/source-python/entities/_base.py +++ b/addons/source-python/packages/source-python/entities/_base.py @@ -103,7 +103,7 @@ def __call__(cls, index, caching=True): """ # Let's first lookup for a cached instance if caching: - obj = cls.cache.get(index, None) + obj = cls._cache.get(index, None) if obj is not None: return obj @@ -112,22 +112,18 @@ def __call__(cls, index, caching=True): # Let's cache the new instance we just created if caching: - cls.cache[index] = obj + cls._cache[index] = obj # We are done, let's return the instance return obj - @property - def cache(self): - return self._cache - def _on_entity_deleted(cls, base_entity): """Called when an entity is deleted.""" if not base_entity.is_networked(): return # Cleanup the cache - cls.cache.pop(base_entity.index, None) + cls._cache.pop(base_entity.index, None) class Entity(BaseEntity, metaclass=_EntityCaching): @@ -289,6 +285,14 @@ def _obj(cls, ptr): """Return an entity instance of the given pointer.""" return cls(index_from_pointer(ptr)) + @property + def cache(self): + """Returns the cached instances of this class. + + :rtype: dict + """ + return self._cache + @property def index(self): """Return the entity's index. From 52075fc2825340bcd8f64ea87f9fd55f9299037f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Mon, 25 Nov 2019 19:15:36 -0500 Subject: [PATCH 8/8] Moved the "cache" property back to the caching class so that it can be accessed directly from the class, instead of an instance. Added a note about it into Entity's docstring. Fixed the entity deletion listeners never being unregistered for subclass from plugins that are unloaded (the callbacks themselves were holding a reference of their class, preventing their garbage collection. Could use a proxy, but well; re-using our existing listener is much better anyway.). --- .../packages/source-python/entities/_base.py | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/addons/source-python/packages/source-python/entities/_base.py b/addons/source-python/packages/source-python/entities/_base.py index 6ebf85ae7..4115a96a1 100644 --- a/addons/source-python/packages/source-python/entities/_base.py +++ b/addons/source-python/packages/source-python/entities/_base.py @@ -11,7 +11,7 @@ # Contextlib from contextlib import suppress # WeakRef -from weakref import finalize +from weakref import WeakSet # Source.Python Imports # Core @@ -69,6 +69,9 @@ # Get a dictionary to store the delays _entity_delays = defaultdict(set) +# Get a set to store the registered entity classes +_entity_classes = WeakSet() + # ============================================================================= # >> CLASSES @@ -81,17 +84,8 @@ def __init__(cls, classname, bases, attributes): # New instances of this class will be cached in that dictionary cls._cache = {} - # Listen for entity deletions for cleanup purposes - on_entity_deleted_listener_manager.register_listener( - cls._on_entity_deleted - ) - - # Unregister the listener when the class is being garbage collected - finalize( - cls, - on_entity_deleted_listener_manager.unregister_listener, - cls._on_entity_deleted - ) + # Add the class to the registered classes + _entity_classes.add(cls) def __call__(cls, index, caching=True): """Called when a new instance of this class is requested. @@ -117,13 +111,13 @@ def __call__(cls, index, caching=True): # We are done, let's return the instance return obj - def _on_entity_deleted(cls, base_entity): - """Called when an entity is deleted.""" - if not base_entity.is_networked(): - return + @property + def cache(cls): + """Returns the cached instances of this class. - # Cleanup the cache - cls._cache.pop(base_entity.index, None) + :rtype: dict + """ + return cls._cache class Entity(BaseEntity, metaclass=_EntityCaching): @@ -138,6 +132,14 @@ class also provides dynamic attributes that depend on the entity that is 2. :attr:`inputs` 3. :attr:`outputs` 4. :attr:`keyvalues` + + :var cache: + A read-only attribute that returns a dictionary containing the cached + instances of this class. + + .. note:: + This is not an instance property, so it can only be + accessed through the class itself. """ def __init__(self, index, caching=True): @@ -285,14 +287,6 @@ def _obj(cls, ptr): """Return an entity instance of the given pointer.""" return cls(index_from_pointer(ptr)) - @property - def cache(self): - """Returns the cached instances of this class. - - :rtype: dict - """ - return self._cache - @property def index(self): """Return the entity's index. @@ -1097,6 +1091,10 @@ def _on_entity_deleted(base_entity): # Get the index of the entity... index = base_entity.index + # Cleanup the cache + for cls in _entity_classes: + cls.cache.pop(index, None) + # Was no delay registered for this entity? if index not in _entity_delays: return