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