Added basic signal system with corresponding tests

This commit is contained in:
ChrisLR 2022-04-12 18:32:55 -04:00
parent 8f1f604708
commit c22a08851f
3 changed files with 348 additions and 3 deletions

View file

@ -5,6 +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
class ComponentProperty:
@ -66,6 +67,7 @@ class ComponentHandler:
self.db_names.append(component.name)
self._add_component_tags(component)
component.at_added(self.host)
self.host.signals.add_object_listeners_and_responders(component)
def add_default(self, name):
"""
@ -87,6 +89,7 @@ class ComponentHandler:
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):
"""
@ -118,6 +121,7 @@ class ComponentHandler:
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:
message = f"Cannot remove {component_name} from {self.host.name} as it is not registered."
@ -140,6 +144,7 @@ class ComponentHandler:
self._remove_component_tags(instance)
instance.at_removed(self.host)
self.host.signals.remove_object_listeners_and_responders(instance)
self.db_names.remove(name)
del self._loaded_components[name]
@ -192,6 +197,7 @@ 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)
@ -214,7 +220,7 @@ class ComponentHandler:
return self.get(name)
class ComponentHolderMixin(object):
class ComponentHolderMixin:
"""
Mixin to add component support to a typeclass
@ -229,7 +235,17 @@ class ComponentHolderMixin(object):
"""
super(ComponentHolderMixin, self).at_init()
setattr(self, "_component_handler", ComponentHandler(self))
setattr(self, "_signal_handler", signals.SignalsHandler(self))
self.components.initialize()
self.signals.trigger("at_after_init")
def at_post_puppet(self, *args, **kwargs):
super().at_post_puppet(*args, **kwargs)
self.signals.trigger("at_post_puppet", *args, **kwargs)
def at_post_unpuppet(self, *args, **kwargs):
super().at_post_unpuppet(*args, **kwargs)
self.signals.trigger("at_post_unpuppet", *args, **kwargs)
def basetype_setup(self):
"""
@ -239,14 +255,17 @@ class ComponentHolderMixin(object):
super().basetype_setup()
component_names = []
setattr(self, "_component_handler", ComponentHandler(self))
setattr(self, "_signal_handler", signals.SignalsHandler(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.signals.add_object_listeners_and_responders(component)
self.db.component_names = component_names
self.signals.trigger("at_basetype_setup")
def basetype_posthook_setup(self):
"""
@ -274,6 +293,10 @@ class ComponentHolderMixin(object):
"""
return self.components
@property
def signals(self) -> signals.SignalsHandler:
return getattr(self, "_signal_handler", None)
class ComponentDoesNotExist(Exception):
pass

View file

@ -0,0 +1,112 @@
def as_listener(func=None, signal_name=None):
if not func and signal_name:
def wrapper(func):
func._listener_signal_name = signal_name
return func
return wrapper
signal_name = func.__name__
func._listener_signal_name = signal_name
return func
def as_responder(func=None, signal_name=None):
if not func and signal_name:
def wrapper(func):
func._responder_signal_name = signal_name
return func
return wrapper
signal_name = func.__name__
func._responder_signal_name = signal_name
return func
class SignalsHandler(object):
def __init__(self, host):
self.host = host
self.listeners = {}
self.responders = {}
self.add_object_listeners_and_responders(host)
def add_listener(self, signal_name, callback):
signal_listeners = self.listeners.setdefault(signal_name, [])
if callback not in signal_listeners:
signal_listeners.append(callback)
def add_responder(self, signal_name, callback):
signal_responders = self.responders.setdefault(signal_name, [])
if callback not in signal_responders:
signal_responders.append(callback)
def remove_listener(self, signal_name, callback):
signal_listeners = self.listeners.get(signal_name)
if not signal_listeners:
return
if callback in signal_listeners:
signal_listeners.remove(callback)
def remove_responder(self, signal_name, callback):
signal_responders = self.responders.get(signal_name)
if not signal_responders:
return
if callback in signal_responders:
signal_responders.remove(callback)
def trigger(self, signal_name, *args, **kwargs):
""" This method fires a signal but does not return anything """
callbacks = self.listeners.get(signal_name)
if not callbacks:
return
for callback in callbacks:
callback(*args, **kwargs)
def query(self, signal_name, *args, default=None, aggregate_func=None, **kwargs):
""" This method fires a signal query that retrieves values """
callbacks = self.responders.get(signal_name)
if not callbacks:
default = [] if default is None else default
if aggregate_func:
return aggregate_func(default)
return default
responses = []
for callback in callbacks:
response = callback(*args, **kwargs)
if response is not None:
responses.append(response)
if aggregate_func and responses:
return aggregate_func(responses)
return responses
def add_object_listeners_and_responders(self, obj):
type_host = type(obj)
for att_name, att_obj in type_host.__dict__.items():
listener_signal_name = getattr(att_obj, '_listener_signal_name', None)
if listener_signal_name:
callback = getattr(obj, att_name)
self.add_listener(signal_name=listener_signal_name, callback=callback)
responder_signal_name = getattr(att_obj, '_responder_signal_name', None)
if responder_signal_name:
callback = getattr(obj, att_name)
self.add_responder(signal_name=responder_signal_name, callback=callback)
def remove_object_listeners_and_responders(self, obj):
type_host = type(obj)
for att_name, att_obj in type_host.__dict__.items():
listener_signal_name = getattr(att_obj, '_listener_signal_name', None)
if listener_signal_name:
callback = getattr(obj, att_name)
self.remove_listener(signal_name=listener_signal_name, callback=callback)
responder_signal_name = getattr(att_obj, '_responder_signal_name', None)
if responder_signal_name:
callback = getattr(obj, att_name)
self.remove_responder(signal_name=responder_signal_name, callback=callback)

View file

@ -1,7 +1,9 @@
from evennia.contrib.base_systems.components import Component, DBField, TagField
from evennia.contrib.base_systems.components import Component, DBField, TagField, signals
from evennia.contrib.base_systems.components.holder import ComponentProperty, ComponentHolderMixin
from evennia.contrib.base_systems.components.signals import as_listener
from evennia.objects.objects import DefaultCharacter
from evennia.utils.test_resources import EvenniaTest
from evennia.utils import create
from evennia.utils.test_resources import EvenniaTest, BaseEvenniaTest
class ComponentTestA(Component):
@ -186,3 +188,211 @@ class TestComponents(EvenniaTest):
assert self.char1.tags.has(key="first value", category="test_b::multiple_tags")
assert self.char1.tags.has(key="second value", category="test_b::multiple_tags")
assert self.char1.tags.has(key="third value", category="test_b::multiple_tags")
class CharWithSignal(ComponentHolderMixin, DefaultCharacter):
@signals.as_listener
def my_signal(self):
setattr(self, 'my_signal_is_called', True)
@signals.as_listener
def my_other_signal(self):
setattr(self, 'my_other_signal_is_called', True)
@signals.as_responder
def my_response(self):
return 1
@signals.as_responder
def my_other_response(self):
return 2
class ComponentWithSignal(Component):
name = "test_signal_a"
@signals.as_listener
def my_signal(self):
setattr(self, 'my_signal_is_called', True)
@signals.as_listener
def my_other_signal(self):
setattr(self, 'my_other_signal_is_called', True)
@signals.as_responder
def my_response(self):
return 1
@signals.as_responder
def my_other_response(self):
return 2
@signals.as_responder
def my_component_response(self):
return 3
class TestComponentSignals(BaseEvenniaTest):
def setUp(self):
super().setUp()
self.char1 = create.create_object(
CharWithSignal, key="Char",
)
def test_host_can_register_as_listener(self):
self.char1.signals.trigger("my_signal")
assert self.char1.my_signal_is_called
assert not getattr(self.char1, 'my_other_signal_is_called', None)
def test_host_can_register_as_responder(self):
responses = self.char1.signals.query("my_response")
assert 1 in responses
assert 2 not in responses
def test_component_can_register_as_listener(self):
char = self.char1
char.components.add(ComponentWithSignal.create(char))
char.signals.trigger("my_signal")
component = char.cmp.test_signal_a
assert component.my_signal_is_called
assert not getattr(component, 'my_other_signal_is_called', None)
def test_component_can_register_as_responder(self):
char = self.char1
char.components.add(ComponentWithSignal.create(char))
responses = char.signals.query("my_response")
assert 1 in responses
assert 2 not in responses
def test_signals_can_add_listener(self):
result = []
def my_fake_listener():
result.append(True)
self.char1.signals.add_listener("my_fake_signal", my_fake_listener)
self.char1.signals.trigger("my_fake_signal")
assert result
def test_signals_can_add_responder(self):
def my_fake_responder():
return 1
self.char1.signals.add_responder("my_fake_response", my_fake_responder)
responses = self.char1.signals.query("my_fake_response")
assert 1 in responses
def test_signals_can_remove_listener(self):
result = []
def my_fake_listener():
result.append(True)
self.char1.signals.add_listener("my_fake_signal", my_fake_listener)
self.char1.signals.remove_listener("my_fake_signal", my_fake_listener)
self.char1.signals.trigger("my_fake_signal")
assert not result
def test_signals_can_remove_responder(self):
def my_fake_responder():
return 1
self.char1.signals.add_responder("my_fake_response", my_fake_responder)
self.char1.signals.remove_responder("my_fake_response", my_fake_responder)
responses = self.char1.signals.query("my_fake_response")
assert not responses
def test_signals_can_trigger_with_args(self):
result = []
def my_fake_listener(arg1, kwarg1):
result.append((arg1, kwarg1))
self.char1.signals.add_listener("my_fake_signal", my_fake_listener)
self.char1.signals.trigger("my_fake_signal", 1, kwarg1=2)
assert (1, 2) in result
def test_signals_can_query_with_args(self):
def my_fake_responder(arg1, kwarg1):
return (arg1, kwarg1)
self.char1.signals.add_responder("my_fake_response", my_fake_responder)
responses = self.char1.signals.query("my_fake_response", 1, kwarg1=2)
assert (1, 2) in responses
def test_signals_trigger_does_not_fail_without_listener(self):
self.char1.signals.trigger("some_unknown_signal")
def test_signals_query_does_not_fail_wihout_responders(self):
self.char1.signals.query("no_responders_allowed")
def test_signals_query_with_aggregate(self):
def my_fake_responder(arg1, kwarg1):
return (arg1, kwarg1)
self.char1.signals.add_responder("my_fake_response", my_fake_responder)
responses = self.char1.signals.query("my_fake_response", 1, kwarg1=2)
assert (1, 2) in responses
def test_signals_can_add_object_listeners_and_responders(self):
result = []
class FakeObj:
@as_listener
def my_signal(self):
result.append(True)
self.char1.signals.add_object_listeners_and_responders(FakeObj())
self.char1.signals.trigger("my_signal")
assert result
def test_signals_can_remove_object_listeners_and_responders(self):
result = []
class FakeObj:
@as_listener
def my_signal(self):
result.append(True)
obj = FakeObj()
self.char1.signals.add_object_listeners_and_responders(obj)
self.char1.signals.remove_object_listeners_and_responders(obj)
self.char1.signals.trigger("my_signal")
assert not result
def test_component_handler_signals_connected_when_adding_default_component(self):
char = self.char1
char.components.add_default("test_signal_a")
responses = char.signals.query("my_component_response")
assert 3 in responses
def test_component_handler_signals_disconnected_when_removing_component(self):
char = self.char1
comp = ComponentWithSignal.create(char)
char.components.add(comp)
char.components.remove(comp)
responses = char.signals.query("my_component_response")
assert not responses
def test_component_handler_signals_disconnected_when_removing_component_by_name(self):
char = self.char1
char.components.add_default("test_signal_a")
char.components.remove_by_name("test_signal_a")
responses = char.signals.query("my_component_response")
assert not responses