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