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