diff --git a/evennia/commands/cmdparser.py b/evennia/commands/cmdparser.py index c9055db8ca..2c78a247ab 100644 --- a/evennia/commands/cmdparser.py +++ b/evennia/commands/cmdparser.py @@ -15,6 +15,107 @@ _MULTIMATCH_REGEX = re.compile(settings.SEARCH_MULTIMATCH_REGEX, re.I + re.U) _CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES +def create_match(cmdname, string, cmdobj, raw_cmdname): + """ + Builds a command match by splitting the incoming string and + evaluating the quality of the match. + + Args: + cmdname (str): Name of command to check for. + string (str): The string to match against. + cmdobj (str): The full Command instance. + raw_cmdname (str, optional): If CMD_IGNORE_PREFIX is set and the cmdname starts with + one of the prefixes to ignore, this contains the raw, unstripped cmdname, + otherwise it is None. + + Returns: + match (tuple): This is on the form (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname), + where `cmdname` is the command's name and `args` is the rest of the incoming + string, without said command name. `cmdobj` is + the Command instance, the cmdlen is the same as len(cmdname) and mratio + is a measure of how big a part of the full input string the cmdname + takes up - an exact match would be 1.0. Finally, the `raw_cmdname` is + the cmdname unmodified by eventual prefix-stripping. + + """ + cmdlen, strlen = len(str(cmdname)), len(str(string)) + mratio = 1 - (strlen - cmdlen) / (1.0 * strlen) + args = string[cmdlen:] + return (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname) + + +def build_matches(raw_string, cmdset, include_prefixes=False): + """ + Build match tuples by matching raw_string against available commands. + + Args: + raw_string (str): Input string that can look in any way; the only assumption is + that the sought command's name/alias must be *first* in the string. + cmdset (CmdSet): The current cmdset to pick Commands from. + include_prefixes (bool): If set, include prefixes like @, ! etc (specified in settings) + in the match, otherwise strip them before matching. + + Returns: + matches (list) A list of match tuples created by `cmdparser.create_match`. + + """ + l_raw_string = raw_string.lower() + matches = [] + try: + if include_prefixes: + # use the cmdname as-is + for cmd in cmdset: + matches.extend([create_match(cmdname, raw_string, cmd, cmdname) + for cmdname in [cmd.key] + cmd.aliases + if cmdname and l_raw_string.startswith(cmdname.lower()) and + (not cmd.arg_regex or + cmd.arg_regex.match(l_raw_string[len(cmdname):]))]) + else: + # strip prefixes set in settings + for cmd in cmdset: + for raw_cmdname in [cmd.key] + cmd.aliases: + cmdname = raw_cmdname.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_cmdname) > 1 else raw_cmdname + if cmdname and l_raw_string.startswith(cmdname.lower()) and \ + (not cmd.arg_regex or cmd.arg_regex.match(l_raw_string[len(cmdname):])): + matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname)) + except Exception: + log_trace("cmdhandler error. raw_input:%s" % raw_string) + return matches + + +def try_num_prefixes(raw_string): + """ + Test if user tried to separate multi-matches with a number separator + (default 1-name, 2-name etc). This is usually called last, if no other + match was found. + + Args: + raw_string (str): The user input to parse. + + Returns: + mindex, new_raw_string (tuple): If a multimatch-separator was detected, + this is stripped out as an integer to separate between the matches. The + new_raw_string is the result of stripping out that identifier. If no + such form was found, returns (None, None). + + Example: + In the default configuration, entering 2-ball (e.g. in a room will more + than one 'ball' object), will lead to a multimatch and this function + will parse `"2-ball"` and return `(2, "ball")`. + + """ + # no matches found + num_ref_match = _MULTIMATCH_REGEX.match(raw_string) + if num_ref_match: + # the user might be trying to identify the command + # with a #num-command style syntax. We expect the regex to + # contain the groups "number" and "name". + mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name") + return mindex, new_raw_string + else: + return None, None + + def cmdparser(raw_string, cmdset, caller, match_index=None): """ This function is called by the cmdhandler once it has @@ -30,6 +131,10 @@ def cmdparser(raw_string, cmdset, caller, match_index=None): the first run resulted in a multimatch, and the index is given to select between the results for the second run. + Returns: + matches (list): This is a list of match-tuples as returned by `create_match`. + If no matches were found, this is an empty list. + Notes: The cmdparser understand the following command combinations (where [] marks optional parts. @@ -47,75 +152,11 @@ def cmdparser(raw_string, cmdset, caller, match_index=None): the remaining arguments, and the matched cmdobject from the cmdset. """ - - def create_match(cmdname, string, cmdobj, raw_cmdname): - """ - Builds a command match by splitting the incoming string and - evaluating the quality of the match. - - Args: - cmdname (str): Name of command to check for. - string (str): The string to match against. - cmdobj (str): The full Command instance. - raw_cmdname (str, optional): If CMD_IGNORE_PREFIX is set and the cmdname starts with - one of the prefixes to ignore, this contains the raw, unstripped cmdname, - otherwise it is None. - - Returns: - match (tuple): This is on the form (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname), where - `cmdname` is the command's name and `args` the rest of the incoming string, - without said command name. `cmdobj` is the Command instance, the cmdlen is - the same as len(cmdname) and mratio is a measure of how big a part of the - full input string the cmdname takes up - an exact match would be 1.0. Finally, - the `raw_cmdname` is the cmdname unmodified by eventual prefix-stripping. - - """ - cmdlen, strlen = len(str(cmdname)), len(str(string)) - mratio = 1 - (strlen - cmdlen) / (1.0 * strlen) - args = string[cmdlen:] - return (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname) - - def build_matches(raw_string, include_prefixes=False): - l_raw_string = raw_string.lower() - matches = [] - try: - if include_prefixes: - # use the cmdname as-is - for cmd in cmdset: - matches.extend([create_match(cmdname, raw_string, cmd, cmdname) - for cmdname in [cmd.key] + cmd.aliases - if cmdname and l_raw_string.startswith(cmdname.lower()) and - (not cmd.arg_regex or - cmd.arg_regex.match(l_raw_string[len(cmdname):]))]) - else: - # strip prefixes set in settings - for cmd in cmdset: - for raw_cmdname in [cmd.key] + cmd.aliases: - cmdname = raw_cmdname.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_cmdname) > 1 else raw_cmdname - if cmdname and l_raw_string.startswith(cmdname.lower()) and \ - (not cmd.arg_regex or cmd.arg_regex.match(l_raw_string[len(cmdname):])): - matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname)) - except Exception: - log_trace("cmdhandler error. raw_input:%s" % raw_string) - return matches - - def try_num_prefixes(raw_string): - if not matches: - # no matches found - num_ref_match = _MULTIMATCH_REGEX.match(raw_string) - if num_ref_match: - # the user might be trying to identify the command - # with a #num-command style syntax. We expect the regex to - # contain the groups "number" and "name". - mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name") - return mindex, new_raw_string - return None, None - if not raw_string: return [] # find mathces, first using the full name - matches = build_matches(raw_string, include_prefixes=True) + matches = build_matches(raw_string, cmdset, include_prefixes=True) if not matches: # try to match a number 1-cmdname, 2-cmdname etc mindex, new_raw_string = try_num_prefixes(raw_string) @@ -124,7 +165,7 @@ def cmdparser(raw_string, cmdset, caller, match_index=None): if _CMD_IGNORE_PREFIXES: # still no match. Try to strip prefixes raw_string = raw_string.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_string) > 1 else raw_string - matches = build_matches(raw_string, include_prefixes=False) + matches = build_matches(raw_string, cmdset, include_prefixes=False) # only select command matches we are actually allowed to call. matches = [match for match in matches if match[2].access(caller, 'cmd')] diff --git a/evennia/commands/default/syscommands.py b/evennia/commands/default/syscommands.py index 2763a093b0..5a1fbf1ce0 100644 --- a/evennia/commands/default/syscommands.py +++ b/evennia/commands/default/syscommands.py @@ -20,6 +20,7 @@ the line is just added to the editor buffer). from evennia.comms.models import ChannelDB from evennia.utils import create +from evennia.utils.utils import at_search_result # The command keys the engine is calling # (the actual names all start with __) @@ -76,57 +77,30 @@ class SystemMultimatch(COMMAND_DEFAULT_CLASS): The cmdhandler adds a special attribute 'matches' to this system command. - matches = [(candidate, cmd) , (candidate, cmd), ...], + matches = [(cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname) , (cmdname, ...), ...] + + Here, `cmdname` is the command's name and `args` the rest of the incoming string, + without said command name. `cmdobj` is the Command instance, the cmdlen is + the same as len(cmdname) and mratio is a measure of how big a part of the + full input string the cmdname takes up - an exact match would be 1.0. Finally, + the `raw_cmdname` is the cmdname unmodified by eventual prefix-stripping. - where candidate is an instance of evennia.commands.cmdparser.CommandCandidate - and cmd is an an instantiated Command object matching the candidate. """ key = CMD_MULTIMATCH locks = "cmd:all()" - def format_multimatches(self, caller, matches): - """ - Format multiple command matches to a useful error. - - This is copied directly from the default method in - evennia.commands.cmdhandler. - - """ - string = "There were multiple matches:" - for num, match in enumerate(matches): - # each match is a tuple (candidate, cmd) - candidate, cmd = match - - is_channel = hasattr(cmd, "is_channel") and cmd.is_channel - if is_channel: - is_channel = " (channel)" - else: - is_channel = "" - is_exit = hasattr(cmd, "is_exit") and cmd.is_exit - if is_exit and cmd.destination: - is_exit = " (exit to %s)" % cmd.destination - else: - is_exit = "" - - id1 = "" - id2 = "" - if not (is_channel or is_exit) and (hasattr(cmd, 'obj') and cmd.obj != caller): - # the command is defined on some other object - id1 = "%s-" % cmd.obj.name - id2 = " (%s-%s)" % (num + 1, candidate.cmdname) - else: - id1 = "%s-" % (num + 1) - id2 = "" - string += "\n %s%s%s%s%s" % (id1, candidate.cmdname, id2, is_channel, is_exit) - return string - def func(self): """ - argument to cmd is a comma-separated string of - all the clashing matches. + Handle multiple-matches by using the at_search_result default handler. + """ - string = self.format_multimatches(self.caller, self.matches) - self.msg(string) + # this was set by the cmdparser and is a tuple + # (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname). See + # evennia.commands.cmdparse.create_match for more details. + matches = self.matches + # at_search_result will itself msg the multimatch options to the caller. + at_search_result( + [match[2] for match in matches], self.caller, query=matches[0][0]) # Command called when the command given at the command line diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 2e12a2e44c..9e35a50998 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -21,9 +21,12 @@ from mock import Mock, mock from evennia.commands.default.cmdset_character import CharacterCmdSet from evennia.utils.test_resources import EvenniaTest -from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin +from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin, syscommands +from evennia.commands.cmdparser import build_matches from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.command import Command, InterruptCommand +from evennia.commands import cmdparser +from evennia.commands.cmdset import CmdSet from evennia.utils import ansi, utils, gametime from evennia.server.sessionhandler import SESSIONS from evennia import search_object @@ -660,3 +663,33 @@ class TestUnconnectedCommand(CommandTest): SESSIONS.account_count(), utils.get_evennia_version()) self.call(unloggedin.CmdUnconnectedInfo(), "", expected) del gametime.SERVER_START_TIME + + +# Test syscommands + +class TestSystemCommands(CommandTest): + + def test_simple_defaults(self): + self.call(syscommands.SystemNoInput(), "") + self.call(syscommands.SystemNoMatch(), "Huh?") + + def test_multimatch(self): + # set up fake matches and store on command instance + cmdset = CmdSet() + cmdset.add(general.CmdLook()) + cmdset.add(general.CmdLook()) + matches = cmdparser.build_matches("look", cmdset) + + multimatch = syscommands.SystemMultimatch() + multimatch.matches = matches + + self.call(multimatch, "look", "") + + @mock.patch("evennia.commands.default.syscommands.ChannelDB") + def test_channelcommand(self, mock_channeldb): + channel = mock.MagicMock() + channel.msg = mock.MagicMock() + mock_channeldb.objects.get_channel = mock.MagicMock(return_value=channel) + + self.call(syscommands.SystemSendToChannel(), "public:Hello") + channel.msg.assert_called() diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index ea5ce2e0cd..49ae8fe6c9 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -165,7 +165,23 @@ def _get_prototype(inprot, protparents, uninherited=None, _workprot=None): uninherited (dict): Parts of prototype to not inherit. _workprot (dict, optional): Work dict for the recursive algorithm. + Returns: + merged (dict): A prototype where parent's have been merged as needed (the + `prototype_parent` key is removed). + """ + def _inherit_tags(old_tags, new_tags): + old = {(tup[0], tup[1]): tup for tup in old_tags} + new = {(tup[0], tup[1]): tup for tup in new_tags} + old.update(new) + return list(old.values()) + + def _inherit_attrs(old_attrs, new_attrs): + old = {(tup[0], tup[2]): tup for tup in old_attrs} + new = {(tup[0], tup[2]): tup for tup in new_attrs} + old.update(new) + return list(old.values()) + _workprot = {} if _workprot is None else _workprot if "prototype_parent" in inprot: # move backwards through the inheritance @@ -173,8 +189,20 @@ def _get_prototype(inprot, protparents, uninherited=None, _workprot=None): # Build the prot dictionary in reverse order, overloading new_prot = _get_prototype(protparents.get(prototype.lower(), {}), protparents, _workprot=_workprot) + + # attrs, tags have internal structure that should be inherited separately + new_prot['attrs'] = _inherit_attrs( + _workprot.get("attrs", {}), new_prot.get("attrs", {})) + new_prot['tags'] = _inherit_tags( + _workprot.get("tags", {}), new_prot.get("tags", {})) + _workprot.update(new_prot) # the inprot represents a higher level (a child prot), which should override parents + + inprot['attrs'] = _inherit_attrs( + _workprot.get("attrs", {}), inprot.get("attrs", {})) + inprot['tags'] = _inherit_tags( + _workprot.get("tags", {}), inprot.get("tags", {})) _workprot.update(inprot) if uninherited: # put back the parts that should not be inherited diff --git a/evennia/server/tests/__init__.py b/evennia/server/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/server/tests/test_initial_setup.py b/evennia/server/tests/test_initial_setup.py new file mode 100644 index 0000000000..b08bde14e0 --- /dev/null +++ b/evennia/server/tests/test_initial_setup.py @@ -0,0 +1,19 @@ +""" +Test initial startup procedure + +""" + +from mock import MagicMock, patch +from django.conf import settings +from django.test import TestCase + +from evennia.server import initial_setup + + +class TestInitialSetup(TestCase): + + @patch("evennia.server.initial_setup.AccountDB") + def test_get_god_account(self, mocked_accountdb): + mocked_accountdb.objects.get = MagicMock(return_value=1) + self.assertEqual(initial_setup.get_god_account(), 1) + mocked_accountdb.objects.get.assert_called_with(id=1) diff --git a/evennia/server/tests.py b/evennia/server/tests/test_misc.py similarity index 73% rename from evennia/server/tests.py rename to evennia/server/tests/test_misc.py index 71c77a2129..e48ff6984b 100644 --- a/evennia/server/tests.py +++ b/evennia/server/tests/test_misc.py @@ -1,28 +1,11 @@ # -*- coding: utf-8 -*- """ -Unit testing of the 'objects' Evennia component. +Testing various individual functionalities in the server package. -Runs as part of the Evennia's test suite with 'manage.py test" - -Please add new tests to this module as needed. - -Guidelines: - A 'test case' is testing a specific component and is defined as a class - inheriting from unittest.TestCase. The test case class can have a method - setUp() that creates and sets up the testing environment. - All methods inside the test case class whose names start with 'test' are - used as test methods by the runner. Inside the test methods, special member - methods assert*() are used to test the behaviour. """ -try: - from django.utils.unittest import TestCase -except ImportError: - from django.test import TestCase -try: - from django.utils import unittest -except ImportError: - import unittest +import unittest +from django.test import TestCase from evennia.server.validators import EvenniaPasswordValidator from evennia.utils.test_resources import EvenniaTest @@ -31,23 +14,7 @@ from django.test.runner import DiscoverRunner from evennia.server.throttle import Throttle -from .deprecations import check_errors - - -class EvenniaTestSuiteRunner(DiscoverRunner): - """ - This test runner only runs tests on the apps specified in evennia/ - avoid running the large number of tests defined by Django - """ - - def build_suite(self, test_labels, extra_tests=None, **kwargs): - """ - Build a test suite for Evennia. test_labels is a list of apps to test. - If not given, a subset of settings.INSTALLED_APPS will be used. - """ - import evennia - evennia._init() - return super().build_suite(test_labels, extra_tests=extra_tests, **kwargs) +from ..deprecations import check_errors class MockSettings(object): diff --git a/evennia/server/tests/testrunner.py b/evennia/server/tests/testrunner.py new file mode 100644 index 0000000000..fb222c88c6 --- /dev/null +++ b/evennia/server/tests/testrunner.py @@ -0,0 +1,27 @@ +""" +Main test-suite runner of Evennia. The runner collates tests from +all over the code base and runs them. + +Runs as part of the Evennia's test suite with 'evennia test evennia" + +""" +from django.test.runner import DiscoverRunner + + +class EvenniaTestSuiteRunner(DiscoverRunner): + """ + Pointed to by the TEST_RUNNER setting. + This test runner only runs tests on the apps specified in evennia/ + avoid running the large number of tests defined by Django + + """ + + def build_suite(self, test_labels, extra_tests=None, **kwargs): + """ + Build a test suite for Evennia. test_labels is a list of apps to test. + If not given, a subset of settings.INSTALLED_APPS will be used. + """ + import evennia + evennia._init() + return super(EvenniaTestSuiteRunner, self).build_suite( + test_labels, extra_tests=extra_tests, **kwargs) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 85c725da08..4e582b11e3 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -831,9 +831,9 @@ AUTH_USERNAME_VALIDATORS = [ {'NAME': 'evennia.server.validators.EvenniaUsernameAvailabilityValidator'}] # Use a custom test runner that just tests Evennia-specific apps. -TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner' +TEST_RUNNER = 'evennia.server.tests.testrunner.EvenniaTestSuiteRunner' -# Messages and Bootstrap don't classify events the same way; this setting maps +# Messages and Bootstrap don't classify events the same way; this setting maps # messages.error() to Bootstrap 'danger' classes. MESSAGE_TAGS = { messages.ERROR: 'danger', diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 9e0ca7174f..ec12b0bd1f 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1853,7 +1853,9 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): Returns: processed_result (Object or None): This is always a single result or `None`. If `None`, any error reporting/handling should - already have happened. + already have happened. The returned object is of the type we are + checking multimatches for (e.g. Objects or Commands) + """ error = ""