Merge branch 'master' into develop

This commit is contained in:
Griatch 2020-04-09 23:40:32 +02:00
commit ae98526a7f
17 changed files with 866 additions and 466 deletions

View file

@ -35,11 +35,13 @@ without arguments starts a full interactive Python console.
- Allow running Evennia test suite from core repo with `make test`.
- Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to
the `TickerHandler.remove` method. This makes it easier to manage tickers.
- EvMore `text` argument can now also be a list - each entry in the list is run
through str(eval()) and ends up on its own line. Good for paginated object lists.
- EvMore auto-justify now defaults to False since this works better with all types
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.
- 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.
@ -55,6 +57,8 @@ without arguments starts a full interactive Python console.
bugfixes.
- Remove `dummy@example.com` as a default account email when unset, a string is no longer
required by Django.
- Fixes to `spawn`, make updating an existing prototype/object work better. Add `/raw` switch
to `spawn` command to extract the raw prototype dict for manual editing.
## Evennia 0.9 (2018-2019)

View file

@ -4,7 +4,7 @@ Typeclass for Account objects
Note that this object is primarily intended to
store OOC information, not game info! This
object represents the actual user (not their
character) and has NO actual precence in the
character) and has NO actual presence in the
game world (this is handled by the associated
character object, so you should customize that
instead for most things).

View file

@ -7,19 +7,19 @@ from django.db.models import Q, Min, Max
from evennia.objects.models import ObjectDB
from evennia.locks.lockhandler import LockException
from evennia.commands.cmdhandler import get_and_merge_cmdsets
from evennia.utils import create, utils, search
from evennia.utils import create, utils, search, logger
from evennia.utils.utils import (
inherits_from,
class_from_module,
get_all_typeclasses,
variable_from_module,
dbref,
dbref, interactive,
list_to_string
)
from evennia.utils.eveditor import EvEditor
from evennia.utils.evmore import EvMore
from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
from evennia.utils.ansi import raw
from evennia.prototypes.menus import _format_diff_text_and_options
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -2099,10 +2099,10 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
# to confirm changes.
if "prototype" in self.switches:
diff, _ = spawner.prototype_diff_from_object(prototype, obj)
txt, options = _format_diff_text_and_options(diff, objects=[obj])
txt = spawner.format_diff(diff)
prompt = (
"Applying prototype '%s' over '%s' will cause the follow changes:\n%s\n"
% (prototype["key"], obj.name, "\n".join(txt))
% (prototype["key"], obj.name, txt)
)
if not reset:
prompt += "\n|yWARNING:|n Use the /reset switch to apply the prototype over a blank state."
@ -3227,6 +3227,10 @@ class CmdTag(COMMAND_DEFAULT_CLASS):
self.caller.msg(string)
# helper functions for spawn
class CmdSpawn(COMMAND_DEFAULT_CLASS):
"""
spawn objects from prototype
@ -3250,13 +3254,14 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
search - search prototype by name or tags.
list - list available prototypes, optionally limit by tags.
show, examine - inspect prototype by key. If not given, acts like list.
raw - show the raw dict of the prototype as a one-line string for manual editing.
save - save a prototype to the database. It will be listable by /list.
delete - remove a prototype from database, if allowed to.
update - find existing objects with the same prototype_key and update
them with latest version of given prototype. If given with /save,
will auto-update all objects with the old version of the prototype
without asking first.
edit, olc - create/manipulate prototype in a menu interface.
edit, menu, olc - create/manipulate prototype in a menu interface.
Example:
spawn GOBLIN
@ -3298,6 +3303,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
"search",
"list",
"show",
"raw",
"examine",
"save",
"delete",
@ -3309,56 +3315,209 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
locks = "cmd:perm(spawn) or perm(Builder)"
help_category = "Building"
def _search_prototype(self, prototype_key, quiet=False):
"""
Search for prototype and handle no/multi-match and access.
Returns a single found prototype or None - in the
case, the caller has already been informed of the
search error we need not do any further action.
"""
prototypes = protlib.search_prototype(prototype_key)
nprots = len(prototypes)
# handle the search result
err = None
if not prototypes:
err = f"No prototype named '{prototype_key}' was found."
elif nprots > 1:
err = "Found {} prototypes matching '{}':\n {}".format(
nprots,
prototype_key,
", ".join(proto.get("prototype_key", "") for proto in prototypes),
)
else:
# we have a single prototype, check access
prototype = prototypes[0]
if not self.caller.locks.check_lockstring(
self.caller, prototype.get("prototype_locks", ""),
access_type="spawn", default=True):
err = "You don't have access to use this prototype."
if err:
# return None on any error
if not quiet:
self.caller.msg(err)
return
return prototype
def _parse_prototype(self, inp, expect=dict):
"""
Parse a prototype dict or key from the input and convert it safely
into a dict if appropriate.
Args:
inp (str): The input from user.
expect (type, optional):
Returns:
prototype (dict, str or None): The parsed prototype. If None, the error
was already reported.
"""
eval_err = None
try:
prototype = _LITERAL_EVAL(inp)
except (SyntaxError, ValueError) as err:
# treat as string
eval_err = err
prototype = utils.to_str(inp)
finally:
# it's possible that the input was a prototype-key, in which case
# it's okay for the LITERAL_EVAL to fail. Only if the result does not
# match the expected type do we have a problem.
if not isinstance(prototype, expect):
if eval_err:
string = (
f"{inp}\n{eval_err}\n|RCritical Python syntax error in argument. Only primitive "
"Python structures are allowed. \nMake sure to use correct "
"Python syntax. Remember especially to put quotes around all "
"strings inside lists and dicts.|n For more advanced uses, embed "
"inlinefuncs in the strings."
)
else:
string = "Expected {}, got {}.".format(expect, type(prototype))
self.caller.msg(string)
return
if expect == dict:
# an actual prototype. We need to make sure it's safe,
# so don't allow exec.
# TODO: Exec support is deprecated. Remove completely for 1.0.
if "exec" in prototype and not self.caller.check_permstring("Developer"):
self.caller.msg(
"Spawn aborted: You are not allowed to " "use the 'exec' prototype key."
)
return
try:
# we homogenize the protoype first, to be more lenient with free-form
protlib.validate_prototype(protlib.homogenize_prototype(prototype))
except RuntimeError as err:
self.caller.msg(str(err))
return
return prototype
def _get_prototype_detail(self, query=None, prototypes=None):
"""
Display the detailed specs of one or more prototypes.
Args:
query (str, optional): If this is given and `prototypes` is not, search for
the prototype(s) by this query. This may be a partial query which
may lead to multiple matches, all being displayed.
prototypes (list, optional): If given, ignore `query` and only show these
prototype-details.
Returns:
display (str, None): A formatted string of one or more prototype details.
If None, the caller was already informed of the error.
"""
if not prototypes:
# we need to query. Note that if query is None, all prototypes will
# be returned.
prototypes = protlib.search_prototype(key=query)
if prototypes:
return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes)
elif query:
self.caller.msg(f"No prototype named '{query}' was found.")
else:
self.caller.msg(f"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,
)
@interactive
def _update_existing_objects(self, caller, prototype_key, quiet=False):
"""
Update existing objects (if any) with this prototype-key to the latest
prototype version.
Args:
caller (Object): This is necessary for @interactive to work.
prototype_key (str): The prototype to update.
quiet (bool, optional): If set, don't report to user if no
old objects were found to update.
Returns:
n_updated (int): Number of updated objects.
"""
prototype = self._search_prototype(prototype_key)
if not prototype:
return
existing_objects = protlib.search_objects_with_prototype(prototype_key)
if not existing_objects:
if not quiet:
caller.msg("No existing objects found with an older version of this prototype.")
return
if existing_objects:
n_existing = len(existing_objects)
slow = " (note that this may be slow)" if n_existing > 10 else ""
string = (
f"There are {n_existing} existing object(s) with an older version "
f"of prototype '{prototype_key}'. Should it be re-applied to them{slow}? [Y]/N"
)
answer = yield (string)
if answer.lower() in ["n", "no"]:
caller.msg(
"|rNo update was done of existing objects. "
"Use spawn/update <key> to apply later as needed.|n"
)
return
try:
n_updated = spawner.batch_update_objects_with_prototype(
prototype, objects=existing_objects)
except Exception:
logger.log_trace()
caller.msg(f"{n_updated} objects were updated.")
return
def _parse_key_desc_tags(self, argstring, desc=True):
"""
Parse ;-separated input list.
"""
key, desc, tags = "", "", []
if ";" in argstring:
parts = [part.strip().lower() for part in argstring.split(";")]
if len(parts) > 1 and desc:
key = parts[0]
desc = parts[1]
tags = parts[2:]
else:
key = parts[0]
tags = parts[1:]
else:
key = argstring.strip().lower()
return key, desc, tags
def func(self):
"""Implements the spawner"""
def _parse_prototype(inp, expect=dict):
err = None
try:
prototype = _LITERAL_EVAL(inp)
except (SyntaxError, ValueError) as err:
# treat as string
prototype = utils.to_str(inp)
finally:
if not isinstance(prototype, expect):
if err:
string = (
"{}\n|RCritical Python syntax error in argument. Only primitive "
"Python structures are allowed. \nYou also need to use correct "
"Python syntax. Remember especially to put quotes around all "
"strings inside lists and dicts.|n For more advanced uses, embed "
"inline functions in the strings.".format(err)
)
else:
string = "Expected {}, got {}.".format(expect, type(prototype))
self.caller.msg(string)
return None
if expect == dict:
# an actual prototype. We need to make sure it's safe. Don't allow exec
if "exec" in prototype and not self.caller.check_permstring("Developer"):
self.caller.msg(
"Spawn aborted: You are not allowed to " "use the 'exec' prototype key."
)
return None
try:
# we homogenize first, to be more lenient
protlib.validate_prototype(protlib.homogenize_prototype(prototype))
except RuntimeError as err:
self.caller.msg(str(err))
return
return prototype
def _search_show_prototype(query, prototypes=None):
# prototype detail
if not prototypes:
prototypes = protlib.search_prototype(key=query)
if prototypes:
return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes)
else:
return False
caller = self.caller
noloc = "noloc" in self.switches
# run the menu/olc
if (
self.cmdstring == "olc"
or "menu" in self.switches
@ -3368,94 +3527,122 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
# OLC menu mode
prototype = None
if self.lhs:
key = self.lhs
prototype = protlib.search_prototype(key=key)
if len(prototype) > 1:
caller.msg(
"More than one match for {}:\n{}".format(
key, "\n".join(proto.get("prototype_key", "") for proto in prototype)
)
)
return
elif prototype:
# one match
prototype = prototype[0]
else:
# no match
caller.msg("No prototype '{}' was found.".format(key))
prototype_key = self.lhs
prototype = self._search_prototype(prototype_key)
if not prototype:
return
olc_menus.start_olc(caller, session=self.session, prototype=prototype)
return
if "search" in self.switches:
# query for a key match
# query for a key match. The arg is a search query or nothing.
if not self.args:
self.switches.append("list")
else:
key, tags = self.args.strip(), None
if ";" in self.args:
key, tags = (part.strip().lower() for part in self.args.split(";", 1))
tags = [tag.strip() for tag in tags.split(",")] if tags else None
EvMore(
caller,
str(protlib.list_prototypes(caller, key=key, tags=tags)),
exit_on_lastpage=True,
)
# an empty search returns the full list
self._list_prototypes()
return
# search for key;tag combinations
key, _, tags = self._parse_key_desc_tags(self.args, desc=False)
self._list_prototypes(key, tags)
return
if "raw" in self.switches:
# query for key match and return the prototype as a safe one-liner string.
if not self.args:
caller.msg("You need to specify a prototype-key to get the raw data for.")
prototype = self._search_prototype(self.args)
if not prototype:
return
caller.msg(str(prototype))
return
if "show" in self.switches or "examine" in self.switches:
# the argument is a key in this case (may be a partial key)
# show a specific prot detail. The argument is a search query or empty.
if not self.args:
self.switches.append("list")
else:
matchstring = _search_show_prototype(self.args)
if matchstring:
caller.msg(matchstring)
else:
caller.msg("No prototype '{}' was found.".format(self.args))
# we don't show the list of all details, that's too spammy.
caller.msg("You need to specify a prototype-key to show.")
return
if "list" in self.switches:
# for list, all optional arguments are tags
# import pudb; pudb.set_trace()
detail_string = self._get_prototype_detail(self.args)
if not detail_string:
return
caller.msg(detail_string)
return
EvMore(
caller,
str(protlib.list_prototypes(caller, tags=self.lhslist)),
exit_on_lastpage=True,
justify_kwargs=False,
)
if "list" in self.switches:
# for list, all optional arguments are tags.
tags = self.lhslist
err = self._list_prototypes(tags=tags)
if err:
caller.msg("No prototypes found with prototype-tag(s): {}".format(
list_to_string(tags, "or")))
return
if "save" in self.switches:
# store a prototype to the database store
if not self.args:
caller.msg(
"Usage: spawn/save <key>[;desc[;tag,tag[,...][;lockstring]]] = <prototype_dict>"
"Usage: spawn/save [<key>[;desc[;tag,tag[,...][;lockstring]]]] = <prototype_dict>"
)
return
if self.rhs:
# input on the form key = prototype
prototype_key, prototype_desc, prototype_tags = self._parse_key_desc_tags(self.lhs)
prototype_key = None if not prototype_key else prototype_key
prototype_desc = None if not prototype_desc else prototype_desc
prototype_tags = None if not prototype_tags else prototype_tags
prototype_input = self.rhs.strip()
else:
prototype_key = prototype_desc = None
prototype_tags = None
prototype_input = self.lhs.strip()
# handle rhs:
prototype = _parse_prototype(self.lhs.strip())
# handle parsing
prototype = self._parse_prototype(prototype_input)
if not prototype:
return
# present prototype to save
new_matchstring = _search_show_prototype("", prototypes=[prototype])
string = "|yCreating new prototype:|n\n{}".format(new_matchstring)
question = "\nDo you want to continue saving? [Y]/N"
prot_prototype_key = prototype.get("prototype_key")
prototype_key = prototype.get("prototype_key")
if not prototype_key:
caller.msg("\n|yTo save a prototype it must have the 'prototype_key' set.")
if not (prototype_key or prot_prototype_key):
caller.msg("A prototype_key must be given, either as `prototype_key = <prototype>` "
"or as a key 'prototype_key' inside the prototype structure.")
return
# check for existing prototype,
old_matchstring = _search_show_prototype(prototype_key)
if prototype_key is None:
prototype_key = prot_prototype_key
if old_matchstring:
string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring)
question = "\n|yDo you want to replace the existing prototype?|n [Y]/N"
if prot_prototype_key != prototype_key:
caller.msg("(Replacing `prototype_key` in prototype with given key.)")
prototype['prototype_key'] = prototype_key
if prototype_desc is not None and prot_prototype_key != prototype_desc:
caller.msg("(Replacing `prototype_desc` in prototype with given desc.)")
prototype['prototype_desc'] = prototype_desc
if prototype_tags is not None and prototype.get("prototype_tags") != prototype_tags:
caller.msg("(Replacing `prototype_tags` in prototype with given tag(s))" )
prototype['prototype_tags'] = prototype_tags
string = ""
# check for existing prototype (exact match)
old_prototype = self._search_prototype(prototype_key, quiet=True)
diff = spawner.prototype_diff(old_prototype, prototype, homogenize=True)
diffstr = spawner.format_diff(diff)
new_prototype_detail = self._get_prototype_detail(prototypes=[prototype])
if old_prototype:
if not diffstr:
string = f"|yAlready existing Prototype:|n\n{new_prototype_detail}\n"
question = "\nThere seems to be no changes. Do you still want to (re)save? [Y]/N"
else:
string = (f"|yExisting prototype \"{prototype_key}\" found. Change:|n\n{diffstr}\n"
f"|yNew changed prototype:|n\n{new_prototype_detail}")
question = "\n|yDo you want to apply the change to the existing prototype?|n [Y]/N"
else:
string = f"|yCreating new prototype:|n\n{new_prototype_detail}"
question = "\nDo you want to continue saving? [Y]/N"
answer = yield (string + question)
if answer.lower() in ["n", "no"]:
@ -3474,82 +3661,52 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
caller.msg("|gSaved prototype:|n {}".format(prototype_key))
# check if we want to update existing objects
existing_objects = protlib.search_objects_with_prototype(prototype_key)
if existing_objects:
if "update" not in self.switches:
n_existing = len(existing_objects)
slow = " (note that this may be slow)" if n_existing > 10 else ""
string = (
"There are {} objects already created with an older version "
"of prototype {}. Should it be re-applied to them{}? [Y]/N".format(
n_existing, prototype_key, slow
)
)
answer = yield (string)
if answer.lower() in ["n", "no"]:
caller.msg(
"|rNo update was done of existing objects. "
"Use spawn/update <key> to apply later as needed.|n"
)
return
n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key)
caller.msg("{} objects were updated.".format(n_updated))
self._update_existing_objects(self.caller, prototype_key, quiet=True)
return
if not self.args:
# all switches beyond this point gets a common non-arg return
ncount = len(protlib.search_prototype())
caller.msg(
"Usage: spawn <prototype-key> or {{key: value, ...}}"
"\n ({} existing prototypes. Use /list to inspect)".format(ncount)
f"\n ({ncount} existing prototypes. Use /list to inspect)"
)
return
if "delete" in self.switches:
# remove db-based prototype
matchstring = _search_show_prototype(self.args)
if matchstring:
string = "|rDeleting prototype:|n\n{}".format(matchstring)
question = "\nDo you want to continue deleting? [Y]/N"
answer = yield (string + question)
if answer.lower() in ["n", "no"]:
caller.msg("|rDeletion cancelled.|n")
return
try:
success = protlib.delete_prototype(self.args)
except protlib.PermissionError as err:
caller.msg("|rError deleting:|R {}|n".format(err))
caller.msg(
"Deletion {}.".format(
"successful" if success else "failed (does the prototype exist?)"
)
)
prototype_detail = self._get_prototype_detail(self.args)
if not prototype_detail:
return
string = f"|rDeleting prototype:|n\n{prototype_detail}"
question = "\nDo you want to continue deleting? [Y]/N"
answer = yield (string + question)
if answer.lower() in ["n", "no"]:
caller.msg("|rDeletion cancelled.|n")
return
try:
success = protlib.delete_prototype(self.args)
except protlib.PermissionError as err:
retmsg = f"|rError deleting:|R {err}|n"
else:
caller.msg("Could not find prototype '{}'".format(key))
retmsg = ("Deletion successful" if success else
"Deletion failed (does the prototype exist?)")
caller.msg(retmsg)
return
if "update" in self.switches:
# update existing prototypes
key = self.args.strip().lower()
existing_objects = protlib.search_objects_with_prototype(key)
if existing_objects:
n_existing = len(existing_objects)
slow = " (note that this may be slow)" if n_existing > 10 else ""
string = (
"There are {} objects already created with an older version "
"of prototype {}. Should it be re-applied to them{}? [Y]/N".format(
n_existing, key, slow
)
)
answer = yield (string)
if answer.lower() in ["n", "no"]:
caller.msg("|rUpdate cancelled.")
return
n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key)
caller.msg("{} objects were updated.".format(n_updated))
prototype_key = self.args.strip().lower()
self._update_existing_objects(self.caller, prototype_key)
return
# A direct creation of an object from a given prototype
# If we get to this point, we use not switches but are trying a
# direct creation of an object from a given prototype or -key
prototype = _parse_prototype(
prototype = self._parse_prototype(
self.args, expect=dict if self.args.strip().startswith("{") else str
)
if not prototype:
@ -3559,35 +3716,20 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
key = "<unnamed>"
if isinstance(prototype, str):
# A prototype key we are looking to apply
key = prototype
prototypes = protlib.search_prototype(prototype)
nprots = len(prototypes)
if not prototypes:
caller.msg("No prototype named '%s'." % prototype)
return
elif nprots > 1:
caller.msg(
"Found {} prototypes matching '{}':\n {}".format(
nprots,
prototype,
", ".join(proto.get("prototype_key", "") for proto in prototypes),
)
)
return
# we have a prototype, check access
prototype = prototypes[0]
if not caller.locks.check_lockstring(
caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True
):
caller.msg("You don't have access to use this prototype.")
return
prototype_key = prototype
prototype = self._search_prototype(prototype_key)
if "noloc" not in self.switches and "location" not in prototype:
prototype["location"] = self.caller.location
if not prototype:
return
# proceed to spawning
try:
for obj in spawner.spawn(prototype):
self.caller.msg("Spawned %s." % obj.get_display_name(self.caller))
if not prototype.get('location') and not noloc:
# we don't hardcode the location in the prototype (unless the user
# did so manually) - that would lead to it having to be 'removed' every
# time we try to update objects with this prototype in the future.
obj.location = caller.location
except RuntimeError as err:
caller.msg(err)

View file

@ -487,6 +487,8 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
locks = "cmd:perm(listscripts) or perm(Admin)"
help_category = "System"
excluded_typeclass_paths = ["evennia.prototypes.prototypes.DbPrototype"]
def func(self):
"""implement method"""
@ -519,6 +521,8 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
if not scripts:
caller.msg("No scripts are running.")
return
# filter any found scripts by tag category.
scripts = scripts.exclude(db_typeclass_path__in=self.excluded_typeclass_paths)
if not scripts:
string = "No scripts found with a key '%s', or on an object named '%s'." % (args, args)
@ -538,19 +542,20 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
# import pdb # DEBUG
# pdb.set_trace() # DEBUG
ScriptDB.objects.validate() # just to be sure all is synced
caller.msg(string)
else:
# multiple matches.
string = "Multiple script matches. Please refine your search:\n"
string += format_script_list(scripts)
EvMore(caller, scripts, page_formatter=format_script_list)
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
nr_started, nr_stopped = ScriptDB.objects.validate(scripts=scripts)
string = "Validated %s scripts. " % ScriptDB.objects.all().count()
string += "Started %s and stopped %s scripts." % (nr_started, nr_stopped)
caller.msg(string)
else:
# No stopping or validation. We just want to view things.
string = format_script_list(scripts)
EvMore(caller, string)
EvMore(caller, scripts, page_formatter=format_script_list)
class CmdObjects(COMMAND_DEFAULT_CLASS):

View file

@ -1228,13 +1228,22 @@ class TestBuilding(CommandTest):
inputs=["y"],
)
self.call(
building.CmdSpawn(),
"/save testprot2 = {'key':'Test Char', "
"'typeclass':'evennia.objects.objects.DefaultCharacter'}",
"(Replacing `prototype_key` in prototype with given key.)|Saved prototype: testprot2",
inputs=["y"],
)
self.call(building.CmdSpawn(), "/search ", "Key ")
self.call(building.CmdSpawn(), "/search test;test2", "")
self.call(
building.CmdSpawn(),
"/save {'key':'Test Char', " "'typeclass':'evennia.objects.objects.DefaultCharacter'}",
"To save a prototype it must have the 'prototype_key' set.",
"A prototype_key must be given, either as `prototype_key = <prototype>` or as "
"a key 'prototype_key' inside the prototype structure.",
)
self.call(building.CmdSpawn(), "/list", "Key ")
@ -1312,7 +1321,7 @@ class TestBuilding(CommandTest):
ball.delete()
# test calling spawn with an invalid prototype.
self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'")
self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST' was found.")
# Test listing commands
self.call(building.CmdSpawn(), "/list", "Key ")
@ -1343,13 +1352,12 @@ class TestBuilding(CommandTest):
# spawn/edit with invalid prototype
msg = self.call(
building.CmdSpawn(), "/edit NO_EXISTS", "No prototype 'NO_EXISTS' was found."
building.CmdSpawn(), "/edit NO_EXISTS", "No prototype named 'NO_EXISTS' was found."
)
# spawn/examine (missing prototype)
# lists all prototypes that exist
msg = self.call(building.CmdSpawn(), "/examine")
assert "testball" in msg and "testprot" in msg
self.call(building.CmdSpawn(), "/examine", "You need to specify a prototype-key to show.")
# spawn/examine with valid prototype
# prints the prototype
@ -1358,7 +1366,7 @@ class TestBuilding(CommandTest):
# spawn/examine with invalid prototype
# shows error
self.call(building.CmdSpawn(), "/examine NO_EXISTS", "No prototype 'NO_EXISTS' was found.")
self.call(building.CmdSpawn(), "/examine NO_EXISTS", "No prototype named 'NO_EXISTS' was found.")
class TestComms(CommandTest):

View file

@ -331,7 +331,7 @@ class LanguageHandler(DefaultScript):
# find out what preceeded this word
wpos = match.start()
preceeding = match.string[:wpos].strip()
start_sentence = preceeding.endswith(".") or not preceeding
start_sentence = preceeding.endswith((".", "!", "?")) or not preceeding
# make up translation on the fly. Length can
# vary from un-translated word.

View file

@ -2066,9 +2066,6 @@ class DefaultCharacter(DefaultObject):
# Set the supplied key as the name of the intended object
kwargs["key"] = key
# Get home for character
kwargs["home"] = ObjectDB.objects.get_id(kwargs.get("home", settings.DEFAULT_HOME))
# Get permissions
kwargs["permissions"] = kwargs.get("permissions", settings.PERMISSION_ACCOUNT_DEFAULT)

View file

@ -1488,7 +1488,7 @@ def node_tags(caller):
as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to
optionally update previously spawned objects when their prototype changes.
""".format(
tag_category=protlib._PROTOTYPE_TAG_CATEGORY
tag_category=protlib.PROTOTYPE_TAG_CATEGORY
)
text = (text, helptext)
@ -2131,12 +2131,13 @@ def _keep_diff(caller, **kwargs):
tmp[path[-1]] = tuple(list(tmp[path[-1]][:-1]) + ["KEEP"])
def _format_diff_text_and_options(diff, **kwargs):
def _format_diff_text_and_options(diff, minimal=True, **kwargs):
"""
Reformat the diff in a way suitable for the olc menu.
Args:
diff (dict): A diff as produced by `prototype_diff`.
minimal (bool, optional): Don't show KEEPs.
Kwargs:
any (any): Forwarded into the generated options as arguments to the callable.
@ -2150,12 +2151,15 @@ def _format_diff_text_and_options(diff, **kwargs):
def _visualize(obj, rootname, get_name=False):
if utils.is_iter(obj):
if not obj:
return str(obj)
if get_name:
return obj[0] if obj[0] else "<unset>"
if rootname == "attrs":
return "{} |W=|n {} |W(category:|n {}|W, locks:|n {}|W)|n".format(*obj)
elif rootname == "tags":
return "{} |W(category:|n {}|W)|n".format(obj[0], obj[1])
return "{}".format(obj)
def _parse_diffpart(diffpart, optnum, *args):
@ -2166,17 +2170,33 @@ def _format_diff_text_and_options(diff, **kwargs):
rootname = args[0]
old, new, instruction = diffpart
if instruction == "KEEP":
texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old, rootname)))
if not minimal:
texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old, rootname)))
else:
# instructions we should be able to revert by a menu choice
vold = _visualize(old, rootname)
vnew = _visualize(new, rootname)
vsep = "" if len(vold) < 78 else "\n"
vinst = "|rREMOVE|n" if instruction == "REMOVE" else "|y{}|n".format(instruction)
texts.append(
" |c[{num}] {inst}|W:|n {old} |W->|n{sep} {new}".format(
inst=vinst, num=optnum, old=vold, sep=vsep, new=vnew
if instruction == "ADD":
texts.append(" |c[{optnum}] |yADD|n: {new}".format(
optnum=optnum, new=_visualize(new, rootname)))
elif instruction == "REMOVE" and not new:
if rootname == "tags" and old[1] == protlib.PROTOTYPE_TAG_CATEGORY:
# special exception for the prototype-tag mechanism
# this is added post-spawn automatically and should
# not be listed as REMOVE.
return texts, options, optnum
texts.append(" |c[{optnum}] |rREMOVE|n: {old}".format(
optnum=optnum, old=_visualize(old, rootname)))
else:
vinst = "|y{}|n".format(instruction)
texts.append(
" |c[{num}] {inst}|W:|n {old} |W->|n{sep} {new}".format(
inst=vinst, num=optnum, old=vold, sep=vsep, new=vnew
)
)
)
options.append(
{
"key": str(optnum),
@ -2203,11 +2223,8 @@ def _format_diff_text_and_options(diff, **kwargs):
for root_key in sorted(diff):
diffpart = diff[root_key]
text, option, optnum = _parse_diffpart(diffpart, optnum, root_key)
heading = "- |w{}:|n ".format(root_key)
if root_key in ("attrs", "tags", "permissions"):
texts.append(heading)
elif text:
if text:
text = [heading + text[0]] + text[1:]
else:
text = [heading]
@ -2277,7 +2294,8 @@ def node_apply_diff(caller, **kwargs):
if not custom_location:
diff.pop("location", None)
txt, options = _format_diff_text_and_options(diff, objects=update_objects, base_obj=base_obj)
txt, options = _format_diff_text_and_options(diff, objects=update_objects,
base_obj=base_obj, prototype=prototype)
if options:
text = [

View file

@ -52,10 +52,12 @@ _PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + (
"tags",
"attrs",
)
_PROTOTYPE_TAG_CATEGORY = "from_prototype"
PROTOTYPE_TAG_CATEGORY = "from_prototype"
_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
PROT_FUNCS = {}
_PROTOTYPE_FALLBACK_LOCK = "spawn:all();edit:all()"
class PermissionError(RuntimeError):
pass
@ -84,8 +86,19 @@ def homogenize_prototype(prototype, custom_keys=None):
homogenizations like adding missing prototype_keys and setting a default typeclass.
"""
if not prototype or not isinstance(prototype, dict):
return {}
reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ())
# correct cases of setting None for certain values
for protkey in prototype:
if prototype[protkey] is None:
if protkey in ("attrs", "tags", "prototype_tags"):
prototype[protkey] = []
elif protkey in ("prototype_key", "prototype_desc"):
prototype[protkey] = ""
attrs = list(prototype.get("attrs", [])) # break reference
tags = make_iter(prototype.get("tags", []))
homogenized_tags = []
@ -111,12 +124,14 @@ def homogenize_prototype(prototype, custom_keys=None):
# add required missing parts that had defaults before
if "prototype_key" not in prototype:
homogenized["prototype_key"] = homogenized.get("prototype_key",
# assign a random hash as key
homogenized["prototype_key"] = "prototype-{}".format(
hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7]
)
"prototype-{}".format(
hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7]))
homogenized["prototype_tags"] = homogenized.get("prototype_tags", [])
homogenized["prototype_locks"] = homogenized.get(
"prototype_lock", _PROTOTYPE_FALLBACK_LOCK)
homogenized["prototype_desc"] = homogenized.get("prototype_desc", "")
if "typeclass" not in prototype and "prototype_parent" not in prototype:
homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS
@ -223,14 +238,11 @@ def save_prototype(prototype):
)
# make sure meta properties are included with defaults
stored_prototype = DbPrototype.objects.filter(db_key=prototype_key)
prototype = stored_prototype[0].prototype if stored_prototype else {}
in_prototype["prototype_desc"] = in_prototype.get(
"prototype_desc", prototype.get("prototype_desc", "")
)
prototype_locks = in_prototype.get(
"prototype_locks", prototype.get("prototype_locks", "spawn:all();edit:perm(Admin)")
"prototype_locks", prototype.get("prototype_locks", _PROTOTYPE_FALLBACK_LOCK)
)
is_valid, err = validate_lockstring(prototype_locks)
if not is_valid:
@ -245,27 +257,26 @@ def save_prototype(prototype):
]
in_prototype["prototype_tags"] = prototype_tags
prototype.update(in_prototype)
stored_prototype = DbPrototype.objects.filter(db_key=prototype_key)
if stored_prototype:
# edit existing prototype
stored_prototype = stored_prototype[0]
stored_prototype.desc = prototype["prototype_desc"]
stored_prototype.desc = in_prototype["prototype_desc"]
if prototype_tags:
stored_prototype.tags.clear(category=_PROTOTYPE_TAG_CATEGORY)
stored_prototype.tags.batch_add(*prototype["prototype_tags"])
stored_prototype.locks.add(prototype["prototype_locks"])
stored_prototype.attributes.add("prototype", prototype)
stored_prototype.tags.clear(category=PROTOTYPE_TAG_CATEGORY)
stored_prototype.tags.batch_add(*in_prototype["prototype_tags"])
stored_prototype.locks.add(in_prototype["prototype_locks"])
stored_prototype.attributes.add("prototype", in_prototype)
else:
# create a new prototype
stored_prototype = create_script(
DbPrototype,
key=prototype_key,
desc=prototype["prototype_desc"],
desc=in_prototype["prototype_desc"],
persistent=True,
locks=prototype_locks,
tags=prototype["prototype_tags"],
attributes=[("prototype", prototype)],
tags=in_prototype["prototype_tags"],
attributes=[("prototype", in_prototype)],
)
return stored_prototype.prototype
@ -410,7 +421,7 @@ def search_objects_with_prototype(prototype_key):
matches (Queryset): All matching objects spawned from this prototype.
"""
return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
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):
@ -713,15 +724,15 @@ def prototype_to_str(prototype):
prototype_desc=prototype.get("prototype_desc", "|wNone|n"),
prototype_parent=prototype.get("prototype_parent", "|wNone|n"),
)
key = prototype.get("key", "")
if key:
key = aliases = attrs = tags = locks = permissions = location = home = destination = ""
if "key" in prototype:
key = prototype["key"]
key = "|ckey:|n {key}".format(key=key)
aliases = prototype.get("aliases", "")
if aliases:
if "aliases" in prototype:
aliases = prototype["aliases"]
aliases = "|caliases:|n {aliases}".format(aliases=", ".join(aliases))
attrs = prototype.get("attrs", "")
if attrs:
if "attrs" in prototype:
attrs = prototype["attrs"]
out = []
for (attrkey, value, category, locks) in attrs:
locks = ", ".join(lock for lock in locks if lock)
@ -740,8 +751,8 @@ def prototype_to_str(prototype):
)
)
attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out))
tags = prototype.get("tags", "")
if tags:
if "tags" in prototype:
tags = prototype['tags']
out = []
for (tagkey, category, data) in tags:
out.append(
@ -750,20 +761,20 @@ def prototype_to_str(prototype):
)
)
tags = "|ctags:|n\n {tags}".format(tags=", ".join(out))
locks = prototype.get("locks", "")
if locks:
if "locks" in prototype:
locks = prototype["locks"]
locks = "|clocks:|n\n {locks}".format(locks=locks)
permissions = prototype.get("permissions", "")
if permissions:
if "permissions" in prototype:
permissions = prototype["permissions"]
permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions))
location = prototype.get("location", "")
if location:
if "location" in prototype:
location = prototype["location"]
location = "|clocation:|n {location}".format(location=location)
home = prototype.get("home", "")
if home:
if "home" in prototype:
home = prototype["home"]
home = "|chome:|n {home}".format(home=home)
destination = prototype.get("destination", "")
if destination:
if "destination" in prototype:
destination = prototype["destination"]
destination = "|cdestination:|n {destination}".format(destination=destination)
body = "\n".join(

View file

@ -138,13 +138,14 @@ from django.conf import settings
import evennia
from evennia.objects.models import ObjectDB
from evennia.utils import logger
from evennia.utils.utils import make_iter, is_iter
from evennia.prototypes import prototypes as protlib
from evennia.prototypes.prototypes import (
value_to_obj,
value_to_obj_or_any,
init_spawn_value,
_PROTOTYPE_TAG_CATEGORY,
PROTOTYPE_TAG_CATEGORY,
)
@ -165,6 +166,18 @@ _PROTOTYPE_ROOT_NAMES = (
_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES
class Unset:
"""
Helper class representing a non-set diff element.
"""
def __bool__(self):
return False
def __str__(self):
return "<Unset>"
# Helper
@ -268,7 +281,7 @@ def prototype_from_object(obj):
"""
# first, check if this object already has a prototype
prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True)
prot = obj.tags.get(category=PROTOTYPE_TAG_CATEGORY, return_list=True)
if prot:
prot = protlib.search_prototype(prot[0])
@ -322,9 +335,9 @@ def prototype_from_object(obj):
return prot
def prototype_diff(prototype1, prototype2, maxdepth=2):
def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implicit_keep=False):
"""
A 'detailed' diff specifies differences down to individual sub-sectiions
A 'detailed' diff specifies differences down to individual sub-sections
of the prototype, like individual attributes, permissions etc. It is used
by the menu to allow a user to customize what should be kept.
@ -334,6 +347,12 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
maxdepth (int, optional): The maximum depth into the diff we go before treating the elements
of iterables as individual entities to compare. This is important since a single
attr/tag (for example) are represented by a tuple.
homogenize (bool, optional): Auto-homogenize both prototypes for the best comparison.
This is most useful for displaying.
implicit_keep (bool, optional): If set, the resulting diff will assume KEEP unless the new
prototype explicitly change them. That is, if a key exists in `prototype1` and
not in `prototype2`, it will not be REMOVEd but set to KEEP instead. This is particularly
useful for auto-generated prototypes when updating objects.
Returns:
diff (dict): A structure detailing how to convert prototype1 to prototype2. All
@ -344,12 +363,16 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP".
"""
_unset = Unset()
def _recursive_diff(old, new, depth=0):
old_type = type(old)
new_type = type(new)
if old_type == new_type and not (old or new):
# both old and new are unset, like [] or None
return (None, None, "KEEP")
if old_type != new_type:
if old and not new:
if depth < maxdepth and old_type == dict:
@ -358,6 +381,9 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
return {
part[0] if is_iter(part) else part: (part, None, "REMOVE") for part in old
}
if isinstance(new, Unset) and implicit_keep:
# the new does not define any change, use implicit-keep
return (old, None, "KEEP")
return (old, new, "REMOVE")
elif not old and new:
if depth < maxdepth and new_type == dict:
@ -371,7 +397,7 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
elif depth < maxdepth and new_type == dict:
all_keys = set(list(old.keys()) + list(new.keys()))
return {
key: _recursive_diff(old.get(key), new.get(key), depth=depth + 1)
key: _recursive_diff(old.get(key, _unset), new.get(key, _unset), depth=depth + 1)
for key in all_keys
}
elif depth < maxdepth and is_iter(new):
@ -379,7 +405,7 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
new_map = {part[0] if is_iter(part) else part: part for part in new}
all_keys = set(list(old_map.keys()) + list(new_map.keys()))
return {
key: _recursive_diff(old_map.get(key), new_map.get(key), depth=depth + 1)
key: _recursive_diff(old_map.get(key, _unset), new_map.get(key, _unset), depth=depth + 1)
for key in all_keys
}
elif old != new:
@ -387,7 +413,10 @@ def prototype_diff(prototype1, prototype2, maxdepth=2):
else:
return (old, new, "KEEP")
diff = _recursive_diff(prototype1, prototype2)
prot1 = protlib.homogenize_prototype(prototype1) if homogenize else prototype1
prot2 = protlib.homogenize_prototype(prototype2) if homogenize else prototype2
diff = _recursive_diff(prot1, prot2)
return diff
@ -460,7 +489,7 @@ def flatten_diff(diff):
return flat_diff
def prototype_diff_from_object(prototype, obj):
def prototype_diff_from_object(prototype, obj, implicit_keep=True):
"""
Get a simple diff for a prototype compared to an object which may or may not already have a
prototype (or has one but changed locally). For more complex migratations a manual diff may be
@ -474,6 +503,11 @@ def prototype_diff_from_object(prototype, obj):
diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...}
obj_prototype (dict): The prototype calculated for the given object. The diff is how to
convert this prototype into the new prototype.
implicit_keep (bool, optional): This is usually what one wants for object updating. When
set, this means the prototype diff will assume KEEP on differences
between the object-generated prototype and that which is not explicitly set in the
new prototype. This means e.g. that even though the object has a location, and the
prototype does not specify the location, it will not be unset.
Notes:
The `diff` is on the following form:
@ -486,11 +520,87 @@ def prototype_diff_from_object(prototype, obj):
"""
obj_prototype = prototype_from_object(obj)
diff = prototype_diff(obj_prototype, protlib.homogenize_prototype(prototype))
diff = prototype_diff(obj_prototype, protlib.homogenize_prototype(prototype),
implicit_keep=implicit_keep)
return diff, obj_prototype
def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
def format_diff(diff, minimal=True):
"""
Reformat a diff for presentation. This is a shortened version
of the olc _format_diff_text_and_options without the options.
Args:
diff (dict): A diff as produced by `prototype_diff`.
minimal (bool, optional): Only show changes (remove KEEPs)
Returns:
texts (str): The formatted text.
"""
valid_instructions = ("KEEP", "REMOVE", "ADD", "UPDATE")
def _visualize(obj, rootname, get_name=False):
if is_iter(obj):
if not obj:
return str(obj)
if get_name:
return obj[0] if obj[0] else "<unset>"
if rootname == "attrs":
return "{} |w=|n {} |w(category:|n |n{}|w, locks:|n {}|w)|n".format(*obj)
elif rootname == "tags":
return "{} |w(category:|n {}|w)|n".format(obj[0], obj[1])
return "{}".format(obj)
def _parse_diffpart(diffpart, rootname):
typ = type(diffpart)
texts = []
if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions:
old, new, instruction = diffpart
if instruction == "KEEP":
if not minimal:
texts.append(" |gKEEP|n: {old}".format(old=_visualize(old, rootname)))
elif instruction == "ADD":
texts.append(" |yADD|n: {new}".format(new=_visualize(new, rootname)))
elif instruction == "REMOVE" and not new:
texts.append(" |rREMOVE|n: {old}".format(old=_visualize(old, rootname)))
else:
vold = _visualize(old, rootname)
vnew = _visualize(new, rootname)
vsep = "" if len(vold) < 78 else "\n"
vinst = " |rREMOVE|n" if instruction == "REMOVE" else "|y{}|n".format(instruction)
varrow = "|r->|n" if instruction == "REMOVE" else "|y->|n"
texts.append(
" {inst}|W:|n {old} |W{varrow}|n{sep} {new}".format(
inst=vinst, old=vold, varrow=varrow, sep=vsep, new=vnew
)
)
else:
for key in sorted(list(diffpart.keys())):
subdiffpart = diffpart[key]
text = _parse_diffpart(subdiffpart, rootname)
texts.extend(text)
return texts
texts = []
for root_key in sorted(diff):
diffpart = diff[root_key]
text = _parse_diffpart(diffpart, root_key)
if text or not minimal:
heading = "- |w{}:|n\n".format(root_key)
if text:
text = [heading + text[0]] + text[1:]
else:
text = [heading]
texts.extend(text)
return "\n ".join(line for line in texts if line)
def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exact=False):
"""
Update existing objects with the latest version of the prototype.
@ -501,6 +611,12 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
If not given this will be constructed from the first object found.
objects (list, optional): List of objects to update. If not given, query for these
objects using the prototype's `prototype_key`.
exact (bool, optional): By default (`False`), keys not explicitly in the prototype will
not be applied to the object, but will be retained as-is. This is usually what is
expected - for example, one usually do not want to remove the object's location even
if it's not set in the prototype. With `exact=True`, all un-specified properties of the
objects will be removed if they exist. This will lead to a more accurate 1:1 correlation
between the object and the prototype but is usually impractical.
Returns:
changed (int): The number of objects that had changes applied to them.
@ -515,7 +631,7 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
prototype_key = new_prototype["prototype_key"]
if not objects:
objects = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
objects = ObjectDB.objects.get_by_tag(prototype_key, category=PROTOTYPE_TAG_CATEGORY)
if not objects:
return 0
@ -525,104 +641,117 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
# make sure the diff is flattened
diff = flatten_diff(diff)
changed = 0
for obj in objects:
do_save = False
old_prot_key = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True)
old_prot_key = obj.tags.get(category=PROTOTYPE_TAG_CATEGORY, return_list=True)
old_prot_key = old_prot_key[0] if old_prot_key else None
if prototype_key != old_prot_key:
obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY)
obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
for key, directive in diff.items():
if directive in ("UPDATE", "REPLACE"):
try:
for key, directive in diff.items():
if key in _PROTOTYPE_META_NAMES:
# prototype meta keys are not stored on-object
if key not in new_prototype and not exact:
# we don't update the object if the prototype does not actually
# contain the key (the diff will report REMOVE but we ignore it
# since exact=False)
continue
val = new_prototype[key]
do_save = True
if directive in ("UPDATE", "REPLACE"):
if key == "key":
obj.db_key = init_spawn_value(val, str)
elif key == "typeclass":
obj.db_typeclass_path = init_spawn_value(val, str)
elif key == "location":
obj.db_location = init_spawn_value(val, value_to_obj)
elif key == "home":
obj.db_home = init_spawn_value(val, value_to_obj)
elif key == "destination":
obj.db_destination = init_spawn_value(val, value_to_obj)
elif key == "locks":
if directive == "REPLACE":
obj.locks.clear()
obj.locks.add(init_spawn_value(val, str))
elif key == "permissions":
if directive == "REPLACE":
obj.permissions.clear()
obj.permissions.batch_add(*(init_spawn_value(perm, str) for perm in val))
elif key == "aliases":
if directive == "REPLACE":
obj.aliases.clear()
obj.aliases.batch_add(*(init_spawn_value(alias, str) for alias in val))
elif key == "tags":
if directive == "REPLACE":
obj.tags.clear()
obj.tags.batch_add(
*(
(init_spawn_value(ttag, str), tcategory, tdata)
for ttag, tcategory, tdata in val
)
)
elif key == "attrs":
if directive == "REPLACE":
obj.attributes.clear()
obj.attributes.batch_add(
*(
(
init_spawn_value(akey, str),
init_spawn_value(aval, value_to_obj),
acategory,
alocks,
if key in _PROTOTYPE_META_NAMES:
# prototype meta keys are not stored on-object
continue
val = new_prototype[key]
do_save = True
if key == "key":
obj.db_key = init_spawn_value(val, str)
elif key == "typeclass":
obj.db_typeclass_path = init_spawn_value(val, str)
elif key == "location":
obj.db_location = init_spawn_value(val, value_to_obj)
elif key == "home":
obj.db_home = init_spawn_value(val, value_to_obj)
elif key == "destination":
obj.db_destination = init_spawn_value(val, value_to_obj)
elif key == "locks":
if directive == "REPLACE":
obj.locks.clear()
obj.locks.add(init_spawn_value(val, str))
elif key == "permissions":
if directive == "REPLACE":
obj.permissions.clear()
obj.permissions.batch_add(*(init_spawn_value(perm, str) for perm in val))
elif key == "aliases":
if directive == "REPLACE":
obj.aliases.clear()
obj.aliases.batch_add(*(init_spawn_value(alias, str) for alias in val))
elif key == "tags":
if directive == "REPLACE":
obj.tags.clear()
obj.tags.batch_add(
*(
(init_spawn_value(ttag, str), tcategory, tdata)
for ttag, tcategory, tdata in val
)
for akey, aval, acategory, alocks in val
)
)
elif key == "exec":
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.add(key, init_spawn_value(val, value_to_obj))
elif directive == "REMOVE":
do_save = True
if key == "key":
obj.db_key = ""
elif key == "typeclass":
# fall back to default
obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS
elif key == "location":
obj.db_location = None
elif key == "home":
obj.db_home = None
elif key == "destination":
obj.db_destination = None
elif key == "locks":
obj.locks.clear()
elif key == "permissions":
obj.permissions.clear()
elif key == "aliases":
obj.aliases.clear()
elif key == "tags":
obj.tags.clear()
elif key == "attrs":
obj.attributes.clear()
elif key == "exec":
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.remove(key)
elif key == "attrs":
if directive == "REPLACE":
obj.attributes.clear()
obj.attributes.batch_add(
*(
(
init_spawn_value(akey, str),
init_spawn_value(aval, value_to_obj),
acategory,
alocks,
)
for akey, aval, acategory, alocks in val
)
)
elif key == "exec":
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.add(key, init_spawn_value(val, value_to_obj))
elif directive == "REMOVE":
do_save = True
if key == "key":
obj.db_key = ""
elif key == "typeclass":
# fall back to default
obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS
elif key == "location":
obj.db_location = None
elif key == "home":
obj.db_home = None
elif key == "destination":
obj.db_destination = None
elif key == "locks":
obj.locks.clear()
elif key == "permissions":
obj.permissions.clear()
elif key == "aliases":
obj.aliases.clear()
elif key == "tags":
obj.tags.clear()
elif key == "attrs":
obj.attributes.clear()
elif key == "exec":
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.remove(key)
except Exception:
logger.log_trace(f"Failed to apply prototype '{prototype_key}' to {obj}.")
finally:
# we must always make sure to re-add the prototype tag
obj.tags.clear(category=PROTOTYPE_TAG_CATEGORY)
obj.tags.add(prototype_key, category=PROTOTYPE_TAG_CATEGORY)
if do_save:
changed += 1
obj.save()
@ -707,7 +836,7 @@ def spawn(*prototypes, **kwargs):
Args:
prototypes (str or dict): Each argument should either be a
prototype_key (will be used to find the prototype) or a full prototype
dictionary. These will be batched-spawned as one object each.
dictionary. These will be batched-spawned as one object each.
Kwargs:
prototype_modules (str or list): A python-path to a prototype
module, or a list of such paths. These will be used to build
@ -804,7 +933,7 @@ def spawn(*prototypes, **kwargs):
prototype_key = prototype.get("prototype_key", None)
if prototype_key:
# we make sure to add a tag identifying which prototype created this object
tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY))
tags.append((prototype_key, PROTOTYPE_TAG_CATEGORY))
val = prot.pop("exec", "")
execs = init_spawn_value(val, make_iter)

View file

@ -11,7 +11,7 @@ from evennia.utils.test_resources import EvenniaTest
from evennia.utils.tests.test_evmenu import TestEvMenu
from evennia.prototypes import spawner, prototypes as protlib
from evennia.prototypes import menus as olc_menus
from evennia.prototypes import protfuncs as protofuncs
from evennia.prototypes import protfuncs as protofuncs, spawner
from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY
@ -212,22 +212,21 @@ class TestUtils(EvenniaTest):
"puppet:pperm(Developer);tell:perm(Admin);view:all()",
"KEEP",
),
"prototype_tags": {},
"prototype_tags": (None, None, 'KEEP'),
"attrs": {
"oldtest": (
("oldtest", "to_keep", None, ""),
("oldtest", "to_keep", None, ""),
"KEEP",
),
"test": (("test", "testval", None, ""), None, "REMOVE"),
"desc": (("desc", "changed desc", None, ""), None, "REMOVE"),
"fooattr": (None, ("fooattr", "fooattrval", None, ""), "ADD"),
"desc": (("desc", "changed desc", None, ""), None, "KEEP"),
"fooattr": (Something, ("fooattr", "fooattrval", None, ""), "ADD"),
"test": (
("test", "testval", None, ""),
("test", "testval_changed", None, ""),
"UPDATE",
),
"new": (None, ("new", "new_val", None, ""), "ADD"),
"new": (Something, ("new", "new_val", None, ""), "ADD"),
},
"key": ("Obj", "Obj", "KEEP"),
"typeclass": (
@ -246,7 +245,7 @@ class TestUtils(EvenniaTest):
spawner.flatten_diff(pdiff),
{
"aliases": "REMOVE",
"attrs": "REPLACE",
"attrs": "UPDATE",
"home": "KEEP",
"key": "KEEP",
"location": "KEEP",
@ -270,7 +269,9 @@ class TestUtils(EvenniaTest):
new_prot = spawner.prototype_from_object(self.obj1)
self.assertEqual(
{
"aliases": ['foo'],
"attrs": [
("desc", "changed desc", None, ""),
("fooattr", "fooattrval", None, ""),
("new", "new_val", None, ""),
("oldtest", "to_keep", None, ""),
@ -293,6 +294,9 @@ class TestUtils(EvenniaTest):
"view:all()",
]
),
'tags': [
('footag', 'foocategory', None),
(Something, 'from_prototype', None)],
"permissions": ["builder"],
"prototype_desc": "Built from Obj",
"prototype_key": Something,
@ -851,7 +855,7 @@ class TestMenuModule(EvenniaTest):
self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject")
self.assertEqual(
obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot["prototype_key"]
obj.tags.get(category=spawner.PROTOTYPE_TAG_CATEGORY), self.test_prot["prototype_key"]
)
# update helpers
@ -912,24 +916,20 @@ class TestMenuModule(EvenniaTest):
texts, options = olc_menus._format_diff_text_and_options(obj_diff)
self.assertEqual(
"\n".join(texts),
"- |wattrs:|n \n"
" |gKEEP|W:|n desc |W=|n This is User #1. |W(category:|n None|W, locks:|n |W)|n\n"
" |c[1] |yADD|n|W:|n None |W->|n foo |W=|n bar |W(category:|n None|W, locks:|n |W)|n\n"
" |gKEEP|W:|n prelogout_location |W=|n #2 |W(category:|n None|W, locks:|n |W)|n\n"
"- |whome:|n |gKEEP|W:|n #2\n"
"- |wkey:|n |gKEEP|W:|n TestChar\n"
"- |wlocks:|n |gKEEP|W:|n boot:false();call:false();control:perm(Developer);delete:false();edit:false();examine:perm(Developer);get:false();msg:all();puppet:false();tell:perm(Admin);view:all()\n"
"- |wpermissions:|n \n"
" |gKEEP|W:|n developer\n"
"- |wprototype_desc:|n |c[2] |rREMOVE|n|W:|n Testobject build |W->|n None\n"
"- |wprototype_key:|n |gKEEP|W:|n TestDiffKey\n"
"- |wprototype_locks:|n |gKEEP|W:|n spawn:all();edit:all()\n"
"- |wprototype_tags:|n \n"
"- |wtags:|n \n"
" |c[3] |yADD|n|W:|n None |W->|n foo |W(category:|n None|W)|n\n"
"- |wtypeclass:|n |gKEEP|W:|n typeclasses.characters.Character",
"\n".join(txt.strip() for txt in texts),
"- |wattrs:|n |c[1] |yADD|n: foo |W=|n bar |W(category:|n None|W, locks:|n |W)|n"
"\n- |whome:|n"
"\n- |wkey:|n"
"\n- |wlocks:|n"
"\n- |wpermissions:|n"
"\n- |wprototype_desc:|n |c[2] |rREMOVE|n: Testobject build"
"\n- |wprototype_key:|n"
"\n- |wprototype_locks:|n"
"\n- |wprototype_tags:|n"
"\n- |wtags:|n |c[3] |yADD|n: foo |W(category:|n None|W)|n"
"\n- |wtypeclass:|n"
)
self.assertEqual(
options,
[

View file

@ -70,14 +70,15 @@ class HTTPChannelWithXForwardedFor(http.HTTPChannel):
Check to see if this is a reverse proxied connection.
"""
CLIENT = 0
http.HTTPChannel.allHeadersReceived(self)
req = self.requests[-1]
client_ip, port = self.transport.client
proxy_chain = req.getHeader("X-FORWARDED-FOR")
if proxy_chain and client_ip in _UPSTREAM_IPS:
forwarded = proxy_chain.split(", ", 1)[CLIENT]
self.transport.client = (forwarded, port)
if self.requests:
CLIENT = 0
http.HTTPChannel.allHeadersReceived(self)
req = self.requests[-1]
client_ip, port = self.transport.client
proxy_chain = req.getHeader("X-FORWARDED-FOR")
if proxy_chain and client_ip in _UPSTREAM_IPS:
forwarded = proxy_chain.split(", ", 1)[CLIENT]
self.transport.client = (forwarded, port)
# Monkey-patch Twisted to handle X-Forwarded-For.

View file

@ -59,7 +59,7 @@ class Attribute(SharedMemoryModel):
# Attribute Database Model setup
#
# These database fields are all set using their corresponding properties,
# named same as the field, but withtout the db_* prefix.
# named same as the field, but without the db_* prefix.
db_key = models.CharField("key", max_length=255, db_index=True)
db_value = PickledObjectField(
"value",

View file

@ -821,6 +821,8 @@ class ANSIString(str, metaclass=ANSIMeta):
by a number.
"""
if not offset:
return []
return [i + offset for i in iterable]
@classmethod
@ -1063,7 +1065,7 @@ class ANSIString(str, metaclass=ANSIMeta):
clean_string = self._clean_string * other
code_indexes = self._code_indexes[:]
char_indexes = self._char_indexes[:]
for i in range(1, other + 1):
for i in range(other):
code_indexes.extend(self._shifter(self._code_indexes, i * len(self._raw_string)))
char_indexes.extend(self._shifter(self._char_indexes, i * len(self._raw_string)))
return ANSIString(

View file

@ -486,8 +486,8 @@ def create_account(
Args:
key (str): The account's name. This should be unique.
email (str or None): Email on valid addr@addr.domain form. If
the empty string, will be set to None.
email (str or None): Email on valid addr@addr.domain form. If
the empty string, will be set to None.
password (str): Password in cleartext.
Kwargs:

View file

@ -28,9 +28,10 @@ caller.msg() construct every time the page is updated.
"""
from django.conf import settings
from django.db.models.query import QuerySet
from evennia import Command, CmdSet
from evennia.commands import cmdhandler
from evennia.utils.utils import justify, make_iter
from evennia.utils.utils import make_iter, inherits_from, justify
_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
@ -117,6 +118,11 @@ class CmdSetMore(CmdSet):
self.add(CmdMoreLook())
# resources for handling queryset inputs
def queryset_maxsize(qs):
return qs.count()
class EvMore(object):
"""
The main pager object
@ -132,6 +138,7 @@ class EvMore(object):
justify_kwargs=None,
exit_on_lastpage=False,
exit_cmd=None,
page_formatter=str,
**kwargs,
):
@ -149,7 +156,7 @@ class EvMore(object):
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 repr() (so one could pass a list of objects).
will be run through `iter_callable`.
always_page (bool, optional): If `False`, the
pager will only kick in if `text` is too big
to fit the screen.
@ -168,6 +175,12 @@ 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:
@ -186,13 +199,7 @@ class EvMore(object):
"""
self._caller = caller
self._kwargs = kwargs
self._pages = []
self._npages = 1
self._npos = 0
self.exit_on_lastpage = exit_on_lastpage
self.exit_cmd = exit_cmd
self._exit_msg = "Exited |wmore|n pager."
self._always_page = always_page
if not session:
# if not supplied, use the first session to
@ -203,81 +210,141 @@ class EvMore(object):
session = sessions[0]
self._session = session
self._justify = justify
self._justify_kwargs = justify_kwargs
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
# set up individual pages for different sessions
height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4)
width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0]
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 hasattr(text, "table") and hasattr(text, "get"):
# This is an EvTable.
table = text
if table.height:
# enforced height of each paged table, plus space for evmore extras
height = table.height - 4
# convert table to string
text = str(text)
justify_kwargs = None # enforce
if not isinstance(text, str):
# not a string - pre-set pages of some form
text = "\n".join(str(repr(element)) for element in make_iter(text))
if "\f" in text:
# we use \f to indicate the user wants to enforce their line breaks
# on their own. If so, we do no automatic line-breaking/justification
# at all.
self._pages = text.split("\f")
self._npages = len(self._pages)
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:
if justify:
# we must break very long lines into multiple ones. Note that this
# will also remove spurious whitespace.
justify_kwargs = justify_kwargs or {}
width = justify_kwargs.get("width", width)
justify_kwargs["width"] = width
justify_kwargs["align"] = justify_kwargs.get("align", "l")
justify_kwargs["indent"] = justify_kwargs.get("indent", 0)
# a string
self.init_str(text)
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")
# kick things into gear
self.start()
# always limit number of chars to 10 000 per page
height = min(10000 // max(1, width), height)
# page formatter
# figure out the pagination
self._pages = ["\n".join(lines[i : i + height]) for i in range(0, len(lines), height)]
self._npages = len(self._pages)
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)
if self._npages <= 1 and not always_page:
# no need for paging; just pass-through.
caller.msg(text=self._get_page(0), session=self._session, **kwargs)
# 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_slice
def init_f_str(self, text):
"""
The input contains \f markers. We use \f to indicate the user wants to
enforce their line breaks on their own. If so, we do no automatic
line-breaking/justification at all.
"""
self._data = text.split("\f")
self._npages = len(self._data)
self._paginator = self.paginator_index
def init_str(self, text):
"""The input is a string"""
if self._justify:
# we must break very long lines into multiple ones. Note that this
# will also remove spurious whitespace.
justify_kwargs = self._justify_kwargs or {}
width = self._justify_kwargs.get("width", self.width)
justify_kwargs["width"] = width
justify_kwargs["align"] = self._justify_kwargs.get("align", "l")
justify_kwargs["indent"] = self._justify_kwargs.get("indent", 0)
lines = []
for line in text.split("\n"):
if len(line) > width:
lines.extend(justify(line, **justify_kwargs).split("\n"))
else:
lines.append(line)
else:
# go into paging mode
# first pass on the msg kwargs
caller.ndb._more = self
caller.cmdset.add(CmdSetMore)
# no justification. Simple division by line
lines = text.split("\n")
# goto top of the text
self.page_top()
self._data = ["\n".join(lines[i: i + self.height])
for i in range(0, len(lines), self.height)]
self._npages = len(self._data)
self._paginator = self.paginator_index
def _get_page(self, pos):
return self._pages[pos]
# display helpers and navigation
def display(self, show_footer=True):
"""
Pretty-print the page.
"""
pos = self._npos
text = self._get_page(pos)
text = self.format_page(self._paginator(pos))
if show_footer:
page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages)
else:
@ -340,6 +407,22 @@ class EvMore(object):
if self.exit_cmd:
self._caller.execute_cmd(self.exit_cmd, session=self._session)
def start(self):
"""
Starts the pagination
"""
if self._npages <= 1 and not self._always_page:
# no need for paging; just pass-through.
self.display(show_footer=False)
else:
# go into paging mode
# first pass on the msg kwargs
self._caller.ndb._more = self
self._caller.cmdset.add(CmdSetMore)
# goto top of the text
self.page_top()
# helper function

View file

@ -1113,7 +1113,7 @@ class HelpDetailView(HelpMixin, EvenniaDetailView):
# Check if this object was requested in a valid manner
if not obj:
raise HttpResponseBadRequest(
return HttpResponseBadRequest(
"No %(verbose_name)s found matching the query"
% {"verbose_name": queryset.model._meta.verbose_name}
)