From f77593994ebd6e926db0268e2611b42de024caa5 Mon Sep 17 00:00:00 2001 From: ChrisLR Date: Thu, 31 Mar 2022 12:00:47 -0400 Subject: [PATCH 01/19] Changed DBField and NDBField delimiters from __ to :: --- evennia/contrib/base_systems/components/README.md | 2 +- evennia/contrib/base_systems/components/dbfield.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/base_systems/components/README.md b/evennia/contrib/base_systems/components/README.md index fc594aa036..8f30b03630 100644 --- a/evennia/contrib/base_systems/components/README.md +++ b/evennia/contrib/base_systems/components/README.md @@ -39,7 +39,7 @@ class Health(Component): 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'. +The key used will be 'component_name::field_name'. They use AttributeProperty under the hood. Example: diff --git a/evennia/contrib/base_systems/components/dbfield.py b/evennia/contrib/base_systems/components/dbfield.py index 7e2d16edee..9adbf8197f 100644 --- a/evennia/contrib/base_systems/components/dbfield.py +++ b/evennia/contrib/base_systems/components/dbfield.py @@ -21,7 +21,7 @@ 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}" + key = f"{owner.name}::{name}" self._key = key db_fields = getattr(owner, "_db_fields", None) if db_fields is None: @@ -45,7 +45,7 @@ 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}" + key = f"{owner.name}::{name}" self._key = key ndb_fields = getattr(owner, "_ndb_fields", None) if ndb_fields is None: From 3a3dbeed3c75908cbeab0a06cac547ec706381e3 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Fri, 1 Apr 2022 14:39:23 -0600 Subject: [PATCH 02/19] start script regardless of whether newly created --- evennia/utils/containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/utils/containers.py b/evennia/utils/containers.py index 6b709fbf75..85678ee03e 100644 --- a/evennia/utils/containers.py +++ b/evennia/utils/containers.py @@ -167,7 +167,7 @@ class GlobalScriptContainer(Container): # store a hash representation of the setup script.attributes.add("_global_script_settings", compare_hash, category="settings_hash") - script.start() + script.start() return script From 4224feb25ddad7aa7d507687e3277b2ff92aa702 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Sun, 3 Apr 2022 16:18:09 -0600 Subject: [PATCH 03/19] preserve output whitespace --- evennia/web/static/webclient/css/webclient.css | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/web/static/webclient/css/webclient.css b/evennia/web/static/webclient/css/webclient.css index bc94b84ae8..55135acc60 100644 --- a/evennia/web/static/webclient/css/webclient.css +++ b/evennia/web/static/webclient/css/webclient.css @@ -49,6 +49,7 @@ div {margin:0px;} .out { color: #aaa; background-color: #000; + white-space: pre-wrap; } /* Error messages (red) */ From f2accc353f820dcd54b1b3b1bd55bb14074e4ca9 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Sun, 3 Apr 2022 16:22:39 -0600 Subject: [PATCH 04/19] remove server-side white-space parsing --- evennia/utils/text2html.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index 91a627f33b..72ffc14c75 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -101,8 +101,6 @@ class TextToHTMLparser(object): r"(?P^ )|(?P\r\n|\r|\n)", re.S | re.M | re.I, ) - re_dblspace = re.compile(r" {2,}", re.M) - re_invisiblespace = re.compile(r"( <.*?>)( )") re_url = re.compile( r'(?\[\]\s])+)(\.(?:\s|$)|&\w+;|)' ) @@ -265,20 +263,6 @@ class TextToHTMLparser(object): # change pages (and losing our webclient session). return self.re_url.sub(r'\1\2', text) - def re_double_space(self, text): - """ - HTML will swallow any normal space after the first, so if any slipped - through we must make sure to replace them with "  " - """ - return self.re_dblspace.sub(self.sub_dblspace, text) - - def re_invisible_space(self, text): - """ - If two spaces are separated by an invisble html element, they act as a - hidden double-space and the last of them should be replaced by   - """ - return self.re_invisiblespace.sub(self.sub_invisiblespace, text) - def sub_mxp_links(self, match): """ Helper method to be passed to re.sub, @@ -340,20 +324,6 @@ class TextToHTMLparser(object): return text return None - def sub_dblspace(self, match): - "clean up double-spaces" - return " " + " " * (len(match.group()) - 1) - - def sub_invisiblespace(self, match): - "clean up invisible spaces" - return match.group(1) + " " - - def handle_single_first_space(self, text): - "Don't swallow an initial lone space" - if text.startswith(" "): - return " " + text[1:] - return text - def parse(self, text, strip_ansi=False): """ Main access function, converts a text containing ANSI codes @@ -383,9 +353,6 @@ class TextToHTMLparser(object): result = self.convert_linebreaks(result) result = self.remove_backspaces(result) result = self.convert_urls(result) - result = self.re_double_space(result) - result = self.re_invisible_space(result) - result = self.handle_single_first_space(result) # clean out eventual ansi that was missed ## result = parse_ansi(result, strip_ansi=True) From e3feffa98943533947faf5abcbbbfd5b10523e23 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Sun, 3 Apr 2022 16:43:27 -0600 Subject: [PATCH 05/19] clean out unnecessary whitespace regex --- evennia/utils/text2html.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index 72ffc14c75..dcffd5ac31 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -97,8 +97,7 @@ class TextToHTMLparser(object): re_blink = re.compile("(?:%s)(.*?)(?=%s|%s)" % (blink.replace("[", r"\["), fgstop, bgstop)) re_inverse = re.compile("(?:%s)(.*?)(?=%s|%s)" % (inverse.replace("[", r"\["), fgstop, bgstop)) re_string = re.compile( - r"(?P[<&>])|(?P[\t]+)|(?P +)|" - r"(?P^ )|(?P\r\n|\r|\n)", + r"(?P[<&>])|(?P[\t]+)|(?P\r\n|\r|\n)", re.S | re.M | re.I, ) re_url = re.compile( @@ -109,20 +108,16 @@ class TextToHTMLparser(object): def _sub_bgfg(self, colormatch): # print("colormatch.groups()", colormatch.groups()) - bgcode, prespace, fgcode, text, postspace = colormatch.groups() + bgcode, fgcode, text = colormatch.groups() if not fgcode: ret = r"""%s%s%s""" % ( self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")), - prespace and " " * len(prespace) or "", - postspace and " " * len(postspace) or "", text, ) else: ret = r"""%s%s%s""" % ( self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")), self.fg_colormap.get(fgcode, self.bg_colormap.get(fgcode, "err")), - prespace and " " * len(prespace) or "", - postspace and " " * len(postspace) or "", text, ) return ret @@ -316,11 +311,7 @@ class TextToHTMLparser(object): elif cdict["lineend"]: return "
" elif cdict["tab"]: - text = cdict["tab"].replace("\t", " " + " " * (self.tabstop - 1)) - return text - elif cdict["space"] or cdict["spacestart"]: - text = cdict["space"] - text = " " if len(text) == 1 else " " + text[1:].replace(" ", " ") + text = cdict["tab"].replace("\t", " " * (self.tabstop)) return text return None From 23938cb8edf6163ba4f947cf952ca24d51cbd66b Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Sun, 3 Apr 2022 16:47:56 -0600 Subject: [PATCH 06/19] update tests --- evennia/utils/tests/test_text2html.py | 28 +++++---------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/evennia/utils/tests/test_text2html.py b/evennia/utils/tests/test_text2html.py index a1c45c06f4..3b67cd426e 100644 --- a/evennia/utils/tests/test_text2html.py +++ b/evennia/utils/tests/test_text2html.py @@ -120,13 +120,6 @@ class TestText2Html(TestCase): ) # TODO: doesn't URL encode correctly - def test_re_double_space(self): - parser = text2html.HTML_PARSER - self.assertEqual("foo", parser.re_double_space("foo")) - self.assertEqual( - "a  red    foo", parser.re_double_space("a red foo") - ) - def test_sub_mxp_links(self): parser = text2html.HTML_PARSER mocked_match = mock.Mock() @@ -156,7 +149,7 @@ class TestText2Html(TestCase): "tab": "\t", "space": "", } - self.assertEqual("  ", parser.sub_text(mocked_match)) + self.assertEqual(" ", parser.sub_text(mocked_match)) mocked_match.groupdict.return_value = { "htmlchars": "", @@ -165,7 +158,7 @@ class TestText2Html(TestCase): "space": " ", "spacestart": " ", } - self.assertEqual("    ", parser.sub_text(mocked_match)) + self.assertEqual(" ", parser.sub_text(mocked_match)) mocked_match.groupdict.return_value = { "htmlchars": "", @@ -181,24 +174,13 @@ class TestText2Html(TestCase): parser = text2html.HTML_PARSER parser.tabstop = 4 # single tab - self.assertEqual(parser.parse("foo|>foo"), "foo    foo") + self.assertEqual(parser.parse("foo|>foo"), "foo foo") # space and tab - self.assertEqual(parser.parse("foo |>foo"), "foo     foo") + self.assertEqual(parser.parse("foo |>foo"), "foo foo") # space, tab, space - self.assertEqual(parser.parse("foo |> foo"), "foo      foo") - - def test_parse_space_to_html(self): - """test space parsing - a single space should be kept, two or more - should get  """ - parser = text2html.HTML_PARSER - # single space - self.assertEqual(parser.parse("foo foo"), "foo foo") - # double space - self.assertEqual(parser.parse("foo foo"), "foo  foo") - # triple space - self.assertEqual(parser.parse("foo foo"), "foo   foo") + self.assertEqual(parser.parse("foo |> foo"), "foo foo") def test_parse_html(self): self.assertEqual("foo", text2html.parse_html("foo")) From ba937457242d37fdd92cc7cfe7c489f2695d6ffb Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Sun, 3 Apr 2022 17:12:11 -0600 Subject: [PATCH 07/19] removing missed regex groups --- evennia/utils/text2html.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index dcffd5ac31..be8f459c87 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -79,11 +79,11 @@ class TextToHTMLparser(object): # create stop markers fgstop = "(?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m|\033\[0m|$" bgstop = "(?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m|\033\[0m|$" - bgfgstop = bgstop[:-2] + r"(\s*)" + fgstop + bgfgstop = bgstop[:-2] + fgstop fgstart = "((?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m)" bgstart = "((?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m)" - bgfgstart = bgstart + r"(\s*)" + "((?:\033\[1m|\033\[22m){0,1}\033\[[3-4][0-8].*?m){0,1}" + bgfgstart = bgstart + r"((?:\033\[1m|\033\[22m){0,1}\033\[[3-4][0-8].*?m){0,1}" # extract color markers, tagging the start marker and the text marked re_fgs = re.compile(fgstart + "(.*?)(?=" + fgstop + ")") @@ -110,12 +110,12 @@ class TextToHTMLparser(object): # print("colormatch.groups()", colormatch.groups()) bgcode, fgcode, text = colormatch.groups() if not fgcode: - ret = r"""%s%s%s""" % ( + ret = r"""%s""" % ( self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")), text, ) else: - ret = r"""%s%s%s""" % ( + ret = r"""%s""" % ( self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")), self.fg_colormap.get(fgcode, self.bg_colormap.get(fgcode, "err")), text, From cd611199b13320c5e892c5db2ab366871cb8835e Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Thu, 7 Apr 2022 13:17:22 -0600 Subject: [PATCH 08/19] cache regex tuples by id --- evennia/contrib/rpg/rpsystem/rpsystem.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index 6a34d39baf..cb2302b3fa 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -318,15 +318,15 @@ def regex_tuple_from_key_alias(obj): """ global _REGEX_TUPLE_CACHE - permutation_string = " ".join([obj.key] + obj.aliases.all()) - if permutation_string not in _REGEX_TUPLE_CACHE: + if obj.id not in _REGEX_TUPLE_CACHE: + permutation_string = " ".join([obj.key] + obj.aliases.all()) _REGEX_TUPLE_CACHE[permutation_string] = ( re.compile(ordered_permutation_regex(permutation_string), _RE_FLAGS), obj, obj.key, ) - return _REGEX_TUPLE_CACHE[permutation_string] + return _REGEX_TUPLE_CACHE[obj.id] def parse_language(speaker, emote): From 76940fd72fb1df15df5200d13b09ea292afb95c1 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Thu, 7 Apr 2022 13:42:05 -0600 Subject: [PATCH 09/19] missed a reference --- evennia/contrib/rpg/rpsystem/rpsystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index cb2302b3fa..d6085e1bfd 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -321,7 +321,7 @@ def regex_tuple_from_key_alias(obj): if obj.id not in _REGEX_TUPLE_CACHE: permutation_string = " ".join([obj.key] + obj.aliases.all()) - _REGEX_TUPLE_CACHE[permutation_string] = ( + _REGEX_TUPLE_CACHE[obj.id] = ( re.compile(ordered_permutation_regex(permutation_string), _RE_FLAGS), obj, obj.key, From 3d94fab1f5cf849709a51cfb1ce151e77af942a7 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Thu, 7 Apr 2022 14:30:15 -0600 Subject: [PATCH 10/19] combine id and key+alias string for key --- evennia/contrib/rpg/rpsystem/rpsystem.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index d6085e1bfd..083d664976 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -318,15 +318,16 @@ def regex_tuple_from_key_alias(obj): """ global _REGEX_TUPLE_CACHE + permutation_string = " ".join([obj.key] + obj.aliases.all()) + cache_key = " ".join((obj.id, permutation_string)) - if obj.id not in _REGEX_TUPLE_CACHE: - permutation_string = " ".join([obj.key] + obj.aliases.all()) - _REGEX_TUPLE_CACHE[obj.id] = ( + if cache_key not in _REGEX_TUPLE_CACHE: + _REGEX_TUPLE_CACHE[cache_key] = ( re.compile(ordered_permutation_regex(permutation_string), _RE_FLAGS), obj, obj.key, ) - return _REGEX_TUPLE_CACHE[obj.id] + return _REGEX_TUPLE_CACHE[cache_key] def parse_language(speaker, emote): From 60ee5e01e8467636fa04fbd793163b92fdcf4b9f Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Thu, 7 Apr 2022 17:05:34 -0600 Subject: [PATCH 11/19] key formatting --- evennia/contrib/rpg/rpsystem/rpsystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index 083d664976..2c431280c0 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -319,7 +319,7 @@ def regex_tuple_from_key_alias(obj): """ global _REGEX_TUPLE_CACHE permutation_string = " ".join([obj.key] + obj.aliases.all()) - cache_key = " ".join((obj.id, permutation_string)) + cache_key = f"{obj.id} {permutation_string}" if cache_key not in _REGEX_TUPLE_CACHE: _REGEX_TUPLE_CACHE[cache_key] = ( From c22a08851f78bd39aa0e620df5a20f330aac6b14 Mon Sep 17 00:00:00 2001 From: ChrisLR Date: Tue, 12 Apr 2022 18:32:55 -0400 Subject: [PATCH 12/19] 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 From 326b32a58715b9b32d2a970013517df3dd86a92d Mon Sep 17 00:00:00 2001 From: ChrisLR Date: Tue, 12 Apr 2022 18:51:09 -0400 Subject: [PATCH 13/19] Added docstrings for signals --- .../base_systems/components/signals.py | 99 ++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/base_systems/components/signals.py b/evennia/contrib/base_systems/components/signals.py index c47cd5c763..23ace839dd 100644 --- a/evennia/contrib/base_systems/components/signals.py +++ b/evennia/contrib/base_systems/components/signals.py @@ -1,4 +1,19 @@ +""" +Components - ChrisLR 2022 + +This file contains classes functions related to signals. +""" + + def as_listener(func=None, signal_name=None): + """ + Decorator style function that marks a method to be connected as listener. + It will use the provided signal name and default to the decorated function name. + + Args: + func (callable): The method to mark as listener + signal_name (str): The name of the signal to listen to, defaults to function name. + """ if not func and signal_name: def wrapper(func): func._listener_signal_name = signal_name @@ -11,6 +26,14 @@ def as_listener(func=None, signal_name=None): def as_responder(func=None, signal_name=None): + """ + Decorator style function that marks a method to be connected as responder. + It will use the provided signal name and default to the decorated function name. + + Args: + func (callable): The method to mark as responder + signal_name (str): The name of the signal to respond to, defaults to function name. + """ if not func and signal_name: def wrapper(func): func._responder_signal_name = signal_name @@ -23,6 +46,12 @@ def as_responder(func=None, signal_name=None): class SignalsHandler(object): + """ + This object handles all about signals. + It holds the connected listeners and responders. + It allows triggering signals or querying responders. + """ + def __init__(self, host): self.host = host self.listeners = {} @@ -30,16 +59,40 @@ class SignalsHandler(object): self.add_object_listeners_and_responders(host) def add_listener(self, signal_name, callback): + """ + Connect a listener to a specific signal. + + Args: + signal_name (str): The name of the signal to listen to + callback (callable): The callable that is called when the signal is triggered + """ + signal_listeners = self.listeners.setdefault(signal_name, []) if callback not in signal_listeners: signal_listeners.append(callback) def add_responder(self, signal_name, callback): + """ + Connect a responder to a specific signal. + + Args: + signal_name (str): The name of the signal to respond to + callback (callable): The callable that is called when the signal is queried + """ + signal_responders = self.responders.setdefault(signal_name, []) if callback not in signal_responders: signal_responders.append(callback) def remove_listener(self, signal_name, callback): + """ + Removes a listener for a specific signal. + + Args: + signal_name (str): The name of the signal to disconnect from + callback (callable): The callable that was used to connect + """ + signal_listeners = self.listeners.get(signal_name) if not signal_listeners: return @@ -48,6 +101,13 @@ class SignalsHandler(object): signal_listeners.remove(callback) def remove_responder(self, signal_name, callback): + """ + Removes a responder for a specific signal. + + Args: + signal_name (str): The name of the signal to disconnect from + callback (callable): The callable that was used to connect + """ signal_responders = self.responders.get(signal_name) if not signal_responders: return @@ -56,7 +116,14 @@ class SignalsHandler(object): signal_responders.remove(callback) def trigger(self, signal_name, *args, **kwargs): - """ This method fires a signal but does not return anything """ + """ + Triggers a specific signal with specified args and kwargs + This method does not return anything + + Args: + signal_name (str): The name of the signal to trigger + """ + callbacks = self.listeners.get(signal_name) if not callbacks: return @@ -65,7 +132,23 @@ class SignalsHandler(object): callback(*args, **kwargs) def query(self, signal_name, *args, default=None, aggregate_func=None, **kwargs): - """ This method fires a signal query that retrieves values """ + """ + Queries a specific signal with specified args and kwargs + This method will return the responses from its connected responders. + If an aggregate_func is specified, it is called with the responses + and its result is returned instead. + + Args: + signal_name (str): The name of the signal to trigger + default (any): The value to use when no responses are given + It will be passed to aggregate_func if it is also given. + aggregate_func (callable): The function to process the results before returning. + + Returns: + list: An iterable of the responses + OR the aggregated result when aggregate_func is specified. + + """ callbacks = self.responders.get(signal_name) if not callbacks: @@ -86,6 +169,12 @@ class SignalsHandler(object): return responses def add_object_listeners_and_responders(self, obj): + """ + This connects the methods marked as listener or responder from an object. + + Args: + obj (object): The instance of an object to connect to this handler. + """ type_host = type(obj) for att_name, att_obj in type_host.__dict__.items(): listener_signal_name = getattr(att_obj, '_listener_signal_name', None) @@ -99,6 +188,12 @@ class SignalsHandler(object): self.add_responder(signal_name=responder_signal_name, callback=callback) def remove_object_listeners_and_responders(self, obj): + """ + This disconnects the methods marked as listener or responder from an object. + + Args: + obj (object): The instance of an object to disconnect from this handler. + """ type_host = type(obj) for att_name, att_obj in type_host.__dict__.items(): listener_signal_name = getattr(att_obj, '_listener_signal_name', None) From 05d77a4a18dc569edbd84de61c5679ce325065e6 Mon Sep 17 00:00:00 2001 From: Christopher League Date: Mon, 4 Apr 2022 15:37:29 -0400 Subject: [PATCH 14/19] Improve coverage of commands "look" and "nick" --- evennia/commands/default/tests.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 79f800b0e4..78a7a32773 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -58,6 +58,13 @@ class TestGeneral(BaseEvenniaCommandTest): rid = self.room1.id self.call(general.CmdLook(), "here", "Room(#{})\nroom_desc".format(rid)) + def test_look_no_location(self): + self.char1.location = None + self.call(general.CmdLook(), "", "You have no location to look at!") + + def test_look_nonexisting(self): + self.call(general.CmdLook(), "yellow sign", "Could not find 'yellow sign'.") + def test_home(self): self.call(general.CmdHome(), "", "You are already home") @@ -90,6 +97,12 @@ class TestGeneral(BaseEvenniaCommandTest): self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account")) self.assertEqual("testaliasedstring3", self.char1.nicks.get("testalias", category="object")) + def test_nick_list(self): + self.call(general.CmdNick(), "/list", "No nicks defined.") + self.call(general.CmdNick(), "test1 = Hello", + "Inputline-nick 'test1' mapped to 'Hello'.") + self.call(general.CmdNick(), "/list", "Defined Nicks:") + def test_get_and_drop(self): self.call(general.CmdGet(), "Obj", "You pick up Obj.") self.call(general.CmdDrop(), "Obj", "You drop Obj.") From c0fe6af2e7db5dce2d51819e81eaa3a69f3e63d3 Mon Sep 17 00:00:00 2001 From: Christopher League Date: Wed, 2 Mar 2022 13:20:58 -0500 Subject: [PATCH 15/19] Add tests to cover all 3 cases of CmdHome --- evennia/commands/default/tests.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 78a7a32773..cfcc622689 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -68,6 +68,14 @@ class TestGeneral(BaseEvenniaCommandTest): def test_home(self): self.call(general.CmdHome(), "", "You are already home") + def test_go_home(self): + self.call(building.CmdTeleport(), "/quiet Room2") + self.call(general.CmdHome(), "", "There's no place like home") + + def test_no_home(self): + self.char1.home = None + self.call(general.CmdHome(), "", "You have no home") + def test_inventory(self): self.call(general.CmdInventory(), "", "You are not carrying anything.") From d5c916ecc3f61971451b590a47d8c74e7526f630 Mon Sep 17 00:00:00 2001 From: Owllex Date: Thu, 14 Apr 2022 23:35:04 -0700 Subject: [PATCH 16/19] Fix issue with init_evennia_properties. --- evennia/objects/tests.py | 12 +++++++++++- evennia/typeclasses/models.py | 5 +++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index 6fe443cab8..95d3c20e3f 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -241,11 +241,17 @@ class TestObjectPropertiesClass(DefaultObject): testalias = AliasProperty() testperm = PermissionProperty() + @property + def base_property(self): + self.property_initialized = True + + class TestProperties(EvenniaTestCase): """ Test Properties. """ + def setUp(self): self.obj = create.create_object(TestObjectPropertiesClass, key="testobj") @@ -270,7 +276,7 @@ class TestProperties(EvenniaTestCase): self.assertFalse(obj.attributes.has("attr3")) self.assertEqual(obj.attr3, "attr3") - obj.attr3 = "attr3b" # stores it in db! + obj.attr3 = "attr3b" # stores it in db! self.assertEqual(obj.db.attr3, "attr3b") self.assertTrue(obj.attributes.has("attr3")) @@ -280,3 +286,7 @@ class TestProperties(EvenniaTestCase): self.assertTrue(obj.aliases.has("testalias")) self.assertTrue(obj.permissions.has("testperm")) + + # Verify that regular properties do not get fetched in init_evennia_properties, + # only Attribute or TagProperties. + self.assertFalse(hasattr(obj, "property_initialized")) diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 2a71835e0c..44677aff05 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -39,11 +39,12 @@ from django.utils.text import slugify from evennia.typeclasses.attributes import ( Attribute, AttributeHandler, + AttributeProperty, ModelAttributeBackend, InMemoryAttributeBackend, ) from evennia.typeclasses.attributes import DbHolder -from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler +from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler, TagProperty from evennia.utils.idmapper.models import SharedMemoryModel, SharedMemoryModelBase from evennia.server.signals import SIGNAL_TYPED_OBJECT_POST_RENAME @@ -331,7 +332,7 @@ class TypedObject(SharedMemoryModel): by fetching them once. """ for propkey, prop in self.__class__.__dict__.items(): - if hasattr(prop, "__set_name__"): + if isinstance(prop, (AttributeProperty, TagProperty)): try: getattr(self, propkey) except Exception: From 014ae2e6e1150824151454728a0bda20ccd199f5 Mon Sep 17 00:00:00 2001 From: Owllex Date: Fri, 15 Apr 2022 08:34:20 -0700 Subject: [PATCH 17/19] Update to support subclasses. --- evennia/objects/tests.py | 15 +++++++++++++++ evennia/typeclasses/models.py | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index 95d3c20e3f..a718d9b01b 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -232,12 +232,22 @@ class TestContentHandler(BaseEvenniaTest): self.assertEqual(self.room2.contents, [self.obj1, self.obj2]) +class SubAttributeProperty(AttributeProperty): + pass + + +class SubTagProperty(TagProperty): + pass + + class TestObjectPropertiesClass(DefaultObject): attr1 = AttributeProperty(default="attr1") attr2 = AttributeProperty(default="attr2", category="attrcategory") attr3 = AttributeProperty(default="attr3", autocreate=False) + attr4 = SubAttributeProperty(default="attr4") tag1 = TagProperty() tag2 = TagProperty(category="tagcategory") + tag3 = SubTagProperty() testalias = AliasProperty() testperm = PermissionProperty() @@ -276,6 +286,10 @@ class TestProperties(EvenniaTestCase): self.assertFalse(obj.attributes.has("attr3")) self.assertEqual(obj.attr3, "attr3") + self.assertEqual(obj.db.attr4, "attr4") + self.assertEqual(obj.attributes.get("attr4"), "attr4") + self.assertEqual(obj.attr4, "attr4") + obj.attr3 = "attr3b" # stores it in db! self.assertEqual(obj.db.attr3, "attr3b") @@ -283,6 +297,7 @@ class TestProperties(EvenniaTestCase): self.assertTrue(obj.tags.has("tag1")) self.assertTrue(obj.tags.has("tag2", category="tagcategory")) + self.assertTrue(obj.tags.has("tag3")) self.assertTrue(obj.aliases.has("testalias")) self.assertTrue(obj.permissions.has("testperm")) diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 44677aff05..ca2be2fc03 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -332,7 +332,7 @@ class TypedObject(SharedMemoryModel): by fetching them once. """ for propkey, prop in self.__class__.__dict__.items(): - if isinstance(prop, (AttributeProperty, TagProperty)): + if inherits_from(prop, AttributeProperty) or inherits_from(prop, TagProperty): try: getattr(self, propkey) except Exception: From 00578a6c5df522f0889593b65909dd431d4a2b02 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Fri, 15 Apr 2022 10:14:54 -0600 Subject: [PATCH 18/19] fix index and iteration for `clear` --- evennia/scripts/tickerhandler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/scripts/tickerhandler.py b/evennia/scripts/tickerhandler.py index 5af87f1445..3b2c95843a 100644 --- a/evennia/scripts/tickerhandler.py +++ b/evennia/scripts/tickerhandler.py @@ -585,9 +585,9 @@ class TickerHandler(object): self.ticker_pool.stop(interval) if interval: self.ticker_storage = dict( - (store_key, store_key) - for store_key in self.ticker_storage - if store_key[1] != interval + (store_key, store_value) + for store_key, store_value in self.ticker_storage.items() + if store_key[3] != interval ) else: self.ticker_storage = {} From 667b12830c68ef38c1aa070f040a9a93b9961e29 Mon Sep 17 00:00:00 2001 From: Owllex Date: Fri, 15 Apr 2022 13:56:37 -0700 Subject: [PATCH 19/19] Revert back to using isinstance. --- evennia/typeclasses/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index ca2be2fc03..44677aff05 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -332,7 +332,7 @@ class TypedObject(SharedMemoryModel): by fetching them once. """ for propkey, prop in self.__class__.__dict__.items(): - if inherits_from(prop, AttributeProperty) or inherits_from(prop, TagProperty): + if isinstance(prop, (AttributeProperty, TagProperty)): try: getattr(self, propkey) except Exception: