diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 4ca9af7e69..200bb5e5d7 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -119,9 +119,11 @@ class CommandMeta(type): # parsing errors. -class Command(object, metaclass=CommandMeta): +class Command(metaclass=CommandMeta): """ - Base command + ## Base command + + (you may see this if a child command had no help text defined) Usage: command [args] diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index baf30bc161..f0606d8209 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -1882,7 +1882,7 @@ class CmdIRCStatus(COMMAND_DEFAULT_CLASS): Check and reboot IRC bot. Usage: - ircstatus [#dbref ping||nicklist||reconnect] + ircstatus [#dbref ping | nicklist | reconnect] If not given arguments, will return a list of all bots (like irc2chan/list). The 'ping' argument will ping the IRC network to diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index 3646214c6f..184f7e2cf2 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -1,9 +1,10 @@ """ -The help command. The basic idea is that help texts for commands -are best written by those that write the commands - the admins. So -command-help is all auto-loaded and searched from the current command -set. The normal, database-tied help system is used for collaborative -creation of other help topics such as RP help or game-world aides. +The help command. The basic idea is that help texts for commands are best +written by those that write the commands - the developers. So command-help is +all auto-loaded and searched from the current command set. The normal, +database-tied help system is used for collaborative creation of other help +topics such as RP help or game-world aides. Help entries can also be created +outside the game in modules given by ``settings.FILE_HELP_ENTRY_MODULES``. """ @@ -34,7 +35,6 @@ _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH _SEP = "|C" + "-" * _DEFAULT_WIDTH + "|n" - @dataclass class HelpCategory: """ @@ -144,7 +144,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): """ start = f"{_SEP}\n" - title = f"|CHelp for |w{topic}|n" if topic else "" + title = f"|CHelp for |w{topic}|n" if topic else "|rNo help found|n" if aliases: aliases = ( @@ -165,7 +165,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): if suggested: suggested = ( - "\n\n|CSuggested other topics:|n\n{}".format( + "\n|CSuggestions:|n\n{}".format( fill("|C,|n ".join(f"|w{sug}|n" for sug in suggested), indent=2)) ) else: @@ -175,7 +175,8 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): return "".join((start, title, aliases, help_text, subtopics, suggested, end)) - def format_help_index(self, cmd_help_dict=None, db_help_dict=None): + + def format_help_index(self, cmd_help_dict=None, db_help_dict=None, title_lone_category=False): """ Output a category-ordered g for displaying the main help, grouped by category. @@ -185,6 +186,10 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): command-based help. db_help_dict (dict): A dict `{"category": [topic, topic], ...]}` for database-based help. + title_lone_category (bool, optional): If a lone category should + be titled with the category name or not. While pointless in a + general index, the title should probably show when explicitly + listing the category itself. Returns: str: The help index organized into a grid. @@ -199,7 +204,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): grid = [] verbatim_elements = [] - if len(help_dict) == 1: + if len(help_dict) == 1 and not title_lone_category: # don't list categories if there is only one for category in help_dict: entries = sorted(set(help_dict.get(category, []))) @@ -226,22 +231,25 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): width = self.client_width() grid = [] verbatim_elements = [] + cmd_grid, db_grid = "", "" - # get the command-help entries by-category - sep1 = (self.index_type_separator_clr - + pad("Commands", width=width, fillchar='-') - + self.index_topic_clr) - grid, verbatim_elements = _group_by_category(cmd_help_dict) - gridrows = format_grid(grid, width, sep=" ", verbatim_elements=verbatim_elements) - cmd_grid = ANSIString("\n").join(gridrows) if gridrows else "" + if any(cmd_help_dict.values()): + # get the command-help entries by-category + sep1 = (self.index_type_separator_clr + + pad("Commands", width=width, fillchar='-') + + self.index_topic_clr) + grid, verbatim_elements = _group_by_category(cmd_help_dict) + gridrows = format_grid(grid, width, sep=" ", verbatim_elements=verbatim_elements) + cmd_grid = ANSIString("\n").join(gridrows) if gridrows else "" - # get db-based help entries by-category - sep2 = (self.index_type_separator_clr - + pad("Game & World", width=width, fillchar='-') - + self.index_topic_clr) - grid, verbatim_elements = _group_by_category(db_help_dict) - gridrows = format_grid(grid, width, sep=" ", verbatim_elements=verbatim_elements) - db_grid = ANSIString("\n").join(gridrows) if gridrows else "" + if any(db_help_dict.values()): + # get db-based help entries by-category + sep2 = (self.index_type_separator_clr + + pad("Game & World", width=width, fillchar='-') + + self.index_topic_clr) + grid, verbatim_elements = _group_by_category(db_help_dict) + gridrows = format_grid(grid, width, sep=" ", verbatim_elements=verbatim_elements) + db_grid = ANSIString("\n").join(gridrows) if gridrows else "" # only show the main separators if there are actually both cmd and db-based help if cmd_grid and db_grid: @@ -328,6 +336,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): cmdset.make_unique(caller) # retrieve all available commands and database / file-help topics + from evennia.commands.default.system import CmdAbout all_cmds = [cmd for cmd in cmdset if self.check_show_help(cmd, caller)] # we group the file-help topics with the db ones, giving the db ones priority @@ -370,30 +379,67 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): # all available options entries = [cmd for cmd in all_cmds if cmd] + all_db_topics + all_categories + + print("CmdAbout in entries: ", CmdAbout in entries) + + # lunr search fields/boosts + search_fields=[ + {"field_name": "key", "boost": 10}, + {"field_name": "aliases", "boost": 9}, + {"field_name": "category", "boost": 8}, + {"field_name": "tags", "boost": 1}, # tags are not used by default + ] match, suggestions = None, None - for match_query in [f"{query}~1", f"{query}*"]: - # We first do an exact word-match followed by a start-by query - # the return of this will either be a HelpCategory, a Command or a HelpEntry. + for match_query in (query, f"{query}*"): + # We first do an exact word-match followed by a start-by query. The + # return of this will either be a HelpCategory, a Command or a + # HelpEntry/FileHelpEntry. matches, suggestions = help_search_with_index( - match_query, entries, suggestion_maxnum=self.suggestion_maxnum + match_query, entries, + suggestion_maxnum=self.suggestion_maxnum, + fields=search_fields ) if matches: match = matches[0] break if not match: - # no exact matches found. Just give suggestions. + # no topic matches found. Only give suggestions. + help_text = f"There is no help topic matching '{query}'." + + if not suggestions: + # we don't even have a good suggestion. Run a second search, + # doing a full-text search in the actual texts of the help + # entries + + search_fields=[ + {"field_name": "text", "boost": 1}, + ] + + for match_query in [query, f"{query}*"]: + _, suggestions = help_search_with_index( + match_query, entries, + suggestion_maxnum=self.suggestion_maxnum, + fields=search_fields + ) + + if suggestions: + help_text += "\n... But matches where found within the help texts of the suggestions below." + break + output = self.format_help_entry( - topic="", - help_text=f"No help entry found for '{query}'", + topic=None, # this will give a no-match style title + help_text=help_text, suggested=suggestions ) + self.msg_help(output) return if isinstance(match, HelpCategory): # no subtopics for categories - these are just lists of topics + output = self.format_help_index( { match.key: [ @@ -409,6 +455,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): if match.key.lower() == topic.help_category ] }, + title_lone_category=True ) self.msg_help(output) return @@ -655,6 +702,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS): old_entry.aliases.add(aliases) self.msg("Entry updated:\n%s%s" % (old_entry.entrytext, aliastxt)) return + if "delete" in switches or "del" in switches: # delete the help entry if not old_entry: diff --git a/evennia/game_template/commands/command.py b/evennia/game_template/commands/command.py index 7805f2be9b..de804eaf5a 100644 --- a/evennia/game_template/commands/command.py +++ b/evennia/game_template/commands/command.py @@ -12,25 +12,23 @@ from evennia.commands.command import Command as BaseCommand class Command(BaseCommand): """ - Inherit from this if you want to create your own command styles - from scratch. Note that Evennia's default commands inherits from - MuxCommand instead. + Base command (you may see this if a child command had no help text defined) - Note that the class's `__doc__` string (this text) is - used by Evennia to create the automatic help entry for - the command, so make sure to document consistently here. - - Each Command implements the following methods, called - in this order (only func() is actually required): - - at_pre_cmd(): If this returns anything truthy, execution is aborted. - - parse(): Should perform any extra parsing needed on self.args - and store the result on self. - - func(): Performs the actual work. - - at_post_cmd(): Extra actions, often things done after - every command, like prompts. + Note that the class's `__doc__` string is used by Evennia to create the + automatic help entry for the command, so make sure to document consistently + here. Without setting one, the parent's docstring will show (like now). """ - + # Each Command class implements the following methods, called in this order + # (only func() is actually required): + # + # - at_pre_cmd(): If this returns anything truthy, execution is aborted. + # - parse(): Should perform any extra parsing needed on self.args + # and store the result on self. + # - func(): Performs the actual work. + # - at_post_cmd(): Extra actions, often things done after + # every command, like prompts. + # pass diff --git a/evennia/help/models.py b/evennia/help/models.py index cb3df222b1..1b4289e647 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -109,11 +109,8 @@ class HelpEntry(SharedMemoryModel): # # - def __str__(self): - return self.key - def __repr__(self): - return "%s" % self.key + return f"" def access(self, accessing_obj, access_type="read", default=False): """ diff --git a/evennia/help/utils.py b/evennia/help/utils.py new file mode 100644 index 0000000000..ae419cf0aa --- /dev/null +++ b/evennia/help/utils.py @@ -0,0 +1,203 @@ +""" +Resources for indexing help entries and for splitting help entries into +sub-categories. + +This is used primarily by the default `help` command. + +""" +import re + + +_LUNR = None +_LUNR_EXCEPTION = None +_RE_HELP_SUBTOPICS_START = re.compile( + r"^\s*?#\s*?subtopics\s*?$", re.I + re.M) +_RE_HELP_SUBTOPIC_SPLIT = re.compile(r"^\s*?(\#{2,6}\s*?\w+?[a-z0-9 \-\?!,\.]*?)$", re.M + re.I) +_RE_HELP_SUBTOPIC_PARSE = re.compile( + r"^(?P\#{2,6})\s*?(?P.*?)$", re.I + re.M) +MAX_SUBTOPIC_NESTING = 5 + + +def help_search_with_index(query, candidate_entries, suggestion_maxnum=5, fields=None): + """ + Lunr-powered fast index search and suggestion wrapper. See https://lunrjs.com/. + + Args: + query (str): The query to search for. + candidate_entries (list): This is the body of possible entities to search. Each + must have a property `.search_index_entry` that returns a dict with all + keys in the `fields` arg. + suggestion_maxnum (int): How many matches to allow at most in a multi-match. + fields (list, optional): A list of Lunr field mappings + ``{"field_name": str, "boost": int}``. See the Lunr documentation + for more details. The field name must exist in the dicts returned + by `.search_index_entry` of the candidates. If not given, a default setup + is used, prefering keys > aliases > category > tags. + Returns: + tuple: A tuple (matches, suggestions), each a list, where the `suggestion_maxnum` limits + how many suggestions are included. + + """ + global _LUNR, _LUNR_EXCEPTION + if not _LUNR: + # we have to delay-load lunr because it messes with logging if it's imported + # before twisted's logging has been set up + from lunr import lunr as _LUNR + from lunr.exceptions import QueryParseError as _LUNR_EXCEPTION + + indx = [cnd.search_index_entry for cnd in candidate_entries] + mapping = {indx[ix]["key"]: cand for ix, cand in enumerate(candidate_entries)} + + if not fields: + fields = [ + {"field_name": "key", "boost": 10}, + {"field_name": "aliases", "boost": 9}, + {"field_name": "category", "boost": 8}, + {"field_name": "tags", "boost": 5}, + ] + + search_index = _LUNR( + ref="key", + fields=fields, + documents=indx, + ) + try: + matches = search_index.search(query)[:suggestion_maxnum] + except _LUNR_EXCEPTION: + # this is a user-input problem + matches = [] + + # matches (objs), suggestions (strs) + return ( + [mapping[match["ref"]] for match in matches], + [str(match["ref"]) for match in matches], # + f" (score {match['score']})") # good debug + ) + + +def parse_entry_for_subcategories(entry): + """ + Parse a command docstring for special sub-category blocks: + + Args: + entry (str): A help entry to parse + + Returns: + dict: The dict is a mapping that splits the entry into subcategories. This + will always hold a key `None` for the main help entry and + zero or more keys holding the subcategories. Each is itself + a dict with a key `None` for the main text of that subcategory + followed by any sub-sub-categories down to a max-depth of 5. + + Example: + :: + + ''' + Main topic text + + # SUBTOPICS + + ## foo + + A subcategory of the main entry, accessible as `help topic foo` + (or using /, like `help topic/foo`) + + ## bar + + Another subcategory, accessed as `help topic bar` + (or `help topic/bar`) + + ### moo + + A subcategory of bar, accessed as `help bar moo` + (or `help bar/moo`) + + #### dum + + A subcategory of moo, accessed `help bar moo dum` + (or `help bar/moo/dum`) + + ''' + + This will result in this returned entry structure: + :: + + { + None: "Main topic text": + "foo": { + None: "main topic/foo text" + }, + "bar": { + None: "Main topic/bar text", + "moo": { + None: "topic/bar/moo text" + "dum": { + None: "topic/bar/moo/dum text" + } + } + } + } + + + Apart from making + sub-categories at the bottom of the entry. + + This will be applied both to command docstrings and database-based help + entries. + + """ + topic, *subtopics = _RE_HELP_SUBTOPICS_START.split(entry, maxsplit=1) + structure = {None: topic.strip()} + + if subtopics: + subtopics = subtopics[0] + else: + return structure + + keypath = [] + current_nesting = 0 + subtopic = None + + # from evennia import set_trace;set_trace() + for part in _RE_HELP_SUBTOPIC_SPLIT.split(subtopics.strip()): + + subtopic_match = _RE_HELP_SUBTOPIC_PARSE.match(part.strip()) + if subtopic_match: + # a new sub(-sub..) category starts. + mdict = subtopic_match.groupdict() + subtopic = mdict['name'].lower().strip() + new_nesting = len(mdict['nesting']) - 1 + + if new_nesting > MAX_SUBTOPIC_NESTING: + raise RuntimeError( + f"Can have max {MAX_SUBTOPIC_NESTING} levels of nested help subtopics.") + + nestdiff = new_nesting - current_nesting + if nestdiff < 0: + # jumping back up in nesting + for _ in range(abs(nestdiff) + 1): + try: + keypath.pop() + except IndexError: + pass + elif nestdiff == 0: + # don't add a deeper nesting but replace the current + try: + keypath.pop() + except IndexError: + pass + keypath.append(subtopic) + current_nesting = new_nesting + else: + # an entry belonging to a subtopic - find the nested location + dct = structure + if not keypath and subtopic is not None: + structure[subtopic] = part + else: + for key in keypath: + if key in dct: + dct = dct[key] + else: + dct[key] = { + None: part + } + return structure diff --git a/evennia/server/deprecations.py b/evennia/server/deprecations.py index 91fd48afdb..5c007f3308 100644 --- a/evennia/server/deprecations.py +++ b/evennia/server/deprecations.py @@ -117,7 +117,7 @@ def check_errors(settings): "Use PORTAL/SERVER_LOG_DAY_ROTATION and PORTAL/SERVER_LOG_MAX_SIZE " "to control log cycling." ) - if hasattr(settings, "CHANNEL_COMMAND_CLASS") or hasaattr(settings, "CHANNEL_HANDLER_CLASS"): + if hasattr(settings, "CHANNEL_COMMAND_CLASS") or hasattr(settings, "CHANNEL_HANDLER_CLASS"): raise DeprecationWarning( "settings.CHANNEL_HANDLER_CLASS and CHANNEL COMMAND_CLASS are " "unused and should be removed. The ChannelHandler is no more; " diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index c389b2c328..17c5f1678a 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1868,12 +1868,14 @@ def format_grid(elements, width=78, sep=" ", verbatim_elements=None): elements = [elements[ie] + sep for ie in range(nelements - 1)] + [elements[-1]] wls = [len(elem) for elem in elements] wls_percentile = [wl for iw, wl in enumerate(wls) if iw not in verbatim_elements] - # from pudb import debugger - # debugger.Debugger().set_trace() - # get the nth percentile as a good representation of average width - averlen = int(percentile(sorted(wls_percentile), 0.9)) + 2 # include extra space - aver_per_row = width // averlen + 1 + if wls_percentile: + # get the nth percentile as a good representation of average width + averlen = int(percentile(sorted(wls_percentile), 0.9)) + 2 # include extra space + aver_per_row = width // averlen + 1 + else: + # no adjustable rows, just keep all as-is + aver_per_row = 1 if aver_per_row == 1: # one line per row, output directly since this is trivial