• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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