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