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