Resolve merge conflicts

This commit is contained in:
Griatch 2020-09-16 23:49:05 +02:00
commit a6a42001a1
25 changed files with 726 additions and 444 deletions

View file

@ -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)

View file

@ -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}")

View file

@ -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)

View file

@ -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 [<prototype_key>]
spawn/update <prototype_key>
@ -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):

View file

@ -37,7 +37,10 @@ __all__ = (
"CmdCdesc",
"CmdPage",
"CmdIRC2Chan",
"CmdIRCStatus",
"CmdRSS2Chan",
"CmdGrapevine2Chan",
)
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH

View file

@ -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 "<No scripts>"
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 "<No 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 "<Global>",
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 "<Global>",
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):

View file

@ -1159,7 +1159,7 @@ class TestBuilding(CommandTest):
"= Obj",
"To create a global script you need scripts/add <typeclass>.",
)
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 <prototype_name>"
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()

View file

@ -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:])

View file

@ -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):

View file

@ -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

View file

@ -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",

View file

@ -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_<name> - 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 = {

View file

@ -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(

View file

@ -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", "<unset>"),
"{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"),
", ".join(list(set(ptags))),
prototype.get("prototype_desc", "<unset>"),
)
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", "<unset>"),
prototype.get("prototype_desc", "<unset>"),
"{}/{}".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(

View file

@ -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.")

View file

@ -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]

View file

@ -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()

View file

@ -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.")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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