pw_rpc.console_tools: Support aliasing renamed commands
- The alias_deprecated_command function creates an alias that redirects
from an old RPC command name to a new name.
- Move console_tools.Watchdog tests to their own file.
Change-Id: Ie7071462e5c17e5782fb549cc2f15aa19e64bbf0
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/44120
Reviewed-by: Alexei Frolov <frolv@google.com>
Commit-Queue: Wyatt Hepler <hepler@google.com>
diff --git a/pw_rpc/py/BUILD.gn b/pw_rpc/py/BUILD.gn
index 71faaee..8b729a0 100644
--- a/pw_rpc/py/BUILD.gn
+++ b/pw_rpc/py/BUILD.gn
@@ -46,6 +46,7 @@
"tests/client_test.py",
"tests/console_tools/console_tools_test.py",
"tests/console_tools/functions_test.py",
+ "tests/console_tools/watchdog_test.py",
"tests/descriptors_test.py",
"tests/ids_test.py",
"tests/packets_test.py",
diff --git a/pw_rpc/py/docs.rst b/pw_rpc/py/docs.rst
index 5aeea46..3e6ef22 100644
--- a/pw_rpc/py/docs.rst
+++ b/pw_rpc/py/docs.rst
@@ -20,4 +20,4 @@
pw_rpc.console_tools
====================
.. automodule:: pw_rpc.console_tools
- :members: Context, ClientInfo, Watchdog, help_as_repr
+ :members: Context, ClientInfo, Watchdog, alias_deprecated_command, help_as_repr
diff --git a/pw_rpc/py/pw_rpc/console_tools/__init__.py b/pw_rpc/py/pw_rpc/console_tools/__init__.py
index 3f157f1..c714962 100644
--- a/pw_rpc/py/pw_rpc/console_tools/__init__.py
+++ b/pw_rpc/py/pw_rpc/console_tools/__init__.py
@@ -13,6 +13,7 @@
# the License.
"""Utilities for building tools that interact with pw_rpc."""
-from pw_rpc.console_tools.console import Context, CommandHelper, ClientInfo
+from pw_rpc.console_tools.console import (Context, CommandHelper, ClientInfo,
+ alias_deprecated_command)
from pw_rpc.console_tools.functions import help_as_repr
from pw_rpc.console_tools.watchdog import Watchdog
diff --git a/pw_rpc/py/pw_rpc/console_tools/console.py b/pw_rpc/py/pw_rpc/console_tools/console.py
index 29797f5..6ccdb63 100644
--- a/pw_rpc/py/pw_rpc/console_tools/console.py
+++ b/pw_rpc/py/pw_rpc/console_tools/console.py
@@ -14,6 +14,7 @@
"""Utilities for creating an interactive console."""
from collections import defaultdict
+import functools
from itertools import chain
import inspect
import textwrap
@@ -217,3 +218,71 @@
self.protos.packages[package_name]._add_item(rpcs) # pylint: disable=protected-access
self.current_client = selected_client
+
+
+def _create_command_alias(command: Any, name: str, message: str) -> object:
+ """Wraps __call__, __getattr__, and __repr__ to print a message."""
+ @functools.wraps(command.__call__)
+ def print_message_and_call(_, *args, **kwargs):
+ print(message)
+ return command(*args, **kwargs)
+
+ def getattr_and_print_message(_, name: str) -> Any:
+ attr = getattr(command, name)
+ print(message)
+ return attr
+
+ return type(
+ name, (),
+ dict(__call__=print_message_and_call,
+ __getattr__=getattr_and_print_message,
+ __repr__=lambda _: message))()
+
+
+def _access_in_dict_or_namespace(item, name: str, create_if_missing: bool):
+ """Gets name as either a key or attribute on item."""
+ try:
+ return item[name]
+ except KeyError:
+ if create_if_missing:
+ try:
+ item[name] = types.SimpleNamespace()
+ return item[name]
+ except TypeError:
+ pass
+ except TypeError:
+ pass
+
+ if create_if_missing and not hasattr(item, name):
+ setattr(item, name, types.SimpleNamespace())
+
+ return getattr(item, name)
+
+
+def _access_names(item, names: Iterable[str], create_if_missing: bool):
+ for name in names:
+ item = _access_in_dict_or_namespace(item, name, create_if_missing)
+
+ return item
+
+
+def alias_deprecated_command(variables: Any, old_name: str,
+ new_name: str) -> None:
+ """Adds an alias for an old command that redirects to the new command.
+
+ The deprecated command prints a message then invokes the new command.
+ """
+ # Get the new command.
+ item = _access_names(variables,
+ new_name.split('.'),
+ create_if_missing=False)
+
+ # Create a wrapper to the new comamnd with the old name.
+ wrapper = _create_command_alias(
+ item, old_name,
+ f'WARNING: {old_name} is DEPRECATED; use {new_name} instead')
+
+ # Add the wrapper to the variables with the old command's name.
+ name_parts = old_name.split('.')
+ item = _access_names(variables, name_parts[:-1], create_if_missing=True)
+ setattr(item, name_parts[-1], wrapper)
diff --git a/pw_rpc/py/tests/console_tools/console_tools_test.py b/pw_rpc/py/tests/console_tools/console_tools_test.py
index 3dbb5ce..36285db 100755
--- a/pw_rpc/py/tests/console_tools/console_tools_test.py
+++ b/pw_rpc/py/tests/console_tools/console_tools_test.py
@@ -12,72 +12,18 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
-"""Tests encoding HDLC frames."""
+"""Tests the pw_rpc.console_tools.console module."""
+import types
import unittest
-from unittest import mock
import pw_status
from pw_protobuf_compiler import python_protos
import pw_rpc
from pw_rpc import callback_client
-from pw_rpc.console_tools import CommandHelper, Context, ClientInfo, Watchdog
-
-
-class TestWatchdog(unittest.TestCase):
- """Tests the Watchdog class."""
- def setUp(self) -> None:
- self._reset = mock.Mock()
- self._expiration = mock.Mock()
- self._while_expired = mock.Mock()
-
- self._watchdog = Watchdog(self._reset, self._expiration,
- self._while_expired, 99999)
-
- def _trigger_timeout(self) -> None:
- # Don't wait for the timeout -- that's too flaky. Call the internal
- # timeout function instead.
- self._watchdog._timeout_expired() # pylint: disable=protected-access
-
- def test_expiration_callbacks(self) -> None:
- self._watchdog.start()
-
- self._expiration.not_called()
-
- self._trigger_timeout()
-
- self._expiration.assert_called_once_with()
- self._while_expired.assert_not_called()
-
- self._trigger_timeout()
-
- self._expiration.assert_called_once_with()
- self._while_expired.assert_called_once_with()
-
- self._trigger_timeout()
-
- self._expiration.assert_called_once_with()
- self._while_expired.assert_called()
-
- def test_reset_not_called_unless_expires(self) -> None:
- self._watchdog.start()
- self._watchdog.reset()
-
- self._reset.assert_not_called()
- self._expiration.assert_not_called()
- self._while_expired.assert_not_called()
-
- def test_reset_called_if_expired(self) -> None:
- self._watchdog.start()
- self._trigger_timeout()
-
- self._watchdog.reset()
-
- self._trigger_timeout()
-
- self._reset.assert_called_once_with()
- self._expiration.assert_called()
+from pw_rpc.console_tools.console import (CommandHelper, Context, ClientInfo,
+ alias_deprecated_command)
class TestCommandHelper(unittest.TestCase):
@@ -229,5 +175,31 @@
self.assertTrue(called_derived_set_target)
+class TestAliasDeprecatedCommand(unittest.TestCase):
+ def test_wraps_command_to_new_package(self) -> None:
+ variables = {'abc': types.SimpleNamespace(command=lambda: 123)}
+ alias_deprecated_command(variables, 'xyz.one.two.three', 'abc.command')
+
+ self.assertEqual(variables['xyz'].one.two.three(), 123)
+
+ def test_wraps_command_to_existing_package(self) -> None:
+ variables = {
+ 'abc': types.SimpleNamespace(NewCmd=lambda: 456),
+ 'one': types.SimpleNamespace(),
+ }
+ alias_deprecated_command(variables, 'one.two.OldCmd', 'abc.NewCmd')
+
+ self.assertEqual(variables['one'].two.OldCmd(), 456)
+
+ def test_error_if_new_command_does_not_exist(self) -> None:
+ variables = {
+ 'abc': types.SimpleNamespace(),
+ 'one': types.SimpleNamespace(),
+ }
+
+ with self.assertRaises(AttributeError):
+ alias_deprecated_command(variables, 'one.two.OldCmd', 'abc.NewCmd')
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/pw_rpc/py/tests/console_tools/watchdog_test.py b/pw_rpc/py/tests/console_tools/watchdog_test.py
new file mode 100644
index 0000000..9bc203c
--- /dev/null
+++ b/pw_rpc/py/tests/console_tools/watchdog_test.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Tests the Watchdog module."""
+
+import unittest
+from unittest import mock
+
+from pw_rpc.console_tools import Watchdog
+
+
+class TestWatchdog(unittest.TestCase):
+ """Tests the Watchdog class."""
+ def setUp(self) -> None:
+ self._reset = mock.Mock()
+ self._expiration = mock.Mock()
+ self._while_expired = mock.Mock()
+
+ self._watchdog = Watchdog(self._reset, self._expiration,
+ self._while_expired, 99999)
+
+ def _trigger_timeout(self) -> None:
+ # Don't wait for the timeout -- that's too flaky. Call the internal
+ # timeout function instead.
+ self._watchdog._timeout_expired() # pylint: disable=protected-access
+
+ def test_expiration_callbacks(self) -> None:
+ self._watchdog.start()
+
+ self._expiration.not_called()
+
+ self._trigger_timeout()
+
+ self._expiration.assert_called_once_with()
+ self._while_expired.assert_not_called()
+
+ self._trigger_timeout()
+
+ self._expiration.assert_called_once_with()
+ self._while_expired.assert_called_once_with()
+
+ self._trigger_timeout()
+
+ self._expiration.assert_called_once_with()
+ self._while_expired.assert_called()
+
+ def test_reset_not_called_unless_expires(self) -> None:
+ self._watchdog.start()
+ self._watchdog.reset()
+
+ self._reset.assert_not_called()
+ self._expiration.assert_not_called()
+ self._while_expired.assert_not_called()
+
+ def test_reset_called_if_expired(self) -> None:
+ self._watchdog.start()
+ self._trigger_timeout()
+
+ self._watchdog.reset()
+
+ self._trigger_timeout()
+
+ self._reset.assert_called_once_with()
+ self._expiration.assert_called()
+
+
+if __name__ == '__main__':
+ unittest.main()