From 217bd711e773e09578569834ac1eec1f6a5b2006 Mon Sep 17 00:00:00 2001 From: ChrisLR Date: Thu, 14 Dec 2023 12:30:53 -0500 Subject: [PATCH] Refactor Component registering with cherry-picked additions --- .../base_systems/components/__init__.py | 23 +-- .../base_systems/components/component.py | 81 ++++---- .../base_systems/components/dbfield.py | 61 +++--- .../base_systems/components/exceptions.py | 10 + .../contrib/base_systems/components/holder.py | 178 ++++++++---------- .../base_systems/components/listing.py | 15 ++ 6 files changed, 194 insertions(+), 174 deletions(-) create mode 100644 evennia/contrib/base_systems/components/exceptions.py create mode 100644 evennia/contrib/base_systems/components/listing.py diff --git a/evennia/contrib/base_systems/components/__init__.py b/evennia/contrib/base_systems/components/__init__.py index 3e31b214da..eb927ad6d5 100644 --- a/evennia/contrib/base_systems/components/__init__.py +++ b/evennia/contrib/base_systems/components/__init__.py @@ -7,23 +7,16 @@ This helps writing isolated code and reusing it over multiple objects. See the docs for more information. """ - +from evennia.contrib.base_systems.components import exceptions +from evennia.contrib.base_systems.components.listing import COMPONENT_LISTING, get_component_class from evennia.contrib.base_systems.components.component import Component -from evennia.contrib.base_systems.components.dbfield import DBField, NDBField, TagField +from evennia.contrib.base_systems.components.dbfield import ( + DBField, + NDBField, + TagField +) + from evennia.contrib.base_systems.components.holder import ( ComponentHolderMixin, ComponentProperty, ) - - -def get_component_class(component_name): - subclasses = Component.__subclasses__() - component_class = next((sc for sc in subclasses if sc.name == component_name), None) - if component_class is None: - message = ( - f"Component named {component_name} has not been found. " - f"Make sure it has been imported before being used." - ) - raise Exception(message) - - return component_class diff --git a/evennia/contrib/base_systems/components/component.py b/evennia/contrib/base_systems/components/component.py index 4be5b86d15..dd2e7132fa 100644 --- a/evennia/contrib/base_systems/components/component.py +++ b/evennia/contrib/base_systems/components/component.py @@ -3,10 +3,32 @@ Components - ChrisLR 2022 This file contains the base class to inherit for creating new components. """ -import itertools + +from evennia.commands.cmdset import CmdSet +from evennia.contrib.base_systems.components import COMPONENT_LISTING, exceptions -class Component: +class BaseComponent(type): + @classmethod + def __new__(cls, *args): + new_type = super().__new__(*args) + if new_type.__base__ == object: + return new_type + + name = getattr(new_type, "name", None) + if not name: + raise ValueError(f"Component {new_type} requires a name.") + + if existing_type := COMPONENT_LISTING.get(name): + if not str(new_type) == str(existing_type): + raise ValueError(f"Component name {name} is a duplicate, must be unique.") + else: + COMPONENT_LISTING[name] = new_type + + return new_type + + +class Component(metaclass=BaseComponent): """ This is the base class for components. Any component must inherit from this class to be considered for usage. @@ -14,10 +36,17 @@ class Component: Each Component must supply the name, it is used as a slot name but also part of the attribute key. """ + __slots__ = ('host',) + name = "" + slot = None + + cmd_set: CmdSet = None + + _fields = {} def __init__(self, host=None): - assert self.name, "All Components must have a Name" + assert self.name, "All Components must have a name" self.host = host @classmethod @@ -61,8 +90,8 @@ class Component: """ This deletes all component attributes from the host's db """ - for attribute in self._all_db_field_names: - delattr(self, attribute) + for name in self._fields.keys(): + delattr(self, name) @classmethod def load(cls, host): @@ -88,12 +117,11 @@ class Component: host (object): The host typeclass instance """ + if self.host and self.host != host: + raise exceptions.InvalidComponentError("Components must not register twice!") - if self.host: - if self.host == host: - return - else: - raise ComponentRegisterError("Components must not register twice!") + if self.cmd_set: + self.host.cmdset.add(self.cmd_set) self.host = host @@ -106,7 +134,11 @@ class Component: """ if host != self.host: - raise ComponentRegisterError("Component attempted to remove from the wrong host.") + raise ValueError("Component attempted to remove from the wrong host.") + + if self.cmd_set: + self.host.cmdset.remove(self.cmd_set) + self.host = None @property @@ -131,25 +163,10 @@ class Component: """ return self.host.nattributes - @property - def _all_db_field_names(self): - return itertools.chain(self.db_field_names, self.ndb_field_names) + @classmethod + def add_field(cls, name, field): + cls._fields[name] = field - @property - def db_field_names(self): - db_fields = getattr(self, "_db_fields", {}) - return db_fields.keys() - - @property - def ndb_field_names(self): - ndb_fields = getattr(self, "_ndb_fields", {}) - return ndb_fields.keys() - - @property - def tag_field_names(self): - tag_fields = getattr(self, "_tag_fields", {}) - return tag_fields.keys() - - -class ComponentRegisterError(Exception): - pass + @classmethod + def get_fields(cls): + return tuple(cls._fields.values()) diff --git a/evennia/contrib/base_systems/components/dbfield.py b/evennia/contrib/base_systems/components/dbfield.py index fc3a104ffb..2129212456 100644 --- a/evennia/contrib/base_systems/components/dbfield.py +++ b/evennia/contrib/base_systems/components/dbfield.py @@ -3,8 +3,13 @@ Components - ChrisLR 2022 This file contains the Descriptors used to set Fields in Components """ +import typing + from evennia.typeclasses.attributes import AttributeProperty, NAttributeProperty +if typing.TYPE_CHECKING: + from evennia.contrib.base_systems.components import Component + class DBField(AttributeProperty): """ @@ -13,7 +18,10 @@ class DBField(AttributeProperty): It uses AttributeProperty under the hood but prefixes the key with the component name. """ - def __set_name__(self, owner, name): + def __init__(self, default=None, autocreate=False, **kwargs): + super().__init__(default=default, autocreate=autocreate, **kwargs) + + def __set_name__(self, owner: 'Component', name): """ Called when descriptor is first assigned to the class. @@ -21,13 +29,15 @@ class DBField(AttributeProperty): owner (object): The component classF on which this is set name (str): The name that was used to set the DBField. """ - key = f"{owner.name}::{name}" - self._key = key - db_fields = getattr(owner, "_db_fields", None) - if db_fields is None: - db_fields = {} - setattr(owner, "_db_fields", db_fields) - db_fields[name] = self + self._key = f"{owner.slot or owner.name}::{name}" + owner.add_field(name, self) + + def at_added(self, instance): + if self._autocreate: + self.__set__(instance, self._default) + + def at_removed(self, instance): + self.__delete__(instance) class NDBField(NAttributeProperty): @@ -37,7 +47,7 @@ class NDBField(NAttributeProperty): It uses NAttributeProperty under the hood but prefixes the key with the component name. """ - def __set_name__(self, owner, name): + def __set_name__(self, owner: 'Component', name): """ Called when descriptor is first assigned to the class. @@ -45,13 +55,15 @@ class NDBField(NAttributeProperty): owner (object): The component class on which this is set name (str): The name that was used to set the DBField. """ - key = f"{owner.name}::{name}" - self._key = key - ndb_fields = getattr(owner, "_ndb_fields", None) - if ndb_fields is None: - ndb_fields = {} - setattr(owner, "_ndb_fields", ndb_fields) - ndb_fields[name] = self + self._key = f"{owner.slot or owner.name}::{name}" + owner.add_field(name, self) + + def at_added(self, instance): + if self._autocreate: + self.__set__(instance, self._default) + + def at_removed(self, instance): + self.__delete__(instance) class TagField: @@ -70,17 +82,13 @@ class TagField: self._default = default self._enforce_single = enforce_single - def __set_name__(self, owner, name): + def __set_name__(self, owner: 'Component', name): """ Called when TagField is first assigned to the class. It is called with the component class and the name of the field. """ - self._category_key = f"{owner.name}::{name}" - tag_fields = getattr(owner, "_tag_fields", None) - if tag_fields is None: - tag_fields = {} - setattr(owner, "_tag_fields", tag_fields) - tag_fields[name] = self + self._category_key = f"{owner.slot or owner.name}::{name}" + owner.add_field(name, self) def __get__(self, instance, owner): """ @@ -114,3 +122,10 @@ class TagField: It is called with the component instance. """ instance.host.tags.clear(category=self._category_key) + + def at_added(self, instance): + if self._default: + self.__set__(instance, self._default) + + def at_removed(self, instance): + self.__delete__(instance) diff --git a/evennia/contrib/base_systems/components/exceptions.py b/evennia/contrib/base_systems/components/exceptions.py new file mode 100644 index 0000000000..7a2254c631 --- /dev/null +++ b/evennia/contrib/base_systems/components/exceptions.py @@ -0,0 +1,10 @@ +class InvalidComponentError(ValueError): + pass + + +class ComponentDoesNotExist(ValueError): + pass + + +class ComponentIsNotRegistered(ValueError): + pass diff --git a/evennia/contrib/base_systems/components/holder.py b/evennia/contrib/base_systems/components/holder.py index 7d19d344b0..f4f4f7f037 100644 --- a/evennia/contrib/base_systems/components/holder.py +++ b/evennia/contrib/base_systems/components/holder.py @@ -5,7 +5,7 @@ This file contains the classes that allow a typeclass to use components. """ from evennia.contrib.base_systems import components -from evennia.contrib.base_systems.components import signals +from evennia.contrib.base_systems.components import signals, exceptions class ComponentProperty: @@ -17,19 +17,19 @@ class ComponentProperty: Defaults can be overridden for this typeclass by passing kwargs """ - def __init__(self, component_name, **kwargs): + def __init__(self, name, **kwargs): """ Initializes the descriptor Args: - component_name (str): The name of the component + name (str): The name of the component **kwargs (any): Key=Values overriding default values of the component """ - self.component_name = component_name + self.name = name self.values = kwargs def __get__(self, instance, owner): - component = instance.components.get(self.component_name) + component = instance.components.get(self.name) return component def __set__(self, instance, value): @@ -37,13 +37,11 @@ class ComponentProperty: def __set_name__(self, owner, name): # Retrieve the class_components set on the direct class only - class_components = owner.__dict__.get("_class_components") + class_components = owner.__dict__.get("_class_components", []) if not class_components: - # Create a new list, including inherited class components - class_components = list(getattr(owner, "_class_components", [])) setattr(owner, "_class_components", class_components) - class_components.append((self.component_name, self.values)) + class_components.append((self.name, self.values)) class ComponentHandler: @@ -57,7 +55,7 @@ class ComponentHandler: self.host = host self._loaded_components = {} - def add(self, component): + def add(self, component: components.Component): """ Method to add a Component to a host. It caches the loaded component and appends its name to the host's component name list. @@ -67,16 +65,19 @@ class ComponentHandler: component (object): The 'loaded' component instance to add. """ + component_name = component.name + self.db_names.append(component_name) + self.host.tags.add(component_name, category="components") self._set_component(component) - self.db_names.append(component.name) - self._add_component_tags(component) + for field in component.get_fields(): + field.at_added(self.host) + component.at_added(self.host) - self.host.signals.add_object_listeners_and_responders(component) def add_default(self, name): """ Method to add a Component initialized to default values on a host. - It will retrieve the proper component and instanciate it with 'default_create'. + It will retrieve the proper component and instantiate it with 'default_create'. It will cache this new component and add it to its list. It will also call the component's 'at_added' method, passing its host. @@ -84,33 +85,11 @@ class ComponentHandler: name (str): The name of the component class to add. """ - component = components.get_component_class(name) - if not component: - raise ComponentDoesNotExist(f"Component {name} does not exist.") + component_class = components.get_component_class(name) + component_instance = component_class.default_create(self.host) + self.add(component_instance) - new_component = component.default_create(self.host) - self._set_component(new_component) - self.db_names.append(name) - self._add_component_tags(new_component) - new_component.at_added(self.host) - self.host.signals.add_object_listeners_and_responders(new_component) - - def _add_component_tags(self, component): - """ - Private method that adds the Tags set on a Component via TagFields - It will also add the name of the component so objects can be filtered - by the components the implement. - - Args: - component (object): The component instance that is added. - """ - self.host.tags.add(component.name, category="components") - for tag_field_name in component.tag_field_names: - default_tag = type(component).__dict__[tag_field_name]._default - if default_tag: - setattr(component, tag_field_name, default_tag) - - def remove(self, component): + def remove(self, component: components.Component): """ Method to remove a component instance from a host. It removes the component from the cache and listing. @@ -120,18 +99,26 @@ class ComponentHandler: component (object): The component instance to remove. """ - component_name = component.name - if component_name in self._loaded_components: - self._remove_component_tags(component) - component.at_removed(self.host) - self.db_names.remove(component_name) - self.host.signals.remove_object_listeners_and_responders(component) - del self._loaded_components[component_name] - else: + name = component.name + slot_name = component.slot or name + if not self.has(slot_name): message = ( - f"Cannot remove {component_name} from {self.host.name} as it is not registered." + f"Cannot remove {name} from {self.host.name} as it is not registered." ) - raise ComponentIsNotRegistered(message) + raise exceptions.ComponentIsNotRegistered(message) + + for field in component.get_fields(): + field.at_removed(self.host) + + component.at_removed(self.host) + if component.cmd_set: + self.host.cmdset.remove(component.cmd_set) + + self.host.tags.remove(component.name, category="components") + self.host.signals.remove_object_listeners_and_responders(component) + + self.db_names.remove(name) + del self._loaded_components[slot_name] def remove_by_name(self, name): """ @@ -140,49 +127,25 @@ class ComponentHandler: It will call the component's 'at_removed' method. Args: - name (str): The name of the component to remove. + name (str): The name of the component to remove or its slot. """ instance = self.get(name) if not instance: message = f"Cannot remove {name} from {self.host.name} as it is not registered." - raise ComponentIsNotRegistered(message) + raise exceptions.ComponentIsNotRegistered(message) - self._remove_component_tags(instance) - instance.at_removed(self.host) - self.host.signals.remove_object_listeners_and_responders(instance) - self.db_names.remove(name) + self.remove(instance) - del self._loaded_components[name] - - def _remove_component_tags(self, component): - """ - Private method that will remove the Tags set on a Component via TagFields - It will also remove the component name tag. - - Args: - component (object): The component instance that is removed. - """ - self.host.tags.remove(component.name, category="components") - for tag_field_name in component.tag_field_names: - delattr(component, tag_field_name) - - def get(self, name): - """ - Method to retrieve a cached Component instance by its name. - - Args: - name (str): The name of the component to retrieve. - - """ + def get(self, name: str) -> components.Component | None: return self._loaded_components.get(name) - def has(self, name): + def has(self, name: str) -> bool: """ Method to check if a component is registered and ready. Args: - name (str): The name of the component. + name (str): The name of the component or the slot. """ return name in self._loaded_components @@ -203,26 +166,35 @@ class ComponentHandler: if component: component_instance = component.load(self.host) self._set_component(component_instance) - self.host.signals.add_object_listeners_and_responders(component_instance) else: message = ( f"Could not initialize runtime component {component_name} of {self.host.name}" ) - raise ComponentDoesNotExist(message) + raise exceptions.ComponentDoesNotExist(message) def _set_component(self, component): - self._loaded_components[component.name] = component + """ + Sets the loaded component in this instance. + """ + slot_name = component.slot or component.name + self._loaded_components[slot_name] = component + self.host.signals.add_object_listeners_and_responders(component) @property def db_names(self): """ - Property shortcut to retrieve the registered component names + Property shortcut to retrieve the registered component keys Returns: component_names (iterable): The name of each component that is registered """ - return self.host.attributes.get("component_names") + names = self.host.attributes.get("component_names") + if names is None: + self.host.db.component_names = [] + names = self.host.db.component_names + + return names def __getattr__(self, name): return self.get(name) @@ -236,7 +208,6 @@ class ComponentHolderMixin: All registered components are initialized on the typeclass. They will be of None value if not present in the class components or runtime components. """ - def at_init(self): """ Method that initializes the ComponentHandler. @@ -261,28 +232,16 @@ class ComponentHolderMixin: components that were set on the typeclass using ComponentProperty. """ super().basetype_setup() - component_names = [] setattr(self, "_component_handler", ComponentHandler(self)) setattr(self, "_signal_handler", signals.SignalsHandler(self)) - class_components = getattr(self, "_class_components", ()) + class_components = self._get_class_components() for component_name, values in class_components: component_class = components.get_component_class(component_name) component = component_class.create(self, **values) - component_names.append(component_name) - self.components._loaded_components[component_name] = component - self.signals.add_object_listeners_and_responders(component) + self.components.add(component) - self.db.component_names = component_names self.signals.trigger("at_basetype_setup") - def basetype_posthook_setup(self): - """ - Method that add component related tags that were set using ComponentProperty. - """ - super().basetype_posthook_setup() - for component in self.components._loaded_components.values(): - self.components._add_component_tags(component) - @property def components(self) -> ComponentHandler: """ @@ -305,10 +264,21 @@ class ComponentHolderMixin: def signals(self) -> signals.SignalsHandler: return getattr(self, "_signal_handler", None) + def _get_class_components(self): + class_components = {} -class ComponentDoesNotExist(Exception): - pass + def base_type_iterator(): + base_stack = [type(self)] + while base_stack: + _base_type = base_stack.pop() + yield _base_type + base_stack.extend(_base_type.__bases__) + for base_type in base_type_iterator(): + base_class_components = getattr(base_type, "_class_components", ()) + class_components.update({cmp[0]: cmp[1] for cmp in base_class_components}) -class ComponentIsNotRegistered(Exception): - pass + instance_components = getattr(self, "_class_components", ()) + class_components.update({cmp[0]: cmp[1] for cmp in instance_components}) + + return tuple(class_components.items()) diff --git a/evennia/contrib/base_systems/components/listing.py b/evennia/contrib/base_systems/components/listing.py new file mode 100644 index 0000000000..4ca51842d5 --- /dev/null +++ b/evennia/contrib/base_systems/components/listing.py @@ -0,0 +1,15 @@ +from evennia.contrib.base_systems.components import exceptions + +COMPONENT_LISTING = {} + + +def get_component_class(name): + component_class = COMPONENT_LISTING.get(name) + if component_class is None: + message = ( + f"Component with name {name} has not been found. " + f"Make sure it has been imported before being used." + ) + raise exceptions.ComponentDoesNotExist(message) + + return component_class