diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index 7beeecc531..275d514079 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -4,6 +4,7 @@ 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. + """ import re @@ -19,7 +20,7 @@ from evennia.utils.utils import ( string_suggestions, class_from_module, inherits_from, - format_grid, + format_grid, pad ) COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -27,10 +28,12 @@ HELP_MORE = settings.HELP_MORE CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES _RE_HELP_SUBTOPICS_START = re.compile( - r"^\s*?#\s*?help[- ]subtopics\s*?$", re.I + re.M) -_RE_HELP_SUBTOPIC_SPLIT = re.compile(r"^\s*?(\#{2,6}\s*?\w+?)$", re.M) + 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 + # limit symbol import for API __all__ = ("CmdHelp", "CmdSetHelp") @@ -123,7 +126,7 @@ def parse_entry_for_subcategories(entry): ''' Main topic text - # HELP-SUBCATEGORIES + # SUBTOPICS ## foo @@ -176,10 +179,9 @@ def parse_entry_for_subcategories(entry): """ topic, *subtopics = _RE_HELP_SUBTOPICS_START.split(entry, maxsplit=1) structure = {None: topic.strip()} - subtopics_index = [] if subtopics: - subctopics = subtopics[0] + subtopics = subtopics[0] else: return structure @@ -187,14 +189,20 @@ def parse_entry_for_subcategories(entry): current_nesting = 0 subtopic = None - for part in _RE_HELP_SUBTOPIC_SPLIT.split(subtopics): - subtopic_match = _RE_HELP_SUBTOPIC_PARSE.match(part) + # 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'].strip() - subtopic_index.append(subtopic) + 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 @@ -203,22 +211,28 @@ def parse_entry_for_subcategories(entry): 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.strip() + structure[subtopic] = dedent(part.strip()) else: for key in keypath: if key in dct: dct = dct[key] else: dct[key] = { - None: part.strip() + None: dedent(part.strip()) } - return structure, subtopic_index + return structure class CmdHelp(COMMAND_DEFAULT_CLASS): @@ -250,6 +264,11 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): # 'HELP_MORE = False' in your settings/conf/settings.py help_more = HELP_MORE + # colors for the help index + index_type_separator_clr = "|w" + index_category_clr = "|W" + index_topic_clr = "|G" + # suggestion cutoff, between 0 and 1 (1 => perfect match) suggestion_cutoff = 0.6 @@ -295,32 +314,41 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): help_text (str): Text of the help entry. aliases (list): List of help-aliases (displayed in header). suggested (list): Strings suggested reading (based on title). - subtopics (list): A list of strings - the subcategories to this entry. + subtopics (list): A list of strings - the subcategories available + for this entry. Returns the formatted string, ready to be sent. """ start = f"{_SEP}\n" + title = f"|CHelp for |w{topic}|n" if topic else "" - aliases = ( - " |C(aliases: {}|C)|n".format("|C,|n ".join(f"|w{ali}|n" for ali in aliases)) - if aliases else "" - ) - help_text = ( - f"\n{dedent(help_text.rstrip())}" if help_text else "" - ) - subtopics = ( - "\nSubtopics (read with |whelp {} / subtopic): {}".format( - topic, "|C,|n ".join(f"|w{subtop}|n" for subtop in subtopics) - if subtopics else "" + + if aliases: + aliases = ( + " |C(aliases: {}|C)|n".format("|C,|n ".join(f"|w{ali}|n" for ali in aliases)) ) - ) - suggested = ( - "\n\n|CSuggested:|n {}".format( - fill("|C,|n ".join(f"|w{sug}|n" for sug in suggested)) + else: + aliases = '' + + help_text = f"\n\n{dedent(' ' + help_text.strip())}\n" if help_text else "" + + if subtopics: + subtopics = ( + "\n|CSubtopics:|n\n {}".format( + "\n ".join(f"|w{topic}/{subtop}|n" for subtop in subtopics)) ) - if suggested else "" - ) + else: + subtopics = '' + + if suggested: + suggested = ( + "\n\n|CSuggested other topics:|n\n{}".format( + fill("|C,|n ".join(f"|w{sug}|n" for sug in suggested), indent=2)) + ) + else: + suggested = '' + end = f"\n{_SEP}" return "".join((start, title, aliases, help_text, subtopics, suggested, end)) @@ -345,27 +373,61 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): custom display of the list of commands and topics. """ - category_clr = "|w" - topic_clr = "|G" + def _group_by_category(help_dict): + grid = [] + verbatim_elements = [] + + if len(help_dict) == 1: + # don't list categories if there is only one + for category in help_dict: + entries = sorted(set(help_dict.get(category, []))) + grid.extend(entries) + else: + # list the categories + for category in sorted(set(list(help_dict.keys()))): + category_str = f"-- {category.title()} " + grid.append( + ANSIString( + self.index_category_clr + category_str + + "-" * (width - len(category_str)) + + self.index_topic_clr + ) + ) + verbatim_elements.append(len(grid) - 1) + + entries = sorted(set(help_dict.get(category, []))) + grid.extend(entries) + + return grid, verbatim_elements + + help_index = "" width = self.client_width() grid = [] verbatim_elements = [] - for category in sorted(set(list(cmd_help_dict.keys()) + list(db_help_dict.keys()))): - - category_str = f"-- {category.title()} " - grid.append( - ANSIString( - category_clr + category_str + "-" * (width - len(category_str)) + topic_clr - ) - ) - verbatim_elements.append(len(grid) - 1) - - entries = sorted(set(cmd_help_dict.get(category, []) + db_help_dict.get(category, []))) - grid.extend(entries) + # 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) - gridrows = ANSIString("\n").join(gridrows) - return gridrows + 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 "" + + # only show the main separators if there are actually both cmd and db-based help + if cmd_grid and db_grid: + help_index = f"{sep1}\n{cmd_grid}\n{sep2}\n{db_grid}" + else: + help_index = f"{cmd_grid}{db_grid}" + + return help_index def check_show_help(self, cmd, caller): """ @@ -518,7 +580,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): { match.key: [ topic.key - for topic in all_topics + for topic in all_db_topics if match.key.lower() == topic.help_category ] }, @@ -530,7 +592,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): # a command match topic = match.key help_text = match.get_help(caller, cmdset) - aliases = match.aliases, + aliases = match.aliases suggested=suggestions[1:] else: # a database match @@ -550,22 +612,44 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): if subtopics: # if we asked for subtopics, parse the found topic_text to see if any match. # the subtopics is a list describing the path through the subtopic_map. + for subtopic_query in subtopics: - subtopic_query_lower = subtopic_query.lower() - checked_topic = topic + f" / {subtopic_query.lower().capitalize()}" + + if subtopic_query not in subtopic_map: + # exact match failed. Try startswith-match + fuzzy_match = False + for key in subtopic_map: + if key and key.startswith(subtopic_query): + subtopic_query = key + fuzzy_match = True + break + + if not fuzzy_match: + # startswith failed - try an 'in' match + for key in subtopic_map: + if key and subtopic_query in key: + subtopic_query = key + fuzzy_match = True + break + + if not fuzzy_match: + # no match found - give up + checked_topic = topic + f"/{subtopic_query}" + output = self.format_help_entry( + topic=topic, + help_text=f"No help entry found for '{checked_topic}'", + subtopics=subtopic_index + ) + self.msg_help(output) + return + + # if we get here we have an exact or fuzzy match + + subtopic_map = subtopic_map.pop(subtopic_query) subtopic_index = [subtopic for subtopic in subtopic_map if subtopic is not None] - if subtopic_query_lower() in subtopic_index: - # keep stepping down into the tree - subtopic_map = subtopic_map.pop(subtopic_query) - topic = checked_topic - else: - output = self.format_help_entry( - topic=topic, - help_text=f"No help entry found for '{checked_topic}'", - subtopics=subtopic_index - ) - self.msg_help(output) - return + # keep stepping down into the tree, append path to show position + topic = topic + f"/{subtopic_query}" + # we reached the bottom of the topic tree help_text = subtopic_map[None] @@ -614,15 +698,51 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS): delete - remove help topic. Examples: - sethelp throw = This throws something at ... + sethelp lore = In the beginning was ... sethelp/append pickpocketing,Thievery = This steals ... sethelp/replace pickpocketing, ,attr(is_thief) = This steals ... sethelp/edit thievery - This command manipulates the help database. A help entry can be created, - appended/merged to and deleted. If you don't assign a category, the - "General" category will be used. If no lockstring is specified, default - is to let everyone read the help file. + If not assigning a category, the "General" category will be used. If no + lockstring is specified, everyone will be able to read the help entry. + Sub-topics are embedded in the help text. + + Note that this cannot modify command-help entries - these are modified + in-code, outside the game. + + # SUBTOPICS + + ## Adding subtopics + + Subtopics helps to break up a long help entry into sub-sections. Users can + access subtopics with |whelp topic/subtopic/...|n Subtopics are created and + stored together with the main topic. + + To start adding subtopics, add the text '# SUBTOPICS' on a new line at the + end of your help text. After this you can now add any number of subtopics, + each starting with '## ' on a line, followed by the + help-text of that subtopic. + Use '### ' to add a sub-subtopic and so on. Max depth is 5. A + subtopic's title is case-insensitive and can consist of multiple words - + the user will be able to enter a partial match to access it. + + For example: + + | Main help text for + | + | # SUBTOPICS + | + | ## about + | + | Text for the '/about' subtopic' + | + | ### more about-info + | + | Text for the '/about/more about-info sub-subtopic + | + | ## extra + | + | Text for the '/extra' subtopic """ diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 47f1491b0d..2801c47002 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -16,6 +16,7 @@ import types import datetime from anything import Anything +from parameterized import parameterized from django.conf import settings from unittest.mock import patch, Mock, MagicMock @@ -442,7 +443,7 @@ class TestHelp(CommandTest): entry = """ Main topic text - # help-subtopics + # subtopics ## foo @@ -484,9 +485,155 @@ class TestHelp(CommandTest): } } - actual_result = help_module.CmdHelp.parse_entry_for_subcategories(entry) + actual_result = help_module.parse_entry_for_subcategories(entry) self.assertEqual(expected, actual_result) + def test_parse_single_entry(self): + """ + Test parsing single subcategory + + """ + + entry = """ + Main topic text + + # SUBTOPICS + + ## creating extra stuff + + Help on creating extra stuff. + + """ + expected = { + None: "Main topic text", + "creating extra stuff": { + None: "Help on creating extra stuff." + } + } + + actual_result = help_module.parse_entry_for_subcategories(entry) + self.assertEqual(expected, actual_result) + + @parameterized.expand([ + ("test", # main help entry + "Help for test\n\n" + "Main help text\n\n" + "Subtopics:\n" + " test/creating extra stuff\n" + " test/something else\n" + " test/more" + ), + ("test/creating extra stuff", # subtopic, full match + "Help for test/creating extra stuff\n\n" + "Help on creating extra stuff.\n\n" + "Subtopics:\n" + " test/creating extra stuff/subsubtopic\n" + ), + ("test/creating", # startswith-match + "Help for test/creating extra stuff\n\n" + "Help on creating extra stuff.\n\n" + "Subtopics:\n" + " test/creating extra stuff/subsubtopic\n" + ), + ("test/extra", # partial match + "Help for test/creating extra stuff\n\n" + "Help on creating extra stuff.\n\n" + "Subtopics:\n" + " test/creating extra stuff/subsubtopic\n" + ), + ("test/extra/subsubtopic", # partial subsub-match + "Help for test/creating extra stuff/subsubtopic\n\n" + "A subsubtopic text" + ), + ("test/creating extra/subsub", # partial subsub-match + "Help for test/creating extra stuff/subsubtopic\n\n" + "A subsubtopic text" + ), + ("test/Something else", # case + "Help for test/something else\n\n" + "Something else" + ), + ("test/More", # case + "Help for test/more\n\n" + "Another text\n\n" + "Subtopics:\n" + " test/more/second-more" + ), + ("test/More/Second-more", + "Help for test/more/second-more\n\n" + "The Second More text.\n\n" + "Subtopics:\n" + " test/more/second-more/more again\n\n" + " test/more/second-more/third more" + ), + ("test/More/-more", # partial match + "Help for test/more/second-more\n\n" + "The Second More text.\n\n" + "Subtopics:\n" + " test/more/second-more/more again\n" + " test/more/second-more/third more" + ), + ("test/more/second/more again", + "Help for test/more/second-more/more again\n" + "Even more text.\n" + ), + ("test/more/second/third", + "Help for test/more/second-more/third more\n\n" + "Third more text\n" + ), + ]) + def test_subtopic_fetch(self, helparg, expected): + """ + Check retrieval of subtopics. + + """ + class TestCmd(Command): + """ + Main help text + + # SUBTOPICS + + ## creating extra stuff + + Help on creating extra stuff. + + ### subsubtopic + + A subsubtopic text + + ## Something else + + Something else + + ## More + + Another text + + ### Second-More + + The Second More text. + + #### More again + + Even more text. + + #### Third more + + Third more text + + """ + key = "test" + + class TestCmdSet(CmdSet): + def at_cmdset_creation(self): + self.add(TestCmd()) + self.add(help_module.CmdHelp()) + + self.call(help_module.CmdHelp(), + helparg, + expected, + cmdset=TestCmdSet()) + class TestSystem(CommandTest): def test_py(self): diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index e55a3f51ce..b5574b26ce 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -1476,7 +1476,7 @@ class NickHandler(AttributeHandler): then must mark numbered arguments as a named regex-groupd named `argN`. For example, `(?P.+?)` will match the behavior of using `$1` in the shell pattern. - kwargs (any, optional): These are passed on to `AttributeHandler.get`. + **kwargs (any, optional): These are passed on to `AttributeHandler.get`. Notes: For most cases, the shell-pattern is much shorter and easier. The diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index d8cab2d586..14e48b0e31 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -168,16 +168,18 @@ def crop(text, width=None, suffix="[...]"): return to_str(text) -def dedent(text, baseline_index=None): +def dedent(text, baseline_index=None, indent=None): """ Safely clean all whitespace at the left of a paragraph. Args: text (str): The text to dedent. - baseline_index (int or None, optional): Which row to use as a 'base' + baseline_index (int, optional): Which row to use as a 'base' for the indentation. Lines will be dedented to this level but no further. If None, indent so as to completely deindent the least indented text. + indent (int, optional): If given, force all lines to this indent. + This bypasses `baseline_index`. Returns: text (str): Dedented string. @@ -190,7 +192,12 @@ def dedent(text, baseline_index=None): """ if not text: return "" - if baseline_index is None: + if indent is not None: + lines = text.split("\n") + ind = " " * indent + indline = "\n" + ind + return ind + indline.join(line.strip() for line in lines) + elif baseline_index is None: return textwrap.dedent(text) else: lines = text.split("\n")