Components Squash (#1)

Adds the first part of Components Contrib and related docs
This commit is contained in:
ChrisLR 2022-03-06 10:34:04 -05:00 committed by GitHub
parent fdc0ad2c88
commit 8b5d978094
6 changed files with 749 additions and 0 deletions

View file

@ -0,0 +1,170 @@
# Components
_Contrib by ChrisLR 2021_
# The Components Contrib
This contrib introduces Components and Composition to Evennia.
Each 'Component' class represents a feature that will be 'enabled' on a typeclass instance.
You can register these components on an entire typeclass or a single object at runtime.
It supports both persisted attributes and in-memory attributes by using Evennia's AttributeHandler.
# Pros
- You can reuse a feature across multiple typeclasses without inheritance
- You can cleanly organize each feature into a self-contained class.
- You can check if your object supports a feature without checking its instance.
# Cons
- It introduces additional complexity.
- A host typeclass instance is required.
# How to install
To enable component support for a typeclass,
import and inherit the ComponentHolderMixin, similar to this
```python
from evennia.contrib.base_systems.components import ComponentHolderMixin
class Character(ComponentHolderMixin, DefaultCharacter):
# ...
```
Components need to inherit the Component class directly and require a name.
```python
from evennia.contrib.components import Component
class Health(Component):
name = "health"
```
Components may define DBFields or NDBFields at the class level.
DBField will store its values in the host's DB with a prefixed key.
NDBField will store its values in the host's NDB and will not persist.
The key used will be 'component_name__field_name'.
They use AttributeProperty under the hood.
Example:
```python
from evennia.contrib.base_systems.components import Component, DBField
class Health(Component):
health = DBField(default=1)
```
Note that default is optional and will default to None
Each typeclass using the ComponentHolderMixin can declare its components
in the class via the ComponentProperty.
These are components that will always be present in a typeclass.
You can also pass kwargs to override the default values
Example
```python
from evennia.contrib.base_systems.components import ComponentHolderMixin
class Character(ComponentHolderMixin, DefaultCharacter):
health = ComponentProperty("health", hp=10, max_hp=50)
```
You can then use character.components.health to access it.
The shorter form character.cmp.health also exists.
character.health would also be accessible but only for typeclasses that have
this component defined on the class.
Alternatively you can add those components at runtime.
You will have to access those via the component handler.
Example
```python
character = self
vampirism = components.Vampirism.create(character)
character.components.add(vampirism)
...
vampirism_from_elsewhere = character.components.get("vampirism")
```
Keep in mind that all components must be imported to be visible in the listing.
As such, I recommend regrouping them in a package.
You can then import all your components in that package's __init__
Because of how Evennia import typeclasses and the behavior of python imports
I recommend placing the components package inside the typeclass package.
In other words, create a folder named components inside your typeclass folder.
Then, inside the 'typeclasses/__init__.py' file add the import to the folder, like
```python
from typeclasses import components
```
This ensures that the components package will be imported when the typeclasses are imported.
You will also need to import each components inside the package's own 'typeclasses/components/__init__.py' file.
You only need to import each module/file from there but importing the right class is a good practice.
```python
from typeclasses.components.health import Health
```
```python
from typeclasses.components import health
```
Both of the above examples will work.
# Full Example
```python
from evennia.contrib.base_systems import components
# This is the Component class
class Health(components.Component):
name = "health"
# Stores the current and max values as Attributes on the host, defaulting to 100
current = components.DBField(default=100)
max = components.DBField(default=100)
def damage(self, value):
if self.current <= 0:
return
self.current -= value
if self.current > 0:
return
self.current = 0
self.on_death()
def heal(self, value):
hp = self.current
hp += value
if hp >= self.max_hp:
hp = self.max_hp
self.current = hp
@property
def is_dead(self):
return self.current <= 0
def on_death(self):
# Behavior is defined on the typeclass
self.host.on_death()
# This is how the Character inherits the mixin and registers the component 'health'
class Character(ComponentHolderMixin, DefaultCharacter):
health = ComponentProperty("health")
# This is an example of a command that checks for the component
class Attack(Command):
key = "attack"
aliases = ('melee', 'hit')
def at_pre_cmd(self):
caller = self.caller
targets = self.caller.search(args, quiet=True)
valid_target = None
for target in targets:
# Attempt to retrieve the component, None is obtained if it does not exist.
if target.components.health:
valid_target = target
if not valid_target:
caller.msg("You can't attack that!")
return True
```

View file

@ -0,0 +1,24 @@
"""
Components - ChrisLR 2022
This is a basic Component System.
It allows you to use components on typeclasses using a simple syntax.
This helps writing isolated code and reusing it over multiple objects.
See the docs for more information.
"""
from evennia.contrib.base_systems.components.component import Component
from evennia.contrib.base_systems.components.dbfield import DBField, NDBField
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

View file

@ -0,0 +1,149 @@
"""
Components - ChrisLR 2022
This file contains the base class to inherit for creating new components.
"""
import itertools
class Component:
"""
This is the base class for components.
Any component must inherit from this class to be considered for usage.
Each Component must supply the name, it is used as a slot name but also part of the attribute key.
"""
name = ""
def __init__(self, host=None):
assert self.name, "All Components must have a Name"
self.host = host
@classmethod
def default_create(cls, host):
"""
This is called when the host is created
and should return the base initialized state of a component.
Args:
host (object): The host typeclass instance
Returns:
Component: The created instance of the component
"""
new = cls(host)
return new
@classmethod
def create(cls, host, **kwargs):
"""
This is the method to call when supplying kwargs to initialize a component.
Args:
host (object): The host typeclass instance
**kwargs: Key-Value of default values to replace.
To persist the value, the key must correspond to a DBField.
Returns:
Component: The created instance of the component
"""
new = cls.default_create(host)
for key, value in kwargs.items():
setattr(new, key, value)
return new
def cleanup(self):
"""
This deletes all component attributes from the host's db
"""
for attribute in self._all_db_field_names:
delattr(self, attribute)
@classmethod
def load(cls, host):
"""
Loads a component instance
This is called whenever a component is loaded (ex: Server Restart)
Args:
host (object): The host typeclass instance
Returns:
Component: The loaded instance of the component
"""
return cls(host)
def at_added(self, host):
"""
This is the method called when a component is registered on a host.
Args:
host (object): The host typeclass instance
"""
if self.host:
if self.host == host:
return
else:
raise ComponentRegisterError("Components must not register twice!")
self.host = host
def at_removed(self, host):
"""
This is the method called when a component is removed from a host.
Args:
host (object): The host typeclass instance
"""
if host != self.host:
raise ComponentRegisterError("Component attempted to remove from the wrong host.")
self.host = None
@property
def attributes(self):
"""
Shortcut property returning the host's AttributeHandler.
Returns:
AttributeHandler: The Host's AttributeHandler
"""
return self.host.attributes
@property
def nattributes(self):
"""
Shortcut property returning the host's In-Memory AttributeHandler (Non Persisted).
Returns:
AttributeHandler: The Host's In-Memory AttributeHandler
"""
return self.host.nattributes
@property
def _all_db_field_names(self):
return itertools.chain(self.db_field_names, self.ndb_field_names)
@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()
class ComponentRegisterError(Exception):
pass

View file

@ -0,0 +1,54 @@
"""
Components - ChrisLR 2022
This file contains the Descriptors used to set Fields in Components
"""
from evennia.typeclasses.attributes import AttributeProperty, NAttributeProperty
class DBField(AttributeProperty):
"""
Component Attribute Descriptor.
Allows you to set attributes related to a component on the class.
It uses AttributeProperty under the hood but prefixes the key with the component name.
"""
def __set_name__(self, owner, name):
"""
Called when descriptor is first assigned to the class.
Args:
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
class NDBField(NAttributeProperty):
"""
Component In-Memory Attribute Descriptor.
Allows you to set in-memory attributes related to a component on the class.
It uses NAttributeProperty under the hood but prefixes the key with the component name.
"""
def __set_name__(self, owner, name):
"""
Called when descriptor is first assigned to the class.
Args:
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

View file

@ -0,0 +1,243 @@
"""
Components - ChrisLR 2022
This file contains the classes that allow a typeclass to use components.
"""
from evennia.contrib.base_systems import components
class ComponentProperty:
"""
This allows you to register a component on a typeclass.
Components registered with this property are automatically added
to any instance of this typeclass.
Defaults can be overridden for this typeclass by passing kwargs
"""
def __init__(self, component_name, **kwargs):
"""
Initializes the descriptor
Args:
component_name (str): The name of the component
**kwargs (any): Key=Values overriding default values of the component
"""
self.component_name = component_name
self.values = kwargs
def __get__(self, instance, owner):
component = instance.components.get(self.component_name)
return component
def __set__(self, instance, value):
raise Exception("Cannot set a class property")
def __set_name__(self, owner, name):
class_components = getattr(owner, "_class_components", None)
if not class_components:
class_components = []
setattr(owner, "_class_components", class_components)
class_components.append((self.component_name, self.values))
class ComponentHandler:
"""
This is the handler that will be added to any typeclass that inherits from ComponentHolder.
It lets you add or remove components and will load components as needed.
It stores the list of registered components on the host .db with component_names as key.
"""
def __init__(self, host):
self.host = host
self._loaded_components = {}
def add(self, 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.
It will also call the component's 'at_added' method, passing its host.
Args:
component (object): The 'loaded' component instance to add.
"""
self._set_component(component)
self.db_names.append(component.name)
component.at_added(self.host)
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 cache this new component and add it to its list.
It will also call the component's 'at_added' method, passing its host.
Args:
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.")
new_component = component.default_create(self.host)
self._set_component(new_component)
self.db_names.append(name)
new_component.at_added(self.host)
def remove(self, component):
"""
Method to remove a component instance from a host.
It removes the component from the cache and listing.
It will call the component's 'at_removed' method.
Args:
component (object): The component instance to remove.
"""
component_name = component.name
if component_name in self._loaded_components:
component.at_removed(self.host)
self.db_names.remove(component_name)
del self._loaded_components[component_name]
else:
message = f"Cannot remove {component_name} from {self.host.name} as it is not registered."
raise ComponentIsNotRegistered(message)
def remove_by_name(self, name):
"""
Method to remove a component instance from a host.
It removes the component from the cache and listing.
It will call the component's 'at_removed' method.
Args:
name (str): The name of the component to remove.
"""
instance = self.get(name)
if not instance:
message = f"Cannot remove {name} from {self.host.name} as it is not registered."
raise ComponentIsNotRegistered(message)
instance.at_removed(self.host)
self.db_names.remove(name)
del self._loaded_components[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.
"""
return self._loaded_components.get(name)
def has(self, name):
"""
Method to check if a component is registered and ready.
Args:
name (str): The name of the component.
"""
return name in self._loaded_components
def initialize(self):
"""
Method that loads and caches each component currently registered on the host.
It retrieves the names from the registered listing and calls 'load' on each
prototype class that can be found from this listing.
"""
component_names = self.db_names
if not component_names:
return
for component_name in component_names:
component = components.get_component_class(component_name)
if component:
component_instance = component.load(self.host)
self._set_component(component_instance)
else:
message = f"Could not initialize runtime component {component_name} of {self.host.name}"
raise ComponentDoesNotExist(message)
def _set_component(self, component):
self._loaded_components[component.name] = component
@property
def db_names(self):
"""
Property shortcut to retrieve the registered component names
Returns:
component_names (iterable): The name of each component that is registered
"""
return self.host.attributes.get("component_names")
def __getattr__(self, name):
return self.get(name)
class ComponentHolderMixin(object):
"""
Mixin to add component support to a typeclass
Components are set on objects using the component.name as an object attribute.
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.
"""
super(ComponentHolderMixin, self).at_init()
setattr(self, "_component_handler", ComponentHandler(self))
self.components.initialize()
def basetype_setup(self):
"""
Method that initializes the ComponentHandler, creates and registers all
components that were set on the typeclass using ComponentProperty.
"""
super().basetype_setup()
component_names = []
setattr(self, "_component_handler", ComponentHandler(self))
class_components = getattr(self, "_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.db.component_names = component_names
@property
def components(self) -> ComponentHandler:
"""
Property getter to retrieve the component_handler.
Returns:
ComponentHandler: This Host's ComponentHandler
"""
return getattr(self, "_component_handler", None)
@property
def cmp(self) -> ComponentHandler:
"""
Shortcut Property getter to retrieve the component_handler.
Returns:
ComponentHandler: This Host's ComponentHandler
"""
return self.components
class ComponentDoesNotExist(Exception):
pass
class ComponentIsNotRegistered(Exception):
pass

View file

@ -0,0 +1,109 @@
from evennia.contrib.base_systems.components import Component, DBField
from evennia.contrib.base_systems.components.holder import ComponentProperty, ComponentHolderMixin
from evennia.objects.objects import DefaultCharacter
from evennia.utils.test_resources import EvenniaTest
class ComponentTestA(Component):
name = "test_a"
my_int = DBField(default=1)
my_list = DBField(default=[])
class ComponentTestB(Component):
name = "test_b"
my_int = DBField(default=1)
my_list = DBField(default=[])
class RuntimeComponentTestC(Component):
name = "test_c"
my_int = DBField(default=6)
my_dict = DBField(default={})
class CharacterWithComponents(ComponentHolderMixin, DefaultCharacter):
test_a = ComponentProperty("test_a")
test_b = ComponentProperty("test_b", my_int=3, my_list=[1, 2, 3])
class TestComponents(EvenniaTest):
character_typeclass = CharacterWithComponents
def test_character_has_class_components(self):
assert self.char1.test_a
assert self.char1.test_b
def test_character_instances_components_properly(self):
assert isinstance(self.char1.test_a, ComponentTestA)
assert isinstance(self.char1.test_b, ComponentTestB)
def test_character_assigns_default_value(self):
assert self.char1.test_a.my_int == 1
assert self.char1.test_a.my_list == []
def test_character_assigns_default_provided_values(self):
assert self.char1.test_b.my_int == 3
assert self.char1.test_b.my_list == [1, 2, 3]
def test_character_can_register_runtime_component(self):
rct = RuntimeComponentTestC.create(self.char1)
self.char1.components.add(rct)
test_c = self.char1.components.get('test_c')
assert test_c
assert test_c.my_int == 6
assert test_c.my_dict == {}
def test_handler_can_add_default_component(self):
self.char1.components.add_default("test_c")
test_c = self.char1.components.get("test_c")
assert test_c
assert test_c.my_int == 6
def test_handler_has_returns_true_for_any_components(self):
rct = RuntimeComponentTestC.create(self.char1)
handler = self.char1.components
handler.add(rct)
assert handler.has("test_a")
assert handler.has("test_b")
assert handler.has("test_c")
def test_can_remove_component(self):
rct = RuntimeComponentTestC.create(self.char1)
handler = self.char1.components
handler.add(rct)
handler.remove(rct)
assert handler.has("test_a")
assert handler.has("test_b")
assert not handler.has("test_c")
def test_can_remove_component_by_name(self):
rct = RuntimeComponentTestC.create(self.char1)
handler = self.char1.components
handler.add(rct)
handler.remove_by_name("test_c")
assert handler.has("test_a")
assert handler.has("test_b")
assert not handler.has("test_c")
def test_cannot_replace_component(self):
with self.assertRaises(Exception):
self.char1.test_a = None
def test_can_get_component(self):
rct = RuntimeComponentTestC.create(self.char1)
handler = self.char1.components
handler.add(rct)
assert handler.get("test_c") is rct
def test_can_access_component_regular_get(self):
assert self.char1.cmp.test_a is self.char1.components.get('test_a')
def test_returns_none_with_regular_get_when_no_attribute(self):
assert self.char1.cmp.does_not_exist is None