Break up Object.search into multiple methods for easier overloading. Resolve #3417

This commit is contained in:
Griatch 2024-02-25 16:37:17 +01:00
parent 32e9520db9
commit 015698d06f
7 changed files with 459 additions and 331 deletions

View file

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

View file

@ -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 = <prototype>` or as "
"a key 'prototype_key' inside the prototype structure.",
(
"A prototype_key must be given, either as `prototype_key = <prototype>` or as "
"a key 'prototype_key' inside the prototype structure."
),
)
self.call(building.CmdSpawn(), "/list", "Key ")
@ -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

View file

@ -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:**
- `#<num>`: search by unique dbref. This is always
a global search.
- `me,self`: self-reference to this object
- `<num>-<string>` - 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):
"""

View file

@ -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 <sdesc> as <alias>' to add new "
"and 'forget <alias>' to remove):\n friend (BarFoo Character)",
(
"Currently recognized (use 'recog <sdesc> as <alias>' to add new "
"and 'forget <alias>' 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

View file

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

View file

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

View file

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