diff --git a/CHANGELOG.md b/CHANGELOG.md index 9495322c00..779504a6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,9 +56,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. @@ -88,7 +86,13 @@ 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. +- `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/accounts/accounts.py b/evennia/accounts/accounts.py index 4045c33662..db8afb7074 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}") diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 009dac0c67..12f31965e0 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: - pass + # 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 @@ -632,19 +640,23 @@ def cmdhandler( if isinstance(ret, types.GeneratorType): # cmd.func() is a generator, execute progressively _progressive_cmd_run(cmd, ret) - yield None + 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 + # post-command hook + yield cmd.at_post_cmd() - # 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 + 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) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 746d8a1c43..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 @@ -3476,16 +3477,11 @@ 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, session=self.session) @interactive def _update_existing_objects(self, caller, prototype_key, quiet=False): 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 diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index ea6e1db9b0..366ed166a3 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 @@ -408,59 +409,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): @@ -549,7 +562,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 @@ -559,7 +572,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 dda315c8e3..2363704ad7 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -1159,7 +1159,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" @@ -1252,10 +1252,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] @@ -1284,7 +1285,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(), @@ -1294,11 +1295,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() @@ -1315,7 +1316,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) @@ -1334,7 +1335,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() @@ -1344,7 +1345,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() @@ -1363,7 +1364,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/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:]) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 33cc8df642..d4cd6247e6 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -872,20 +872,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." ) @@ -896,7 +893,7 @@ class TestDice(CommandTest): # Test email-login -from evennia.contrib import email_login +from evennia.contrib import email_login # noqa class TestEmailLogin(CommandTest): 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 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/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/objects/objects.py b/evennia/objects/objects.py index 8985233c2d..b9257a2971 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -343,22 +343,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/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 9da1fb1b65..9da0a023d9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -9,9 +9,13 @@ 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.evmore import EvMore from evennia.utils.utils import ( all_from_module, make_iter, @@ -163,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 @@ -320,7 +325,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 +336,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. @@ -381,33 +392,51 @@ def search_prototype(key=None, tags=None, require_single=False): # 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 = ( - 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] + exact_match = ( + db_matches + .filter( + 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 - 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 + # 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) + ndbprots = db_matches.count() + if nmodules + ndbprots != 1: + raise KeyError(f"Found {nmodules + ndbprots} matching prototypes.") - nmatches = len(matches) - if nmatches != 1 and require_single: - raise KeyError("Found {} matching prototypes.".format(nmatches)) - - return matches + 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 + return db_matches, module_prototypes + else: + # full fetch, no pagination (compatibility mode) + return list(db_matches) + module_prototypes def search_objects_with_prototype(prototype_key): @@ -424,7 +453,109 @@ def search_objects_with_prototype(prototype_key): return ObjectDB.objects.get_by_tag(key=prototype_key, category=PROTOTYPE_TAG_CATEGORY) -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): +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, 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__(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 + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + + 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( + 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("{}".format(ptag[0])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + 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", ""), + ) + + return str(table) + + +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. @@ -434,66 +565,26 @@ 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. + PrototypeEvMore: An EvMore subclass optimized for prototype listings. + 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] - # get prototypes for readonly and db-based prototypes - prototypes = search_prototype(key, tags) + dbprot_query, modprot_list = search_prototype(key, tags, return_iterators=True) - # 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 + 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 @@ -569,7 +660,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/prototypes/tests.py b/evennia/prototypes/tests.py index edbb5acc51..755d7f2e13 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 @@ -628,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.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)))) @@ -1073,3 +1077,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.") 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] diff --git a/evennia/server/connection_wizard.py b/evennia/server/connection_wizard.py index 925edf46a6..5db2a52301 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 c43ec57c47..83c6e34763 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. """ @@ -1283,12 +1278,11 @@ def check_main_evennia_dependencies(): # only the main version (1.5, not 1.5.4.0) dversion_main = ".".join(dversion.split(".")[:2]) 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 @@ -1368,10 +1362,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.") diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index f4c9463284..eae1838e99 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -160,7 +160,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/settings_default.py b/evennia/settings_default.py index 148680bf64..f90a9d4d5f 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -740,9 +740,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 diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 426a8d02b6..ccda876b5b 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -778,6 +778,7 @@ class InMemoryAttributeBackend(IAttributeBackend): See parent class. strvalue has no meaning for InMemory attributes. + """ new_attr = self._attrclass( pk=self._next_id(), key=key, category=category, lock_storage=lockstring, value=value diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 2342cf374c..05117f7a15 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -35,6 +35,7 @@ the `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.ansi import ANSIString @@ -140,7 +141,7 @@ class EvMore(object): def __init__( self, caller, - text, + inp, always_page=False, session=None, justify=False, @@ -152,29 +153,28 @@ class EvMore(object): ): """ - EvMore pager + Initialization of the EvMore pager Args: caller (Object or Account): Entity reading the text. - text (str, EvTable or iterator): The text or data to put under paging. - - - If a string, paginate normally. If this text contains one or more `\\\\f` format - symbols, automatic pagination and justification are force-disabled and page-breaks - will only happen after each `\\\\f`. + 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 - expected to be a line in the final display. Each line - will be run through `iter_callable`. + setting on each page if it is too long. The table + decorations will be considered in the size of the page. + - 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 @@ -185,12 +185,6 @@ 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: @@ -198,16 +192,21 @@ class EvMore(object): ```python super_long_text = " ... " EvMore(caller, super_long_text) - + ``` + Paginator + ```python 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 + ```python + 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) ``` """ @@ -228,141 +227,40 @@ 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] # 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.init_pages(inp) # kick things into gear self.start() - # page formatter - - def format_page(self, page): - """ - Page formatter. Uses the page_formatter callable by default. - This allows to easier override the class if needed. - """ - 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] - - # 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_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. - - Args: - text (str): The string to format with f-markers. - - """ - 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 = [ - _LBR.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)) + 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: @@ -442,6 +340,180 @@ 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. + + Args: + text (str): The string to format with f-markers. + + """ + 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 = [ + _LBR.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) + self._paginator = self.paginator_index + 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_slice + 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): + """ + Paginator. The data operated upon is in `self._data`. + + Args: + 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). + + """ + 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 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) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 175e7b11a3..409c9436a4 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -2099,6 +2099,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) diff --git a/requirements.txt b/requirements.txt index ae1ebb5496..10485f708b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # general attrs >= 19.2.0 -django >= 2.2.5, < 2.3 +django >= 2.2.5, < 3.0 twisted >= 20.3.0, < 21.0.0 pytz djangorestframework >= 3.10.3, < 3.12