• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2021 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Utilities for creating an interactive console."""
15
16from collections import defaultdict
17import functools
18from itertools import chain
19import inspect
20import textwrap
21import types
22from typing import Any, Collection, Dict, Iterable, Mapping, NamedTuple
23
24import pw_status
25from pw_protobuf_compiler import python_protos
26
27import pw_rpc
28from pw_rpc.descriptors import Method
29from pw_rpc.console_tools import functions
30
31_INDENT = '    '
32
33
34class CommandHelper:
35    """Used to implement a help command in an RPC console."""
36    @classmethod
37    def from_methods(cls,
38                     methods: Iterable[Method],
39                     variables: Mapping[str, object],
40                     header: str,
41                     footer: str = '') -> 'CommandHelper':
42        return cls({m.full_name: m
43                    for m in methods}, variables, header, footer)
44
45    def __init__(self,
46                 methods: Mapping[str, object],
47                 variables: Mapping[str, object],
48                 header: str,
49                 footer: str = ''):
50        self._methods = methods
51        self._variables = variables
52        self.header = header
53        self.footer = footer
54
55    def help(self, item: object = None) -> str:
56        """Returns a help string with a command or all commands listed."""
57
58        if item is None:
59            all_vars = '\n'.join(sorted(self._variables_without_methods()))
60            all_rpcs = '\n'.join(self._methods)
61            return (f'{self.header}\n\n'
62                    f'All variables:\n\n{textwrap.indent(all_vars, _INDENT)}'
63                    '\n\n'
64                    f'All commands:\n\n{textwrap.indent(all_rpcs, _INDENT)}'
65                    f'\n\n{self.footer}'.strip())
66
67        # If item is a string, find commands matching that.
68        if isinstance(item, str):
69            matches = {n: m for n, m in self._methods.items() if item in n}
70            if not matches:
71                return f'No matches found for {item!r}'
72
73            if len(matches) == 1:
74                name, method = next(iter(matches.items()))
75                return f'{name}\n\n{inspect.getdoc(method)}'
76
77            return f'Multiple matches for {item!r}:\n\n' + textwrap.indent(
78                '\n'.join(matches), _INDENT)
79
80        return inspect.getdoc(item) or f'No documentation for {item!r}.'
81
82    def _variables_without_methods(self) -> Mapping[str, object]:
83        packages = frozenset(
84            n.split('.', 1)[0] for n in self._methods if '.' in n)
85
86        return {
87            name: var
88            for name, var in self._variables.items() if name not in packages
89        }
90
91    def __call__(self, item: object = None) -> None:
92        """Prints the help string."""
93        print(self.help(item))
94
95    def __repr__(self) -> str:
96        """Returns the help, so foo and foo() are equivalent in a console."""
97        return self.help()
98
99
100class ClientInfo(NamedTuple):
101    """Information about an RPC client as it appears in the console."""
102    # The name to use in the console to refer to this client.
103    name: str
104
105    # An object to use in the console for the client. May be a pw_rpc.Client.
106    client: object
107
108    # The pw_rpc.Client; may be the same object as client.
109    rpc_client: pw_rpc.Client
110
111
112def flattened_rpc_completions(
113    client_info_list: Collection[ClientInfo], ) -> Dict[str, str]:
114    """Create a flattened list of rpc commands for repl auto-completion.
115
116    This gathers all rpc commands from a set of ClientInfo variables and
117    produces a flattened list of valid rpc commands to run in an RPC
118    console. This is useful for passing into
119    prompt_toolkit.completion.WordCompleter.
120
121    Args:
122      client_info_list: List of ClientInfo variables
123
124    Returns:
125      Dict of flattened rpc commands as keys, and 'RPC' as values.
126      For example: ::
127
128        {
129            'device.rpcs.pw.rpc.EchoService.Echo': 'RPC,
130            'device.rpcs.pw.rpc.BatteryService.GetBatteryStatus': 'RPC',
131        }
132    """
133    rpc_list = list(
134        chain.from_iterable([
135            '{}.rpcs.{}'.format(c.name, a.full_name)
136            for a in c.rpc_client.methods()
137        ] for c in client_info_list))
138
139    # Dict should contain completion text as keys and descriptions as values.
140    custom_word_completions = {
141        flattened_rpc_name: 'RPC'
142        for flattened_rpc_name in rpc_list
143    }
144    return custom_word_completions
145
146
147class Context:
148    """The Context class is used to set up an interactive RPC console.
149
150    The Context manages a set of variables that make it easy to access RPCs and
151    protobufs in a REPL.
152
153    As an example, this class can be used to set up a console with IPython:
154
155    .. code-block:: python
156
157      context = console_tools.Context(
158          clients, default_client, protos, help_header=WELCOME_MESSAGE)
159      IPython.terminal.embed.InteractiveShellEmbed().mainloop(
160          module=types.SimpleNamespace(**context.variables()))
161    """
162    def __init__(self,
163                 client_info: Collection[ClientInfo],
164                 default_client: Any,
165                 protos: python_protos.Library,
166                 *,
167                 help_header: str = '') -> None:
168        """Creates an RPC console context.
169
170        Protos and RPC services are accessible by their proto package and name.
171        The target for these can be set with the set_target function.
172
173        Args:
174          client_info: ClientInfo objects that represent the clients this
175              console uses to communicate with other devices
176          default_client: default client object; must be one of the clients
177          protos: protobufs to use for RPCs for all clients
178          help_header: Message to display for the help command
179        """
180        assert client_info, 'At least one client must be provided!'
181
182        self.client_info = client_info
183        self.current_client = default_client
184        self.protos = protos
185
186        # Store objects with references to RPC services, sorted by package.
187        self._services: Dict[str, types.SimpleNamespace] = defaultdict(
188            types.SimpleNamespace)
189
190        self._variables: Dict[str, object] = dict(
191            Status=pw_status.Status,
192            set_target=functions.help_as_repr(self.set_target),
193            # The original built-in help function is available as 'python_help'.
194            python_help=help,
195        )
196
197        # Make the RPC clients and protos available in the console.
198        self._variables.update((c.name, c.client) for c in self.client_info)
199
200        # Make the proto package hierarchy directly available in the console.
201        for package in self.protos.packages:
202            self._variables[package._package] = package  # pylint: disable=protected-access
203
204        # Monkey patch the message types to use an improved repr function.
205        for message_type in self.protos.messages():
206            message_type.__repr__ = python_protos.proto_repr
207
208        # Set up the 'help' command.
209        all_methods = chain.from_iterable(c.rpc_client.methods()
210                                          for c in self.client_info)
211        self._helper = CommandHelper.from_methods(
212            all_methods, self._variables, help_header,
213            'Type a command and hit Enter to see detailed help information.')
214
215        self._variables['help'] = self._helper
216
217        # Call set_target to set up for the default target.
218        self.set_target(self.current_client)
219
220    def flattened_rpc_completions(self):
221        """Create a flattened list of rpc commands for repl auto-completion."""
222        return flattened_rpc_completions(self.client_info)
223
224    def variables(self) -> Dict[str, Any]:
225        """Returns a mapping of names to variables for use in an RPC console."""
226        return self._variables
227
228    def set_target(self,
229                   selected_client: object,
230                   channel_id: int = None) -> None:
231        """Sets the default target for commands."""
232        # Make sure the variable is one of the client variables.
233        name = ''
234        rpc_client: Any = None
235
236        for name, client, rpc_client in self.client_info:
237            if selected_client is client:
238                print('CURRENT RPC TARGET:', name)
239                break
240        else:
241            raise ValueError('Supported targets :' +
242                             ', '.join(c.name for c in self.client_info))
243
244        # Update the RPC services to use the newly selected target.
245        for service_client in rpc_client.channel(channel_id).rpcs:
246            # Patch all method protos to use the improved __repr__ function too.
247            for method in (m.method for m in service_client):
248                method.request_type.__repr__ = python_protos.proto_repr
249                method.response_type.__repr__ = python_protos.proto_repr
250
251            service = service_client._service  # pylint: disable=protected-access
252            setattr(self._services[service.package], service.name,
253                    service_client)
254
255        # Add the RPC methods to their proto packages.
256        for package_name, rpcs in self._services.items():
257            self.protos.packages[package_name]._add_item(rpcs)  # pylint: disable=protected-access
258
259        self.current_client = selected_client
260
261
262def _create_command_alias(command: Any, name: str, message: str) -> object:
263    """Wraps __call__, __getattr__, and __repr__ to print a message."""
264    @functools.wraps(command.__call__)
265    def print_message_and_call(_, *args, **kwargs):
266        print(message)
267        return command(*args, **kwargs)
268
269    def getattr_and_print_message(_, name: str) -> Any:
270        attr = getattr(command, name)
271        print(message)
272        return attr
273
274    return type(
275        name, (),
276        dict(__call__=print_message_and_call,
277             __getattr__=getattr_and_print_message,
278             __repr__=lambda _: message))()
279
280
281def _access_in_dict_or_namespace(item, name: str, create_if_missing: bool):
282    """Gets name as either a key or attribute on item."""
283    try:
284        return item[name]
285    except KeyError:
286        if create_if_missing:
287            try:
288                item[name] = types.SimpleNamespace()
289                return item[name]
290            except TypeError:
291                pass
292    except TypeError:
293        pass
294
295    if create_if_missing and not hasattr(item, name):
296        setattr(item, name, types.SimpleNamespace())
297
298    return getattr(item, name)
299
300
301def _access_names(item, names: Iterable[str], create_if_missing: bool):
302    for name in names:
303        item = _access_in_dict_or_namespace(item, name, create_if_missing)
304
305    return item
306
307
308def alias_deprecated_command(variables: Any, old_name: str,
309                             new_name: str) -> None:
310    """Adds an alias for an old command that redirects to the new command.
311
312    The deprecated command prints a message then invokes the new command.
313    """
314    # Get the new command.
315    item = _access_names(variables,
316                         new_name.split('.'),
317                         create_if_missing=False)
318
319    # Create a wrapper to the new comamnd with the old name.
320    wrapper = _create_command_alias(
321        item, old_name,
322        f'WARNING: {old_name} is DEPRECATED; use {new_name} instead')
323
324    # Add the wrapper to the variables with the old command's name.
325    name_parts = old_name.split('.')
326    item = _access_names(variables, name_parts[:-1], create_if_missing=True)
327    setattr(item, name_parts[-1], wrapper)
328