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