From a46acda63421a0a0f2c9d2fb2b99981341fd5f75 Mon Sep 17 00:00:00 2001 From: luyijun Date: Sun, 2 Aug 2020 10:48:47 +0800 Subject: [PATCH 01/23] Fix a bug that script whose db_interval is 0 will be run when call its start method twice. --- evennia/scripts/scripts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 2f5f95852b..7a95a80b43 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -437,7 +437,7 @@ class DefaultScript(ScriptBase): if self.is_active and not force_restart: # The script is already running, but make sure we have a _task if # this is after a cache flush - if not self.ndb._task and self.db_interval >= 0: + if not self.ndb._task and self.db_interval > 0: self.ndb._task = ExtendedLoopingCall(self._step_task) try: start_delay, callcount = SCRIPT_FLUSH_TIMERS[self.id] From c3831ea09cfb55906bacccbe7421309c0f28b74d Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 6 Aug 2020 00:22:48 +0200 Subject: [PATCH 02/23] Fix unittest error for dice contrib --- evennia/contrib/tests.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index d394cd5a3b..d0eda87ea2 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -874,20 +874,17 @@ class TestCustomGameTime(EvenniaTest): # Test dice module +from evennia.contrib import dice # noqa -@patch("random.randint", return_value=5) + +@patch("evennia.contrib.dice.randint", return_value=5) class TestDice(CommandTest): def test_roll_dice(self, mocked_randint): - # we must import dice here for the mocked randint to apply correctly. - from evennia.contrib import dice - self.assertEqual(dice.roll_dice(6, 6, modifier=("+", 4)), mocked_randint() * 6 + 4) self.assertEqual(dice.roll_dice(6, 6, conditional=("<", 35)), True) self.assertEqual(dice.roll_dice(6, 6, conditional=(">", 33)), False) def test_cmddice(self, mocked_randint): - from evennia.contrib import dice - self.call( dice.CmdDice(), "3d6 + 4", "You roll 3d6 + 4.| Roll(s): 5, 5 and 5. Total result is 19." ) @@ -898,7 +895,7 @@ class TestDice(CommandTest): # Test email-login -from evennia.contrib import email_login +from evennia.contrib import email_login # noqa class TestEmailLogin(CommandTest): From 40efd23ad5a55fa8ab687573b8bcb4a12acc00d1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 31 Aug 2020 19:38:57 +0200 Subject: [PATCH 03/23] Fix of unittest to account for mysql/psql variations --- evennia/commands/default/tests.py | 2 +- evennia/utils/tests/test_create_functions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index bbf1e5428c..3fef90e0d9 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -1146,7 +1146,7 @@ class TestBuilding(CommandTest): "= Obj", "To create a global script you need scripts/add .", ) - self.call(building.CmdScript(), "Obj = ", "dbref obj") + self.call(building.CmdScript(), "Obj ", "dbref ") self.call( building.CmdScript(), "/start Obj", "0 scripts started on Obj" diff --git a/evennia/utils/tests/test_create_functions.py b/evennia/utils/tests/test_create_functions.py index 392d62a9ee..c9ae5b2cb7 100644 --- a/evennia/utils/tests/test_create_functions.py +++ b/evennia/utils/tests/test_create_functions.py @@ -159,7 +159,7 @@ class TestCreateMessage(EvenniaTest): locks=locks, tags=tags, ) - self.assertEqual(msg.receivers, [self.char1, self.char2]) + self.assertEqual(set(msg.receivers), set([self.char1, self.char2])) self.assertTrue(all(lock in msg.locks.all() for lock in locks.split(";"))) self.assertEqual(msg.tags.all(), tags) From 1d233aa8f071044dec1b5febe77064f0f175eb72 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 1 Sep 2020 19:10:07 +0200 Subject: [PATCH 04/23] Fix error in pluralization inflection. Resolves #2183. --- evennia/objects/objects.py | 13 +++++++------ evennia/utils/utils.py | 4 ++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 35ea3089e2..862f7cd832 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -339,22 +339,23 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): singular (str): The singular form to display. plural (str): The determined plural form of the key, including the count. """ + plural_category = "plural_key" key = kwargs.get("key", self.key) key = ansi.ANSIString(key) # this is needed to allow inflection of colored names try: - plural = _INFLECT.plural(key, 2) - plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural) + plural = _INFLECT.plural(key, count) + plural = "{} {}".format(_INFLECT.number_to_words(count, threshold=12), plural) except IndexError: # this is raised by inflect if the input is not a proper noun plural = key singular = _INFLECT.an(key) - if not self.aliases.get(plural, category="plural_key"): + if not self.aliases.get(plural, category=plural_category): # we need to wipe any old plurals/an/a in case key changed in the interrim - self.aliases.clear(category="plural_key") - self.aliases.add(plural, category="plural_key") + self.aliases.clear(category=plural_category) + self.aliases.add(plural, category=plural_category) # save the singular form as an alias here too so we can display "an egg" and also # look at 'an egg'. - self.aliases.add(singular, category="plural_key") + self.aliases.add(singular, category=plural_category) return singular, plural def search( diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 3127fc25a2..bd5d460b5b 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1917,6 +1917,10 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): for num, result in enumerate(matches): # we need to consider Commands, where .aliases is a list aliases = result.aliases.all() if hasattr(result.aliases, "all") else result.aliases + # remove any pluralization aliases + aliases = [alias for alias in aliases if + hasattr(alias, "category") + and alias.category not in ("plural_key", )] error += _MULTIMATCH_TEMPLATE.format( number=num + 1, name=result.get_display_name(caller) From ad7a8ae0d11f80e26cfa94ba19a2a1d0ea557be9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 1 Sep 2020 20:35:16 +0200 Subject: [PATCH 05/23] Fix settings-reset functionality. Resolve #2187 --- evennia/server/evennia_launcher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 7a00f8e07d..50349651a1 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1368,10 +1368,10 @@ def create_settings_file(init=True, secret_settings=False): if not init: # if not --init mode, settings file may already exist from before if os.path.exists(settings_path): - inp = eval(input("%s already exists. Do you want to reset it? y/[N]> " % settings_path)) + inp = input("%s already exists. Do you want to reset it? y/[N]> " % settings_path) if not inp.lower() == "y": print("Aborted.") - return + sys.exit() else: print("Reset the settings file.") From 09e53d5efe59520f2f067b7e61fa0d4142697361 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 1 Sep 2020 20:40:48 +0200 Subject: [PATCH 06/23] Default to attrread as locktype for Attribute read access. Resolve #2185 --- evennia/typeclasses/attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index bc6b61dccf..16779ba4a8 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -186,7 +186,7 @@ class Attribute(SharedMemoryModel): def __repr__(self): return "%s(%s)" % (self.db_key, self.id) - def access(self, accessing_obj, access_type="read", default=False, **kwargs): + def access(self, accessing_obj, access_type="attrread", default=False, **kwargs): """ Determines if another object has permission to access. From 29b381fbe67452a51675fbf1bf1a42c67e37b27c Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 1 Sep 2020 22:36:54 +0200 Subject: [PATCH 07/23] Use localtime for MudInfo channel output. Resolves #2182. --- evennia/accounts/accounts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index f9c97a90f7..fa76774e8e 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -1262,7 +1262,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): ] except Exception: logger.log_trace() - now = timezone.now() + now = timezone.localtime() now = "%02i-%02i-%02i(%02i:%02i)" % (now.year, now.month, now.day, now.hour, now.minute) if _MUDINFO_CHANNEL: _MUDINFO_CHANNEL.tempmsg(f"[{_MUDINFO_CHANNEL.key}, {now}]: {message}") From 4ba2f444ff92651350d5ba1f502f44dca1715a98 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 1 Sep 2020 22:58:54 +0200 Subject: [PATCH 08/23] Make at_post_cmd() run after func() also for delayed commands. Resolve #2179 --- CHANGELOG.md | 2 ++ evennia/commands/cmdhandler.py | 31 +++++++++++++++++++++---------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b889f0598..471aec0b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ without arguments starts a full interactive Python console. - Make `INLINEFUNC_STACK_MAXSIZE` default visible in `settings_default.py`. - Change how `ic` finds puppets; non-priveleged users will use `_playable_characters` list as candidates, Builders+ will use list, local search and only global search if no match found. +- Make `cmd.at_post_cmd()` always run after `cmd.func()`, even when the latter uses delays + with yield. ## Evennia 0.9 (2018-2019) diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index dd0f84e872..b22719ca35 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -206,7 +206,7 @@ def _progressive_cmd_run(cmd, generator, response=None): else: value = generator.send(response) except StopIteration: - pass + raise else: if isinstance(value, (int, float)): utils.delay(value, _progressive_cmd_run, cmd, generator) @@ -631,20 +631,31 @@ def cmdhandler( ret = cmd.func() if isinstance(ret, types.GeneratorType): # cmd.func() is a generator, execute progressively - _progressive_cmd_run(cmd, ret) + in_generator = True + try: + _progressive_cmd_run(cmd, ret) + except StopIteration: + # this means func() has run its course + in_generator = False yield None else: + in_generator = False ret = yield ret - # post-command hook - yield cmd.at_post_cmd() + if not in_generator: + # this will only run if we are out of the generator for this + # cmd, otherwise we would have at_post_cmd run before a delayed + # func() finished - if cmd.save_for_next: - # store a reference to this command, possibly - # accessible by the next command. - caller.ndb.last_cmd = yield copy(cmd) - else: - caller.ndb.last_cmd = None + # post-command hook + yield cmd.at_post_cmd() + + if cmd.save_for_next: + # store a reference to this command, possibly + # accessible by the next command. + caller.ndb.last_cmd = yield copy(cmd) + else: + caller.ndb.last_cmd = None # return result to the deferred returnValue(ret) From 709515a332882ea4ea401617351842aa767d3772 Mon Sep 17 00:00:00 2001 From: davewiththenicehat <54369722+davewiththenicehat@users.noreply.github.com> Date: Wed, 2 Sep 2020 17:25:39 -0400 Subject: [PATCH 09/23] Update example_batch_code.py Updated so /interactive switch works as expected for tutorial: https://github.com/evennia/evennia/wiki/Batch-Code-Processor --- evennia/contrib/tutorial_examples/example_batch_code.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/tutorial_examples/example_batch_code.py b/evennia/contrib/tutorial_examples/example_batch_code.py index edb2483468..7aeca3f3a0 100644 --- a/evennia/contrib/tutorial_examples/example_batch_code.py +++ b/evennia/contrib/tutorial_examples/example_batch_code.py @@ -39,7 +39,7 @@ # -# HEADER +#HEADER # everything in this block will be appended to the beginning of # all other #CODE blocks when they are executed. @@ -51,7 +51,7 @@ from evennia import DefaultObject limbo = search_object("Limbo")[0] -# CODE +#CODE # This is the first code block. Within each block, Python # code works as normal. Note how we make use if imports and @@ -67,7 +67,7 @@ red_button = create_object( # we take a look at what we created caller.msg("A %s was created." % red_button.key) -# CODE +#CODE # this code block has 'table' and 'chair' set as deletable # objects. This means that when the batchcode processor runs in From b9d3dae76edac67848b05b15b4c5fa141492a8c2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 5 Sep 2020 23:30:08 +0200 Subject: [PATCH 10/23] Optimize queries for prototype lookup, as part of #2126. --- CHANGELOG.md | 2 + evennia/prototypes/prototypes.py | 69 +++++++++++++++++++++----------- evennia/prototypes/tests.py | 30 +++++++++++++- 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 471aec0b1a..9d131062da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,8 @@ without arguments starts a full interactive Python console. candidates, Builders+ will use list, local search and only global search if no match found. - Make `cmd.at_post_cmd()` always run after `cmd.func()`, even when the latter uses delays with yield. +- Add new `return_iterators` kwarg to `search_prototypes` function in order to prepare for + more paginated handling of prototype returns. ## Evennia 0.9 (2018-2019) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 98cfa9505c..9aea89711a 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -9,8 +9,11 @@ import hashlib import time from ast import literal_eval from django.conf import settings +from django.db.models import Q, Subquery +from django.core.paginator import Paginator from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB +from evennia.typeclasses.attributes import Attribute from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, @@ -320,7 +323,7 @@ def delete_prototype(prototype_key, caller=None): return True -def search_prototype(key=None, tags=None, require_single=False): +def search_prototype(key=None, tags=None, require_single=False, return_iterators=False): """ Find prototypes based on key and/or tags, or all prototypes. @@ -331,11 +334,17 @@ def search_prototype(key=None, tags=None, require_single=False): tag category. require_single (bool): If set, raise KeyError if the result was not found or if there are multiple matches. + return_iterators (bool): Optimized return for large numbers of db-prototypes. + If set, separate returns of module based prototypes and paginate + the db-prototype return. Return: - matches (list): All found prototype dicts. Empty list if + matches (list): Default return, all found prototype dicts. Empty list if no match was found. Note that if neither `key` nor `tags` were given, *all* available prototypes will be returned. + list, queryset: If `return_iterators` are found, this is a list of + module-based prototypes followed by a *paginated* queryset of + db-prototypes. Raises: KeyError: If `require_single` is True and there are 0 or >1 matches. @@ -387,27 +396,41 @@ def search_prototype(key=None, tags=None, require_single=False): if key: # exact or partial match on key db_matches = ( - db_matches.filter(db_key=key) or db_matches.filter(db_key__icontains=key) - ).order_by("id") - # return prototype - db_prototypes = [dbprot.prototype for dbprot in db_matches] - - matches = db_prototypes + module_prototypes - nmatches = len(matches) - if nmatches > 1 and key: - key = key.lower() - # avoid duplicates if an exact match exist between the two types - filter_matches = [ - mta for mta in matches if mta.get("prototype_key") and mta["prototype_key"] == key - ] - if filter_matches and len(filter_matches) < nmatches: - matches = filter_matches - - nmatches = len(matches) - if nmatches != 1 and require_single: - raise KeyError("Found {} matching prototypes.".format(nmatches)) - - return matches + db_matches + .filter( + Q(db_key__iexact=key) | Q(db_key__icontains=key)) + .order_by("id") + ) + # convert to prototype + db_ids = db_matches.values_list("id", flat=True) + db_matches = ( + Attribute.objects + .filter(scriptdb__pk__in=db_ids, db_key="prototype") + .values_list("db_value", flat=True) + ) + if key: + matches = list(db_matches) + module_prototypes + nmatches = len(matches) + if nmatches > 1: + key = key.lower() + # avoid duplicates if an exact match exist between the two types + filter_matches = [ + mta for mta in matches if mta.get("prototype_key") and mta["prototype_key"] == key + ] + if filter_matches and len(filter_matches) < nmatches: + matches = filter_matches + nmatches = len(matches) + if nmatches != 1 and require_single: + raise KeyError("Found {} matching prototypes.".format(nmatches)) + return matches + elif return_iterators: + # trying to get the entire set of prototypes - we must paginate + # we must paginate the result of trying to fetch the entire set + db_pages = Paginator(db_matches, 500) + return module_prototypes, db_pages + else: + # full fetch, no pagination + return list(db_matches) + module_prototypes def search_objects_with_prototype(prototype_key): diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index edbb5acc51..574013ee4d 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -3,8 +3,10 @@ Unit tests for the prototypes and spawner """ -from random import randint +from random import randint, sample import mock +import uuid +from time import time from anything import Something from django.test.utils import override_settings from evennia.utils.test_resources import EvenniaTest @@ -1073,3 +1075,29 @@ class TestOLCMenu(TestEvMenu): ["node_index", "node_index", "node_index"], ], ] + +class PrototypeCrashTest(EvenniaTest): + + # increase this to 1000 for optimization testing + num_prototypes = 10 + + def create(self, num=None): + if not num: + num = self.num_prototypes + # print(f"Creating {num} additional prototypes...") + for x in range(num): + prot = { + 'prototype_key': str(uuid.uuid4()), + 'some_attributes': [str(uuid.uuid4()) for x in range(10)], + 'prototype_tags': list(sample(['demo', 'test', 'stuff'], 2)), + } + protlib.save_prototype(prot) + + def test_prototype_dos(self, *args, **kwargs): + num_prototypes = self.num_prototypes + for x in range(2): + self.create(num_prototypes) + # print("Attempting to list prototypes...") + start_time = time() + self.char1.execute_cmd('spawn/list') + # print(f"Prototypes listed in {time()-start_time} seconds.") From 61f389bccb6cbcb41c2b6cf43fa4e856d62c27a5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 5 Sep 2020 23:34:08 +0200 Subject: [PATCH 11/23] Add two default commands to `__all__` tuple. Resolve #2189 --- evennia/commands/default/comms.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index ed732ed227..55e96ecd4e 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -37,7 +37,10 @@ __all__ = ( "CmdCdesc", "CmdPage", "CmdIRC2Chan", + "CmdIRCStatus", "CmdRSS2Chan", + "CmdGrapevine2Chan", + ) _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH From 25c1526fc4a02579c58e9cd087abea00a047b499 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 6 Sep 2020 10:48:50 +0200 Subject: [PATCH 12/23] Fix unittest for postgres ordering --- evennia/prototypes/tests.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 574013ee4d..50aa3277c0 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -630,8 +630,10 @@ class TestPrototypeStorage(EvenniaTest): # partial match with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}): - self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) - self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) + self.assertEqual( + set(protlib.search_prototype("prot")), set([prot1b, prot2, prot3])) + self.assertEqual( + set(protlib.search_prototype(tags="foo1")), set([prot1b, prot2, prot3])) self.assertTrue(str(str(protlib.list_prototypes(self.char1)))) From 6170da556bb6b47ba1d0a1933986769b39ecf263 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 6 Sep 2020 13:09:43 +0200 Subject: [PATCH 13/23] Update prototype example module --- evennia/game_template/world/prototypes.py | 73 ++++++++++++-------- evennia/prototypes/prototypes.py | 2 +- evennia/utils/evmore.py | 81 ++++++++++++++++------- 3 files changed, 104 insertions(+), 52 deletions(-) diff --git a/evennia/game_template/world/prototypes.py b/evennia/game_template/world/prototypes.py index b64dd1b135..04aba091f3 100644 --- a/evennia/game_template/world/prototypes.py +++ b/evennia/game_template/world/prototypes.py @@ -2,40 +2,56 @@ Prototypes A prototype is a simple way to create individualized instances of a -given `Typeclass`. For example, you might have a Sword typeclass that -implements everything a Sword would need to do. The only difference -between different individual Swords would be their key, description -and some Attributes. The Prototype system allows to create a range of -such Swords with only minor variations. Prototypes can also inherit -and combine together to form entire hierarchies (such as giving all -Sabres and all Broadswords some common properties). Note that bigger -variations, such as custom commands or functionality belong in a -hierarchy of typeclasses instead. +given typeclass. It is dictionary with specific key names. -Example prototypes are read by the `@spawn` command but is also easily -available to use from code via `evennia.spawn` or `evennia.utils.spawner`. -Each prototype should be a dictionary. Use the same name as the -variable to refer to other prototypes. +For example, you might have a Sword typeclass that implements everything a +Sword would need to do. The only difference between different individual Swords +would be their key, description and some Attributes. The Prototype system +allows to create a range of such Swords with only minor variations. Prototypes +can also inherit and combine together to form entire hierarchies (such as +giving all Sabres and all Broadswords some common properties). Note that bigger +variations, such as custom commands or functionality belong in a hierarchy of +typeclasses instead. + +A prototype can either be a dictionary placed into a global variable in a +python module (a 'module-prototype') or stored in the database as a dict on a +special Script (a db-prototype). The former can be created just by adding dicts +to modules Evennia looks at for prototypes, the latter is easiest created +in-game via the `olc` command/menu. + +Prototypes are read and used to create new objects with the `spawn` command +or directly via `evennia.spawn` or the full path `evennia.prototypes.spawner.spawn`. + +A prototype dictionary have the following keywords: Possible keywords are: - prototype_parent - string pointing to parent prototype of this structure. - key - string, the main object identifier. - typeclass - string, if not set, will use `settings.BASE_OBJECT_TYPECLASS`. - location - this should be a valid object or #dbref. - home - valid object or #dbref. - destination - only valid for exits (object or dbref). +- `prototype_key` - the name of the prototype. This is required for db-prototypes, + for module-prototypes, the global variable name of the dict is used instead +- `prototype_parent` - string pointing to parent prototype if any. Prototype inherits + in a similar way as classes, with children overriding values in their partents. +- `key` - string, the main object identifier. +- `typeclass` - string, if not set, will use `settings.BASE_OBJECT_TYPECLASS`. +- `location` - this should be a valid object or #dbref. +- `home` - valid object or #dbref. +- `destination` - only valid for exits (object or #dbref). +- `permissions` - string or list of permission strings. +- `locks` - a lock-string to use for the spawned object. +- `aliases` - string or list of strings. +- `attrs` - Attributes, expressed as a list of tuples on the form `(attrname, value)`, + `(attrname, value, category)`, or `(attrname, value, category, locks)`. If using one + of the shorter forms, defaults are used for the rest. +- `tags` - Tags, as a list of tuples `(tag,)`, `(tag, category)` or `(tag, category, data)`. +- Any other keywords are interpreted as Attributes with no category or lock. + These will internally be added to `attrs` (eqivalent to `(attrname, value)`. - permissions - string or list of permission strings. - locks - a lock-string. - aliases - string or list of strings. - - ndb_ - value of a nattribute (the "ndb_" part is ignored). - any other keywords are interpreted as Attributes and their values. - -See the `@spawn` command and `evennia.utils.spawner` for more info. +See the `spawn` command and `evennia.prototypes.spawner.spawn` for more info. """ +## example of module-based prototypes using +## the variable name as `prototype_key` and +## simple Attributes + # from random import randint # # GOBLIN = { @@ -43,7 +59,8 @@ See the `@spawn` command and `evennia.utils.spawner` for more info. # "health": lambda: randint(20,30), # "resists": ["cold", "poison"], # "attacks": ["fists"], -# "weaknesses": ["fire", "light"] +# "weaknesses": ["fire", "light"], +# "tags": = [("greenskin", "monster"), ("humanoid", "monster")] # } # # GOBLIN_WIZARD = { diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 9aea89711a..9e99a766fe 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -592,7 +592,7 @@ def validate_prototype( protparent = protparents.get(protstring) if not protparent: _flags["errors"].append( - "Prototype {}'s prototype_parent '{}' was not found.".format((protkey, protstring)) + "Prototype {}'s prototype_parent '{}' was not found.".format(protkey, protstring) ) if id(prototype) in _flags["visited"]: _flags["errors"].append( diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 1db4c8d67c..9814cc6331 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -29,6 +29,7 @@ caller.msg() construct every time the page is updated. """ from django.conf import settings from django.db.models.query import QuerySet +from django.core.paginator import Paginator from evennia import Command, CmdSet from evennia.commands import cmdhandler from evennia.utils.utils import make_iter, inherits_from, justify @@ -131,7 +132,7 @@ class EvMore(object): def __init__( self, caller, - text, + inp, always_page=False, session=None, justify=False, @@ -143,28 +144,28 @@ class EvMore(object): ): """ - Initialization of the text handler. + Initialization of the inp handler. Args: caller (Object or Account): Entity reading the text. - text (str, EvTable or iterator): The text or data to put under paging. + inp (str, EvTable, Paginator or iterator): The text or data to put under paging. - If a string, paginage normally. If this text contains one or more `\f` format symbol, automatic pagination and justification are force-disabled and page-breaks will only happen after each `\f`. - If `EvTable`, the EvTable will be paginated with the same setting on each page if it is too long. The table decorations will be considered in the size of the page. - - Otherwise `text` is converted to an iterator, where each step is + - Otherwise `inp` is converted to an iterator, where each step is expected to be a line in the final display. Each line will be run through `iter_callable`. always_page (bool, optional): If `False`, the - pager will only kick in if `text` is too big + pager will only kick in if `inp` is too big to fit the screen. session (Session, optional): If given, this session will be used to determine the screen width and will receive all output. justify (bool, optional): If set, auto-justify long lines. This must be turned off for fixed-width or formatted output, like tables. It's force-disabled - if `text` is an EvTable. + if `inp` is an EvTable. justify_kwargs (dict, optional): Keywords for the justifiy function. Used only if `justify` is True. If this is not set, default arguments will be used. exit_on_lastpage (bool, optional): If reaching the last page without the @@ -230,31 +231,51 @@ class EvMore(object): # always limit number of chars to 10 000 per page self.height = min(10000 // max(1, self.width), height) - if inherits_from(text, "evennia.utils.evtable.EvTable"): - # an EvTable - self.init_evtable(text) - elif isinstance(text, QuerySet): - # a queryset - self.init_queryset(text) - elif not isinstance(text, str): - # anything else not a str - self.init_iterable(text) - elif "\f" in text: - # string with \f line-break markers in it - self.init_f_str(text) - else: - # a string - self.init_str(text) + # does initial parsing of input + self.parse_input(inp) # kick things into gear self.start() - # page formatter + # Hooks for customizing input handling and formatting (use if overriding this class) + + def parse_input(self, inp): + """ + Parse the input to figure out the size of the data, how many pages it + consist of and pick the correct paginator mechanism. Override this if + you want to support a new type of input. + + Each initializer should set self._paginator and optionally self._page_formatter + for properly handling the input data. + + """ + if inherits_from(inp, "evennia.utils.evtable.EvTable"): + # an EvTable + self.init_evtable(inp) + elif isinstance(inp, QuerySet): + # a queryset + self.init_queryset(inp) + elif isinstance(inp, Paginator): + self.init_django_paginator(inp) + elif not isinstance(inp, str): + # anything else not a str + self.init_iterable(inp) + elif "\f" in inp: + # string with \f line-break markers in it + self.init_f_str(inp) + else: + # a string + self.init_str(inp) def format_page(self, page): """ Page formatter. Uses the page_formatter callable by default. This allows to easier override the class if needed. + + Args: + page (any): A piece of data representing one page to display. This must + be poss + Returns: """ return self._page_formatter(page) @@ -269,7 +290,13 @@ class EvMore(object): Paginate by slice. This is done with an eye on memory efficiency (usually for querysets); to avoid fetching all objects at the same time. """ - return self._data[pageno * self.height : pageno * self.height + self.height] + return self._data[pageno * self.height: pageno * self.height + self.height] + + def paginator_django(self, pageno): + """ + Paginate using the django queryset Paginator API. Note that his is indexed from 1. + """ + return self._data.page(pageno + 1) # inits for different input types @@ -292,6 +319,14 @@ class EvMore(object): self._data = qs self._paginator = self.paginator_slice + def init_django_paginator(self, pages): + """ + The input is a django Paginator object. + """ + self._npages = pages.num_pages + self._data = pages + self._paginator = self.paginator_django + def init_iterable(self, inp): """The input is something other than a string - convert to iterable of strings""" inp = make_iter(inp) From 8046f78b9f2054c47ed147f0949f875692076e5e Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 6 Sep 2020 20:02:21 +0200 Subject: [PATCH 14/23] Use assertCountEqual for better handling db variations --- evennia/prototypes/tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 50aa3277c0..ae7e5bbfe2 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -630,10 +630,10 @@ class TestPrototypeStorage(EvenniaTest): # partial match with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}): - self.assertEqual( - set(protlib.search_prototype("prot")), set([prot1b, prot2, prot3])) - self.assertEqual( - set(protlib.search_prototype(tags="foo1")), set([prot1b, prot2, prot3])) + self.assertCountEqual( + protlib.search_prototype("prot"), [prot1b, prot2, prot3]) + self.assertCountEqual( + protlib.search_prototype(tags="foo1"), [prot1b, prot2, prot3]) self.assertTrue(str(str(protlib.list_prototypes(self.char1)))) From c963fe46b90dcd8f33cdfe49c9da2274b4592ce6 Mon Sep 17 00:00:00 2001 From: davewiththenicehat <54369722+davewiththenicehat@users.noreply.github.com> Date: Thu, 10 Sep 2020 08:29:23 -0400 Subject: [PATCH 15/23] gendersub msg method removed positional argument `text` in the msg method of GenderSub is a positional argument causing it to not function as expected in regard to projects documentation on the DefaultObject.msg method. This changes text to being an optional kwarg. --- evennia/contrib/gendersub.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/gendersub.py b/evennia/contrib/gendersub.py index ec8a14f7ee..7a1fde2069 100644 --- a/evennia/contrib/gendersub.py +++ b/evennia/contrib/gendersub.py @@ -120,7 +120,7 @@ class GenderCharacter(DefaultCharacter): pronoun = _GENDER_PRONOUN_MAP[gender][typ.lower()] return pronoun.capitalize() if typ.isupper() else pronoun - def msg(self, text, from_obj=None, session=None, **kwargs): + def msg(self, text=None, from_obj=None, session=None, **kwargs): """ Emits something to a session attached to the object. Overloads the default msg() implementation to include @@ -141,6 +141,10 @@ class GenderCharacter(DefaultCharacter): All extra kwargs will be passed on to the protocol. """ + if text is None: + super().msg(from_obj=from_obj, session=session, **kwargs) + return + try: if text and isinstance(text, tuple): text = (_RE_GENDER_PRONOUN.sub(self._get_pronoun, text[0]), *text[1:]) From f05d54ba5a8a4c1483d696763db4a113ab8b41b7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 9 Sep 2020 10:06:59 +0200 Subject: [PATCH 16/23] Optimize EvMore for prototypes, as per #2126 --- evennia/commands/default/building.py | 14 +- evennia/prototypes/prototypes.py | 183 ++++++++++---- evennia/prototypes/tests.py | 2 +- evennia/utils/evmore.py | 346 +++++++++++++++------------ 4 files changed, 330 insertions(+), 215 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 746d8a1c43..ba3911b3d9 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3476,16 +3476,16 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): elif query: self.caller.msg(f"No prototype named '{query}' was found.") else: - self.caller.msg(f"No prototypes found.") + self.caller.msg("No prototypes found.") def _list_prototypes(self, key=None, tags=None): """Display prototypes as a list, optionally limited by key/tags. """ - table = protlib.list_prototypes(self.caller, key=key, tags=tags) - if not table: - return True - EvMore( - self.caller, str(table), exit_on_lastpage=True, justify_kwargs=False, - ) + protlib.list_prototypes(self.caller, key=key, tags=tags) + # if not table: + # return True + # EvMore( + # self.caller, str(table), exit_on_lastpage=True, justify_kwargs=False, + # ) @interactive def _update_existing_objects(self, caller, prototype_key, quiet=False): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 9e99a766fe..b82760c745 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -15,6 +15,7 @@ from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB from evennia.typeclasses.attributes import Attribute from evennia.utils.create import create_script +from evennia.utils.evmore import EvMore from evennia.utils.utils import ( all_from_module, make_iter, @@ -393,6 +394,7 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories) else: db_matches = DbPrototype.objects.all().order_by("id") + if key: # exact or partial match on key db_matches = ( @@ -425,8 +427,8 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators return matches elif return_iterators: # trying to get the entire set of prototypes - we must paginate - # we must paginate the result of trying to fetch the entire set - db_pages = Paginator(db_matches, 500) + # the result instead of trying to fetch the entire set at once + db_pages = Paginator(db_matches, 20) return module_prototypes, db_pages else: # full fetch, no pagination @@ -447,6 +449,74 @@ def search_objects_with_prototype(prototype_key): return ObjectDB.objects.get_by_tag(key=prototype_key, category=PROTOTYPE_TAG_CATEGORY) +class PrototypeEvMore(EvMore): + """ + Listing 1000+ prototypes can be very slow. So we customize EvMore to + display an EvTable per paginated page rather than to try creating an + EvTable for the entire dataset and then paginate it. + """ + + def __init__(self, *args, **kwargs): + """Store some extra properties on the EvMore class""" + self.show_non_use = kwargs.pop("show_non_use", False) + self.show_non_edit = kwargs.pop("show_non_edit", False) + super().__init__(*args, **kwargs) + + def page_formatter(self, page): + """Input is a queryset page from django.Paginator""" + caller = self._caller + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + + for prototype in page: + lock_use = caller.locks.check_lockstring( + caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True + ) + if not self.show_non_use and not lock_use: + continue + if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get("prototype_locks", ""), access_type="edit", default=True + ) + if not self.show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get("prototype_tags", []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + display_tuples.append( + ( + prototype.get("prototype_key", ""), + prototype.get("prototype_desc", ""), + "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), + ",".join(ptags), + ) + ) + + if not display_tuples: + return "" + + table = [] + width = 78 + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) + table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align="c") + table.reformat_column(3, width=16) + return str(table) + + def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -465,57 +535,66 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed # this allows us to pass lists of empty strings tags = [tag for tag in make_iter(tags) if tag] - # get prototypes for readonly and db-based prototypes - prototypes = search_prototype(key, tags) + if key is not None: + # get specific prototype (one value or exception) + return PrototypeEvMore(caller, [search_prototype(key, tags)], + show_non_use=show_non_use, + show_non_edit=show_non_edit) + else: + # list all + # get prototypes for readonly and db-based prototypes + module_prots, db_prots = search_prototype(key, tags, return_iterators=True) + return PrototypeEvMore(caller, db_prots, + show_non_use=show_non_use, show_non_edit=show_non_edit) - # get use-permissions of readonly attributes (edit is always False) - display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d.get("prototype_key", "")): - lock_use = caller.locks.check_lockstring( - caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True - ) - if not show_non_use and not lock_use: - continue - if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES: - lock_edit = False - else: - lock_edit = caller.locks.check_lockstring( - caller, prototype.get("prototype_locks", ""), access_type="edit", default=True - ) - if not show_non_edit and not lock_edit: - continue - ptags = [] - for ptag in prototype.get("prototype_tags", []): - if is_iter(ptag): - if len(ptag) > 1: - ptags.append("{} (category: {}".format(ptag[0], ptag[1])) - else: - ptags.append(ptag[0]) - else: - ptags.append(str(ptag)) - - display_tuples.append( - ( - prototype.get("prototype_key", ""), - prototype.get("prototype_desc", ""), - "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), - ",".join(ptags), - ) - ) - - if not display_tuples: - return "" - - table = [] - width = 78 - for i in range(len(display_tuples[0])): - table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) - table.reformat_column(0, width=22) - table.reformat_column(1, width=29) - table.reformat_column(2, width=11, align="c") - table.reformat_column(3, width=16) - return table +# # get use-permissions of readonly attributes (edit is always False) +# display_tuples = [] +# for prototype in sorted(prototypes, key=lambda d: d.get("prototype_key", "")): +# lock_use = caller.locks.check_lockstring( +# caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True +# ) +# if not show_non_use and not lock_use: +# continue +# if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES: +# lock_edit = False +# else: +# lock_edit = caller.locks.check_lockstring( +# caller, prototype.get("prototype_locks", ""), access_type="edit", default=True +# ) +# if not show_non_edit and not lock_edit: +# continue +# ptags = [] +# for ptag in prototype.get("prototype_tags", []): +# if is_iter(ptag): +# if len(ptag) > 1: +# ptags.append("{} (category: {}".format(ptag[0], ptag[1])) +# else: +# ptags.append(ptag[0]) +# else: +# ptags.append(str(ptag)) +# +# display_tuples.append( +# ( +# prototype.get("prototype_key", ""), +# prototype.get("prototype_desc", ""), +# "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), +# ",".join(ptags), +# ) +# ) +# +# if not display_tuples: +# return "" +# +# table = [] +# width = 78 +# for i in range(len(display_tuples[0])): +# table.append([str(display_tuple[i]) for display_tuple in display_tuples]) +# table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) +# table.reformat_column(0, width=22) +# table.reformat_column(1, width=29) +# table.reformat_column(2, width=11, align="c") +# table.reformat_column(3, width=16) +# return table def validate_prototype( diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index ae7e5bbfe2..755d7f2e13 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -1100,6 +1100,6 @@ class PrototypeCrashTest(EvenniaTest): for x in range(2): self.create(num_prototypes) # print("Attempting to list prototypes...") - start_time = time() + # start_time = time() self.char1.execute_cmd('spawn/list') # print(f"Prototypes listed in {time()-start_time} seconds.") diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 9814cc6331..37b64abca2 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -176,27 +176,30 @@ class EvMore(object): the caller when the more page exits. Note that this will be using whatever cmdset the user had *before* the evmore pager was activated (so none of the evmore commands will be available when this is run). - page_formatter (callable, optional): If given, this function will be passed the - contents of each extracted page. This is useful when paginating - data consisting something other than a string or a list of strings. Especially - queryset data is likely to always need this argument specified. Note however, - that all size calculations assume this function to return one single line - per element on the page! kwargs (any, optional): These will be passed on to the `caller.msg` method. Examples: + + Basic use: + ``` super_long_text = " ... " EvMore(caller, super_long_text) - + ``` + Paginator + ``` from django.core.paginator import Paginator query = ObjectDB.objects.all() pages = Paginator(query, 10) # 10 objs per page - EvMore(caller, pages) # will repr() each object per line, 10 to a page - - multi_page_table = [ [[..],[..]], ...] - EvMore(caller, multi_page_table, use_evtable=True, - evtable_args=("Header1", "Header2"), - evtable_kwargs={"align": "r", "border": "tablecols"}) + EvMore(caller, pages) + ``` + Every page an EvTable + ``` + from evennia import EvTable + def _to_evtable(page): + table = ... # convert page to a table + return EvTable(*headers, table=table, ...) + EvMore(caller, pages, page_formatter=_to_evtable) + ``` """ self._caller = caller @@ -216,15 +219,17 @@ class EvMore(object): self.exit_on_lastpage = exit_on_lastpage self.exit_cmd = exit_cmd self._exit_msg = "Exited |wmore|n pager." - self._page_formatter = page_formatter self._kwargs = kwargs self._data = None - self._paginator = None + self._pages = [] - self._npages = 1 self._npos = 0 + self._npages = 1 + self._paginator = self.paginator_index + self._page_formatter = str + # set up individual pages for different sessions height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4) self.width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0] @@ -232,155 +237,19 @@ class EvMore(object): self.height = min(10000 // max(1, self.width), height) # does initial parsing of input - self.parse_input(inp) + self.init_pages(inp) # kick things into gear self.start() - # Hooks for customizing input handling and formatting (use if overriding this class) - - def parse_input(self, inp): - """ - Parse the input to figure out the size of the data, how many pages it - consist of and pick the correct paginator mechanism. Override this if - you want to support a new type of input. - - Each initializer should set self._paginator and optionally self._page_formatter - for properly handling the input data. - - """ - if inherits_from(inp, "evennia.utils.evtable.EvTable"): - # an EvTable - self.init_evtable(inp) - elif isinstance(inp, QuerySet): - # a queryset - self.init_queryset(inp) - elif isinstance(inp, Paginator): - self.init_django_paginator(inp) - elif not isinstance(inp, str): - # anything else not a str - self.init_iterable(inp) - elif "\f" in inp: - # string with \f line-break markers in it - self.init_f_str(inp) - else: - # a string - self.init_str(inp) - - def format_page(self, page): - """ - Page formatter. Uses the page_formatter callable by default. - This allows to easier override the class if needed. - - Args: - page (any): A piece of data representing one page to display. This must - be poss - Returns: - """ - return self._page_formatter(page) - - # paginators - responsible for extracting a specific page number - - def paginator_index(self, pageno): - """Paginate to specific, known index""" - return self._data[pageno] - - def paginator_slice(self, pageno): - """ - Paginate by slice. This is done with an eye on memory efficiency (usually for - querysets); to avoid fetching all objects at the same time. - """ - return self._data[pageno * self.height: pageno * self.height + self.height] - - def paginator_django(self, pageno): - """ - Paginate using the django queryset Paginator API. Note that his is indexed from 1. - """ - return self._data.page(pageno + 1) - - # inits for different input types - - def init_evtable(self, table): - """The input is an EvTable.""" - if table.height: - # enforced height of each paged table, plus space for evmore extras - self.height = table.height - 4 - - # convert table to string - text = str(table) - self._justify = False - self._justify_kwargs = None # enforce - self.init_str(text) - - def init_queryset(self, qs): - """The input is a queryset""" - nsize = qs.count() # we assume each will be a line - self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) - self._data = qs - self._paginator = self.paginator_slice - - def init_django_paginator(self, pages): - """ - The input is a django Paginator object. - """ - self._npages = pages.num_pages - self._data = pages - self._paginator = self.paginator_django - - def init_iterable(self, inp): - """The input is something other than a string - convert to iterable of strings""" - inp = make_iter(inp) - nsize = len(inp) - self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) - self._data = inp - self._paginator = self.paginator_slice - - def init_f_str(self, text): - """ - The input contains \f markers. We use \f to indicate the user wants to - enforce their line breaks on their own. If so, we do no automatic - line-breaking/justification at all. - """ - self._data = text.split("\f") - self._npages = len(self._data) - self._paginator = self.paginator_index - - def init_str(self, text): - """The input is a string""" - - if self._justify: - # we must break very long lines into multiple ones. Note that this - # will also remove spurious whitespace. - justify_kwargs = self._justify_kwargs or {} - width = self._justify_kwargs.get("width", self.width) - justify_kwargs["width"] = width - justify_kwargs["align"] = self._justify_kwargs.get("align", "l") - justify_kwargs["indent"] = self._justify_kwargs.get("indent", 0) - - lines = [] - for line in text.split("\n"): - if len(line) > width: - lines.extend(justify(line, **justify_kwargs).split("\n")) - else: - lines.append(line) - else: - # no justification. Simple division by line - lines = text.split("\n") - - self._data = [ - "\n".join(lines[i : i + self.height]) for i in range(0, len(lines), self.height) - ] - self._npages = len(self._data) - self._paginator = self.paginator_index - - # display helpers and navigation + # EvMore functional methods def display(self, show_footer=True): """ Pretty-print the page. """ pos = self._npos - text = self.format_page(self._paginator(pos)) + text = self.page_formatter(self.paginator(pos)) if show_footer: page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages) else: @@ -460,6 +329,173 @@ class EvMore(object): self.page_top() + # default paginators - responsible for extracting a specific page number + + def paginator_index(self, pageno): + """Paginate to specific, known index""" + return self._data[pageno] + + def paginator_slice(self, pageno): + """ + Paginate by slice. This is done with an eye on memory efficiency (usually for + querysets); to avoid fetching all objects at the same time. + """ + return self._data[pageno * self.height: pageno * self.height + self.height] + + def paginator_django(self, pageno): + """ + Paginate using the django queryset Paginator API. Note that his is indexed from 1. + """ + return self._data.page(pageno + 1) + + # default helpers to set up particular input types + + def init_evtable(self, table): + """The input is an EvTable.""" + if table.height: + # enforced height of each paged table, plus space for evmore extras + self.height = table.height - 4 + + # convert table to string + text = str(table) + self._justify = False + self._justify_kwargs = None # enforce + self.init_str(text) + + def init_queryset(self, qs): + """The input is a queryset""" + nsize = qs.count() # we assume each will be a line + self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) + self._data = qs + + def init_django_paginator(self, pages): + """ + The input is a django Paginator object. + """ + self._npages = pages.num_pages + self._data = pages + + def init_iterable(self, inp): + """The input is something other than a string - convert to iterable of strings""" + inp = make_iter(inp) + nsize = len(inp) + self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) + self._data = inp + + def init_f_str(self, text): + """ + The input contains \f markers. We use \f to indicate the user wants to + enforce their line breaks on their own. If so, we do no automatic + line-breaking/justification at all. + """ + self._data = text.split("\f") + self._npages = len(self._data) + + def init_str(self, text): + """The input is a string""" + + if self._justify: + # we must break very long lines into multiple ones. Note that this + # will also remove spurious whitespace. + justify_kwargs = self._justify_kwargs or {} + width = self._justify_kwargs.get("width", self.width) + justify_kwargs["width"] = width + justify_kwargs["align"] = self._justify_kwargs.get("align", "l") + justify_kwargs["indent"] = self._justify_kwargs.get("indent", 0) + + lines = [] + for line in text.split("\n"): + if len(line) > width: + lines.extend(justify(line, **justify_kwargs).split("\n")) + else: + lines.append(line) + else: + # no justification. Simple division by line + lines = text.split("\n") + + self._data = [ + "\n".join(lines[i : i + self.height]) for i in range(0, len(lines), self.height) + ] + self._npages = len(self._data) + + # Hooks for customizing input handling and formatting (override in a child class) + + def init_pages(self, inp): + """ + Initialize the pagination. By default, will analyze input type to determine + how pagination automatically. + + Args: + inp (any): Incoming data to be paginated. By default, handles pagination of + strings, querysets, django.Paginator, EvTables and any iterables with strings. + + Notes: + If overridden, this method must perform the following actions: + - read and re-store `self._data` (the incoming data set) if needed for pagination to work. + - set `self._npages` to the total number of pages. Default is 1. + - set `self._paginator` to a callable that will take a page number 1...N and return + the data to display on that page (not any decorations or next/prev buttons). If only + wanting to change the paginator, override `self.paginator` instead. + - set `self._page_formatter` to a callable that will receive the page from `self._paginator` + and format it with one element per line. Default is `str`. Or override `self.page_formatter` + directly instead. + + By default, helper methods are called that perform these actions + depending on supported inputs. + + """ + if inherits_from(inp, "evennia.utils.evtable.EvTable"): + # an EvTable + self.init_evtable(inp) + elif isinstance(inp, QuerySet): + # a queryset + self.init_queryset(inp) + self._paginator = self.paginator_slice + elif isinstance(inp, Paginator): + self.init_django_paginator(inp) + self._paginator = self.paginator_django + elif not isinstance(inp, str): + # anything else not a str + self.init_iterable(inp) + self._paginator = self.paginator_django + elif "\f" in inp: + # string with \f line-break markers in it + self.init_f_str(inp) + else: + # a string + self.init_str(inp) + + def paginator(self, pageno): + """ + Paginator. The data operated upon is in `self._data`. + + Args: + pageno (int): The page number to view, from 1...N + Returns: + str: The page to display (without any decorations, those are added + by EvMore). + + """ + return self._paginator(pageno) + + def page_formatter(self, page): + """ + Page formatter. Every page passes through this method. Override + it to customize behvaior per-page. A common use is to generate a new + EvTable for every page (this is more efficient than to generate one huge + EvTable across many pages and feed it into EvMore all at once). + + Args: + page (any): A piece of data representing one page to display. This must + + Returns: + str: A ready-formatted page to display. Extra footer with help about + switching to the next/prev page will be added automatically + + """ + return self._page_formatter(page) + + # helper function From eec1e3bf4e7d31a44b74426da9a559b3a7abf18a Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 9 Sep 2020 20:44:07 +0200 Subject: [PATCH 17/23] Add width logic for prototype display. Still no mix of module and db prototypes in list --- evennia/commands/default/building.py | 7 +-- evennia/prototypes/prototypes.py | 83 ++++++++-------------------- 2 files changed, 24 insertions(+), 66 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index ba3911b3d9..bdfae2499b 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3480,12 +3480,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def _list_prototypes(self, key=None, tags=None): """Display prototypes as a list, optionally limited by key/tags. """ - protlib.list_prototypes(self.caller, key=key, tags=tags) - # if not table: - # return True - # EvMore( - # self.caller, str(table), exit_on_lastpage=True, justify_kwargs=False, - # ) + protlib.list_prototypes(self.caller, key=key, tags=tags, session=self.session) @interactive def _update_existing_objects(self, caller, prototype_key, quiet=False): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index b82760c745..cd95468ce8 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -456,11 +456,21 @@ class PrototypeEvMore(EvMore): EvTable for the entire dataset and then paginate it. """ - def __init__(self, *args, **kwargs): + def __init__(self, caller, *args, session=None, **kwargs): """Store some extra properties on the EvMore class""" self.show_non_use = kwargs.pop("show_non_use", False) self.show_non_edit = kwargs.pop("show_non_edit", False) - super().__init__(*args, **kwargs) + + # set up table width + width = settings.CLIENT_DEFAULT_WIDTH + if not session: + # fall back to the first session + session = caller.sessions.all()[0] + if session: + width = session.protocol_flags.get("SCREENWIDTH", {0: width})[0] + self.width = width + + super().__init__(caller, *args, session=session, **kwargs) def page_formatter(self, page): """Input is a queryset page from django.Paginator""" @@ -487,7 +497,7 @@ class PrototypeEvMore(EvMore): for ptag in prototype.get("prototype_tags", []): if is_iter(ptag): if len(ptag) > 1: - ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + ptags.append("{} (category: {})".format(ptag[0], ptag[1])) else: ptags.append(ptag[0]) else: @@ -496,9 +506,9 @@ class PrototypeEvMore(EvMore): display_tuples.append( ( prototype.get("prototype_key", ""), - prototype.get("prototype_desc", ""), "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), - ",".join(ptags), + "\n".join(list(set(ptags))), + prototype.get("prototype_desc", ""), ) ) @@ -506,18 +516,17 @@ class PrototypeEvMore(EvMore): return "" table = [] - width = 78 for i in range(len(display_tuples[0])): table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) + table = EvTable("Key", "Spawn/Edit", "Tags", "Desc", table=table, crop=True, width=self.width) table.reformat_column(0, width=22) - table.reformat_column(1, width=29) - table.reformat_column(2, width=11, align="c") - table.reformat_column(3, width=16) + table.reformat_column(1, width=9, align="c") + table.reformat_column(2) + table.reformat_column(3) return str(table) -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True, session=None): """ Collate a list of found prototypes based on search criteria and access. @@ -527,6 +536,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed tags (str or list, optional): Tag key or keys to query for. show_non_use (bool, optional): Show also prototypes the caller may not use. show_non_edit (bool, optional): Show also prototypes the caller may not edit. + session (Session, optional): If given, this is used for display formatting. Returns: table (EvTable or None): An EvTable representation of the prototypes. None if no prototypes were found. @@ -538,6 +548,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed if key is not None: # get specific prototype (one value or exception) return PrototypeEvMore(caller, [search_prototype(key, tags)], + session=session, show_non_use=show_non_use, show_non_edit=show_non_edit) else: @@ -545,57 +556,9 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed # get prototypes for readonly and db-based prototypes module_prots, db_prots = search_prototype(key, tags, return_iterators=True) return PrototypeEvMore(caller, db_prots, + session=session, show_non_use=show_non_use, show_non_edit=show_non_edit) -# # get use-permissions of readonly attributes (edit is always False) -# display_tuples = [] -# for prototype in sorted(prototypes, key=lambda d: d.get("prototype_key", "")): -# lock_use = caller.locks.check_lockstring( -# caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True -# ) -# if not show_non_use and not lock_use: -# continue -# if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES: -# lock_edit = False -# else: -# lock_edit = caller.locks.check_lockstring( -# caller, prototype.get("prototype_locks", ""), access_type="edit", default=True -# ) -# if not show_non_edit and not lock_edit: -# continue -# ptags = [] -# for ptag in prototype.get("prototype_tags", []): -# if is_iter(ptag): -# if len(ptag) > 1: -# ptags.append("{} (category: {}".format(ptag[0], ptag[1])) -# else: -# ptags.append(ptag[0]) -# else: -# ptags.append(str(ptag)) -# -# display_tuples.append( -# ( -# prototype.get("prototype_key", ""), -# prototype.get("prototype_desc", ""), -# "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), -# ",".join(ptags), -# ) -# ) -# -# if not display_tuples: -# return "" -# -# table = [] -# width = 78 -# for i in range(len(display_tuples[0])): -# table.append([str(display_tuple[i]) for display_tuple in display_tuples]) -# table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) -# table.reformat_column(0, width=22) -# table.reformat_column(1, width=29) -# table.reformat_column(2, width=11, align="c") -# table.reformat_column(3, width=16) -# return table - def validate_prototype( prototype, protkey=None, protparents=None, is_prototype_base=True, strict=True, _flags=None From fcc8a394431104d76b9cf49ebdf5bed1c9077be2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 14 Sep 2020 19:13:18 +0200 Subject: [PATCH 18/23] Fix optimized prototype search mechanism. Still no dual db/mod search --- CHANGELOG.md | 4 +--- evennia/prototypes/prototypes.py | 38 ++++++++++++++++---------------- evennia/utils/evmore.py | 12 +++++++--- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d131062da..de062a528a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,9 +38,7 @@ without arguments starts a full interactive Python console. of texts (such as tables). New `justify` bool. Old `justify_kwargs` remains but is now only used to pass extra kwargs into the justify function. - EvMore `text` argument can now also be a list or a queryset. Querysets will be - sliced to only return the required data per page. EvMore takes a new kwarg - `page_formatter` which will be called for each page. This allows to customize - the display of queryset data, build a new EvTable per page etc. + sliced to only return the required data per page. - Improve performance of `find` and `objects` commands on large data sets (strikaco) - New `CHANNEL_HANDLER_CLASS` setting allows for replacing the ChannelHandler entirely. - Made `py` interactive mode support regular quit() and more verbose. diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index cd95468ce8..a6b2965712 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -410,22 +410,13 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators .filter(scriptdb__pk__in=db_ids, db_key="prototype") .values_list("db_value", flat=True) ) - if key: - matches = list(db_matches) + module_prototypes - nmatches = len(matches) - if nmatches > 1: - key = key.lower() - # avoid duplicates if an exact match exist between the two types - filter_matches = [ - mta for mta in matches if mta.get("prototype_key") and mta["prototype_key"] == key - ] - if filter_matches and len(filter_matches) < nmatches: - matches = filter_matches - nmatches = len(matches) - if nmatches != 1 and require_single: - raise KeyError("Found {} matching prototypes.".format(nmatches)) - return matches - elif return_iterators: + if key and require_single: + nmodules = len(module_prototypes) + ndbprots = db_matches.count() + if nmodules + ndbprots != 1: + raise KeyError(f"Found {nmodules + ndbprots} matching prototypes.") + + if return_iterators: # trying to get the entire set of prototypes - we must paginate # the result instead of trying to fetch the entire set at once db_pages = Paginator(db_matches, 20) @@ -479,6 +470,8 @@ class PrototypeEvMore(EvMore): # get use-permissions of readonly attributes (edit is always False) display_tuples = [] + print("page", page) + for prototype in page: lock_use = caller.locks.check_lockstring( caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True @@ -538,16 +531,23 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed show_non_edit (bool, optional): Show also prototypes the caller may not edit. session (Session, optional): If given, this is used for display formatting. Returns: - table (EvTable or None): An EvTable representation of the prototypes. None - if no prototypes were found. + PrototypeEvMore: An EvMore subclass optimized for prototype listings. + None: If a `key` was given and no matches was found. In this case the caller + has already been notified. """ # this allows us to pass lists of empty strings tags = [tag for tag in make_iter(tags) if tag] if key is not None: + matches = search_prototype(key, tags) + if not matches: + caller.msg("No prototypes found.", session=session) + return None + if len(matches) < 2: + matches = [matches] # get specific prototype (one value or exception) - return PrototypeEvMore(caller, [search_prototype(key, tags)], + return PrototypeEvMore(caller, matches, session=session, show_non_use=show_non_use, show_non_edit=show_non_edit) diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 37b64abca2..e623a4fdc5 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -248,8 +248,11 @@ class EvMore(object): """ Pretty-print the page. """ - pos = self._npos - text = self.page_formatter(self.paginator(pos)) + pos = 0 + text = "[no content]" + if self._npages > 0: + pos = self._npos + text = self.page_formatter(self.paginator(pos)) if show_footer: page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages) else: @@ -447,6 +450,7 @@ class EvMore(object): if inherits_from(inp, "evennia.utils.evtable.EvTable"): # an EvTable self.init_evtable(inp) + self._paginator = self.paginator_index elif isinstance(inp, QuerySet): # a queryset self.init_queryset(inp) @@ -457,13 +461,15 @@ class EvMore(object): elif not isinstance(inp, str): # anything else not a str self.init_iterable(inp) - self._paginator = self.paginator_django + self._paginator = self.paginator_index elif "\f" in inp: # string with \f line-break markers in it self.init_f_str(inp) + self._paginator = self.paginator_index else: # a string self.init_str(inp) + self._paginator = self.paginator_index def paginator(self, pageno): """ From 72ba38bdde1115297712763a7ee95f4bba56cfd9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 14 Sep 2020 23:31:10 +0200 Subject: [PATCH 19/23] Implement new EvMore functionality. Resolves #2126 --- CHANGELOG.md | 8 +- evennia/commands/default/building.py | 7 +- evennia/commands/default/system.py | 109 +++++++++++--------- evennia/commands/default/tests.py | 21 ++-- evennia/prototypes/prototypes.py | 145 ++++++++++++++++----------- evennia/server/portal/amp_server.py | 2 +- evennia/utils/evmore.py | 4 +- 7 files changed, 169 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de062a528a..a6665a90f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,9 +70,11 @@ without arguments starts a full interactive Python console. candidates, Builders+ will use list, local search and only global search if no match found. - Make `cmd.at_post_cmd()` always run after `cmd.func()`, even when the latter uses delays with yield. -- Add new `return_iterators` kwarg to `search_prototypes` function in order to prepare for - more paginated handling of prototype returns. - +- `EvMore` support for db queries and django paginators as well as easier to override for custom + pagination (e.g. to create EvTables for every page instead of splittine one table) +- Using `EvMore pagination`, dramatically improves performance of `spawn/list` and `scripts` listings + (100x speed increase for displaying 1000+ prototypes/scripts). + ## Evennia 0.9 (2018-2019) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index bdfae2499b..8006130336 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3076,9 +3076,9 @@ class CmdScript(COMMAND_DEFAULT_CLASS): result.append("No scripts defined on %s." % obj.get_display_name(caller)) elif not self.switches: # view all scripts - from evennia.commands.default.system import format_script_list - - result.append(format_script_list(scripts)) + from evennia.commands.default.system import ScriptEvMore + ScriptEvMore(self.caller, scripts.order_by("id"), session=self.session) + return elif "start" in self.switches: num = sum([obj.scripts.start(script.key) for script in scripts]) result.append("%s scripts started on %s." % (num, obj.get_display_name(caller))) @@ -3285,6 +3285,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): spawn/search [prototype_keykey][;tag[,tag]] spawn/list [tag, tag, ...] + spawn/list modules - list only module-based prototypes spawn/show [] spawn/update diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index c121c27c4e..d323cb24e1 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -16,6 +16,7 @@ import twisted import time from django.conf import settings +from django.core.paginator import Paginator from evennia.server.sessionhandler import SESSIONS from evennia.scripts.models import ScriptDB from evennia.objects.models import ObjectDB @@ -406,59 +407,71 @@ class CmdPy(COMMAND_DEFAULT_CLASS): ) -# helper function. Kept outside so it can be imported and run -# by other commands. +class ScriptEvMore(EvMore): + """ + Listing 1000+ Scripts can be very slow and memory-consuming. So + we use this custom EvMore child to build en EvTable only for + each page of the list. + """ -def format_script_list(scripts): - """Takes a list of scripts and formats the output.""" - if not scripts: - return "" + def init_pages(self, scripts): + """Prepare the script list pagination""" + script_pages = Paginator(scripts, max(1, int(self.height / 2))) + super().init_pages(script_pages) - table = EvTable( - "|wdbref|n", - "|wobj|n", - "|wkey|n", - "|wintval|n", - "|wnext|n", - "|wrept|n", - "|wdb", - "|wtypeclass|n", - "|wdesc|n", - align="r", - border="tablecols", - ) + def page_formatter(self, scripts): + """Takes a page of scripts and formats the output + into an EvTable.""" - for script in scripts: + if not scripts: + return "" - nextrep = script.time_until_next_repeat() - if nextrep is None: - nextrep = "PAUSED" if script.db._paused_time else "--" - else: - nextrep = "%ss" % nextrep - - maxrepeat = script.repeats - remaining = script.remaining_repeats() or 0 - if maxrepeat: - rept = "%i/%i" % (maxrepeat - remaining, maxrepeat) - else: - rept = "-/-" - - table.add_row( - script.id, - f"{script.obj.key}({script.obj.dbref})" - if (hasattr(script, "obj") and script.obj) - else "", - script.key, - script.interval if script.interval > 0 else "--", - nextrep, - rept, - "*" if script.persistent else "-", - script.typeclass_path.rsplit(".", 1)[-1], - crop(script.desc, width=20), + table = EvTable( + "|wdbref|n", + "|wobj|n", + "|wkey|n", + "|wintval|n", + "|wnext|n", + "|wrept|n", + "|wdb", + "|wtypeclass|n", + "|wdesc|n", + align="r", + border="tablecols", + width=self.width ) - return "%s" % table + for script in scripts: + + nextrep = script.time_until_next_repeat() + if nextrep is None: + nextrep = "PAUSED" if script.db._paused_time else "--" + else: + nextrep = "%ss" % nextrep + + maxrepeat = script.repeats + remaining = script.remaining_repeats() or 0 + if maxrepeat: + rept = "%i/%i" % (maxrepeat - remaining, maxrepeat) + else: + rept = "-/-" + + table.add_row( + script.id, + f"{script.obj.key}({script.obj.dbref})" + if (hasattr(script, "obj") and script.obj) + else "", + script.key, + script.interval if script.interval > 0 else "--", + nextrep, + rept, + "*" if script.persistent else "-", + script.typeclass_path.rsplit(".", 1)[-1], + crop(script.desc, width=20), + ) + + return str(table) class CmdScripts(COMMAND_DEFAULT_CLASS): @@ -547,7 +560,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): caller.msg(string) else: # multiple matches. - EvMore(caller, scripts, page_formatter=format_script_list) + ScriptEvMore(caller, scripts, session=self.session) caller.msg("Multiple script matches. Please refine your search") elif self.switches and self.switches[0] in ("validate", "valid", "val"): # run validation on all found scripts @@ -557,7 +570,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): caller.msg(string) else: # No stopping or validation. We just want to view things. - EvMore(caller, scripts, page_formatter=format_script_list) + ScriptEvMore(caller, scripts.order_by('id'), session=self.session) class CmdObjects(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 3fef90e0d9..739f5f9a34 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -1239,10 +1239,11 @@ class TestBuilding(CommandTest): ) def test_spawn(self): - def getObject(commandTest, objKeyStr): + + def get_object(commandTest, obj_key): # A helper function to get a spawned object and # check that it exists in the process. - query = search_object(objKeyStr) + query = search_object(obj_key) commandTest.assertIsNotNone(query) commandTest.assertTrue(bool(query)) obj = query[0] @@ -1271,7 +1272,7 @@ class TestBuilding(CommandTest): ) self.call(building.CmdSpawn(), "/search ", "Key ") - self.call(building.CmdSpawn(), "/search test;test2", "") + self.call(building.CmdSpawn(), "/search test;test2", "No prototypes found.") self.call( building.CmdSpawn(), @@ -1281,11 +1282,11 @@ class TestBuilding(CommandTest): ) self.call(building.CmdSpawn(), "/list", "Key ") - self.call(building.CmdSpawn(), "testprot", "Spawned Test Char") - # Tests that the spawned object's location is the same as the caharacter's location, since + + # Tests that the spawned object's location is the same as the character's location, since # we did not specify it. - testchar = getObject(self, "Test Char") + testchar = get_object(self, "Test Char") self.assertEqual(testchar.location, self.char1.location) testchar.delete() @@ -1302,7 +1303,7 @@ class TestBuilding(CommandTest): "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin", ) - goblin = getObject(self, "goblin") + goblin = get_object(self, "goblin") # Tests that the spawned object's type is a DefaultCharacter. self.assertIsInstance(goblin, DefaultCharacter) self.assertEqual(goblin.location, spawnLoc) @@ -1321,7 +1322,7 @@ class TestBuilding(CommandTest): # Tests "spawn " self.call(building.CmdSpawn(), "testball", "Spawned Ball") - ball = getObject(self, "Ball") + ball = get_object(self, "Ball") self.assertEqual(ball.location, self.char1.location) self.assertIsInstance(ball, DefaultObject) ball.delete() @@ -1331,7 +1332,7 @@ class TestBuilding(CommandTest): self.call( building.CmdSpawn(), "/n 'BALL'", "Spawned Ball" ) # /n switch is abbreviated form of /noloc - ball = getObject(self, "Ball") + ball = get_object(self, "Ball") self.assertIsNone(ball.location) ball.delete() @@ -1350,7 +1351,7 @@ class TestBuilding(CommandTest): % spawnLoc.dbref, "Spawned Ball", ) - ball = getObject(self, "Ball") + ball = get_object(self, "Ball") self.assertEqual(ball.location, spawnLoc) ball.delete() diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index a6b2965712..79d48cac1d 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -391,24 +391,37 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators # exact match on tag(s) tags = make_iter(tags) tag_categories = ["db_prototype" for _ in tags] - db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories) + db_matches = DbPrototype.objects.get_by_tag( + tags, tag_categories) else: - db_matches = DbPrototype.objects.all().order_by("id") + db_matches = DbPrototype.objects.all() if key: # exact or partial match on key - db_matches = ( + exact_match = ( db_matches .filter( - Q(db_key__iexact=key) | Q(db_key__icontains=key)) - .order_by("id") + Q(db_key__iexact=key)) + .order_by("db_key") ) + if not exact_match: + # try with partial match instead + db_matches = ( + db_matches + .filter( + Q(db_key__icontains=key)) + .order_by("db_key") + ) + else: + db_matches = exact_match + # convert to prototype db_ids = db_matches.values_list("id", flat=True) db_matches = ( Attribute.objects .filter(scriptdb__pk__in=db_ids, db_key="prototype") .values_list("db_value", flat=True) + .order_by("scriptdb__db_key") ) if key and require_single: nmodules = len(module_prototypes) @@ -419,10 +432,9 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators if return_iterators: # trying to get the entire set of prototypes - we must paginate # the result instead of trying to fetch the entire set at once - db_pages = Paginator(db_matches, 20) - return module_prototypes, db_pages + return db_matches, module_prototypes else: - # full fetch, no pagination + # full fetch, no pagination (compatibility mode) return list(db_matches) + module_prototypes @@ -451,18 +463,45 @@ class PrototypeEvMore(EvMore): """Store some extra properties on the EvMore class""" self.show_non_use = kwargs.pop("show_non_use", False) self.show_non_edit = kwargs.pop("show_non_edit", False) - - # set up table width - width = settings.CLIENT_DEFAULT_WIDTH - if not session: - # fall back to the first session - session = caller.sessions.all()[0] - if session: - width = session.protocol_flags.get("SCREENWIDTH", {0: width})[0] - self.width = width - super().__init__(caller, *args, session=session, **kwargs) + def init_pages(self, inp): + """ + This will be initialized with a tuple (mod_prototype_list, paginated_db_query) + and we must handle these separately since they cannot be paginated in the same + way. We will build the prototypes so that the db-prototypes come first (they + are likely the most volatile), followed by the mod-prototypes. + """ + dbprot_query, modprot_list = inp + # set the number of entries per page to half the reported height of the screen + # to account for long descs etc + dbprot_paged = Paginator(dbprot_query, max(1, int(self.height / 2))) + + # we separate the different types of data, so we track how many pages there are + # of each. + n_mod = len(modprot_list) + self._npages_mod = n_mod // self.height + (0 if n_mod % self.height == 0 else 1) + self._db_count = dbprot_paged.count + self._npages_db = dbprot_paged.num_pages if self._db_count > 0 else 0 + # total number of pages + self._npages = self._npages_mod + self._npages_db + self._data = (dbprot_paged, modprot_list) + self._paginator = self.prototype_paginator + + def prototype_paginator(self, pageno): + """ + The listing is separated in db/mod prototypes, so we need to figure out which + one to pick based on the page number. Also, pageno starts from 0. + """ + dbprot_pages, modprot_list = self._data + + if self._db_count and pageno < self._npages_db: + return dbprot_pages.page(pageno + 1) + else: + # get the correct slice, adjusted for the db-prototypes + pageno = max(0, pageno - self._npages_db) + return modprot_list[pageno * self.height: pageno * self.height + self.height] + def page_formatter(self, page): """Input is a queryset page from django.Paginator""" caller = self._caller @@ -470,7 +509,15 @@ class PrototypeEvMore(EvMore): # get use-permissions of readonly attributes (edit is always False) display_tuples = [] - print("page", page) + table = EvTable( + "|wKey|n", + "|wSpawn/Edit|n", + "|wTags|n", + "|wDesc|n", + border="tablecols", + crop=True, + width=self.width + ) for prototype in page: lock_use = caller.locks.check_lockstring( @@ -490,36 +537,24 @@ class PrototypeEvMore(EvMore): for ptag in prototype.get("prototype_tags", []): if is_iter(ptag): if len(ptag) > 1: - ptags.append("{} (category: {})".format(ptag[0], ptag[1])) + ptags.append("{}".format(ptag[0])) else: ptags.append(ptag[0]) else: ptags.append(str(ptag)) - display_tuples.append( - ( - prototype.get("prototype_key", ""), - "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), - "\n".join(list(set(ptags))), - prototype.get("prototype_desc", ""), - ) + table.add_row( + prototype.get("prototype_key", ""), + "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), + ", ".join(list(set(ptags))), + prototype.get("prototype_desc", ""), ) - if not display_tuples: - return "" - - table = [] - for i in range(len(display_tuples[0])): - table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Spawn/Edit", "Tags", "Desc", table=table, crop=True, width=self.width) - table.reformat_column(0, width=22) - table.reformat_column(1, width=9, align="c") - table.reformat_column(2) - table.reformat_column(3) return str(table) -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True, session=None): +def list_prototypes(caller, key=None, tags=None, show_non_use=False, + show_non_edit=True, session=None): """ Collate a list of found prototypes based on search criteria and access. @@ -532,33 +567,23 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed session (Session, optional): If given, this is used for display formatting. Returns: PrototypeEvMore: An EvMore subclass optimized for prototype listings. - None: If a `key` was given and no matches was found. In this case the caller - has already been notified. + None: If no matches were found. In this case the caller has already been notified. """ # this allows us to pass lists of empty strings tags = [tag for tag in make_iter(tags) if tag] - if key is not None: - matches = search_prototype(key, tags) - if not matches: - caller.msg("No prototypes found.", session=session) - return None - if len(matches) < 2: - matches = [matches] - # get specific prototype (one value or exception) - return PrototypeEvMore(caller, matches, - session=session, - show_non_use=show_non_use, - show_non_edit=show_non_edit) - else: - # list all - # get prototypes for readonly and db-based prototypes - module_prots, db_prots = search_prototype(key, tags, return_iterators=True) - return PrototypeEvMore(caller, db_prots, - session=session, - show_non_use=show_non_use, show_non_edit=show_non_edit) + dbprot_query, modprot_list = search_prototype(key, tags, return_iterators=True) + if not dbprot_query and not modprot_list: + caller.msg("No prototypes found.", session=session) + return None + + # get specific prototype (one value or exception) + return PrototypeEvMore(caller, (dbprot_query, modprot_list), + session=session, + show_non_use=show_non_use, + show_non_edit=show_non_edit) def validate_prototype( prototype, protkey=None, protparents=None, is_prototype_base=True, strict=True, _flags=None diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index de8a08ecf9..d5931f182b 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -159,7 +159,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ # start the Server - print("Portal starting server ... {}".format(server_twistd_cmd)) + print("Portal starting server ... ") process = None with open(settings.SERVER_LOG_FILE, "a") as logfile: # we link stdout to a file in order to catch diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index e623a4fdc5..f992446979 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -461,7 +461,7 @@ class EvMore(object): elif not isinstance(inp, str): # anything else not a str self.init_iterable(inp) - self._paginator = self.paginator_index + self._paginator = self.paginator_slice elif "\f" in inp: # string with \f line-break markers in it self.init_f_str(inp) @@ -476,7 +476,7 @@ class EvMore(object): Paginator. The data operated upon is in `self._data`. Args: - pageno (int): The page number to view, from 1...N + pageno (int): The page number to view, from 0...N-1 Returns: str: The page to display (without any decorations, those are added by EvMore). From d9667115f66ae14c6c977b5d4baaa6db83cb7c42 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 15 Sep 2020 20:54:31 +0200 Subject: [PATCH 20/23] Fix django requirements for current LTS version --- evennia/server/evennia_launcher.py | 25 ++++++++++--------------- requirements.txt | 2 +- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 50349651a1..2fa12f2a56 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -93,8 +93,8 @@ SRESET = chr(19) # shutdown server in reset mode # requirements PYTHON_MIN = "3.7" TWISTED_MIN = "18.0.0" -DJANGO_MIN = "2.1" -DJANGO_REC = "2.2" +DJANGO_MIN = "2.2.5" +DJANGO_LT = "3.0" try: sys.path[1] = EVENNIA_ROOT @@ -374,8 +374,8 @@ ERROR_NOTWISTED = """ """ ERROR_DJANGO_MIN = """ - ERROR: Django {dversion} found. Evennia requires version {django_min} - or higher. + ERROR: Django {dversion} found. Evennia requires at least version {django_min} (but + no higher than {django_lt}). If you are using a virtualenv, use the command `pip install --upgrade -e evennia` where `evennia` is the folder to where you cloned the Evennia library. If not @@ -386,14 +386,9 @@ ERROR_DJANGO_MIN = """ any warnings and don't run `makemigrate` even if told to. """ -NOTE_DJANGO_MIN = """ - NOTE: Django {dversion} found. This will work, but Django {django_rec} is - recommended for production. - """ - NOTE_DJANGO_NEW = """ NOTE: Django {dversion} found. This is newer than Evennia's - recommended version ({django_rec}). It might work, but may be new + recommended version ({django_rec}). It might work, but is new enough to not be fully tested yet. Report any issues. """ @@ -1282,13 +1277,13 @@ def check_main_evennia_dependencies(): dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int)) # only the main version (1.5, not 1.5.4.0) dversion_main = ".".join(dversion.split(".")[:2]) + print("dversion", dversion, DJANGO_MIN, DJANGO_LT) if LooseVersion(dversion) < LooseVersion(DJANGO_MIN): - print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN)) + print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN, + django_lt=DJANGO_LT)) error = True - elif LooseVersion(DJANGO_MIN) <= LooseVersion(dversion) < LooseVersion(DJANGO_REC): - print(NOTE_DJANGO_MIN.format(dversion=dversion_main, django_rec=DJANGO_REC)) - elif LooseVersion(DJANGO_REC) < LooseVersion(dversion_main): - print(NOTE_DJANGO_NEW.format(dversion=dversion_main, django_rec=DJANGO_REC)) + elif LooseVersion(DJANGO_LT) <= LooseVersion(dversion_main): + print(NOTE_DJANGO_NEW.format(dversion=dversion_main, django_rec=DJANGO_LT)) except ImportError: print(ERROR_NODJANGO) error = True diff --git a/requirements.txt b/requirements.txt index 7e70b36daa..2c42bd2d50 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # Evennia dependencies # general -django >= 2.2.5, < 2.3 +django >= 2.2.5, < 3.0 twisted >= 20.3.0, < 21.0.0 pytz django-sekizai From 4dcb3e2f473fe2d1fb02164075fbb4c0ddaf13b7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 16 Sep 2020 19:55:10 +0200 Subject: [PATCH 21/23] Some fixes to the connection wizard; Add MSSP guide. --- evennia/game_template/server/conf/mssp.py | 16 +-- evennia/server/connection_wizard.py | 120 +++++++++++++++------- evennia/server/evennia_launcher.py | 1 - evennia/settings_default.py | 4 +- 4 files changed, 91 insertions(+), 50 deletions(-) diff --git a/evennia/game_template/server/conf/mssp.py b/evennia/game_template/server/conf/mssp.py index 711447b81f..270c8f5bcb 100644 --- a/evennia/game_template/server/conf/mssp.py +++ b/evennia/game_template/server/conf/mssp.py @@ -20,11 +20,11 @@ needed on the Evennia side. MSSPTable = { # Required fields - "NAME": "Evennia", + "NAME": "Mygame", # usually the same as SERVERNAME # Generic - "CRAWL DELAY": "-1", # limit how often crawler updates the listing. -1 for no limit - "HOSTNAME": "", # current or new hostname - "PORT": ["4000"], # most important port should be *last* in list! + "CRAWL DELAY": "-1", # limit how often crawler may update the listing. -1 for no limit + "HOSTNAME": "", # telnet hostname + "PORT": ["4000"], # telnet port - most important port should be *last* in list! "CODEBASE": "Evennia", "CONTACT": "", # email for contacting the mud "CREATED": "", # year MUD was created @@ -33,7 +33,7 @@ MSSPTable = { "LANGUAGE": "", # name of language used, e.g. English "LOCATION": "", # full English name of server country "MINIMUM AGE": "0", # set to 0 if not applicable - "WEBSITE": "www.evennia.com", + "WEBSITE": "", # http:// address to your game website # Categorisation "FAMILY": "Custom", # evennia goes under 'Custom' "GENRE": "None", # Adult, Fantasy, Historical, Horror, Modern, None, or Science Fiction @@ -41,10 +41,10 @@ MSSPTable = { # Player versus Player, Player versus Environment, # Roleplaying, Simulation, Social or Strategy "GAMEPLAY": "", - "STATUS": "Open Beta", # Alpha, Closed Beta, Open Beta, Live + "STATUS": "Open Beta", # Allowed: Alpha, Closed Beta, Open Beta, Live "GAMESYSTEM": "Custom", # D&D, d20 System, World of Darkness, etc. Use Custom if homebrew # Subgenre: LASG, Medieval Fantasy, World War II, Frankenstein, - # Cyberpunk, Dragonlance, etc. Or None if not available. + # Cyberpunk, Dragonlance, etc. Or None if not applicable. "SUBGENRE": "None", # World "AREAS": "0", @@ -56,7 +56,7 @@ MSSPTable = { "LEVELS": "0", # use 0 if level-less "RACES": "0", # use 0 if race-less "SKILLS": "0", # use 0 if skill-less - # Protocols set to 1 or 0) + # Protocols set to 1 or 0; should usually not be changed) "ANSI": "1", "GMCP": "1", "MSDP": "1", diff --git a/evennia/server/connection_wizard.py b/evennia/server/connection_wizard.py index 8fe0090f63..cce74fb703 100644 --- a/evennia/server/connection_wizard.py +++ b/evennia/server/connection_wizard.py @@ -44,7 +44,7 @@ class ConnectionWizard(object): resp = str(default) if resp.lower() in options: - self.display(f" Selected '{resp}'.") + # self.display(f" Selected '{resp}'.") desc, callback, kwargs = options[resp.lower()] callback(self, **kwargs) elif resp.lower() in ("quit", "q"): @@ -161,8 +161,10 @@ class ConnectionWizard(object): def node_start(wizard): text = """ - This wizard helps activate external networks with Evennia. It will create - a config that will be attached to the bottom of the game settings file. + This wizard helps to attach your Evennia server to external networks. It + will save to a file `server/conf/connection_settings.py` that will be + imported from the bottom of your game settings file. Once generated you can + also modify that file directly. Make sure you have at least started the game once before continuing! @@ -174,11 +176,18 @@ def node_start(wizard): node_game_index_start, {}, ), - # "2": ("Add MSSP information (for mud-list crawlers)", - # node_mssp_start, {}), + "2": ("MSSP setup (for mud-list crawlers)", + node_mssp_start, {} + ), # "3": ("Add Grapevine listing", # node_grapevine_start, {}), - "2": ("View and Save created settings", node_view_and_apply_settings, {}), + # "4": ("Add IRC link", + # "node_irc_start", {}), + # "5" ("Add RSS feed", + # "node_rss_start", {}), + "s": ("View and (optionally) Save created settings", + node_view_and_apply_settings, {}), + "q": ("Quit", lambda *args: sys.exit(), {}), } wizard.display(text) @@ -189,13 +198,13 @@ def node_start(wizard): def node_game_index_start(wizard, **kwargs): - text = f""" + text = """ The Evennia game index (http://games.evennia.com) lists both active Evennia games as well as games in various stages of development. You can put up your game in the index also if you are not (yet) open for - players. If so, put 'None' for the connection details. Just tell us you - are out there and make us excited about your upcoming game! + players. If so, put 'None' for the connection details - you are just telling + us that you are out there, making us excited about your upcoming game! Please check the listing online first to see that your exact game name is not colliding with an existing game-name in the list (be nice!). @@ -222,9 +231,9 @@ def node_game_index_fields(wizard, status=None): - pre-alpha: a game in its very early stages, mostly unfinished or unstarted - alpha: a working concept, probably lots of bugs and incomplete features - beta: a working game, but expect bugs and changing features - - launched: a full, working game that may still be expanded upon and improved later + - launched: a full, working game (that may still be expanded upon and improved later) - Current value: + Current value (return to keep): {status_default} """ @@ -233,6 +242,31 @@ def node_game_index_fields(wizard, status=None): wizard.display(text) wizard.game_index_listing["game_status"] = wizard.ask_choice("Select one: ", options) + # game name + + name_default = settings.SERVERNAME + text = f""" + Your game's name should usually be the same as `settings.SERVERNAME`, but + you can set it to something else here if you want. + + Current value: + {name_default} + """ + + def name_validator(inp): + tmax = 80 + tlen = len(inp) + if tlen > tmax: + print(f"The name must be shorter than {tmax} characters (was {tlen}).") + wizard.ask_continue() + return False + return True + + wizard.display(text) + wizard.game_index_listing['game_name'] = wizard.ask_input( + default=name_default, validator=name_validator + ) + # short desc sdesc_default = wizard.game_index_listing.get("short_description", None) @@ -249,7 +283,7 @@ def node_game_index_fields(wizard, status=None): def sdesc_validator(inp): tmax = 255 tlen = len(inp) - if tlen > 255: + if tlen > tmax: print(f"The short desc must be shorter than {tmax} characters (was {tlen}).") wizard.ask_continue() return False @@ -341,7 +375,7 @@ def node_game_index_fields(wizard, status=None): Evennia is its own web server and runs your game's website. Enter the URL of the website here, like http://yourwebsite.com, here. - Wtite 'None' if you are not offering a publicly visible website at this time. + Write 'None' if you are not offering a publicly visible website at this time. Current value: {website_default} @@ -359,7 +393,7 @@ def node_game_index_fields(wizard, status=None): your specific URL here (when clicking this link you should launch into the web client) - Wtite 'None' if you don't want to list a publicly accessible webclient. + Write 'None' if you don't want to list a publicly accessible webclient. Current value: {webclient_default} @@ -388,24 +422,26 @@ def node_game_index_fields(wizard, status=None): def node_mssp_start(wizard): - mssp_module = mod_import(settings.MSSP_META_MODULE) - filename = mssp_module.__file__ + mssp_module = mod_import(settings.MSSP_META_MODULE or "server.conf.mssp") + try: + filename = mssp_module.__file__ + except AttributeError: + filename = "server/conf/mssp.py" text = f""" - MSSP (Mud Server Status Protocol) allows online MUD-listing sites/crawlers - to continuously monitor your game and list information about it. Some of - this, like active player-count, Evennia will automatically add for you, - whereas many fields are manually added info about your game. + MSSP (Mud Server Status Protocol) has a vast amount of options so it must + be modified outside this wizard by directly editing its config file here: + + '{filename}' + + MSSP allows traditional online MUD-listing sites/crawlers to continuously + monitor your game and list information about it. Some of this, like active + player-count, Evennia will automatically add for you, whereas most fields + you need to set manually. To use MSSP you should generally have a publicly open game that external players can connect to. You also need to register at a MUD listing site to - tell them to list your game. - - MSSP has a large number of configuration options and we found it was simply - a lot easier to set them in a file rather than using this wizard. So to - configure MSSP, edit the empty template listing found here: - - '{filename}' + tell them to crawl your game. """ wizard.display(text) @@ -456,25 +492,31 @@ def node_view_and_apply_settings(wizard): pp = pprint.PrettyPrinter(indent=4) saves = False - game_index_txt = "No changes to save for Game Index." - if hasattr(wizard, "game_index_listing"): - if wizard.game_index_listing != settings.GAME_INDEX_LISTING: - game_index_txt = "No changes to save for Game Index." - else: - game_index_txt = "GAME_INDEX_ENABLED = True\n" "GAME_INDEX_LISTING = \\\n" + pp.pformat( - wizard.game_index_listing - ) - saves = True + # game index + game_index_save_text = "" + game_index_listing = (wizard.game_index_listing if + hasattr(wizard, "game_index_listing") else None) + if not game_index_listing and settings.GAME_INDEX_ENABLED: + game_index_listing = settings.GAME_INDEX_LISTING + if game_index_listing: + game_index_save_text = ( + "GAME_INDEX_ENABLED = True\n" + "GAME_INDEX_LISTING = \\\n" + pp.pformat(game_index_listing) + ) + saves = True + else: + game_index_save_text = "# No Game Index settings found." - text = game_index_txt + # potentially add other wizards in the future + text = game_index_save_text wizard.display(f"Settings to save:\n\n{text}") if saves: - if wizard.ask_yesno("Do you want to save these settings?") == "yes": + if wizard.ask_yesno("\nDo you want to save these settings?") == "yes": wizard.save_output = text _save_changes(wizard) - wizard.display("... saved!") + wizard.display("... saved!\nThe changes will apply after you reload your server.") else: wizard.display("... cancelled.") wizard.ask_continue() diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 2fa12f2a56..69517fa38e 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1277,7 +1277,6 @@ def check_main_evennia_dependencies(): dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int)) # only the main version (1.5, not 1.5.4.0) dversion_main = ".".join(dversion.split(".")[:2]) - print("dversion", dversion, DJANGO_MIN, DJANGO_LT) if LooseVersion(dversion) < LooseVersion(DJANGO_MIN): print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN, django_lt=DJANGO_LT)) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index dc91c3990e..b41df49aae 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -730,9 +730,9 @@ CHANNEL_CONNECTINFO = None GAME_INDEX_ENABLED = False # This dict GAME_INDEX_LISTING = { - "game_name": SERVERNAME, + "game_name": "Mygame", # usually SERVERNAME "game_status": "pre-alpha", # pre-alpha, alpha, beta or launched - "short_description": GAME_SLOGAN, + "short_description": "", # could be GAME_SLOGAN "long_description": "", "listing_contact": "", # email "telnet_hostname": "", # mygame.com From 14bc59bee05835ff8b444d9a7f5eb8102e6f5ba9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 16 Sep 2020 23:29:50 +0200 Subject: [PATCH 22/23] Fix cmdhandler regression related to earlier fix. Resolves #2190 --- evennia/commands/cmdhandler.py | 75 +++++++++++++++++----------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index b22719ca35..b22a80ed19 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -174,6 +174,27 @@ def _msg_err(receiver, stringtuple): ) +def _process_input(caller, prompt, result, cmd, generator): + """ + Specifically handle the get_input value to send to _progressive_cmd_run as + part of yielding from a Command's `func`. + + Args: + caller (Character, Account or Session): the caller. + prompt (str): The sent prompt. + result (str): The unprocessed answer. + cmd (Command): The command itself. + generator (GeneratorType): The generator. + + Returns: + result (bool): Always `False` (stop processing). + + """ + # We call it using a Twisted deferLater to make sure the input is properly closed. + deferLater(reactor, 0, _progressive_cmd_run, cmd, generator, response=result) + return False + + def _progressive_cmd_run(cmd, generator, response=None): """ Progressively call the command that was given in argument. Used @@ -206,7 +227,15 @@ def _progressive_cmd_run(cmd, generator, response=None): else: value = generator.send(response) except StopIteration: - raise + # duplicated from cmdhandler._run_command, to have these + # run in the right order while staying inside the deferred + cmd.at_post_cmd() + if cmd.save_for_next: + # store a reference to this command, possibly + # accessible by the next command. + cmd.caller.ndb.last_cmd = copy(cmd) + else: + cmd.caller.ndb.last_cmd = None else: if isinstance(value, (int, float)): utils.delay(value, _progressive_cmd_run, cmd, generator) @@ -216,27 +245,6 @@ def _progressive_cmd_run(cmd, generator, response=None): raise ValueError("unknown type for a yielded value in command: {}".format(type(value))) -def _process_input(caller, prompt, result, cmd, generator): - """ - Specifically handle the get_input value to send to _progressive_cmd_run as - part of yielding from a Command's `func`. - - Args: - caller (Character, Account or Session): the caller. - prompt (str): The sent prompt. - result (str): The unprocessed answer. - cmd (Command): The command itself. - generator (GeneratorType): The generator. - - Returns: - result (bool): Always `False` (stop processing). - - """ - # We call it using a Twisted deferLater to make sure the input is properly closed. - deferLater(reactor, 0, _progressive_cmd_run, cmd, generator, response=result) - return False - - # custom Exceptions @@ -631,22 +639,15 @@ def cmdhandler( ret = cmd.func() if isinstance(ret, types.GeneratorType): # cmd.func() is a generator, execute progressively - in_generator = True - try: - _progressive_cmd_run(cmd, ret) - except StopIteration: - # this means func() has run its course - in_generator = False - yield None - else: - in_generator = False + _progressive_cmd_run(cmd, ret) + ret = yield ret + # note that the _progressive_cmd_run will itself run + # the at_post_cmd etc as it finishes; this is a bit of + # code duplication but there seems to be no way to + # catch the StopIteration here (it's not in the same + # frame since this is in a deferred chain) + else: ret = yield ret - - if not in_generator: - # this will only run if we are out of the generator for this - # cmd, otherwise we would have at_post_cmd run before a delayed - # func() finished - # post-command hook yield cmd.at_post_cmd() From 46544805a49c00fa77f3819f567aedb637dc38e9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 16 Sep 2020 23:37:55 +0200 Subject: [PATCH 23/23] Fix bug in module-prototype loading. Resolve #2195. --- evennia/prototypes/prototypes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 79d48cac1d..75410f20d3 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -167,7 +167,8 @@ for mod in settings.PROTOTYPE_MODULES: if "prototype_locks" in prot else "use:all();edit:false()" ), - "prototype_tags": list(set(make_iter(prot.get("prototype_tags", [])) + ["module"])), + "prototype_tags": list(set(list( + make_iter(prot.get("prototype_tags", []))) + ["module"])), } ) _MODULE_PROTOTYPES[actual_prot_key] = prot