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