diff --git a/CHANGELOG.md b/CHANGELOG.md index 35356c8995..03d41faee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,8 @@ Up requirements to Django 3.2+, Twisted 21+ since their work is now fully handled by the single `channel` command. - Expand `examine` command's code to much more extensible and modular. Show attribute categories and value types (when not strings). +- `AttributeHandler.remove(key, return_exception=False, category=None, ...)` changed + to `.remove(key, category=None, return_exception=False, ...)` for consistency. ### Evennia 0.9.5 (2019-2020) @@ -216,6 +218,7 @@ without arguments starts a full interactive Python console. - Add `PermissionHandler.check` method for straight string perm-checks without needing lockstrings. - Add `evennia.utils.utils.strip_unsafe_input` for removing html/newlines/tags from user input. The `INPUT_CLEANUP_BYPASS_PERMISSIONS` is a list of perms that bypass this safety stripping. +- Make default `set` and `examine` commands aware of Attribute categories. ## Evennia 0.9 (2018-2019) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 5a7ce91341..8cf4700f93 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1577,10 +1577,10 @@ class CmdSetAttribute(ObjManipCommand): set attribute on an object or account Usage: - set / = - set / = - set / - set */ = + set[/switch] /[:category] = + set[/switch] /[:category] = # delete attribute + set[/switch] /[:category] # view attribute + set[/switch] */[:category] = Switch: edit: Open the line editor (string values only) @@ -1631,7 +1631,7 @@ class CmdSetAttribute(ObjManipCommand): """ return True - def check_attr(self, obj, attr_name): + def check_attr(self, obj, attr_name, category): """ This may be overridden by subclasses in case restrictions need to be placed on what attributes can be set by who beyond the normal lock. @@ -1688,7 +1688,7 @@ class CmdSetAttribute(ObjManipCommand): return self.not_found return result - def view_attr(self, obj, attr): + def view_attr(self, obj, attr, category): """ Look up the value of an attribute and return a string displaying it. """ @@ -1699,45 +1699,49 @@ class CmdSetAttribute(ObjManipCommand): val = obj.attributes.get(key) val = self.do_nested_lookup(val, *nested_keys) if val is not self.not_found: - return "\nAttribute %s/%s = %s" % (obj.name, attr, val) - error = "\n%s has no attribute '%s'." % (obj.name, attr) + return f"\nAttribute {obj.name}/|w{attr}|n [category:{category}] = {val}" + error = f"\nAttribute {obj.name}/|w{attr} [category:{category}] does not exist." if nested: error += " (Nested lookups attempted)" return error - def rm_attr(self, obj, attr): + def rm_attr(self, obj, attr, category): """ Remove an attribute from the object, or a nested data structure, and report back. """ nested = False for key, nested_keys in self.split_nested_attr(attr): nested = True - if obj.attributes.has(key): + if obj.attributes.has(key, category): if nested_keys: del_key = nested_keys[-1] - val = obj.attributes.get(key) + val = obj.attributes.get(key, category=category) deep = self.do_nested_lookup(val, *nested_keys[:-1]) if deep is not self.not_found: try: del deep[del_key] except (IndexError, KeyError, TypeError): continue - return "\nDeleted attribute '%s' (= nested) from %s." % (attr, obj.name) + return f"\nDeleted attribute {obj.name}/|w{attr}|n [category:{category}]." else: - exists = obj.attributes.has(key) - obj.attributes.remove(attr) - return "\nDeleted attribute '%s' (= %s) from %s." % (attr, exists, obj.name) - error = "\n%s has no attribute '%s'." % (obj.name, attr) + exists = obj.attributes.has(key, category) + if exists: + obj.attributes.remove(attr, category=category) + return f"\nDeleted attribute {obj.name}/|w{attr}|n [category:{category}]." + else: + return (f"\nNo attribute {obj.name}/|w{attr}|n [category: {category}] " + "was found to delete.") + error = f"\nNo attribute {obj.name}/|w{attr}|n [category: {category}] was found to delete." if nested: error += " (Nested lookups attempted)" return error - def set_attr(self, obj, attr, value): + def set_attr(self, obj, attr, value, category): done = False for key, nested_keys in self.split_nested_attr(attr): - if obj.attributes.has(key) and nested_keys: + if obj.attributes.has(key, category) and nested_keys: acc_key = nested_keys[-1] - lookup_value = obj.attributes.get(key) + lookup_value = obj.attributes.get(key, category) deep = self.do_nested_lookup(lookup_value, *nested_keys[:-1]) if deep is not self.not_found: # To support appending and inserting to lists @@ -1764,7 +1768,7 @@ class CmdSetAttribute(ObjManipCommand): deep[acc_key] = value except TypeError as err: # Tuples can't be modified - return "\n%s - %s" % (err, deep) + return f"\n{err} - {deep}" value = lookup_value attr = key @@ -1774,8 +1778,8 @@ class CmdSetAttribute(ObjManipCommand): verb = "Modified" if obj.attributes.has(attr) else "Created" try: if not done: - obj.attributes.add(attr, value) - return "\n%s attribute %s/%s = %s" % (verb, obj.name, attr, repr(value)) + obj.attributes.add(attr, value, category) + return f"\n{verb} attribute {obj.name}/|w{attr}|n [category:{category}] = {value}" except SyntaxError: # this means literal_eval tried to parse a faulty string return ( @@ -1861,13 +1865,14 @@ class CmdSetAttribute(ObjManipCommand): caller = self.caller if not self.args: - caller.msg("Usage: set obj/attr = value. Use empty value to clear.") + caller.msg("Usage: set obj/attr[:category] = value. Use empty value to clear.") return # get values prepared by the parser value = self.rhs objname = self.lhs_objattr[0]["name"] attrs = self.lhs_objattr[0]["attrs"] + category = self.lhs_objs[0].get("option") # None if unset obj = self.search_for_obj(objname) if not obj: @@ -1897,11 +1902,11 @@ class CmdSetAttribute(ObjManipCommand): if self.rhs is None: # no = means we inspect the attribute(s) if not attrs: - attrs = [attr.key for attr in obj.attributes.all()] + attrs = [attr.key for attr in obj.attributes.get(category=None)] for attr in attrs: - if not self.check_attr(obj, attr): + if not self.check_attr(obj, attr, category): continue - result.append(self.view_attr(obj, attr)) + result.append(self.view_attr(obj, attr, category)) # we view it without parsing markup. self.caller.msg("".join(result).strip(), options={"raw": True}) return @@ -1911,19 +1916,19 @@ class CmdSetAttribute(ObjManipCommand): caller.msg("You don't have permission to edit %s." % obj.key) return for attr in attrs: - if not self.check_attr(obj, attr): + if not self.check_attr(obj, attr, category): continue - result.append(self.rm_attr(obj, attr)) + result.append(self.rm_attr(obj, attr, category)) else: # setting attribute(s). Make sure to convert to real Python type before saving. if not (obj.access(self.caller, "control") or obj.access(self.caller, "edit")): caller.msg("You don't have permission to edit %s." % obj.key) return for attr in attrs: - if not self.check_attr(obj, attr): + if not self.check_attr(obj, attr, category): continue value = _convert_from_string(self, value) - result.append(self.set_attr(obj, attr, value)) + result.append(self.set_attr(obj, attr, value, category)) # send feedback caller.msg("".join(result).strip("\n")) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index ea74614765..7bee7df667 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -965,15 +965,17 @@ class TestBuilding(CommandTest): self.call( building.CmdSetAttribute(), 'Obj/test1="value1"', - "Created attribute Obj/test1 = 'value1'", + "Created attribute Obj/test1 [category:None] = value1", ) self.call( building.CmdSetAttribute(), 'Obj2/test2="value2"', - "Created attribute Obj2/test2 = 'value2'", + "Created attribute Obj2/test2 [category:None] = value2", ) - self.call(building.CmdSetAttribute(), "Obj2/test2", "Attribute Obj2/test2 = value2") - self.call(building.CmdSetAttribute(), "Obj2/NotFound", "Obj2 has no attribute 'notfound'.") + self.call(building.CmdSetAttribute(), + "Obj2/test2", "Attribute Obj2/test2 [category:None] = value2") + self.call(building.CmdSetAttribute(), + "Obj2/NotFound", "Attribute Obj2/notfound [category:None] does not exist.") with patch("evennia.commands.default.building.EvEditor") as mock_ed: self.call(building.CmdSetAttribute(), "/edit Obj2/test3") @@ -982,14 +984,18 @@ class TestBuilding(CommandTest): self.call( building.CmdSetAttribute(), 'Obj2/test3="value3"', - "Created attribute Obj2/test3 = 'value3'", + "Created attribute Obj2/test3 [category:None] = value3", ) self.call( building.CmdSetAttribute(), "Obj2/test3 = ", - "Deleted attribute 'test3' (= True) from Obj2.", + "Deleted attribute Obj2/test3 [category:None].", + ) + self.call( + building.CmdSetAttribute(), + "Obj2/test4:Foo = 'Bar'", + "Created attribute Obj2/test4 [category:Foo] = Bar", ) - self.call( building.CmdCpAttr(), "/copy Obj2/test2 = Obj2/test3", @@ -1008,123 +1014,162 @@ class TestBuilding(CommandTest): def test_nested_attribute_commands(self): # list - adding white space proves real parsing self.call( - building.CmdSetAttribute(), "Obj/test1=[1,2]", "Created attribute Obj/test1 = [1, 2]" + building.CmdSetAttribute(), + "Obj/test1=[1,2]", "Created attribute Obj/test1 [category:None] = [1, 2]" ) - self.call(building.CmdSetAttribute(), "Obj/test1", "Attribute Obj/test1 = [1, 2]") - self.call(building.CmdSetAttribute(), "Obj/test1[0]", "Attribute Obj/test1[0] = 1") - self.call(building.CmdSetAttribute(), "Obj/test1[1]", "Attribute Obj/test1[1] = 2") + self.call(building.CmdSetAttribute(), + "Obj/test1", + "Attribute Obj/test1 [category:None] = [1, 2]") + self.call(building.CmdSetAttribute(), + "Obj/test1[0]", + "Attribute Obj/test1[0] [category:None] = 1") + self.call(building.CmdSetAttribute(), + "Obj/test1[1]", + "Attribute Obj/test1[1] [category:None] = 2") self.call( building.CmdSetAttribute(), "Obj/test1[0] = 99", - "Modified attribute Obj/test1 = [99, 2]", + "Modified attribute Obj/test1 [category:None] = [99, 2]", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test1[0]", + "Attribute Obj/test1[0] [category:None] = 99" ) - self.call(building.CmdSetAttribute(), "Obj/test1[0]", "Attribute Obj/test1[0] = 99") # list delete self.call( building.CmdSetAttribute(), "Obj/test1[0] =", - "Deleted attribute 'test1[0]' (= nested) from Obj.", + "Deleted attribute Obj/test1[0] [category:None].", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test1[0]", + "Attribute Obj/test1[0] [category:None] = 2" ) - self.call(building.CmdSetAttribute(), "Obj/test1[0]", "Attribute Obj/test1[0] = 2") self.call( building.CmdSetAttribute(), "Obj/test1[1]", - "Obj has no attribute 'test1[1]'. (Nested lookups attempted)", + "Attribute Obj/test1[1] [category:None] does not exist. (Nested lookups attempted)", ) # Delete non-existent self.call( building.CmdSetAttribute(), "Obj/test1[5] =", - "Obj has no attribute 'test1[5]'. (Nested lookups attempted)", + "No attribute Obj/test1[5] [category: None] was found to " + "delete. (Nested lookups attempted)" ) # Append self.call( building.CmdSetAttribute(), "Obj/test1[+] = 42", - "Modified attribute Obj/test1 = [2, 42]", + "Modified attribute Obj/test1 [category:None] = [2, 42]", ) self.call( building.CmdSetAttribute(), "Obj/test1[+0] = -1", - "Modified attribute Obj/test1 = [-1, 2, 42]", + "Modified attribute Obj/test1 [category:None] = [-1, 2, 42]", ) # dict - removing white space proves real parsing self.call( building.CmdSetAttribute(), "Obj/test2={ 'one': 1, 'two': 2 }", - "Created attribute Obj/test2 = {'one': 1, 'two': 2}", + "Created attribute Obj/test2 [category:None] = {'one': 1, 'two': 2}", ) self.call( - building.CmdSetAttribute(), "Obj/test2", "Attribute Obj/test2 = {'one': 1, 'two': 2}" + building.CmdSetAttribute(), + "Obj/test2", "Attribute Obj/test2 [category:None] = {'one': 1, 'two': 2}" + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['one']", + "Attribute Obj/test2['one'] [category:None] = 1" + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['one]", + "Attribute Obj/test2['one] [category:None] = 1" ) - self.call(building.CmdSetAttribute(), "Obj/test2['one']", "Attribute Obj/test2['one'] = 1") - self.call(building.CmdSetAttribute(), "Obj/test2['one]", "Attribute Obj/test2['one] = 1") self.call( building.CmdSetAttribute(), "Obj/test2['one']=99", - "Modified attribute Obj/test2 = {'one': 99, 'two': 2}", + "Modified attribute Obj/test2 [category:None] = {'one': 99, 'two': 2}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['one']", + "Attribute Obj/test2['one'] [category:None] = 99" + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['two']", + "Attribute Obj/test2['two'] [category:None] = 2" ) - self.call(building.CmdSetAttribute(), "Obj/test2['one']", "Attribute Obj/test2['one'] = 99") - self.call(building.CmdSetAttribute(), "Obj/test2['two']", "Attribute Obj/test2['two'] = 2") self.call( building.CmdSetAttribute(), "Obj/test2[+'three']", - "Obj has no attribute 'test2[+'three']'. (Nested lookups attempted)", + "Attribute Obj/test2[+'three'] [category:None] does not exist. (Nested lookups attempted)" ) self.call( building.CmdSetAttribute(), "Obj/test2[+'three'] = 3", - "Modified attribute Obj/test2 = {'one': 99, 'two': 2, \"+'three'\": 3}", + "Modified attribute Obj/test2 [category:None] = {'one': 99, 'two': 2, \"+'three'\": 3}", ) self.call( building.CmdSetAttribute(), "Obj/test2[+'three'] =", - "Deleted attribute 'test2[+'three']' (= nested) from Obj.", + "Deleted attribute Obj/test2[+'three'] [category:None]." ) self.call( building.CmdSetAttribute(), "Obj/test2['three']=3", - "Modified attribute Obj/test2 = {'one': 99, 'two': 2, 'three': 3}", + "Modified attribute Obj/test2 [category:None] = {'one': 99, 'two': 2, 'three': 3}", ) # Dict delete self.call( building.CmdSetAttribute(), "Obj/test2['two'] =", - "Deleted attribute 'test2['two']' (= nested) from Obj.", + "Deleted attribute Obj/test2['two'] [category:None].", ) self.call( building.CmdSetAttribute(), "Obj/test2['two']", - "Obj has no attribute 'test2['two']'. (Nested lookups attempted)", + "Attribute Obj/test2['two'] [category:None] does not exist. (Nested lookups attempted)" ) self.call( - building.CmdSetAttribute(), "Obj/test2", "Attribute Obj/test2 = {'one': 99, 'three': 3}" + building.CmdSetAttribute(), + "Obj/test2", + "Attribute Obj/test2 [category:None] = {'one': 99, 'three': 3}" ) self.call( building.CmdSetAttribute(), "Obj/test2[0]", - "Obj has no attribute 'test2[0]'. (Nested lookups attempted)", + "Attribute Obj/test2[0] [category:None] does not exist. (Nested lookups attempted)" ) self.call( building.CmdSetAttribute(), "Obj/test2['five'] =", - "Obj has no attribute 'test2['five']'. (Nested lookups attempted)", + "No attribute Obj/test2['five'] [category: None] " + "was found to delete. (Nested lookups attempted)" ) self.call( building.CmdSetAttribute(), "Obj/test2[+]=42", - "Modified attribute Obj/test2 = {'one': 99, 'three': 3, '+': 42}", + "Modified attribute Obj/test2 [category:None] = {'one': 99, 'three': 3, '+': 42}", ) self.call( building.CmdSetAttribute(), "Obj/test2[+1]=33", - "Modified attribute Obj/test2 = {'one': 99, 'three': 3, '+': 42, '+1': 33}", + "Modified attribute Obj/test2 [category:None] = " + "{'one': 99, 'three': 3, '+': 42, '+1': 33}", ) # tuple self.call( - building.CmdSetAttribute(), "Obj/tup = (1,2)", "Created attribute Obj/tup = (1, 2)" + building.CmdSetAttribute(), + "Obj/tup = (1,2)", + "Created attribute Obj/tup [category:None] = (1, 2)" ) self.call( building.CmdSetAttribute(), @@ -1145,54 +1190,85 @@ class TestBuilding(CommandTest): building.CmdSetAttribute(), # Special case for tuple, could have a better message "Obj/tup[1] = ", - "Obj has no attribute 'tup[1]'. (Nested lookups attempted)", + "No attribute Obj/tup[1] [category: None] " + "was found to delete. (Nested lookups attempted)" ) # Deaper nesting self.call( building.CmdSetAttribute(), "Obj/test3=[{'one': 1}]", - "Created attribute Obj/test3 = [{'one': 1}]", + "Created attribute Obj/test3 [category:None] = [{'one': 1}]", ) self.call( - building.CmdSetAttribute(), "Obj/test3[0]['one']", "Attribute Obj/test3[0]['one'] = 1" + building.CmdSetAttribute(), + "Obj/test3[0]['one']", + "Attribute Obj/test3[0]['one'] [category:None] = 1" + ) + self.call( + building.CmdSetAttribute(), + "Obj/test3[0]", + "Attribute Obj/test3[0] [category:None] = {'one': 1}" ) - self.call(building.CmdSetAttribute(), "Obj/test3[0]", "Attribute Obj/test3[0] = {'one': 1}") self.call( building.CmdSetAttribute(), "Obj/test3[0]['one'] =", - "Deleted attribute 'test3[0]['one']' (= nested) from Obj.", + "Deleted attribute Obj/test3[0]['one'] [category:None]." + ) + self.call( + building.CmdSetAttribute(), + "Obj/test3[0]", + "Attribute Obj/test3[0] [category:None] = {}") + self.call( + building.CmdSetAttribute(), + "Obj/test3", + "Attribute Obj/test3 [category:None] = [{}]" ) - self.call(building.CmdSetAttribute(), "Obj/test3[0]", "Attribute Obj/test3[0] = {}") - self.call(building.CmdSetAttribute(), "Obj/test3", "Attribute Obj/test3 = [{}]") # Naughty keys self.call( building.CmdSetAttribute(), "Obj/test4[0]='foo'", - "Created attribute Obj/test4[0] = 'foo'", + "Created attribute Obj/test4[0] [category:None] = foo", ) - self.call(building.CmdSetAttribute(), "Obj/test4[0]", "Attribute Obj/test4[0] = foo") + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]", + "Attribute Obj/test4[0] [category:None] = foo") self.call( building.CmdSetAttribute(), "Obj/test4=[{'one': 1}]", - "Created attribute Obj/test4 = [{'one': 1}]", + "Created attribute Obj/test4 [category:None] = [{'one': 1}]", ) - self.call( - building.CmdSetAttribute(), "Obj/test4[0]['one']", "Attribute Obj/test4[0]['one'] = 1" - ) - # Prefer nested items - self.call(building.CmdSetAttribute(), "Obj/test4[0]", "Attribute Obj/test4[0] = {'one': 1}") - self.call( - building.CmdSetAttribute(), "Obj/test4[0]['one']", "Attribute Obj/test4[0]['one'] = 1" - ) - # Restored access - self.call(building.CmdWipe(), "Obj/test4", "Wiped attributes test4 on Obj.") - self.call(building.CmdSetAttribute(), "Obj/test4[0]", "Attribute Obj/test4[0] = foo") self.call( building.CmdSetAttribute(), "Obj/test4[0]['one']", - "Obj has no attribute 'test4[0]['one']'.", + "Attribute Obj/test4[0]['one'] [category:None] = 1" + ) + # Prefer nested items + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]", + "Attribute Obj/test4[0] [category:None] = {'one': 1}" + ) + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]['one']", + "Attribute Obj/test4[0]['one'] [category:None] = 1" + ) + # Restored access + self.call( + building.CmdWipe(), + "Obj/test4", + "Wiped attributes test4 on Obj.") + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]", + "Attribute Obj/test4[0] [category:None] = foo") + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]['one']", + "Attribute Obj/test4[0]['one'] [category:None] does not exist. (Nested lookups attempted)" ) def test_split_nested_attr(self): @@ -1864,7 +1940,7 @@ class TestBuilding(CommandTest): ) - +import evennia.commands.default.comms as cmd_comms # noqa from evennia.utils.create import create_channel # noqa class TestCommsChannel(CommandTest): @@ -1878,7 +1954,7 @@ class TestCommsChannel(CommandTest): key="testchannel", desc="A test channel") self.channel.connect(self.char1) - self.cmdchannel = comms.CmdChannel + self.cmdchannel = cmd_comms.CmdChannel self.cmdchannel.account_caller = False def tearDown(self): diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index e934e4c4e4..257af1fc8e 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -1248,8 +1248,8 @@ class AttributeHandler: def remove( self, key=None, - raise_exception=False, category=None, + raise_exception=False, accessing_obj=None, default_access=True, ): @@ -1260,11 +1260,11 @@ class AttributeHandler: key (str or list, optional): An Attribute key to remove or a list of keys. If multiple keys, they must all be of the same `category`. If None and category is not given, remove all Attributes. + category (str, optional): The category within which to + remove the Attribute. raise_exception (bool, optional): If set, not finding the Attribute to delete will raise an exception instead of just quietly failing. - category (str, optional): The category within which to - remove the Attribute. accessing_obj (object, optional): An object to check against the `attredit` lock. If not given, the check will be skipped. diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index f84c169ea4..753b617b9d 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -395,12 +395,13 @@ def iter_to_str(iterable, endsep=", and", addquote=False): """ if not iterable: return "" + iterable = list(make_iter(iterable)) len_iter = len(iterable) if addquote: - iterable = tuple(f'"{val}"' for val in make_iter(iterable)) + iterable = tuple(f'"{val}"' for val in iterable) else: - iterable = tuple(str(val) for val in make_iter(iterable)) + iterable = tuple(str(val) for val in iterable) if endsep.startswith(","): # oxford comma alternative