diff --git a/CHANGELOG.md b/CHANGELOG.md index 5252f8f67a..ac78239c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,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) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 1ab8e8714a..8ce409300f 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -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 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 [;desc[;tag,tag[,...][;lockstring]]] = " + "Usage: spawn/save [[;desc[;tag,tag[,...][;lockstring]]]] = " ) 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 = ` " + "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 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 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 = "" 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) diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index b6ff6ba25c..4124d0423e 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -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) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index d41a1ca4ca..4edaccb51d 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -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 = ` 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): diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index bba55b1eee..1024b9d63f 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -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 "" 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 = [ diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 94a3e04eb0..5d29d15b44 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -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( diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index fab4d2cfdc..fc8015bfbd 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -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 "" + + + # 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 "" + 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) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 107e2a127f..dfa592adbb 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -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, [