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 preferences""" 15 16from __future__ import annotations 17 18import dataclasses 19import os 20from pathlib import Path 21from typing import Callable 22 23from prompt_toolkit.key_binding import KeyBindings 24import yaml 25 26from pw_config_loader.yaml_config_loader_mixin import YamlConfigLoaderMixin 27 28from pw_console.style import get_theme_colors, generate_styles 29from pw_console.key_bindings import DEFAULT_KEY_BINDINGS 30 31_DEFAULT_REPL_HISTORY: Path = Path.home() / '.pw_console_history' 32_DEFAULT_SEARCH_HISTORY: Path = Path.home() / '.pw_console_search' 33 34_DEFAULT_CONFIG = { 35 # History files 36 'repl_history': _DEFAULT_REPL_HISTORY, 37 'search_history': _DEFAULT_SEARCH_HISTORY, 38 # Appearance 39 'ui_theme': 'dark', 40 'code_theme': 'pigweed-code', 41 'swap_light_and_dark': False, 42 'spaces_between_columns': 2, 43 'column_order_omit_unspecified_columns': False, 44 'column_order': [], 45 'column_colors': {}, 46 'show_python_file': False, 47 'show_python_logger': False, 48 'show_source_file': False, 49 'hide_date_from_log_time': False, 50 'recolor_log_lines_to_match_level': True, 51 # Window arrangement 52 'windows': {}, 53 'window_column_split_method': 'vertical', 54 'command_runner': { 55 'width': 80, 56 'height': 10, 57 'position': {'top': 3}, 58 }, 59 'key_bindings': DEFAULT_KEY_BINDINGS, 60 'snippets': {}, 61 'user_snippets': {}, 62} 63 64_DEFAULT_PROJECT_FILE = Path('$PW_PROJECT_ROOT/.pw_console.yaml') 65_DEFAULT_PROJECT_USER_FILE = Path('$PW_PROJECT_ROOT/.pw_console.user.yaml') 66_DEFAULT_USER_FILE = Path('$HOME/.pw_console.yaml') 67 68 69class UnknownWindowTitle(Exception): 70 """Exception for window titles not present in the window manager layout.""" 71 72 73class EmptyWindowList(Exception): 74 """Exception for window lists with no content.""" 75 76 77class EmptyPreviousPreviousDescription(Exception): 78 """Previous snippet description is empty for 'description: USE_PREVIOUS'.""" 79 80 81@dataclasses.dataclass 82class CodeSnippet: 83 """Stores a single code snippet for inserting into the Python Repl. 84 85 Attributes: 86 87 title: The displayed title in the command runner window. 88 code: Python code text to be inserted. 89 description: Optional help text to be displayed below the snippet 90 selection window. 91 """ 92 93 title: str 94 code: str 95 description: str | None = None 96 97 @staticmethod 98 def from_yaml( 99 title: str, 100 value: str | dict, 101 previous_description: str | None = None, 102 ) -> CodeSnippet: 103 if isinstance(value, str): 104 return CodeSnippet(title=title, code=value) 105 106 assert isinstance(value, dict) 107 108 code = value.get('code', None) 109 description = value.get('description', None) 110 if description == 'USE_PREVIOUS': 111 if not previous_description: 112 raise EmptyPreviousPreviousDescription( 113 f'\nERROR: pw_console.yaml snippet "{title}" has ' 114 '"description: USE_PREVIOUS" but the previous snippet ' 115 'description is empty.' 116 ) 117 description = previous_description 118 return CodeSnippet(title=title, code=code, description=description) 119 120 121def error_unknown_window( 122 window_title: str, existing_pane_titles: list[str] 123) -> None: 124 """Raise an error when the window config has an unknown title. 125 126 If a window title does not already exist on startup it must have a loggers: 127 or duplicate_of: option set.""" 128 129 pane_title_text = ' ' + '\n '.join(existing_pane_titles) 130 existing_pane_title_example = 'Window Title' 131 if existing_pane_titles: 132 existing_pane_title_example = existing_pane_titles[0] 133 raise UnknownWindowTitle( 134 f'\n\n"{window_title}" does not exist.\n' 135 'Existing windows include:\n' 136 f'{pane_title_text}\n' 137 'If this window should be a duplicate of one of the above,\n' 138 f'add "duplicate_of: {existing_pane_title_example}" to your config.\n' 139 'If this is a brand new window, include a "loggers:" section.\n' 140 'See also: ' 141 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config' 142 ) 143 144 145def error_empty_window_list( 146 window_list_title: str, 147) -> None: 148 """Raise an error if a window list is empty.""" 149 150 raise EmptyWindowList( 151 f'\n\nError: The window layout heading "{window_list_title}" contains ' 152 'no windows.\n' 153 'See also: ' 154 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config' 155 ) 156 157 158class ConsolePrefs(YamlConfigLoaderMixin): 159 """Pigweed Console preferences storage class.""" 160 161 # pylint: disable=too-many-public-methods 162 163 def __init__( 164 self, 165 project_file: Path | bool = _DEFAULT_PROJECT_FILE, 166 project_user_file: Path | bool = _DEFAULT_PROJECT_USER_FILE, 167 user_file: Path | bool = _DEFAULT_USER_FILE, 168 ) -> None: 169 self.config_init( 170 config_section_title='pw_console', 171 project_file=project_file, 172 project_user_file=project_user_file, 173 user_file=user_file, 174 default_config=_DEFAULT_CONFIG, 175 environment_var='PW_CONSOLE_CONFIG_FILE', 176 ) 177 178 self._snippet_completions: list[CodeSnippet] = [] 179 self.registered_commands = DEFAULT_KEY_BINDINGS 180 self.registered_commands.update(self.user_key_bindings) 181 182 @property 183 def ui_theme(self) -> str: 184 return self._config.get('ui_theme', '') 185 186 def set_ui_theme(self, theme_name: str): 187 self._config['ui_theme'] = theme_name 188 189 @property 190 def theme_colors(self): 191 return get_theme_colors(self.ui_theme) 192 193 @property 194 def code_theme(self) -> str: 195 return self._config.get('code_theme', '') 196 197 def set_code_theme(self, theme_name: str): 198 self._config['code_theme'] = theme_name 199 200 @property 201 def swap_light_and_dark(self) -> bool: 202 return self._config.get('swap_light_and_dark', False) 203 204 @property 205 def repl_history(self) -> Path: 206 history = Path(self._config['repl_history']) 207 history = Path(os.path.expandvars(str(history.expanduser()))) 208 return history 209 210 @property 211 def search_history(self) -> Path: 212 history = Path(self._config['search_history']) 213 history = Path(os.path.expandvars(str(history.expanduser()))) 214 return history 215 216 @property 217 def spaces_between_columns(self) -> int: 218 spaces = self._config.get('spaces_between_columns', 2) 219 assert isinstance(spaces, int) and spaces > 0 220 return spaces 221 222 @property 223 def omit_unspecified_columns(self) -> bool: 224 return self._config.get('column_order_omit_unspecified_columns', False) 225 226 @property 227 def hide_date_from_log_time(self) -> bool: 228 return self._config.get('hide_date_from_log_time', False) 229 230 @property 231 def recolor_log_lines_to_match_level(self) -> bool: 232 return self._config.get('recolor_log_lines_to_match_level', True) 233 234 @property 235 def show_python_file(self) -> bool: 236 return self._config.get('show_python_file', False) 237 238 @property 239 def show_source_file(self) -> bool: 240 return self._config.get('show_source_file', False) 241 242 @property 243 def show_python_logger(self) -> bool: 244 return self._config.get('show_python_logger', False) 245 246 def toggle_bool_option(self, name: str): 247 existing_setting = self._config[name] 248 assert isinstance(existing_setting, bool) 249 self._config[name] = not existing_setting 250 251 @property 252 def column_order(self) -> list: 253 return self._config.get('column_order', []) 254 255 def column_style( 256 self, column_name: str, column_value: str, default='' 257 ) -> str: 258 column_colors = self._config.get('column_colors', {}) 259 column_style = default 260 261 if column_name in column_colors: 262 # If key exists but doesn't have any values. 263 if not column_colors[column_name]: 264 return default 265 # Check for user supplied default. 266 column_style = column_colors[column_name].get('default', default) 267 # Check for value specific color, otherwise use the default. 268 column_style = column_colors[column_name].get( 269 column_value, column_style 270 ) 271 return column_style 272 273 def pw_console_color_config(self) -> dict[str, dict]: 274 column_colors = self._config.get('column_colors', {}) 275 theme_styles = generate_styles(self.ui_theme) 276 style_classes = dict(theme_styles.style_rules) 277 278 color_config = {} 279 color_config['classes'] = style_classes 280 color_config['column_values'] = column_colors 281 return {'__pw_console_colors': color_config} 282 283 @property 284 def window_column_split_method(self) -> str: 285 return self._config.get('window_column_split_method', 'vertical') 286 287 @property 288 def windows(self) -> dict: 289 return self._config.get('windows', {}) 290 291 def set_windows(self, new_config: dict) -> None: 292 self._config['windows'] = new_config 293 294 @property 295 def window_column_modes(self) -> list: 296 return list(column_type for column_type in self.windows.keys()) 297 298 @property 299 def command_runner_position(self) -> dict[str, int]: 300 position = self._config.get('command_runner', {}).get( 301 'position', {'top': 3} 302 ) 303 return { 304 key: value 305 for key, value in position.items() 306 if key in ['top', 'bottom', 'left', 'right'] 307 } 308 309 @property 310 def command_runner_width(self) -> int: 311 return self._config.get('command_runner', {}).get('width', 80) 312 313 @property 314 def command_runner_height(self) -> int: 315 return self._config.get('command_runner', {}).get('height', 10) 316 317 @property 318 def user_key_bindings(self) -> dict[str, list[str]]: 319 return self._config.get('key_bindings', {}) 320 321 def current_config_as_yaml(self) -> str: 322 yaml_options = dict( 323 sort_keys=True, default_style='', default_flow_style=False 324 ) 325 326 title = {'config_title': 'pw_console'} 327 text = '\n' 328 text += yaml.safe_dump(title, **yaml_options) # type: ignore 329 330 keys = {'key_bindings': self.registered_commands} 331 text += '\n' 332 text += yaml.safe_dump(keys, **yaml_options) # type: ignore 333 334 return text 335 336 @property 337 def unique_window_titles(self) -> set: 338 titles = [] 339 for window_list_title, column in self.windows.items(): 340 if not column: 341 error_empty_window_list(window_list_title) 342 343 for window_key_title, window_dict in column.items(): 344 window_options = window_dict if window_dict else {} 345 # Use 'duplicate_of: Title' if it exists, otherwise use the key. 346 titles.append( 347 window_options.get('duplicate_of', window_key_title) 348 ) 349 return set(titles) 350 351 def get_function_keys(self, name: str) -> list: 352 """Return the keys for the named function.""" 353 try: 354 return self.registered_commands[name] 355 except KeyError as error: 356 raise KeyError('Unbound key function: {}'.format(name)) from error 357 358 def register_named_key_function( 359 self, name: str, default_bindings: list[str] 360 ) -> None: 361 self.registered_commands[name] = default_bindings 362 363 def register_keybinding( 364 self, name: str, key_bindings: KeyBindings, **kwargs 365 ) -> Callable: 366 """Apply registered keys for the given named function.""" 367 368 def decorator(handler: Callable) -> Callable: 369 "`handler` is a callable or Binding." 370 for keys in self.get_function_keys(name): 371 key_bindings.add(*keys.split(' '), **kwargs)(handler) 372 return handler 373 374 return decorator 375 376 @property 377 def snippets(self) -> dict: 378 return self._config.get('snippets', {}) 379 380 @property 381 def user_snippets(self) -> dict: 382 return self._config.get('user_snippets', {}) 383 384 def snippet_completions(self) -> list[CodeSnippet]: 385 if self._snippet_completions: 386 return self._snippet_completions 387 388 all_snippets: list[CodeSnippet] = [] 389 390 def previous_description() -> str | None: 391 if not all_snippets: 392 return None 393 return all_snippets[-1].description 394 395 for title, value in self.user_snippets.items(): 396 all_snippets.append( 397 CodeSnippet.from_yaml(title, value, previous_description()) 398 ) 399 for title, value in self.snippets.items(): 400 all_snippets.append( 401 CodeSnippet.from_yaml(title, value, previous_description()) 402 ) 403 404 self._snippet_completions = all_snippets 405 406 return self._snippet_completions 407