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