From c22a08851f78bd39aa0e620df5a20f330aac6b14 Mon Sep 17 00:00:00 2001 From: ChrisLR Date: Tue, 12 Apr 2022 18:32:55 -0400 Subject: [PATCH] Added basic signal system with corresponding tests --- .../contrib/base_systems/components/holder.py | 25 +- .../base_systems/components/signals.py | 112 +++++++++ .../contrib/base_systems/components/tests.py | 214 +++++++++++++++++- 3 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 evennia/contrib/base_systems/components/signals.py diff --git a/evennia/contrib/base_systems/components/holder.py b/evennia/contrib/base_systems/components/holder.py index ddd606151d..d5112083cd 100644 --- a/evennia/contrib/base_systems/components/holder.py +++ b/evennia/contrib/base_systems/components/holder.py @@ -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 diff --git a/evennia/contrib/base_systems/components/signals.py b/evennia/contrib/base_systems/components/signals.py new file mode 100644 index 0000000000..c47cd5c763 --- /dev/null +++ b/evennia/contrib/base_systems/components/signals.py @@ -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) diff --git a/evennia/contrib/base_systems/components/tests.py b/evennia/contrib/base_systems/components/tests.py index c374971e3f..413b964c18 100644 --- a/evennia/contrib/base_systems/components/tests.py +++ b/evennia/contrib/base_systems/components/tests.py @@ -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