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