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