xref: /openbmc/qemu/python/qemu/qmp/qmp_tui.py (revision 7c9d65f9e48fe5ff27563d5415a3ec5aade7a12b)
1# Copyright (c) 2021
2#
3# Authors:
4#  Niteesh Babu G S <niteesh.gs@gmail.com>
5#
6# This work is licensed under the terms of the GNU LGPL, version 2 or
7# later.  See the COPYING file in the top-level directory.
8"""
9QMP TUI
10
11QMP TUI is an asynchronous interface built on top the of the QMP library.
12It is the successor of QMP-shell and is bought-in as a replacement for it.
13
14Example Usage: qmp-tui <SOCKET | TCP IP:PORT>
15Full Usage: qmp-tui --help
16"""
17
18import argparse
19import asyncio
20import json
21import logging
22from logging import Handler, LogRecord
23import signal
24import sys
25from typing import (
26    List,
27    Optional,
28    Tuple,
29    Type,
30    Union,
31    cast,
32)
33
34
35try:
36    from pygments import lexers
37    from pygments import token as Token
38    import urwid
39    import urwid_readline
40except ModuleNotFoundError as exc:
41    print(
42        f"Module '{exc.name}' not found.",
43        "You need the optional 'tui' group: pip install qemu.qmp[tui]",
44        sep='\n',
45        file=sys.stderr,
46    )
47    sys.exit(1)
48
49from .error import ProtocolError
50from .legacy import QEMUMonitorProtocol, QMPBadPortError
51from .message import DeserializationError, Message, UnexpectedTypeError
52from .protocol import ConnectError, Runstate
53from .qmp_client import ExecInterruptedError, QMPClient
54from .util import get_or_create_event_loop, pretty_traceback
55
56
57# The name of the signal that is used to update the history list
58UPDATE_MSG: str = 'UPDATE_MSG'
59
60
61palette = [
62    (Token.Punctuation, '', '', '', 'h15,bold', 'g7'),
63    (Token.Text, '', '', '', '', 'g7'),
64    (Token.Name.Tag, '', '', '', 'bold,#f88', 'g7'),
65    (Token.Literal.Number.Integer, '', '', '', '#fa0', 'g7'),
66    (Token.Literal.String.Double, '', '', '', '#6f6', 'g7'),
67    (Token.Keyword.Constant, '', '', '', '#6af', 'g7'),
68    ('DEBUG', '', '', '', '#ddf', 'g7'),
69    ('INFO', '', '', '', 'g100', 'g7'),
70    ('WARNING', '', '', '', '#ff6', 'g7'),
71    ('ERROR', '', '', '', '#a00', 'g7'),
72    ('CRITICAL', '', '', '', '#a00', 'g7'),
73    ('background', '', 'black', '', '', 'g7'),
74]
75
76
77def format_json(msg: str) -> str:
78    """
79    Formats valid/invalid multi-line JSON message into a single-line message.
80
81    Formatting is first tried using the standard json module. If that fails
82    due to an decoding error then a simple string manipulation is done to
83    achieve a single line JSON string.
84
85    Converting into single line is more aesthetically pleasing when looking
86    along with error messages.
87
88    Eg:
89    Input:
90          [ 1,
91            true,
92            3 ]
93    The above input is not a valid QMP message and produces the following error
94    "QMP message is not a JSON object."
95    When displaying this in TUI in multiline mode we get
96
97        [ 1,
98          true,
99          3 ]: QMP message is not a JSON object.
100
101    whereas in singleline mode we get the following
102
103        [1, true, 3]: QMP message is not a JSON object.
104
105    The single line mode is more aesthetically pleasing.
106
107    :param msg:
108        The message to formatted into single line.
109
110    :return: Formatted singleline message.
111    """
112    try:
113        msg = json.loads(msg)
114        return str(json.dumps(msg))
115    except json.decoder.JSONDecodeError:
116        msg = msg.replace('\n', '')
117        words = msg.split(' ')
118        words = list(filter(None, words))
119        return ' '.join(words)
120
121
122def has_handler_type(logger: logging.Logger,
123                     handler_type: Type[Handler]) -> bool:
124    """
125    The Logger class has no interface to check if a certain type of handler is
126    installed or not. So we provide an interface to do so.
127
128    :param logger:
129        Logger object
130    :param handler_type:
131        The type of the handler to be checked.
132
133    :return: returns True if handler of type `handler_type`.
134    """
135    for handler in logger.handlers:
136        if isinstance(handler, handler_type):
137            return True
138    return False
139
140
141class App(QMPClient):
142    """
143    Implements the QMP TUI.
144
145    Initializes the widgets and starts the urwid event loop.
146
147    :param address:
148        Address of the server to connect to.
149    :param num_retries:
150        The number of times to retry before stopping to reconnect.
151    :param retry_delay:
152        The delay(sec) before each retry
153    """
154    def __init__(self, address: Union[str, Tuple[str, int]], num_retries: int,
155                 retry_delay: Optional[int]) -> None:
156        urwid.register_signal(type(self), UPDATE_MSG)
157        self.window = Window(self)
158        self.address = address
159        self.aloop: Optional[asyncio.AbstractEventLoop] = None
160        self.num_retries = num_retries
161        self.retry_delay = retry_delay if retry_delay else 2
162        self.retry: bool = False
163        self.exiting: bool = False
164        super().__init__()
165
166    def add_to_history(self, msg: str, level: Optional[str] = None) -> None:
167        """
168        Appends the msg to the history list.
169
170        :param msg:
171            The raw message to be appended in string type.
172        """
173        urwid.emit_signal(self, UPDATE_MSG, msg, level)
174
175    def _cb_outbound(self, msg: Message) -> Message:
176        """
177        Callback: outbound message hook.
178
179        Appends the outgoing messages to the history box.
180
181        :param msg: raw outbound message.
182        :return: final outbound message.
183        """
184        str_msg = str(msg)
185
186        if not has_handler_type(logging.getLogger(), TUILogHandler):
187            logging.debug('Request: %s', str_msg)
188        self.add_to_history('<-- ' + str_msg)
189        return msg
190
191    def _cb_inbound(self, msg: Message) -> Message:
192        """
193        Callback: outbound message hook.
194
195        Appends the incoming messages to the history box.
196
197        :param msg: raw inbound message.
198        :return: final inbound message.
199        """
200        str_msg = str(msg)
201
202        if not has_handler_type(logging.getLogger(), TUILogHandler):
203            logging.debug('Request: %s', str_msg)
204        self.add_to_history('--> ' + str_msg)
205        return msg
206
207    async def _send_to_server(self, msg: Message) -> None:
208        """
209        This coroutine sends the message to the server.
210        The message has to be pre-validated.
211
212        :param msg:
213            Pre-validated message to be to sent to the server.
214
215        :raise Exception: When an unhandled exception is caught.
216        """
217        try:
218            await self._raw(msg, assign_id='id' not in msg)
219        except ExecInterruptedError as err:
220            logging.info('Error server disconnected before reply %s', str(err))
221            self.add_to_history('Server disconnected before reply', 'ERROR')
222        except Exception as err:
223            logging.error('Exception from _send_to_server: %s', str(err))
224            raise err
225
226    def cb_send_to_server(self, raw_msg: str) -> None:
227        """
228        Validates and sends the message to the server.
229        The raw string message is first converted into a Message object
230        and is then sent to the server.
231
232        :param raw_msg:
233            The raw string message to be sent to the server.
234
235        :raise Exception: When an unhandled exception is caught.
236        """
237        try:
238            msg = Message(bytes(raw_msg, encoding='utf-8'))
239            asyncio.create_task(self._send_to_server(msg))
240        except (DeserializationError, UnexpectedTypeError) as err:
241            raw_msg = format_json(raw_msg)
242            logging.info('Invalid message: %s', err.error_message)
243            self.add_to_history(f'{raw_msg}: {err.error_message}', 'ERROR')
244
245    def unhandled_input(self, key: str) -> None:
246        """
247        Handle's keys which haven't been handled by the child widgets.
248
249        :param key:
250            Unhandled key
251        """
252        if key == 'esc':
253            self.kill_app()
254
255    def kill_app(self) -> None:
256        """
257        Initiates killing of app. A bridge between asynchronous and synchronous
258        code.
259        """
260        asyncio.create_task(self._kill_app())
261
262    async def _kill_app(self) -> None:
263        """
264        This coroutine initiates the actual disconnect process and calls
265        urwid.ExitMainLoop() to kill the TUI.
266
267        :raise Exception: When an unhandled exception is caught.
268        """
269        self.exiting = True
270        await self.disconnect()
271        logging.debug('Disconnect finished. Exiting app')
272        raise urwid.ExitMainLoop()
273
274    async def disconnect(self) -> None:
275        """
276        Overrides the disconnect method to handle the errors locally.
277        """
278        try:
279            await super().disconnect()
280        except (OSError, EOFError) as err:
281            logging.info('disconnect: %s', str(err))
282            self.retry = True
283        except ProtocolError as err:
284            logging.info('disconnect: %s', str(err))
285        except Exception as err:
286            logging.error('disconnect: Unhandled exception %s', str(err))
287            raise err
288
289    def _set_status(self, msg: str) -> None:
290        """
291        Sets the message as the status.
292
293        :param msg:
294            The message to be displayed in the status bar.
295        """
296        self.window.footer.set_text(msg)
297
298    def _get_formatted_address(self) -> str:
299        """
300        Returns a formatted version of the server's address.
301
302        :return: formatted address
303        """
304        if isinstance(self.address, tuple):
305            host, port = self.address
306            addr = f'{host}:{port}'
307        else:
308            addr = f'{self.address}'
309        return addr
310
311    async def _initiate_connection(self) -> Optional[ConnectError]:
312        """
313        Tries connecting to a server a number of times with a delay between
314        each try. If all retries failed then return the error faced during
315        the last retry.
316
317        :return: Error faced during last retry.
318        """
319        current_retries = 0
320        err = None
321
322        # initial try
323        await self.connect_server()
324        while self.retry and current_retries < self.num_retries:
325            logging.info('Connection Failed, retrying in %d', self.retry_delay)
326            status = f'[Retry #{current_retries} ({self.retry_delay}s)]'
327            self._set_status(status)
328
329            await asyncio.sleep(self.retry_delay)
330
331            err = await self.connect_server()
332            current_retries += 1
333        # If all retries failed report the last error
334        if err:
335            logging.info('All retries failed: %s', err)
336            return err
337        return None
338
339    async def manage_connection(self) -> None:
340        """
341        Manage the connection based on the current run state.
342
343        A reconnect is issued when the current state is IDLE and the number
344        of retries is not exhausted.
345        A disconnect is issued when the current state is DISCONNECTING.
346        """
347        while not self.exiting:
348            if self.runstate == Runstate.IDLE:
349                err = await self._initiate_connection()
350                # If retry is still true then, we have exhausted all our tries.
351                if err:
352                    self._set_status(f'[Error: {err.error_message}]')
353                else:
354                    addr = self._get_formatted_address()
355                    self._set_status(f'[Connected {addr}]')
356            elif self.runstate == Runstate.DISCONNECTING:
357                self._set_status('[Disconnected]')
358                await self.disconnect()
359                # check if a retry is needed
360                # mypy 1.4.0 doesn't believe runstate can change after
361                # disconnect(), hence the cast.
362                state = cast(Runstate, self.runstate)
363                if state == Runstate.IDLE:
364                    continue
365            await self.runstate_changed()
366
367    async def connect_server(self) -> Optional[ConnectError]:
368        """
369        Initiates a connection to the server at address `self.address`
370        and in case of a failure, sets the status to the respective error.
371        """
372        try:
373            await self.connect(self.address)
374            self.retry = False
375        except ConnectError as err:
376            logging.info('connect_server: ConnectError %s', str(err))
377            self.retry = True
378            return err
379        return None
380
381    def run(self, debug: bool = False) -> None:
382        """
383        Starts the long running co-routines and the urwid event loop.
384
385        :param debug:
386            Enables/Disables asyncio event loop debugging
387        """
388        screen = urwid.raw_display.Screen()
389        screen.set_terminal_properties(256)
390        self.aloop = get_or_create_event_loop()
391        self.aloop.set_debug(debug)
392
393        # Gracefully handle SIGTERM and SIGINT signals
394        cancel_signals = [signal.SIGTERM, signal.SIGINT]
395        for sig in cancel_signals:
396            self.aloop.add_signal_handler(sig, self.kill_app)
397
398        event_loop = urwid.AsyncioEventLoop(loop=self.aloop)
399        main_loop = urwid.MainLoop(urwid.AttrMap(self.window, 'background'),
400                                   unhandled_input=self.unhandled_input,
401                                   screen=screen,
402                                   palette=palette,
403                                   handle_mouse=True,
404                                   event_loop=event_loop)
405
406        self.aloop.create_task(self.manage_connection())
407        try:
408            main_loop.run()
409        except Exception as err:
410            logging.error('%s\n%s\n', str(err), pretty_traceback())
411            raise err
412
413
414class StatusBar(urwid.Text):
415    """
416    A simple statusbar modelled using the Text widget. The status can be
417    set using the set_text function. All text set is aligned to right.
418
419    :param text: Initial text to be displayed. Default is empty str.
420    """
421    def __init__(self, text: str = ''):
422        super().__init__(text, align='right')
423
424
425class Editor(urwid_readline.ReadlineEdit):
426    """
427    A simple editor modelled using the urwid_readline.ReadlineEdit widget.
428    Mimcs GNU readline shortcuts and provides history support.
429
430    The readline shortcuts can be found below:
431    https://github.com/rr-/urwid_readline#features
432
433    Along with the readline features, this editor also has support for
434    history. Pressing the 'up'/'down' switches between the prev/next messages
435    available in the history.
436
437    Currently there is no support to save the history to a file. The history of
438    previous commands is lost on exit.
439
440    :param parent: Reference to the TUI object.
441    """
442    def __init__(self, parent: App) -> None:
443        super().__init__(caption='> ', multiline=True)
444        self.parent = parent
445        self.history: List[str] = []
446        self.last_index: int = -1
447        self.show_history: bool = False
448
449    def keypress(self, size: Tuple[int, int], key: str) -> Optional[str]:
450        """
451        Handles the keypress on this widget.
452
453        :param size:
454            The current size of the widget.
455        :param key:
456            The key to be handled.
457
458        :return: Unhandled key if any.
459        """
460        msg = self.get_edit_text()
461        if key == 'up' and not msg:
462            # Show the history when 'up arrow' is pressed with no input text.
463            # NOTE: The show_history logic is necessary because in 'multiline'
464            # mode (which we use) 'up arrow' is used to move between lines.
465            if not self.history:
466                return None
467            self.show_history = True
468            last_msg = self.history[self.last_index]
469            self.set_edit_text(last_msg)
470            self.edit_pos = len(last_msg)
471        elif key == 'up' and self.show_history:
472            self.last_index = max(self.last_index - 1, -len(self.history))
473            self.set_edit_text(self.history[self.last_index])
474            self.edit_pos = len(self.history[self.last_index])
475        elif key == 'down' and self.show_history:
476            if self.last_index == -1:
477                self.set_edit_text('')
478                self.show_history = False
479            else:
480                self.last_index += 1
481                self.set_edit_text(self.history[self.last_index])
482                self.edit_pos = len(self.history[self.last_index])
483        elif key == 'meta enter':
484            # When using multiline, enter inserts a new line into the editor
485            # send the input to the server on alt + enter
486            self.parent.cb_send_to_server(msg)
487            self.history.append(msg)
488            self.set_edit_text('')
489            self.last_index = -1
490            self.show_history = False
491        else:
492            self.show_history = False
493            self.last_index = -1
494            return cast(Optional[str], super().keypress(size, key))
495        return None
496
497
498class EditorWidget(urwid.Filler):
499    """
500    Wrapper around the editor widget.
501
502    The Editor is a flow widget and has to wrapped inside a box widget.
503    This class wraps the Editor inside filler widget.
504
505    :param parent: Reference to the TUI object.
506    """
507    def __init__(self, parent: App) -> None:
508        super().__init__(Editor(parent), valign='top')
509
510
511class HistoryBox(urwid.ListBox):
512    """
513    This widget is modelled using the ListBox widget, contains the list of
514    all messages both QMP messages and log messages to be shown in the TUI.
515
516    The messages are urwid.Text widgets. On every append of a message, the
517    focus is shifted to the last appended message.
518
519    :param parent: Reference to the TUI object.
520    """
521    def __init__(self, parent: App) -> None:
522        self.parent = parent
523        self.history = urwid.SimpleFocusListWalker([])
524        super().__init__(self.history)
525
526    def add_to_history(self,
527                       history: Union[str, List[Tuple[str, str]]]) -> None:
528        """
529        Appends a message to the list and set the focus to the last appended
530        message.
531
532        :param history:
533            The history item(message/event) to be appended to the list.
534        """
535        self.history.append(urwid.Text(history))
536        self.history.set_focus(len(self.history) - 1)
537
538    def mouse_event(self, size: Tuple[int, int], _event: str, button: float,
539                    _x: int, _y: int, focus: bool) -> None:
540        # Unfortunately there are no urwid constants that represent the mouse
541        # events.
542        if button == 4:  # Scroll up event
543            super().keypress(size, 'up')
544        elif button == 5:  # Scroll down event
545            super().keypress(size, 'down')
546
547
548class HistoryWindow(urwid.Frame):
549    """
550    This window composes the HistoryBox and EditorWidget in a horizontal split.
551    By default the first focus is given to the history box.
552
553    :param parent: Reference to the TUI object.
554    """
555    def __init__(self, parent: App) -> None:
556        self.parent = parent
557        self.editor_widget = EditorWidget(parent)
558        self.editor = urwid.LineBox(self.editor_widget)
559        self.history = HistoryBox(parent)
560        self.body = urwid.Pile([('weight', 80, self.history),
561                                ('weight', 20, self.editor)])
562        super().__init__(self.body)
563        urwid.connect_signal(self.parent, UPDATE_MSG, self.cb_add_to_history)
564
565    def cb_add_to_history(self, msg: str, level: Optional[str] = None) -> None:
566        """
567        Appends a message to the history box
568
569        :param msg:
570            The message to be appended to the history box.
571        :param level:
572            The log level of the message, if it is a log message.
573        """
574        formatted = []
575        if level:
576            msg = f'[{level}]: {msg}'
577            formatted.append((level, msg))
578        else:
579            lexer = lexers.JsonLexer()  # pylint: disable=no-member
580            for token in lexer.get_tokens(msg):
581                formatted.append(token)
582        self.history.add_to_history(formatted)
583
584
585class Window(urwid.Frame):
586    """
587    This window is the top most widget of the TUI and will contain other
588    windows. Each child of this widget is responsible for displaying a specific
589    functionality.
590
591    :param parent: Reference to the TUI object.
592    """
593    def __init__(self, parent: App) -> None:
594        self.parent = parent
595        footer = StatusBar()
596        body = HistoryWindow(parent)
597        super().__init__(body, footer=footer)
598
599
600class TUILogHandler(Handler):
601    """
602    This handler routes all the log messages to the TUI screen.
603    It is installed to the root logger to so that the log message from all
604    libraries begin used is routed to the screen.
605
606    :param tui: Reference to the TUI object.
607    """
608    def __init__(self, tui: App) -> None:
609        super().__init__()
610        self.tui = tui
611
612    def emit(self, record: LogRecord) -> None:
613        """
614        Emits a record to the TUI screen.
615
616        Appends the log message to the TUI screen
617        """
618        level = record.levelname
619        msg = record.getMessage()
620        self.tui.add_to_history(msg, level)
621
622
623def main() -> None:
624    """
625    Driver of the whole script, parses arguments, initialize the TUI and
626    the logger.
627    """
628    parser = argparse.ArgumentParser(description='QMP TUI')
629    parser.add_argument('qmp_server', help='Address of the QMP server. '
630                        'Format <UNIX socket path | TCP addr:port>')
631    parser.add_argument('--num-retries', type=int, default=10,
632                        help='Number of times to reconnect before giving up.')
633    parser.add_argument('--retry-delay', type=int,
634                        help='Time(s) to wait before next retry. '
635                        'Default action is to wait 2s between each retry.')
636    parser.add_argument('--log-file', help='The Log file name')
637    parser.add_argument('--log-level', default='WARNING',
638                        help='Log level <CRITICAL|ERROR|WARNING|INFO|DEBUG|>')
639    parser.add_argument('--asyncio-debug', action='store_true',
640                        help='Enable debug mode for asyncio loop. '
641                        'Generates lot of output, makes TUI unusable when '
642                        'logs are logged in the TUI. '
643                        'Use only when logging to a file.')
644    args = parser.parse_args()
645
646    try:
647        address = QEMUMonitorProtocol.parse_address(args.qmp_server)
648    except QMPBadPortError as err:
649        parser.error(str(err))
650
651    app = App(address, args.num_retries, args.retry_delay)
652
653    root_logger = logging.getLogger()
654    root_logger.setLevel(logging.getLevelName(args.log_level))
655
656    if args.log_file:
657        root_logger.addHandler(logging.FileHandler(args.log_file))
658    else:
659        root_logger.addHandler(TUILogHandler(app))
660
661    app.run(args.asyncio_debug)
662
663
664if __name__ == '__main__':
665    main()
666