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"""pw_console embed class.""" 15 16import asyncio 17import logging 18from pathlib import Path 19from typing import Any, Dict, List, Iterable, Optional, Tuple, Union 20 21from prompt_toolkit.completion import WordCompleter 22 23from pw_console.console_app import ConsoleApp 24from pw_console.get_pw_console_app import PW_CONSOLE_APP_CONTEXTVAR 25from pw_console.plugin_mixin import PluginMixin 26from pw_console.python_logging import ( 27 setup_python_logging as pw_console_setup_python_logging, 28) 29from pw_console.widgets import ( 30 FloatingWindowPane, 31 WindowPane, 32 WindowPaneToolbar, 33) 34 35 36def _set_console_app_instance(plugin: Any, console_app: ConsoleApp) -> None: 37 if hasattr(plugin, 'pw_console_init'): 38 plugin.pw_console_init(console_app) 39 else: 40 plugin.application = console_app 41 42 43class PwConsoleEmbed: 44 """Embed class for customizing the console before startup.""" 45 46 # pylint: disable=too-many-instance-attributes 47 def __init__( 48 self, 49 global_vars=None, 50 local_vars=None, 51 loggers: Optional[ 52 Union[Dict[str, Iterable[logging.Logger]], Iterable] 53 ] = None, 54 test_mode=False, 55 repl_startup_message: Optional[str] = None, 56 help_text: Optional[str] = None, 57 app_title: Optional[str] = None, 58 config_file_path: Optional[Union[str, Path]] = None, 59 ) -> None: 60 """Call this to embed pw console at the call point within your program. 61 62 Example usage: 63 64 .. code-block:: python 65 66 import logging 67 68 from pw_console import PwConsoleEmbed 69 70 # Create the pw_console embed instance 71 console = PwConsoleEmbed( 72 global_vars=globals(), 73 local_vars=locals(), 74 loggers={ 75 'Host Logs': [ 76 logging.getLogger(__package__), 77 logging.getLogger(__name__), 78 ], 79 'Device Logs': [ 80 logging.getLogger('usb_gadget'), 81 ], 82 }, 83 app_title='My Awesome Console', 84 config_file_path='/home/user/project/.pw_console.yaml', 85 ) 86 # Optional: Add custom completions 87 console.add_sentence_completer( 88 { 89 'some_function', 'Function', 90 'some_variable', 'Variable', 91 } 92 ) 93 94 # Setup Python loggers to output to a file instead of STDOUT. 95 console.setup_python_logging() 96 97 # Then run the console with: 98 console.embed() 99 100 Args: 101 global_vars: Dictionary representing the desired global symbol 102 table. Similar to what is returned by `globals()`. 103 local_vars: Dictionary representing the desired local symbol 104 table. Similar to what is returned by `locals()`. 105 loggers: Dict with keys of log window titles and values of either: 106 107 1. List of `logging.getLogger() 108 <https://docs.python.org/3/library/logging.html#logging.getLogger>`_ 109 instances. 110 2. A single pw_console.log_store.LogStore instance. 111 112 app_title: Custom title text displayed in the user interface. 113 repl_startup_message: Custom text shown by default in the repl 114 output pane. 115 help_text: Custom text shown at the top of the help window before 116 keyboard shortcuts. 117 config_file_path: Path to a pw_console yaml config file. 118 """ 119 120 self.global_vars = global_vars 121 self.local_vars = local_vars 122 self.loggers = loggers 123 self.test_mode = test_mode 124 self.repl_startup_message = repl_startup_message 125 self.help_text = help_text 126 self.app_title = app_title 127 self.config_file_path = ( 128 Path(config_file_path) if config_file_path else None 129 ) 130 131 self.console_app: Optional[ConsoleApp] = None 132 self.extra_completers: List = [] 133 134 self.setup_python_logging_called = False 135 self.hidden_by_default_windows: List[str] = [] 136 self.window_plugins: List[WindowPane] = [] 137 self.floating_window_plugins: List[Tuple[FloatingWindowPane, Dict]] = [] 138 self.top_toolbar_plugins: List[WindowPaneToolbar] = [] 139 self.bottom_toolbar_plugins: List[WindowPaneToolbar] = [] 140 141 def add_window_plugin(self, window_pane: WindowPane) -> None: 142 """Include a custom window pane plugin. 143 144 Args: 145 window_pane: Any instance of the WindowPane class. 146 """ 147 self.window_plugins.append(window_pane) 148 149 def add_floating_window_plugin( 150 self, window_pane: FloatingWindowPane, **float_args 151 ) -> None: 152 """Include a custom floating window pane plugin. 153 154 This adds a FloatingWindowPane class to the pw_console UI. The first 155 argument should be the window to add and the remaining keyword arguments 156 are passed to the prompt_toolkit Float() class. This allows positioning 157 of the floating window. By default the floating window will be 158 centered. To anchor the window to a side or corner of the screen set the 159 ``left``, ``right``, ``top``, or ``bottom`` keyword args. 160 161 For example: 162 163 .. code-block:: python 164 165 from pw_console import PwConsoleEmbed 166 167 console = PwConsoleEmbed(...) 168 my_plugin = MyPlugin() 169 # Anchor this floating window 2 rows away from the top and 4 columns 170 # away from the left edge of the screen. 171 console.add_floating_window_plugin(my_plugin, top=2, left=4) 172 173 See all possible keyword args in the prompt_toolkit documentation: 174 https://python-prompt-toolkit.readthedocs.io/en/stable/pages/reference.html#prompt_toolkit.layout.Float 175 176 Args: 177 window_pane: Any instance of the FloatingWindowPane class. 178 left: Distance to the left edge of the screen 179 right: Distance to the right edge of the screen 180 top: Distance to the top edge of the screen 181 bottom: Distance to the bottom edge of the screen 182 """ 183 self.floating_window_plugins.append((window_pane, float_args)) 184 185 def add_top_toolbar(self, toolbar: WindowPaneToolbar) -> None: 186 """Include a toolbar plugin to display on the top of the screen. 187 188 Top toolbars appear above all window panes and just below the main menu 189 bar. They span the full width of the screen. 190 191 Args: 192 toolbar: Instance of the WindowPaneToolbar class. 193 """ 194 self.top_toolbar_plugins.append(toolbar) 195 196 def add_bottom_toolbar(self, toolbar: WindowPaneToolbar) -> None: 197 """Include a toolbar plugin to display at the bottom of the screen. 198 199 Bottom toolbars appear below all window panes and span the full width of 200 the screen. 201 202 Args: 203 toolbar: Instance of the WindowPaneToolbar class. 204 """ 205 self.bottom_toolbar_plugins.append(toolbar) 206 207 def add_sentence_completer( 208 self, word_meta_dict: Dict[str, str], ignore_case=True 209 ) -> None: 210 """Include a custom completer that matches on the entire repl input. 211 212 Args: 213 word_meta_dict: Dictionary representing the sentence completions 214 and descriptions. Keys are completion text, values are 215 descriptions. 216 """ 217 218 # Don't modify completion if empty. 219 if len(word_meta_dict) == 0: 220 return 221 222 sentences: List[str] = list(word_meta_dict.keys()) 223 word_completer = WordCompleter( 224 sentences, 225 meta_dict=word_meta_dict, 226 ignore_case=ignore_case, 227 # Whole input field should match 228 sentence=True, 229 ) 230 231 self.extra_completers.append(word_completer) 232 233 def _setup_log_panes(self) -> None: 234 """Add loggers to ConsoleApp log pane(s).""" 235 if not self.loggers: 236 return 237 238 assert isinstance(self.console_app, ConsoleApp) 239 240 if isinstance(self.loggers, list): 241 self.console_app.add_log_handler('Logs', self.loggers) 242 243 elif isinstance(self.loggers, dict): 244 for window_title, logger_instances in self.loggers.items(): 245 window_pane = self.console_app.add_log_handler( 246 window_title, logger_instances 247 ) 248 249 if ( 250 window_pane 251 and window_pane.pane_title() 252 in self.hidden_by_default_windows 253 ): 254 window_pane.show_pane = False 255 256 def setup_python_logging( 257 self, 258 last_resort_filename: Optional[str] = None, 259 loggers_with_no_propagation: Optional[Iterable[logging.Logger]] = None, 260 ) -> None: 261 """Setup friendly logging for full-screen prompt_toolkit applications. 262 263 This function sets up Python log handlers to be friendly for full-screen 264 prompt_toolkit applications. That is, logging to terminal STDOUT and 265 STDERR is disabled so the terminal user interface can be drawn. 266 267 Specifically, all Python STDOUT and STDERR log handlers are 268 disabled. It also sets `log propagation to True 269 <https://docs.python.org/3/library/logging.html#logging.Logger.propagate>`_. 270 to ensure that all log messages are sent to the root logger. 271 272 Args: 273 last_resort_filename: If specified use this file as a fallback for 274 unhandled Python logging messages. Normally Python will output 275 any log messages with no handlers to STDERR as a fallback. If 276 None, a temp file will be created instead. See Python 277 documentation on `logging.lastResort 278 <https://docs.python.org/3/library/logging.html#logging.lastResort>`_ 279 for more info. 280 loggers_with_no_propagation: List of logger instances to skip 281 setting ``propagate = True``. This is useful if you would like 282 log messages from a particular source to not appear in the root 283 logger. 284 """ 285 self.setup_python_logging_called = True 286 pw_console_setup_python_logging( 287 last_resort_filename, loggers_with_no_propagation 288 ) 289 290 def hide_windows(self, *window_titles) -> None: 291 """Hide window panes specified by title on console startup.""" 292 for window_title in window_titles: 293 self.hidden_by_default_windows.append(window_title) 294 295 def embed(self, override_window_config: Optional[Dict] = None) -> None: 296 """Start the console.""" 297 298 # Create the ConsoleApp instance. 299 self.console_app = ConsoleApp( 300 global_vars=self.global_vars, 301 local_vars=self.local_vars, 302 repl_startup_message=self.repl_startup_message, 303 help_text=self.help_text, 304 app_title=self.app_title, 305 extra_completers=self.extra_completers, 306 floating_window_plugins=self.floating_window_plugins, 307 ) 308 PW_CONSOLE_APP_CONTEXTVAR.set(self.console_app) # type: ignore 309 # Setup Python logging and log panes. 310 if not self.setup_python_logging_called: 311 self.setup_python_logging() 312 self._setup_log_panes() 313 314 # Add window pane plugins to the layout. 315 for window_pane in self.window_plugins: 316 _set_console_app_instance(window_pane, self.console_app) 317 # Hide window plugins if the title is hidden by default. 318 if window_pane.pane_title() in self.hidden_by_default_windows: 319 window_pane.show_pane = False 320 self.console_app.window_manager.add_pane(window_pane) 321 322 # Add toolbar plugins to the layout. 323 for toolbar in self.top_toolbar_plugins: 324 _set_console_app_instance(toolbar, self.console_app) 325 self.console_app.window_manager.add_top_toolbar(toolbar) 326 for toolbar in self.bottom_toolbar_plugins: 327 _set_console_app_instance(toolbar, self.console_app) 328 self.console_app.window_manager.add_bottom_toolbar(toolbar) 329 330 # Init floating window plugins. 331 for floating_window, _ in self.floating_window_plugins: 332 _set_console_app_instance(floating_window, self.console_app) 333 334 # Rebuild prompt_toolkit containers, menu items, and help content with 335 # any new plugins added above. 336 self.console_app.refresh_layout() 337 338 # Load external config if passed in. 339 if self.config_file_path: 340 self.console_app.load_clean_config(self.config_file_path) 341 342 if override_window_config: 343 self.console_app.prefs.set_windows(override_window_config) 344 self.console_app.apply_window_config() 345 346 # Hide the repl pane if it's in the hidden windows list. 347 if 'Python Repl' in self.hidden_by_default_windows: 348 self.console_app.repl_pane.show_pane = False 349 350 # Start a thread for running user code. 351 self.console_app.start_user_code_thread() 352 353 # Startup any background threads and tasks required by plugins. 354 for window_pane in self.window_plugins: 355 if isinstance(window_pane, PluginMixin): 356 window_pane.plugin_start() 357 for toolbar in self.bottom_toolbar_plugins: 358 if isinstance(toolbar, PluginMixin): 359 toolbar.plugin_start() 360 for toolbar in self.top_toolbar_plugins: 361 if isinstance(toolbar, PluginMixin): 362 toolbar.plugin_start() 363 364 # Start the prompt_toolkit UI app. 365 asyncio.run( 366 self.console_app.run(test_mode=self.test_mode), debug=self.test_mode 367 ) 368