refactor(commands): direct help call only when the method also accepts the message

Space bunny on d60e7cf1: operators may install their own command files inside modules/commands or modules/commands/alternatives (documented, and preserved across upgrades), so a class under modules. is not necessarily bundled. Such a command written as get_help_text(self) was treated as bundled, called with the message and raised, so help returned nothing where dev returned its text. help_text_for now calls once with the message only when the method comes from a class under modules. and its signature binds the message; otherwise, including when the signature can't be read, it uses dev's call-then-retry. New test: an operator command in the modules.commands namespace with get_help_text(self) gets its help.
This commit is contained in:
agessaman committed 2026-10-03 23:47:56 -07:00
1 parent dabb59b823
commit f0649558e8
2 files changed
+36 -5

No files matched your search

+17 -5
View File
@@ -4,6 +4,7 @@ Base command class for all MeshCore Bot commands
Provides common functionality and interface for command implementations Provides common functionality and interface for command implementations
""" """
import inspect
import re import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
@@ -71,17 +72,28 @@ def _defined_by_bundled_class(command: Any, method: Any) -> bool:
return False return False
def _accepts_message(method: Any, message: Any) -> bool:
"""Whether ``method(message)`` binds, per its signature (False when that can't be read)."""
try:
inspect.signature(method).bind(message)
except (TypeError, ValueError):
return False
return True
def help_text_for(command: Any, message: Any) -> str: def help_text_for(command: Any, message: Any) -> str:
"""``command.get_help_text(message)``, retrying without the message for local plugins. """``command.get_help_text(message)``, retrying without the message for local plugins.
Every bundled command's get_help_text takes the message, so when the Every bundled command's get_help_text takes the message, so when the
method comes from a bundled class it is called once and an error from its method comes from a class under ``modules.`` and its signature accepts the
body propagates. Anything else (a local plugin or subclass override, which message, it is called once and an error from its body propagates. Anything
may implement ``get_help_text(self)``, or any other callable) keeps the old else keeps the old contract, where a TypeError from the message call is
contract: a TypeError from the message call is retried without the message. retried without the message: local plugins and overrides, operator-installed
commands inside modules/commands written as ``get_help_text(self)``, and
any other callable.
""" """
get_help_text = command.get_help_text get_help_text = command.get_help_text
if _defined_by_bundled_class(command, get_help_text): if _defined_by_bundled_class(command, get_help_text) and _accepts_message(get_help_text, message):
return get_help_text(message) return get_help_text(message)
try: try:
return get_help_text(message) return get_help_text(message)
+19
View File
@@ -252,6 +252,25 @@ class TestGetSpecificHelp:
cmd = HelpCommand(bot) cmd = HelpCommand(bot)
cmd.get_specific_help("ping", Mock()) # must not raise cmd.get_specific_help("ping", Mock()) # must not raise
def test_operator_command_inside_modules_without_message_param_gets_help(self):
"""An installed-only command file in modules/commands may use get_help_text(self)."""
from modules.commands.base_command import BaseCommand
class OperatorWeatherCommand(BaseCommand):
name = "operatorweather"
def get_help_text(self):
return "operator help"
async def execute(self, message):
return True
OperatorWeatherCommand.__module__ = "modules.commands.operatorweather"
bot = _make_bot()
bot.command_manager.commands = {"operatorweather": object.__new__(OperatorWeatherCommand)}
cmd = HelpCommand(bot)
cmd.get_specific_help("operatorweather", Mock()) # must not raise
def test_local_plugin_help_keeps_the_typeerror_retry(self): def test_local_plugin_help_keeps_the_typeerror_retry(self):
"""A local get_help_text(message=None) that raises TypeError is retried without it.""" """A local get_help_text(message=None) that raises TypeError is retried without it."""
bot = _make_bot() bot = _make_bot()