diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c560a8ae3..9f7c9fbc22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,20 @@ ## main branch -- [Feature] Add [`evennia.ON_DEMAND_HANDLER`][new-ondemandhandler] for making it +- Feature: Add [`evennia.ON_DEMAND_HANDLER`][new-ondemandhandler] for making it easier to implement changes that are calculated on-demand (Griatch) - [Feature][pull3412]: Make it possible to add custom webclient css in `webclient/css/custom.css`, same as for website (InspectorCaracal) - [Feature][pull3367]: [Component contrib][pull3367extra] got better inheritance, slot names to choose attr storage, speedups and fixes (ChrisLR) -- [Fix] Remove `AMP_ENABLED` setting since it services no real purpose and +- Feature: Break up `DefaultObject.search` method into several helpers to make + it easier to override (Griatch) +- Fix: Resolve multimatch error with rpsystem contrib (Griatch) +- Fix: Remove `AMP_ENABLED` setting since it services no real purpose and erroring out on setting it would make it even less useful (Griatch). -- [Feature] Remove too-strict password restrictions for Evennia logins, using +- Feature: Remove too-strict password restrictions for Evennia logins, using django defaults instead for passwords with more varied characters. -- [Fix] `services` command with no args would traceback (regression) (Griatch) +- Fix `services` command with no args would traceback (regression) (Griatch) - [Fix][pull3423]: Fix wilderness contrib error moving to an already existing wilderness room (InspectorCaracal) - [Fix][pull3425]: Don't always include example the crafting recipe when diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 18cf493859..7e8a6aa969 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -14,13 +14,10 @@ main test suite started with import datetime from unittest.mock import MagicMock, Mock, patch +import evennia from anything import Anything from django.conf import settings from django.test import override_settings -from parameterized import parameterized -from twisted.internet import task - -import evennia from evennia import ( DefaultCharacter, DefaultExit, @@ -32,14 +29,7 @@ from evennia import ( from evennia.commands import cmdparser from evennia.commands.cmdset import CmdSet from evennia.commands.command import Command, InterruptCommand -from evennia.commands.default import ( - account, - admin, - batchprocess, - building, - comms, - general, -) +from evennia.commands.default import account, admin, batchprocess, building, comms, general from evennia.commands.default import help as help_module from evennia.commands.default import syscommands, system, unloggedin from evennia.commands.default.cmdset_character import CharacterCmdSet @@ -48,6 +38,8 @@ from evennia.prototypes import prototypes as protlib from evennia.utils import create, gametime, utils from evennia.utils.test_resources import BaseEvenniaCommandTest # noqa from evennia.utils.test_resources import BaseEvenniaTest, EvenniaCommandTest +from parameterized import parameterized +from twisted.internet import task # ------------------------------------------------------------ # Command testing @@ -149,14 +141,18 @@ class TestGeneral(BaseEvenniaCommandTest): self.call( CmdTest(), "/t", - "test: Ambiguous switch supplied: " - "Did you mean /test or /testswitch or /testswitch2?|Switches matched: []", + ( + "test: Ambiguous switch supplied: " + "Did you mean /test or /testswitch or /testswitch2?|Switches matched: []" + ), ) self.call( CmdTest(), "/tests", - "test: Ambiguous switch supplied: " - "Did you mean /testswitch or /testswitch2?|Switches matched: []", + ( + "test: Ambiguous switch supplied: " + "Did you mean /testswitch or /testswitch2?|Switches matched: []" + ), ) def test_say(self): @@ -846,8 +842,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdCpAttr(), "/copy Obj2/test2 = Obj2/test3", - '@cpattr: Extra switch "/copy" ignored.|\nCopied Obj2.test2 -> Obj2.test3. ' - "(value: 'value2')", + ( + '@cpattr: Extra switch "/copy" ignored.|\nCopied Obj2.test2 -> Obj2.test3. ' + "(value: 'value2')" + ), ) self.call(building.CmdMvAttr(), "", "Usage: ") self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 -> Obj.test3") @@ -902,8 +900,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSetAttribute(), "Obj/test1[5] =", - "No attribute Obj/test1[5] [category: None] was found to " - "delete. (Nested lookups attempted)", + ( + "No attribute Obj/test1[5] [category: None] was found to " + "delete. (Nested lookups attempted)" + ), ) # Append self.call( @@ -956,8 +956,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSetAttribute(), "Obj/test2[+'three']", - "Attribute Obj/test2[+'three'] [category:None] does not exist. (Nested lookups" - " attempted)", + ( + "Attribute Obj/test2[+'three'] [category:None] does not exist. (Nested lookups" + " attempted)" + ), ) self.call( building.CmdSetAttribute(), @@ -998,8 +1000,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSetAttribute(), "Obj/test2['five'] =", - "No attribute Obj/test2['five'] [category: None] " - "was found to delete. (Nested lookups attempted)", + ( + "No attribute Obj/test2['five'] [category: None] " + "was found to delete. (Nested lookups attempted)" + ), ) self.call( building.CmdSetAttribute(), @@ -1009,8 +1013,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSetAttribute(), "Obj/test2[+1]=33", - "Modified attribute Obj/test2 [category:None] = " - "{'one': 99, 'three': 3, '+': 42, '+1': 33}", + ( + "Modified attribute Obj/test2 [category:None] = " + "{'one': 99, 'three': 3, '+': 42, '+1': 33}" + ), ) # dict - case sensitive keys @@ -1071,8 +1077,10 @@ class TestBuilding(BaseEvenniaCommandTest): building.CmdSetAttribute(), # Special case for tuple, could have a better message "Obj/tup[1] = ", - "No attribute Obj/tup[1] [category: None] " - "was found to delete. (Nested lookups attempted)", + ( + "No attribute Obj/tup[1] [category: None] " + "was found to delete. (Nested lookups attempted)" + ), ) # Deaper nesting @@ -1147,8 +1155,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSetAttribute(), "Obj/test4[0]['one']", - "Attribute Obj/test4[0]['one'] [category:None] does not exist. (Nested lookups" - " attempted)", + ( + "Attribute Obj/test4[0]['one'] [category:None] does not exist. (Nested lookups" + " attempted)" + ), ) def test_split_nested_attr(self): @@ -1255,8 +1265,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdDestroy(), "Obj", - "Could not find 'Obj'.| (Objects to destroy " - "must either be local or specified with a unique #dbref.)", + ( + "Could not find 'Obj'.| (Objects to destroy " + "must either be local or specified with a unique #dbref.)" + ), ) settings.DEFAULT_HOME = f"#{self.room1.dbid}" self.call( @@ -1376,14 +1388,18 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultExit", - "Obj changed typeclass from evennia.objects.objects.DefaultObject " - "to evennia.objects.objects.DefaultExit.", + ( + "Obj changed typeclass from evennia.objects.objects.DefaultObject " + "to evennia.objects.objects.DefaultExit." + ), ) self.call( building.CmdTypeclass(), "Obj2 = evennia.objects.objects.DefaultExit", - "Obj2 changed typeclass from evennia.objects.objects.DefaultObject " - "to evennia.objects.objects.DefaultExit.", + ( + "Obj2 changed typeclass from evennia.objects.objects.DefaultObject " + "to evennia.objects.objects.DefaultExit." + ), cmdstring="swap", inputs=["yes"], ) @@ -1396,8 +1412,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultExit", - "Obj already has the typeclass 'evennia.objects.objects.DefaultExit'. Use /force to" - " override.", + ( + "Obj already has the typeclass 'evennia.objects.objects.DefaultExit'. Use /force to" + " override." + ), ) self.call( building.CmdTypeclass(), @@ -1413,16 +1431,20 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdTypeclass(), "Obj", - "Obj updated its existing typeclass (evennia.objects.objects.DefaultObject).\nOnly the" - " at_object_creation hook was run (update mode). Attributes set before swap were not" - " removed\n(use `swap` or `type/reset` to clear all).", + ( + "Obj updated its existing typeclass (evennia.objects.objects.DefaultObject).\nOnly" + " the at_object_creation hook was run (update mode). Attributes set before swap" + " were not removed\n(use `swap` or `type/reset` to clear all)." + ), cmdstring="update", ) self.call( building.CmdTypeclass(), "/reset/force Obj=evennia.objects.objects.DefaultObject", - "Obj updated its existing typeclass (evennia.objects.objects.DefaultObject).\n" - "All object creation hooks were run. All old attributes where deleted before the swap.", + ( + "Obj updated its existing typeclass (evennia.objects.objects.DefaultObject).\nAll" + " object creation hooks were run. All old attributes where deleted before the swap." + ), inputs=["yes"], ) @@ -1446,11 +1468,13 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdTypeclass(), "/prototype Obj=testkey", - "replaced_obj changed typeclass from evennia.objects.objects.DefaultObject to " - "typeclasses.objects.Object.\nOnly the at_object_creation hook was run " - "(update mode). Attributes set before swap were not removed\n" - "(use `swap` or `type/reset` to clear all). Prototype 'replaced_obj' was " - "successfully applied over the object type.", + ( + "replaced_obj changed typeclass from evennia.objects.objects.DefaultObject to " + "typeclasses.objects.Object.\nOnly the at_object_creation hook was run " + "(update mode). Attributes set before swap were not removed\n" + "(use `swap` or `type/reset` to clear all). Prototype 'replaced_obj' was " + "successfully applied over the object type." + ), ) assert self.obj1.db.desc == "protdesc" @@ -1467,8 +1491,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdLock(), "/view Obj = edit:false()", - "Switch(es) view can not be used with a lock assignment. " - "Use e.g. lock/del objname/locktype instead.", + ( + "Switch(es) view can not be used with a lock assignment. " + "Use e.g. lock/del objname/locktype instead." + ), ) self.call(building.CmdLock(), "Obj = control:false()") self.call(building.CmdLock(), "Obj = edit:false()") @@ -1594,9 +1620,11 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdScripts(), "/delete #{}-#{}".format(script1.id, script3.id), - f"Global Script Deleted - #{script1.id} (evennia.scripts.scripts.DefaultScript)|" - f"Global Script Deleted - #{script2.id} (evennia.scripts.scripts.DefaultScript)|" - f"Global Script Deleted - #{script3.id} (evennia.scripts.scripts.DefaultScript)", + ( + f"Global Script Deleted - #{script1.id} (evennia.scripts.scripts.DefaultScript)|" + f"Global Script Deleted - #{script2.id} (evennia.scripts.scripts.DefaultScript)|" + f"Global Script Deleted - #{script3.id} (evennia.scripts.scripts.DefaultScript)" + ), inputs=["y"], ) self.assertFalse(script1.pk) @@ -1617,9 +1645,8 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdTeleport(), "Obj = Room2", - "Obj(#{}) is leaving Room(#{}), heading for Room2(#{}).|Teleported Obj -> Room2.".format( - oid, rid, rid2 - ), + "Obj(#{}) is leaving Room(#{}), heading for Room2(#{}).|Teleported Obj -> Room2." + .format(oid, rid, rid2), ) self.call(building.CmdTeleport(), "NotFound = Room", "Could not find 'NotFound'.") self.call( @@ -1692,16 +1719,20 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSpawn(), - "/save {'prototype_key': 'testprot', 'key':'Test Char', " - "'typeclass':'evennia.objects.objects.DefaultCharacter'}", + ( + "/save {'prototype_key': 'testprot', 'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}" + ), "Saved prototype: testprot", inputs=["y"], ) self.call( building.CmdSpawn(), - "/save testprot2 = {'key':'Test Char', " - "'typeclass':'evennia.objects.objects.DefaultCharacter'}", + ( + "/save testprot2 = {'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}" + ), "(Replacing `prototype_key` in prototype with given key.)|Saved prototype: testprot2", inputs=["y"], ) @@ -1712,8 +1743,10 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSpawn(), "/save {'key':'Test Char', 'typeclass':'evennia.objects.objects.DefaultCharacter'}", - "A prototype_key must be given, either as `prototype_key = ` or as " - "a key 'prototype_key' inside the prototype structure.", + ( + "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 ") @@ -1735,7 +1768,8 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSpawn(), "{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', " - "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, + "'key':'goblin', 'location':'%s'}" + % spawnLoc.dbref, "Spawned goblin", ) goblin = get_object(self, "goblin") @@ -1783,7 +1817,8 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSpawn(), "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo'," - " 'location':'%s'}" % spawnLoc.dbref, + " 'location':'%s'}" + % spawnLoc.dbref, "Spawned Ball", ) ball = get_object(self, "Ball") @@ -2021,8 +2056,10 @@ class TestComms(BaseEvenniaCommandTest): self.call( comms.CmdPage(), "TestAccount2 = Test", - "TestAccount2 is offline. They will see your message if they list their pages later." - "|You paged TestAccount2 with: 'Test'.", + ( + "TestAccount2 is offline. They will see your message if they list their pages" + " later.|You paged TestAccount2 with: 'Test'." + ), receiver=self.account, ) @@ -2095,8 +2132,10 @@ class TestBatchProcess(BaseEvenniaCommandTest): self.call( batchprocess.CmdBatchCommands(), "batchprocessor.example_batch_cmds_test", - "Running Batch-command processor - Automatic mode for" - " batchprocessor.example_batch_cmds", + ( + "Running Batch-command processor - Automatic mode for" + " batchprocessor.example_batch_cmds" + ), ) # we make sure to delete the button again here to stop the running reactor confirm = building.CmdDestroy.confirm diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index 6d3c04f933..b422fb06ab 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -154,18 +154,12 @@ from string import punctuation import inflect from django.conf import settings - from evennia.commands.cmdset import CmdSet from evennia.commands.command import Command from evennia.objects.models import ObjectDB from evennia.objects.objects import DefaultCharacter, DefaultObject from evennia.utils import ansi, logger -from evennia.utils.utils import ( - iter_to_str, - lazy_property, - make_iter, - variable_from_module, -) +from evennia.utils.utils import iter_to_str, lazy_property, make_iter, variable_from_module _INFLECT = inflect.engine() @@ -179,7 +173,7 @@ _AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", # The prefix is the (single-character) symbol used to find the start # of a object reference, such as /tall (note that # the system will understand multi-word references like '/a tall man' too). -_PREFIX = getattr(settings, 'RPSYSTEM_EMOTE_PREFIX', "/") +_PREFIX = getattr(settings, "RPSYSTEM_EMOTE_PREFIX", "/") # The num_sep is the (single-character) symbol used to separate the # sdesc from the number when trying to separate identical sdescs from @@ -1317,145 +1311,25 @@ class ContribRPObject(DefaultObject): self.db.pose_default = "is here." self.db._sdesc = "" - def search( + def get_search_result( self, searchdata, - global_search=False, - use_nicks=True, - typeclass=None, - location=None, attribute_name=None, - quiet=False, - exact=False, + typeclass=None, candidates=None, - nofound_string=None, - multimatch_string=None, + exact=False, use_dbref=None, + tags=None, + **kwargs, ): """ - Returns an Object matching a search string/condition, taking - sdescs into account. - - Perform a standard object search in the database, handling - multiple results and lack thereof gracefully. By default, only - objects in the current `location` of `self` or its inventory are searched for. - - Args: - searchdata (str or obj): Primary search criterion. Will be matched - against `object.key` (with `object.aliases` second) unless - the keyword attribute_name specifies otherwise. - **Special strings:** - - `#`: search by unique dbref. This is always - a global search. - - `me,self`: self-reference to this object - - `-` - can be used to differentiate - between multiple same-named matches - global_search (bool): Search all objects globally. This is overruled - by `location` keyword. - use_nicks (bool): Use nickname-replace (nicktype "object") on `searchdata`. - typeclass (str or Typeclass, or list of either): Limit search only - to `Objects` with this typeclass. May be a list of typeclasses - for a broader search. - location (Object or list): Specify a location or multiple locations - to search. Note that this is used to query the *contents* of a - location and will not match for the location itself - - if you want that, don't set this or use `candidates` to specify - exactly which objects should be searched. - attribute_name (str): Define which property to search. If set, no - key+alias search will be performed. This can be used - to search database fields (db_ will be automatically - appended), and if that fails, it will try to return - objects having Attributes with this name and value - equal to searchdata. A special use is to search for - "key" here if you want to do a key-search without - including aliases. - quiet (bool): don't display default error messages - this tells the - search method that the user wants to handle all errors - themselves. It also changes the return value type, see - below. - exact (bool): if unset (default) - prefers to match to beginning of - string rather than not matching at all. If set, requires - exact matching of entire string. - candidates (list of objects): this is an optional custom list of objects - to search (filter) between. It is ignored if `global_search` - is given. If not set, this list will automatically be defined - to include the location, the contents of location and the - caller's contents (inventory). - nofound_string (str): optional custom string for not-found error message. - multimatch_string (str): optional custom string for multimatch error header. - use_dbref (bool or None): If None, only turn off use_dbref if we are of a lower - permission than Builder. Otherwise, honor the True/False value. - - Returns: - match (Object, None or list): will return an Object/None if `quiet=False`, - otherwise it will return a list of 0, 1 or more matches. - - Notes: - To find Accounts, use eg. `evennia.account_search`. If - `quiet=False`, error messages will be handled by - `settings.SEARCH_AT_RESULT` and echoed automatically (on - error, return will be `None`). If `quiet=True`, the error - messaging is assumed to be handled by the caller. + Override of the parent method for producing search results that understands sdescs. + These are used in the main .search() method of the parent class. """ - is_string = isinstance(searchdata, str) - - if is_string: - # searchdata is a string; wrap some common self-references - if searchdata.lower() in ("here",): - return [self.location] if quiet else self.location - if searchdata.lower() in ("me", "self"): - return [self] if quiet else self - - if use_nicks: - # do nick-replacement on search - searchdata = self.nicks.nickreplace( - searchdata, categories=("object", "account"), include_account=True - ) - - if global_search or ( - is_string - and searchdata.startswith("#") - and len(searchdata) > 1 - and searchdata[1:].isdigit() - ): - # only allow exact matching if searching the entire database - # or unique #dbrefs - exact = True - elif candidates is None: - # no custom candidates given - get them automatically - if location: - # location(s) were given - candidates = [] - for obj in make_iter(location): - candidates.extend(obj.contents) - else: - # local search. Candidates are taken from - # self.contents, self.location and - # self.location.contents - location = self.location - candidates = self.contents - if location: - candidates = candidates + [location] + location.contents - else: - # normally we don't need this since we are - # included in location.contents - candidates.append(self) - - # the sdesc-related substitution + # we also want to use the default search method + search_obj = super().get_search_result is_builder = self.locks.check_lockstring(self, "perm(Builder)") - use_dbref = is_builder if use_dbref is None else use_dbref - - def search_obj(string): - "helper wrapper for searching" - return ObjectDB.objects.object_search( - string, - attribute_name=attribute_name, - typeclass=typeclass, - candidates=candidates, - exact=exact, - use_dbref=use_dbref, - ) if candidates: candidates = parse_sdescs_and_recogs( @@ -1478,16 +1352,7 @@ class ContribRPObject(DefaultObject): # only done in code, so is controlled, #dbrefs are turned off # for non-Builders. results = search_obj(searchdata) - - if quiet: - return results - return _AT_SEARCH_RESULT( - results, - self, - query=searchdata, - nofound_string=nofound_string, - multimatch_string=multimatch_string, - ) + return results def get_posed_sdesc(self, sdesc, **kwargs): """ diff --git a/evennia/contrib/rpg/rpsystem/tests.py b/evennia/contrib/rpg/rpsystem/tests.py index 3dc534e914..773fd76d6e 100644 --- a/evennia/contrib/rpg/rpsystem/tests.py +++ b/evennia/contrib/rpg/rpsystem/tests.py @@ -5,8 +5,7 @@ Tests for RP system import time from anything import Anything - -from evennia import create_object +from evennia import DefaultObject, create_object, default_cmds from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.test_resources import BaseEvenniaTest @@ -157,12 +156,11 @@ class TestRPSystem(BaseEvenniaTest): self.assertEqual( rpsystem.parse_language(self.speaker, language_emote), ( - 'For a change of pace, /me says, {##0}', - {"##0": ('elvish', '"This is in elvish!"')}, + "For a change of pace, /me says, {##0}", + {"##0": ("elvish", '"This is in elvish!"')}, ), ) - def test_parse_sdescs_and_recogs(self): speaker = self.speaker speaker.sdesc.add(sdesc0) @@ -251,18 +249,24 @@ class TestRPSystem(BaseEvenniaTest): rpsystem.send_emote(speaker, receivers, emote, case_sensitive=False) self.assertEqual( self.out0[0], - "With a flair, |mSender|n looks at |bThe first receiver of emotes.|n " - 'and |bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n', + ( + "With a flair, |mSender|n looks at |bThe first receiver of emotes.|n " + 'and |bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n' + ), ) self.assertEqual( self.out1[0], - "With a flair, |bA nice sender of emotes|n looks at |mReceiver1|n and " - '|bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n', + ( + "With a flair, |bA nice sender of emotes|n looks at |mReceiver1|n and " + '|bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n' + ), ) self.assertEqual( self.out2[0], - "With a flair, |bA nice sender of emotes|n looks at |bThe first " - 'receiver of emotes.|n and |mReceiver2|n. She says |w"This is a test."|n', + ( + "With a flair, |bA nice sender of emotes|n looks at |bThe first " + 'receiver of emotes.|n and |mReceiver2|n. She says |w"This is a test."|n' + ), ) def test_send_emote_fallback(self): @@ -287,8 +291,10 @@ class TestRPSystem(BaseEvenniaTest): ) self.assertEqual( self.out2[0], - "|bA nice sender of emotes|n is distracted from |bthe first receiver of emotes.|n by" - " something.", + ( + "|bA nice sender of emotes|n is distracted from |bthe first receiver of emotes.|n" + " by something." + ), ) def test_send_case_sensitive_emote(self): @@ -306,21 +312,27 @@ class TestRPSystem(BaseEvenniaTest): rpsystem.send_emote(speaker, receivers, case_emote) self.assertEqual( self.out0[0], - "|mSender|n looks at |bthe first receiver of emotes.|n. Then, |mSender|n " - "looks at |bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of emotes.|n " - "and |bAnother nice colliding sdesc-guy for tests|n twice.", + ( + "|mSender|n looks at |bthe first receiver of emotes.|n. Then, |mSender|n " + "looks at |bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of emotes.|n " + "and |bAnother nice colliding sdesc-guy for tests|n twice." + ), ) self.assertEqual( self.out1[0], - "|bA nice sender of emotes|n looks at |mReceiver1|n. Then, " - "|ba nice sender of emotes|n looks at |mReceiver1|n, |mReceiver1|n " - "and |bAnother nice colliding sdesc-guy for tests|n twice.", + ( + "|bA nice sender of emotes|n looks at |mReceiver1|n. Then, " + "|ba nice sender of emotes|n looks at |mReceiver1|n, |mReceiver1|n " + "and |bAnother nice colliding sdesc-guy for tests|n twice." + ), ) self.assertEqual( self.out2[0], - "|bA nice sender of emotes|n looks at |bthe first receiver of emotes.|n. " - "Then, |ba nice sender of emotes|n looks at |bTHE FIRST RECEIVER OF EMOTES.|n, " - "|bThe first receiver of emotes.|n and |mReceiver2|n twice.", + ( + "|bA nice sender of emotes|n looks at |bthe first receiver of emotes.|n. " + "Then, |ba nice sender of emotes|n looks at |bTHE FIRST RECEIVER OF EMOTES.|n, " + "|bThe first receiver of emotes.|n and |mReceiver2|n twice." + ), ) def test_rpsearch(self): @@ -371,8 +383,10 @@ class TestRPSystemCommands(BaseEvenniaCommandTest): self.call( rpsystem.CmdRecog(), "", - "Currently recognized (use 'recog as ' to add new " - "and 'forget ' to remove):\n friend (BarFoo Character)", + ( + "Currently recognized (use 'recog as ' to add new " + "and 'forget ' to remove):\n friend (BarFoo Character)" + ), ) self.call( rpsystem.CmdRecog(), @@ -382,3 +396,31 @@ class TestRPSystemCommands(BaseEvenniaCommandTest): ) self.call(rpsystem.CmdSdesc(), "clear", 'Cleared sdesc, using name "Char".', inputs=["Y"]) + + def test_multi_match_search(self): + """ + Test that the multi-match search works as expected + + """ + mushroom1 = create_object(rpsystem.ContribRPObject, key="Mushroom", location=self.room1) + mushroom1.db.desc = "The first mushroom is brown." + mushroom2 = create_object(rpsystem.ContribRPObject, key="Mushroom", location=self.room1) + mushroom2.db.desc = "The second mushroom is red." + + # check locations and contents + self.assertEqual(self.char1.location, self.room1) + self.assertTrue(set(self.room1.contents).intersection(set([mushroom1, mushroom2]))) + + expected_first_call = [ + "More than one match for 'Mushroom' (please narrow target):", + f" Mushroom({mushroom1.dbref})-1 []", + f" Mushroom({mushroom2.dbref})-2 []", + ] + + self.call(default_cmds.CmdLook(), "Mushroom", "\n".join(expected_first_call)) # PASSES + + expected_second_call = f"Mushroom({mushroom1.dbref})\nThe first mushroom is brown." + self.call(default_cmds.CmdLook(), "Mushroom-1", expected_second_call) # FAILS + + expected_third_call = f"Mushroom({mushroom2.dbref})\nThe second mushroom is red." + self.call(default_cmds.CmdLook(), "Mushroom-2", expected_third_call) # FAILS diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 7e742b1cbc..b974f01ef4 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -26,6 +26,7 @@ from evennia.typeclasses.models import TypeclassBase from evennia.utils import ansi, create, funcparser, logger, search from evennia.utils.utils import ( class_from_module, + dbref, is_iter, iter_to_str, lazy_property, @@ -326,7 +327,213 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ return [exi for exi in self.contents if exi.destination] - # main methods + # search methods and hooks + + def get_search_query_replacement(self, searchdata, **kwargs): + """ + This method is called by the search method to allow for direct + replacements of the search string before it is used in the search. + + Args: + searchdata (str): The search string to replace. + **kwargs (any): These are the same as passed to the `search` method. + + Returns: + str: The (potentially modified) search string. + + """ + if kwargs.get("use_nicks"): + return self.nicks.nickreplace( + searchdata, categories=("object", "account"), include_account=True + ) + return searchdata + + def get_search_direct_match(self, searchdata, **kwargs): + """ + This method is called by the search method to allow for direct + replacements, such as 'me' always being an alias for this object. + + Args: + searchdata (str): The search string to replace. + **kwargs (any): These are the same as passed to the `search` method. + + Returns: + tuple: `(should_return, str or Obj)`, where `should_return` is a boolean indicating + the `.search` method should return the result immediately without further + processing. If `should_return` is `True`, the second element of the tuple is the result + that is returned. + + """ + if isinstance(searchdata, str): + match searchdata.lower(): + case "me" | "self": + return True, self + case "here": + return True, self.location + return False, searchdata + + def get_search_candidates(self, searchdata, **kwargs): + """ + Get the candidates for a search. Also the `candidates` provided to the + search function is included, and could be modified in-place here. + + Args: + searchdata (str): The search criterion (could be modified by `get_search_query_replacement`). + **kwargs (any): These are the same as passed to the `search` method. + + Returns: + list: A list of objects to search between. + + Notes: + If `searchdata` is a #dbref, this method should always return `None`. This is because + the search should always be global in this case. If `candidates` were already given, + they should be used as is. If `location` was given, the candidates should be based on + that. + + """ + if kwargs.get("global_search") or dbref(searchdata): + # global searches (dbref-searches are always global too) should not have any candidates + return None + + # if candidates were already given, use them + candidates = kwargs.get("candidates") + if candidates: + return candidates + + # find candidates based on location + location = kwargs.get("location") + + if location: + # location(s) were given + candidates = [] + for obj in make_iter(location): + candidates.extend(obj.contents) + else: + # local search. Candidates are taken from + # self.contents, self.location and + # self.location.contents + location = self.location + candidates = self.contents + if location: + candidates = candidates + [location] + location.contents + else: + # normally we don't need this since we are + # included in location.contents + candidates.append(self) + return candidates + + def get_search_result( + self, + searchdata, + attribute_name=None, + typeclass=None, + candidates=None, + exact=False, + use_dbref=None, + tags=None, + **kwargs, + ): + """ + This is a wrapper for actually searching for objects, used by the `search` method. + This is broken out into a separate method to allow for easier overriding in child classes. + + Args: + searchdata (str): The search criterion. + attribute_name (str): The attribute to search on (default is `. + typeclass (Typeclass or list): The typeclass to search for. + candidates (list): A list of objects to search between. + exact (bool): Require exact match. + use_dbref (bool): Allow dbref search. + tags (list): Tags to search for. + + """ + + return ObjectDB.objects.search_object( + searchdata, + attribute_name=attribute_name, + typeclass=typeclass, + candidates=candidates, + exact=exact, + use_dbref=use_dbref, + tags=tags, + ) + + def get_stacked_results(self, results, **kwargs): + """ + This method is called by the search method to allow for handling of multi-match + results that should be stacked. + + Args: + results (list): The list of results from the search. + + Returns: + tuple: `(stacked, results)`, where `stacked` is a boolean indicating if the + result is stacked and `results` is the list of results to return. If `stacked` + is True, the ".search" method will return `results` immediately without further + processing (it will not result in a multimatch-error). + + Notes: + The `stacked` keyword argument is an integer that controls the max size of each stack + (if >0). It's important to make sure to only stack _identical_ objects, otherwise we + risk losing track of objects. + + """ + nresults = len(results) + max_stack_size = kwargs.get("stacked", 0) + typeclass = kwargs.get("typeclass") + exact = kwargs.get("exact", False) + + if max_stack_size > 0 and nresults > 1: + nstack = nresults + if not exact: + # we re-run exact match against one of the matches to make sure all are indeed + # equal and we were not catching partial matches not belonging to the stack + nstack = len( + ObjectDB.objects.get_objs_with_key_or_alias( + results[0].key, + exact=True, + candidates=list(results), + typeclasses=[typeclass] if typeclass else None, + ) + ) + if nstack == nresults: + # a valid stack of identical items, return multiple results + return True, list(results)[:max_stack_size] + + return False, results + + def handle_search_results(self, searchdata, results, **kwargs): + """ + This method is called by the search method to allow for handling of the final search result. + + Args: + searchdata (str): The original search criterion (potentially modified by + `get_search_query_replacement`). + results (list): The list of results from the search. + **kwargs (any): These are the same as passed to the `search` method. + + Returns: + Object, None or list: Normally this is a single object, but if `quiet=True` it should be + a list. If quiet=False and we have to handle a no/multi-match error (directly messaging + the user), this should return `None`. + + """ + if kwargs.get("quiet"): + # don't care about no/multi-match errors, just return list of whatever we have + return list(results) + + # handle any error messages, otherwise return a single result + + nofound_string = kwargs.get("nofound_string") + multimatch_string = kwargs.get("multimatch_string") + + return _AT_SEARCH_RESULT( + results, + self, + query=searchdata, + nofound_string=nofound_string, + multimatch_string=multimatch_string, + ) def search( self, @@ -411,10 +618,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): more tag definitions on the form `tagname` or `(tagname, tagcategory)`. stacked (int, optional): If > 0, multimatches will be analyzed to determine if they only contains identical objects; these are then assumed 'stacked' and no multi-match - error will be generated, instead `stacked` number of matches will be returned. If - `stacked` is larger than number of matches, returns that number of matches. If - the found stack is a mix of objects, return None and handle the multi-match - error depending on the value of `quiet`. + error will be generated, instead `stacked` number of matches will be returned as a + list. If `stacked` is larger than number of matches, returns that number of matches. + If the found stack is a mix of objects, return None and handle the multi-match error + depending on the value of `quiet`. Returns: Object, None or list: Will return an `Object` or `None` if `quiet=False`. Will return @@ -429,59 +636,40 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): messaging is assumed to be handled by the caller. """ - is_string = isinstance(searchdata, str) + # store input kwargs for sub-methods (this must be done first in this method) + input_kwargs = { + key: value for key, value in locals().items() if key not in ("self", "searchdata") + } - if is_string: - # searchdata is a string; wrap some common self-references - if searchdata.lower() in ("here",): - return [self.location] if quiet else self.location - if searchdata.lower() in ("me", "self"): - return [self] if quiet else self + # replace incoming searchdata string with a potentially modified version + searchdata = self.get_search_query_replacement(searchdata, **input_kwargs) - if use_dbref is None: - use_dbref = self.locks.check_lockstring(self, "_dummy:perm(Builder)") + # handle special input strings, like "me" or "here". + should_return, searchdata = self.get_search_direct_match(searchdata, **input_kwargs) + if should_return: + # we got an actual result, return it immediately + return [searchdata] if quiet else searchdata - if use_nicks: - # do nick-replacement on search - searchdata = self.nicks.nickreplace( - searchdata, categories=("object", "account"), include_account=True - ) + # if use_dbref is None, we use a lock to determine if dbref search is allowed + use_dbref = ( + self.locks.check_lockstring(self, "_dummy:perm(Builder)") + if use_dbref is None + else use_dbref + ) - if global_search or ( - is_string - and searchdata.startswith("#") - and len(searchdata) > 1 - and searchdata[1:].isdigit() - ): - # only allow exact matching if searching the entire database - # or unique #dbrefs - exact = True - candidates = None + # convert tags into tag tuples suitable for query + tags = [ + (tagkey, tagcat[0] if tagcat else None) for tagkey, *tagcat in make_iter(tags or []) + ] - elif candidates is None: - # no custom candidates given - get them automatically - if location: - # location(s) were given - candidates = [] - for obj in make_iter(location): - candidates.extend(obj.contents) - else: - # local search. Candidates are taken from - # self.contents, self.location and - # self.location.contents - location = self.location - candidates = self.contents - if location: - candidates = candidates + [location] + location.contents - else: - # normally we don't need this since we are - # included in location.contents - candidates.append(self) + # always use exact match for dbref/global searches + exact = True if global_search or dbref(searchdata) else exact - if tags: - tags = [(tagkey, tagcat[0] if tagcat else None) for tagkey, *tagcat in make_iter(tags)] + # get candidates + candidates = self.get_search_candidates(searchdata, **input_kwargs) - results = ObjectDB.objects.search_object( + # do the actual search + results = self.get_search_result( searchdata, attribute_name=attribute_name, typeclass=typeclass, @@ -491,41 +679,18 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): tags=tags, ) + # filter out objects we are not allowed to search if use_locks: results = [x for x in list(results) if x.access(self, "search", default=True)] - nresults = len(results) - if stacked > 0 and nresults > 1: - # handle stacks, disable multimatch errors - nstack = nresults - if not exact: - # we re-run exact match against one of the matches to - # make sure we were not catching partial matches not belonging - # to the stack - nstack = len( - ObjectDB.objects.get_objs_with_key_or_alias( - results[0].key, - exact=True, - candidates=list(results), - typeclasses=[typeclass] if typeclass else None, - ) - ) - if nstack == nresults: - # a valid stack, return multiple results - return list(results)[:stacked] + # handle stacked objects + is_stacked, results = self.get_stacked_results(results, **input_kwargs) + if is_stacked: + # we have a stacked result, return it immediately (a list) + return results - if quiet: - # don't auto-handle error messaging - return list(results) - - # handle error messages - return _AT_SEARCH_RESULT( - results, - self, - query=searchdata, - nofound_string=nofound_string, - multimatch_string=multimatch_string, - ) + # handle the end (unstacked) results, returning a single object, a list or None + return self.handle_search_results(searchdata, results, **input_kwargs) def search_account(self, searchdata, quiet=False): """ diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index 34f3c589bc..63ad546473 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -115,25 +115,41 @@ class DefaultObjectTest(BaseEvenniaTest): self.assertEqual(self.char1.search("co", stacked=2), None) def test_get_default_lockstring_base(self): - pattern = f"control:pid({self.account.id}) or id({self.char1.id}) or perm(Admin);delete:pid({self.account.id}) or id({self.char1.id}) or perm(Admin);edit:pid({self.account.id}) or id({self.char1.id}) or perm(Admin)" + pattern = ( + f"control:pid({self.account.id}) or id({self.char1.id}) or" + f" perm(Admin);delete:pid({self.account.id}) or id({self.char1.id}) or" + f" perm(Admin);edit:pid({self.account.id}) or id({self.char1.id}) or perm(Admin)" + ) self.assertEqual( DefaultObject.get_default_lockstring(account=self.account, caller=self.char1), pattern ) def test_get_default_lockstring_room(self): - pattern = f"control:pid({self.account.id}) or id({self.char1.id}) or perm(Admin);delete:pid({self.account.id}) or id({self.char1.id}) or perm(Admin);edit:pid({self.account.id}) or id({self.char1.id}) or perm(Admin)" + pattern = ( + f"control:pid({self.account.id}) or id({self.char1.id}) or" + f" perm(Admin);delete:pid({self.account.id}) or id({self.char1.id}) or" + f" perm(Admin);edit:pid({self.account.id}) or id({self.char1.id}) or perm(Admin)" + ) self.assertEqual( DefaultRoom.get_default_lockstring(account=self.account, caller=self.char1), pattern ) def test_get_default_lockstring_exit(self): - pattern = f"control:pid({self.account.id}) or id({self.char1.id}) or perm(Admin);delete:pid({self.account.id}) or id({self.char1.id}) or perm(Admin);edit:pid({self.account.id}) or id({self.char1.id}) or perm(Admin)" + pattern = ( + f"control:pid({self.account.id}) or id({self.char1.id}) or" + f" perm(Admin);delete:pid({self.account.id}) or id({self.char1.id}) or" + f" perm(Admin);edit:pid({self.account.id}) or id({self.char1.id}) or perm(Admin)" + ) self.assertEqual( DefaultExit.get_default_lockstring(account=self.account, caller=self.char1), pattern ) def test_get_default_lockstring_character(self): - pattern = f"puppet:pid({self.account.id}) or perm(Developer) or pperm(Developer);delete:pid({self.account.id}) or perm(Admin);edit:pid({self.account.id}) or perm(Admin)" + pattern = ( + f"puppet:pid({self.account.id}) or perm(Developer) or" + f" pperm(Developer);delete:pid({self.account.id}) or" + f" perm(Admin);edit:pid({self.account.id}) or perm(Admin)" + ) self.assertEqual( DefaultCharacter.get_default_lockstring(account=self.account, caller=self.char1), pattern, diff --git a/evennia/server/tests/test_misc.py b/evennia/server/tests/test_misc.py index da0827d31b..7686187b53 100644 --- a/evennia/server/tests/test_misc.py +++ b/evennia/server/tests/test_misc.py @@ -8,9 +8,7 @@ import unittest from django.test import TestCase from django.test.runner import DiscoverRunner - from evennia.server.throttle import Throttle -from evennia.server.validators import EvenniaPasswordValidator from evennia.utils.test_resources import BaseEvenniaTest from ..deprecations import check_errors