New FileHelp system to create help entries from external files

This commit is contained in:
Griatch 2021-05-08 14:03:50 +02:00
parent 8a7e19db16
commit f5fd398480
10 changed files with 432 additions and 290 deletions

View file

@ -48,6 +48,7 @@
- `utils.evmenu.ask_yes_no` is a helper function that makes it easy to ask a yes/no question
to the user and respond to their input. This complements the existing `get_input` helper.
- Allow sending messages with `page/tell` without a `=` if target name contains no spaces.
- New FileHelpStorage system allows adding help entries via external files.
### Evennia 0.9.5 (2019-2020)

View file

@ -874,7 +874,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS):
caller,
f"Are you sure you want to delete channel '{channel.key}'"
"(make sure name is correct!)? This will disconnect and "
"remove all users' aliases. {yesno}?",
"remove all users' aliases. {options}?",
_perform_delete,
"Aborted."
)
@ -969,7 +969,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS):
caller,
f"Are you sure you want to boot user {target.key} from "
f"channel(s) {channames} (make sure name/channels are correct{reasonwarn}). "
"{yesno}?",
"{options}?",
_boot_user,
"Aborted.",
default="Y"
@ -1022,7 +1022,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS):
caller,
f"Are you sure you want to ban user {target.key} from "
f"channel(s) {channames} (make sure name/channels are correct{reasonwarn}) "
"{yesno}?",
"{options}?",
_ban_user,
"Aborted.",
)

View file

@ -8,229 +8,53 @@ creation of other help topics such as RP help or game-world aides.
"""
import re
from dataclasses import dataclass
from django.conf import settings
from collections import defaultdict
from evennia.utils.utils import fill, dedent
from evennia.help.models import HelpEntry
from evennia.utils import create, evmore
from evennia.utils.ansi import ANSIString
from evennia.help.filehelp import FILE_HELP_ENTRIES
from evennia.utils.eveditor import EvEditor
from evennia.utils.utils import (
class_from_module,
inherits_from,
format_grid, pad
)
from evennia.help.utils import help_search_with_index, parse_entry_for_subcategories
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
HELP_MORE = settings.HELP_MORE
CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
_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<nesting>\#{2,6})\s*?(?P<name>.*?)$", re.I + re.M)
MAX_SUBTOPIC_NESTING = 5
# limit symbol import for API
__all__ = ("CmdHelp", "CmdSetHelp")
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
_SEP = "|C" + "-" * _DEFAULT_WIDTH + "|n"
_LUNR = None
_LUNR_EXCEPTION = None
@dataclass
class HelpCategory:
def __init__(self, key):
self.key = key
"""
Mock 'help entry' to search categories with the same code.
"""
key: str
@property
def search_index_entry(self):
return {
"key": str(self),
"key": self.key,
"aliases": "",
"category": self.key,
"tags": "",
"text": "",
}
def __str__(self):
return f"Category: {self.key}"
def __eq__(self, other):
return str(self).lower() == str(other).lower()
def __hash__(self):
return id(self)
def help_search_with_index(query, candidate_entries, suggestion_maxnum=5):
"""
Lunr-powered fast index search and suggestion wrapper
"""
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)}
search_index = _LUNR(
ref="key",
fields=[
{"field_name": "key", "boost": 10},
{"field_name": "aliases", "boost": 9},
{"field_name": "category", "boost": 8},
{"field_name": "tags", "boost": 5},
{"field_name": "text", "boost": 1},
],
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] = dedent(part.strip())
else:
for key in keypath:
if key in dct:
dct = dct[key]
else:
dct[key] = {
None: dedent(part.strip())
}
return structure
return hash(id(self))
class CmdHelp(COMMAND_DEFAULT_CLASS):
@ -329,7 +153,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
else:
aliases = ''
help_text = f"\n\n{dedent(' ' + help_text.strip())}\n" if help_text else ""
help_text = "\n\n" + dedent(help_text.strip(), indent=0) + "\n" if help_text else ""
if subtopics:
subtopics = (
@ -503,11 +327,17 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
# having to allow doublet commands to manage exits etc.
cmdset.make_unique(caller)
# retrieve all available commands and database topics
# retrieve all available commands and database / file-help topics
all_cmds = [cmd for cmd in cmdset if self.check_show_help(cmd, caller)]
all_db_topics = [
topic for topic in HelpEntry.objects.all() if topic.access(caller, "view", default=True)
]
# we group the file-help topics with the db ones, giving the db ones priority
file_help_topics = FILE_HELP_ENTRIES.all(return_dict=True)
db_topics = {
topic.key.lower().strip(): topic for topic in HelpEntry.objects.all()
if topic.access(caller, "view", default=True)
}
all_db_topics = list({**file_help_topics, **db_topics}.values())
all_categories = list(set(
[HelpCategory(cmd.help_category) for cmd in all_cmds]
+ [HelpCategory(topic.help_category) for topic in all_db_topics]
@ -539,7 +369,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
# Lunr search engine
# all available options
entries = [cmd for cmd in all_cmds if cmd] + list(HelpEntry.objects.all()) + all_categories
entries = [cmd for cmd in all_cmds if cmd] + all_db_topics + all_categories
match, suggestions = None, None
for match_query in [f"{query}~1", f"{query}*"]:
@ -590,10 +420,10 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
aliases = match.aliases
suggested = suggestions[1:]
else:
# a database match
# a database (or file-help) match
topic = match.key
help_text = match.entrytext
aliases = match.aliases.all()
aliases = match.aliases if isinstance(match.aliases, list) else match.aliases.all()
suggested = suggestions[1:]
# parse for subtopics. The subtopic_map is a dict with the current topic/subtopic

View file

@ -424,7 +424,7 @@ class TestHelp(CommandTest):
logging.disable(level=logging.ERROR)
def test_help(self):
self.call(help_module.CmdHelp(), "", "Admin", cmdset=CharacterCmdSet())
self.call(help_module.CmdHelp(), "", "Commands", cmdset=CharacterCmdSet())
def test_set_help(self):
self.call(
@ -434,85 +434,6 @@ class TestHelp(CommandTest):
)
self.call(help_module.CmdHelp(), "testhelp", "Help for testhelp", cmdset=CharacterCmdSet())
def test_parse_entry(self):
"""
Test for subcategories
"""
entry = """
Main topic text
# subtopics
## foo
Foo sub-category
### moo
Foo/Moo subsub-category
#### dum
Foo/Moo/Dum subsubsub-category
## bar
Bar subcategory
### moo
Bar/Moo subcategory
"""
expected = {
None: "Main topic text",
"foo": {
None: "Foo sub-category",
"moo": {
None: "Foo/Moo subsub-category",
"dum": {
None: "Foo/Moo/Dum subsubsub-category",
}
},
},
"bar": {
None: "Bar subcategory",
"moo": {
None: "Bar/Moo subcategory"
}
}
}
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"

View file

@ -0,0 +1,60 @@
"""
File-based help entries. These complements command-based help and help entries
added in the database using the `sethelp` command in-game.
Control where Evennia reads these entries with `settings.FILE_HELP_ENTRY_MODULES`,
which is a list of python-paths to modules to read.
A module like this should hold a global `HELP_ENTRY_DICTS` list, containing
dicts that each represent a help entry. If no `HELP_ENTRY_DICTS` variable is
given, all top-level variables that are dicts in the module are read as help
entries.
Each dict is on the form
::
{'key': <str>,
'category': <str>, # optional, otherwise settings.FILE_DEFAULT_HELP_CATEGORY
'aliases': <list>, # optional
'text': <str>}`` # the actual help text. Can contain # subtopic sections
"""
HELP_ENTRY_DICTS = [
{
"key": "evennia",
"aliases": ['ev'],
"category": "General",
"text": """
Evennia is a MUD game server in Python.
# subtopics
## Installation
You'll find installation instructions on https:evennia.com
## Community
There are many ways to get help and communicate with other devs!
### IRC
The irc channel is #evennia on irc.freenode.net
### Discord
There is also a discord channel you can find from the sidebard on evennia.com.
"""
},
{
"key": "building",
"category": "building",
"text": """
Evennia comes with a bunch of default building commands. You can
find a building tutorial in the evennia documentation.
"""
}
]

178
evennia/help/filehelp.py Normal file
View file

@ -0,0 +1,178 @@
"""
The filehelp-system allows for defining help files outside of the game. These
will be treated as non-command help entries and displayed in the same way as
help entries created using the `sethelp` default command. After changing an
entry on-disk you need to reload the server to have the change show in-game.
An filehelp file is a regular python modules with dicts representing each help
entry. If a list `HELP_ENTRY_DICTS` is found in the module, this should be a list of
dicts. Otherwise *all* top-level dicts in the module will be assumed to be a
help-entry dict.
Each help-entry dict is on the form
::
{'key': <str>,
'category': <str>, # optional, otherwise settings.FILE_DEFAULT_HELP_CATEGORY
'aliases': <list>, # optional
'text': <str>}``
where the ``category`` is optional and the ``text`` should be formatted on the
same form as other help entry-texts and contain ``# subtopics`` as normal.
New help-entry modules are added to the system by providing the python-path to
the module to `settings.FILE_HELP_ENTRY_MODULES`. Note that if same-key entries are
added, entries in latter modules will override that of earlier ones. Use
``settings.FILE_DEFAULT_HELP_CATEGORY`` to customize what category is used if
not set explicitly.
An example of the contents of a module:
::
help_entry1 = {
"key": "The Gods", # case-insensitive, can be searched by 'gods' as well
"aliases": ['pantheon', 'religion']
"category": "Lore",
"text": '''
The gods formed the world ...
# Subtopics
## Pantheon
...
### God of love
...
### God of war
...
'''
}
HELP_ENTRY_DICTS = [
help_entry1,
...
]
----
"""
from dataclasses import dataclass
from django.conf import settings
from evennia.utils.utils import (
variable_from_module, make_iter, all_from_module)
from evennia.utils import logger
@dataclass
class FileHelpEntry:
"""
Represents a help entry read from file. This mimics the api of the
database-bound HelpEntry so that they can be used interchangeably in the
help command.
"""
key: str
aliases: list
help_category: str
entrytext: str
@property
def search_index_entry(self):
"""
Property for easily retaining a search index entry for this object.
"""
return {
"key": self.key,
"aliases": " ".join(self.aliases),
"category": self.help_category,
"tags": "",
"text": self.entrytext,
}
class FileHelpStorageHandler:
"""
This reads and stores help entries for quick access. By default
it reads modules from `settings.FILE_HELP_ENTRY_MODULES`.
Note that this is not meant to any searching/lookup - that is all handled
by the help command.
"""
def __init__(self, help_file_modules=settings.FILE_HELP_ENTRY_MODULES):
"""
Initialize the storage.
"""
self.help_file_modules = [str(part).strip()
for part in make_iter(help_file_modules)]
self.help_entries = []
self.help_entries_dict = {}
self.load()
def load(self):
"""
Load/reload file-based help-entries from file.
"""
loaded_help_dicts = []
for module_or_path in self.help_file_modules:
help_dict_list = variable_from_module(
module_or_path, variable="HELP_ENTRY_DICTS"
)
if not help_dict_list:
help_dict_list = [
dct for dct in all_from_module(module_or_path).values()
if isinstance(dct, dict)]
if help_dict_list:
loaded_help_dicts.extend(help_dict_list)
else:
logger.log_err(f"Could not find file-help module {module_or_path} (skipping).")
# validate and parse dicts into FileEntryHelp objects and make sure they are unique-by-key
# by letting latter added ones override earlier ones.
unique_help_entries = {}
for dct in loaded_help_dicts:
key = dct.get('key').lower().strip()
category = dct.get('category', settings.FILE_DEFAULT_HELP_CATEGORY).strip()
aliases = list(dct.get('aliases', []))
entrytext = dct.get('text')
if not key and entrytext:
logger.error(f"Cannot load file-help-entry (missing key or text): {dct}")
continue
unique_help_entries[key] = FileHelpEntry(
key=key, help_category=category, aliases=aliases,
entrytext=entrytext)
self.help_entries_dict = unique_help_entries
self.help_entries = list(unique_help_entries.values())
def all(self, return_dict=False):
"""
Get all help entries.
Args:
return_dict (bool): Return a dict ``{key: FileHelpEntry,...}``. Otherwise,
return a list of ``FileHelpEntry`.
Returns:
dict or list: Depending on the setting of ``return_dict``.
"""
return self.help_entries_dict if return_dict else self.help_entries
# singleton to hold the loaded help entries
FILE_HELP_ENTRIES = FileHelpStorageHandler()

145
evennia/help/tests.py Normal file
View file

@ -0,0 +1,145 @@
"""
Unittests for help code (The default help-command is tested as part of default
command test-suite).
"""
from unittest import mock
from evennia.utils.test_resources import TestCase
from evennia.utils.utils import dedent
from evennia.help import filehelp, utils as help_utils
class TestParseSubtopics(TestCase):
"""
Test the subtopic parser.
"""
def test_parse_entry(self):
"""
Test for subcategories
"""
self.maxDiff = None
entry = dedent("""
Main topic text
# subtopics
## foo
Foo sub-category
### moo
Foo/Moo subsub-category
#### dum
Foo/Moo/Dum subsubsub-category
## bar
Bar subcategory
### moo
Bar/Moo subcategory
""", indent=0)
expected = {
None: "Main topic text",
"foo": {
None: "\nFoo sub-category\n",
"moo": {
None: "\nFoo/Moo subsub-category\n",
"dum": {
None: "\nFoo/Moo/Dum subsubsub-category\n",
}
},
},
"bar": {
None: "\nBar subcategory\n",
"moo": {
None: "\nBar/Moo subcategory"
}
}
}
actual_result = help_utils.parse_entry_for_subcategories(entry)
self.assertEqual(expected, actual_result)
def test_parse_single_entry(self):
"""
Test parsing single subcategory
"""
entry = dedent("""
Main topic text
# SUBTOPICS
## creating extra stuff
Help on creating extra stuff.
""", indent=0)
expected = {
None: "Main topic text",
"creating extra stuff": {
None: "\nHelp on creating extra stuff."
}
}
actual_result = help_utils.parse_entry_for_subcategories(entry)
self.assertEqual(expected, actual_result)
# test filehelp system
HELP_ENTRY_DICTS = [
{
"key": "evennia",
"aliases": ['ev'],
"category": "General",
"text": """
Evennia is a MUD game server in Python.
# subtopics
## Installation
You'll find installation instructions on https:evennia.com
## Community
There are many ways to get help and communicate with other devs!
### IRC
The irc channel is #evennia on irc.freenode.net
### Discord
There is also a discord channel you can find from the sidebard on evennia.com.
"""
},
{
"key": "building",
"category": "building",
"text": """
Evennia comes with a bunch of default building commands. You can
find a building tutorial in the evennia documentation.
"""
}
]
class TestFileHelp(TestCase):
"""
Test the File-help system
"""
@mock.patch("evennia.help.filehelp.variable_from_module")
def test_file_help(self, mock_variable_from_module):
mock_variable_from_module.return_value = HELP_ENTRY_DICTS
# we just need anything here since we mock the load anyway
storage = filehelp.FileHelpStorageHandler(help_file_modules=["dummypath"])
result = storage.all()
for inum, helpentry in enumerate(result):
self.assertEqual(HELP_ENTRY_DICTS[inum]['key'], helpentry.key)
self.assertEqual(HELP_ENTRY_DICTS[inum].get('aliases', []), helpentry.aliases)
self.assertEqual(HELP_ENTRY_DICTS[inum]['category'], helpentry.help_category)
self.assertEqual(HELP_ENTRY_DICTS[inum]['text'], helpentry.entrytext)

View file

@ -143,9 +143,6 @@ def _server_maintenance():
if _MAINTENANCE_COUNT % 5 == 0:
# check cache size every 5 minutes
_FLUSH_CACHE(_IDMAPPER_CACHE_MAXSIZE)
if _MAINTENANCE_COUNT % 60 == 0:
# validate scripts every hour
evennia.ScriptDB.objects.validate()
if _MAINTENANCE_COUNT % 61 == 0:
# validate channels off-sync with scripts
evennia.CHANNEL_HANDLER.update()

View file

@ -448,8 +448,6 @@ COMMAND_DEFAULT_ARG_REGEX = None
# calling the Command. This may be more intuitive for users in certain
# multisession modes.
COMMAND_DEFAULT_MSG_ALL_SESSIONS = False
# The help category of a command if not otherwise specified.
COMMAND_DEFAULT_HELP_CATEGORY = "general"
# The default lockstring of a command.
COMMAND_DEFAULT_LOCKS = ""
# The Channel Handler is responsible for managing all available channels. By
@ -597,6 +595,21 @@ TIME_GAME_EPOCH = None
# the real time (add a different epoch to shift time)
TIME_IGNORE_DOWNTIMES = False
######################################################################
# Help system
######################################################################
# Help output from CmdHelp are wrapped in an EvMore call
# (excluding webclient with separate help popups). If continuous scroll
# is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT
HELP_MORE = True
# The help category of a command if not specified.
COMMAND_DEFAULT_HELP_CATEGORY = "general"
# The help category of a file-based help entry if not specified
FILE_DEFAULT_HELP_CATEGORY = "general"
# File-based help entries. These are modules containing dicts defining help
# entries. They can be used together with in-database entries created in-game.
FILE_HELP_ENTRY_MODULES = ["world.help_entries"]
######################################################################
# FuncParser
#
@ -679,10 +692,6 @@ PERMISSION_ACCOUNT_DEFAULT = "Player"
CLIENT_DEFAULT_WIDTH = 78
# telnet standard height is 24; does anyone use such low-res displays anymore?
CLIENT_DEFAULT_HEIGHT = 45
# Help output from CmdHelp are wrapped in an EvMore call
# (excluding webclient with separate help popups). If continuous scroll
# is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT
HELP_MORE = True
# Set rate limits per-IP on account creations and login attempts
CREATION_THROTTLE_LIMIT = 2
CREATION_THROTTLE_TIMEOUT = 10 * 60

View file

@ -1335,18 +1335,19 @@ def all_from_module(module):
already imported module object (e.g. `models`)
Returns:
variables (dict): A dict of {variablename: variable} for all
dict: A dict of {variablename: variable} for all
variables in the given module.
Notes:
Ignores modules and variable names starting with an underscore.
Ignores modules and variable names starting with an underscore, as well
as variables imported into the module from other modules.
"""
mod = mod_import(module)
if not mod:
return {}
# make sure to only return variables actually defined in this
# module if available (try to avoid not imports)
# module if available (try to avoid imports)
members = getmembers(mod, predicate=lambda obj: getmodule(obj) in (mod, None))
return dict((key, val) for key, val in members if not key.startswith("_"))