pw_rpc: Class for a 'help' command in a console

Change-Id: I48351dd80de515398cc29f60ac2788ce60f76489
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/33127
Reviewed-by: Alexei Frolov <frolv@google.com>
Commit-Queue: Wyatt Hepler <hepler@google.com>
diff --git a/pw_rpc/py/console_tools_test.py b/pw_rpc/py/console_tools_test.py
index 5da2aba..d75f67e 100644
--- a/pw_rpc/py/console_tools_test.py
+++ b/pw_rpc/py/console_tools_test.py
@@ -17,7 +17,7 @@
 import unittest
 from unittest import mock
 
-from pw_rpc.console_tools import Watchdog
+from pw_rpc.console_tools import CommandHelper, Watchdog
 
 
 class TestWatchdog(unittest.TestCase):
@@ -75,5 +75,24 @@
         self._expiration.assert_called()
 
 
+class TestCommandHelper(unittest.TestCase):
+    def setUp(self) -> None:
+        self._commands = {'command_a': 'A', 'command_B': 'B'}
+        self._helper = CommandHelper(self._commands, 'The header',
+                                     'The footer')
+
+    def test_help_contents(self):
+        help_contents = self._helper.help()
+
+        self.assertTrue(help_contents.startswith('The header'))
+        self.assertIn('The footer', help_contents)
+
+        for cmd_name in self._commands:
+            self.assertIn(cmd_name, help_contents)
+
+    def test_repr_is_help(self):
+        self.assertEqual(repr(self._helper), self._helper.help())
+
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/pw_rpc/py/pw_rpc/console_tools.py b/pw_rpc/py/pw_rpc/console_tools.py
index cb43a6e..1b70238 100644
--- a/pw_rpc/py/pw_rpc/console_tools.py
+++ b/pw_rpc/py/pw_rpc/console_tools.py
@@ -13,8 +13,12 @@
 # the License.
 """Utilities for building tools that interact with pw_rpc."""
 
+import inspect
+import textwrap
 import threading
-from typing import Any, Callable
+from typing import Any, Callable, Dict, Iterable
+
+from pw_rpc.descriptors import Method
 
 
 class Watchdog:
@@ -81,3 +85,53 @@
             self._on_expiration()
 
         self.start()
+
+
+_INDENT = '    '
+
+
+class CommandHelper:
+    """Used to implement a help command in an RPC console."""
+    @classmethod
+    def from_methods(cls,
+                     methods: Iterable[Method],
+                     header: str,
+                     footer: str = '') -> 'CommandHelper':
+        return cls({m.full_name: m for m in methods}, header, footer)
+
+    def __init__(self, methods: Dict[str, Any], header: str, footer: str = ''):
+        self._methods = methods
+        self.header = header
+        self.footer = footer
+
+    def help(self, item: Any = None) -> str:
+        """Returns a help string with a command or all commands listed."""
+
+        if item is None:
+            all_rpcs = '\n'.join(self._methods)
+            return (f'{self.header}\n\n'
+                    f'All commands:\n\n{textwrap.indent(all_rpcs, _INDENT)}'
+                    f'\n\n{self.footer}'.strip())
+
+        # If item is a string, find commands matching that.
+        if isinstance(item, str):
+            matches = {n: m for n, m in self._methods.items() if item in n}
+            if not matches:
+                return f'No matches found for {item!r}'
+
+            if len(matches) == 1:
+                name, method = next(iter(matches.items()))
+                return f'{name}\n\n{inspect.getdoc(method)}'
+
+            return f'Multiple matches for {item!r}:\n\n' + textwrap.indent(
+                '\n'.join(matches), _INDENT)
+
+        return inspect.getdoc(item) or f'No documentation for {item!r}.'
+
+    def __call__(self, item: Any = None) -> None:
+        """Prints the help string."""
+        print(self.help(item))
+
+    def __repr__(self) -> str:
+        """Returns the help, so foo and foo() are equivalent in a console."""
+        return self.help()