]>
git.proxmox.com Git - mirror_qemu.git/blob - python/qemu/qmp/qmp_tui.py
4 # Niteesh Babu G S <niteesh.gs@gmail.com>
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.
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.
14 Example Usage: qmp-tui <SOCKET | TCP IP:PORT>
15 Full Usage: qmp-tui --help
22 from logging
import Handler
, LogRecord
33 from pygments
import lexers
34 from pygments
import token
as Token
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
46 # The name of the signal that is used to update the history list
47 UPDATE_MSG
: str = 'UPDATE_MSG'
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'),
66 def format_json(msg
: str) -> str:
68 Formats valid/invalid multi-line JSON message into a single-line message.
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.
74 Converting into single line is more asthetically pleasing when looking
75 along with error messages.
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
88 3 ]: QMP message is not a JSON object.
90 whereas in singleline mode we get the following
92 [1, true, 3]: QMP message is not a JSON object.
94 The single line mode is more asthetically pleasing.
97 The message to formatted into single line.
99 :return: Formatted singleline message.
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
)
111 def has_handler_type(logger
: logging
.Logger
,
112 handler_type
: Type
[Handler
]) -> bool:
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.
120 The type of the handler to be checked.
122 :return: returns True if handler of type `handler_type`.
124 for handler
in logger
.handlers
:
125 if isinstance(handler
, handler_type
):
130 class App(QMPClient
):
132 Implements the QMP TUI.
134 Initializes the widgets and starts the urwid event loop.
137 Address of the server to connect to.
139 The number of times to retry before stopping to reconnect.
141 The delay(sec) before each retry
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
155 def add_to_history(self
, msg
: str, level
: Optional
[str] = None) -> None:
157 Appends the msg to the history list.
160 The raw message to be appended in string type.
162 urwid
.emit_signal(self
, UPDATE_MSG
, msg
, level
)
164 def _cb_outbound(self
, msg
: Message
) -> Message
:
166 Callback: outbound message hook.
168 Appends the outgoing messages to the history box.
170 :param msg: raw outbound message.
171 :return: final outbound message.
175 if not has_handler_type(logging
.getLogger(), TUILogHandler
):
176 logging
.debug('Request: %s', str_msg
)
177 self
.add_to_history('<-- ' + str_msg
)
180 def _cb_inbound(self
, msg
: Message
) -> Message
:
182 Callback: outbound message hook.
184 Appends the incoming messages to the history box.
186 :param msg: raw inbound message.
187 :return: final inbound message.
191 if not has_handler_type(logging
.getLogger(), TUILogHandler
):
192 logging
.debug('Request: %s', str_msg
)
193 self
.add_to_history('--> ' + str_msg
)
196 async def _send_to_server(self
, msg
: Message
) -> None:
198 This coroutine sends the message to the server.
199 The message has to be pre-validated.
202 Pre-validated message to be to sent to the server.
204 :raise Exception: When an unhandled exception is caught.
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
))
215 def cb_send_to_server(self
, raw_msg
: str) -> None:
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.
222 The raw string message to be sent to the server.
224 :raise Exception: When an unhandled exception is caught.
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')
234 def unhandled_input(self
, key
: str) -> None:
236 Handle's keys which haven't been handled by the child widgets.
244 def kill_app(self
) -> None:
246 Initiates killing of app. A bridge between asynchronous and synchronous
249 create_task(self
._kill
_app
())
251 async def _kill_app(self
) -> None:
253 This coroutine initiates the actual disconnect process and calls
254 urwid.ExitMainLoop() to kill the TUI.
256 :raise Exception: When an unhandled exception is caught.
259 await self
.disconnect()
260 logging
.debug('Disconnect finished. Exiting app')
261 raise urwid
.ExitMainLoop()
263 async def disconnect(self
) -> None:
265 Overrides the disconnect method to handle the errors locally.
268 await super().disconnect()
269 except (OSError, EOFError) as err
:
270 logging
.info('disconnect: %s', str(err
))
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
))
278 def _set_status(self
, msg
: str) -> None:
280 Sets the message as the status.
283 The message to be displayed in the status bar.
285 self
.window
.footer
.set_text(msg
)
287 def _get_formatted_address(self
) -> str:
289 Returns a formatted version of the server's address.
291 :return: formatted address
293 if isinstance(self
.address
, tuple):
294 host
, port
= self
.address
295 addr
= f
'{host}:{port}'
297 addr
= f
'{self.address}'
300 async def _initiate_connection(self
) -> Optional
[ConnectError
]:
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
306 :return: Error faced during last retry.
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
)
318 await asyncio
.sleep(self
.retry_delay
)
320 err
= await self
.connect_server()
322 # If all retries failed report the last error
324 logging
.info('All retries failed: %s', err
)
328 async def manage_connection(self
) -> None:
330 Manage the connection based on the current run state.
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.
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.
341 self
._set
_status
(f
'[Error: {err.error_message}]')
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
:
351 await self
.runstate_changed()
353 async def connect_server(self
) -> Optional
[ConnectError
]:
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.
359 await self
.connect(self
.address
)
361 except ConnectError
as err
:
362 logging
.info('connect_server: ConnectError %s', str(err
))
367 def run(self
, debug
: bool = False) -> None:
369 Starts the long running co-routines and the urwid event loop.
372 Enables/Disables asyncio event loop debugging
374 screen
= urwid
.raw_display
.Screen()
375 screen
.set_terminal_properties(256)
377 self
.aloop
= asyncio
.get_event_loop()
378 self
.aloop
.set_debug(debug
)
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
)
385 event_loop
= urwid
.AsyncioEventLoop(loop
=self
.aloop
)
386 main_loop
= urwid
.MainLoop(urwid
.AttrMap(self
.window
, 'background'),
387 unhandled_input
=self
.unhandled_input
,
391 event_loop
=event_loop
)
393 create_task(self
.manage_connection(), self
.aloop
)
396 except Exception as err
:
397 logging
.error('%s\n%s\n', str(err
), pretty_traceback())
401 class StatusBar(urwid
.Text
):
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.
406 :param text: Initial text to be displayed. Default is empty str.
408 def __init__(self
, text
: str = ''):
409 super().__init
__(text
, align
='right')
412 class Editor(urwid_readline
.ReadlineEdit
):
414 A simple editor modelled using the urwid_readline.ReadlineEdit widget.
415 Mimcs GNU readline shortcuts and provides history support.
417 The readline shortcuts can be found below:
418 https://github.com/rr-/urwid_readline#features
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.
424 Currently there is no support to save the history to a file. The history of
425 previous commands is lost on exit.
427 :param parent: Reference to the TUI object.
429 def __init__(self
, parent
: App
) -> None:
430 super().__init
__(caption
='> ', multiline
=True)
432 self
.history
: List
[str] = []
433 self
.last_index
: int = -1
434 self
.show_history
: bool = False
436 def keypress(self
, size
: Tuple
[int, int], key
: str) -> Optional
[str]:
438 Handles the keypress on this widget.
441 The current size of the widget.
443 The key to be handled.
445 :return: Unhandled key if any.
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.
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
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('')
477 self
.show_history
= False
479 self
.show_history
= False
481 return cast(Optional
[str], super().keypress(size
, key
))
485 class EditorWidget(urwid
.Filler
):
487 Wrapper around the editor widget.
489 The Editor is a flow widget and has to wrapped inside a box widget.
490 This class wraps the Editor inside filler widget.
492 :param parent: Reference to the TUI object.
494 def __init__(self
, parent
: App
) -> None:
495 super().__init
__(Editor(parent
), valign
='top')
498 class HistoryBox(urwid
.ListBox
):
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.
503 The messages are urwid.Text widgets. On every append of a message, the
504 focus is shifted to the last appended message.
506 :param parent: Reference to the TUI object.
508 def __init__(self
, parent
: App
) -> None:
510 self
.history
= urwid
.SimpleFocusListWalker([])
511 super().__init
__(self
.history
)
513 def add_to_history(self
,
514 history
: Union
[str, List
[Tuple
[str, str]]]) -> None:
516 Appends a message to the list and set the focus to the last appended
520 The history item(message/event) to be appended to the list.
522 self
.history
.append(urwid
.Text(history
))
523 self
.history
.set_focus(len(self
.history
) - 1)
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
529 if button
== 4: # Scroll up event
530 super().keypress(size
, 'up')
531 elif button
== 5: # Scroll down event
532 super().keypress(size
, 'down')
535 class HistoryWindow(urwid
.Frame
):
537 This window composes the HistoryBox and EditorWidget in a horizontal split.
538 By default the first focus is given to the history box.
540 :param parent: Reference to the TUI object.
542 def __init__(self
, parent
: App
) -> None:
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
)
552 def cb_add_to_history(self
, msg
: str, level
: Optional
[str] = None) -> None:
554 Appends a message to the history box
557 The message to be appended to the history box.
559 The log level of the message, if it is a log message.
563 msg
= f
'[{level}]: {msg}'
564 formatted
.append((level
, msg
))
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
)
572 class Window(urwid
.Frame
):
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
578 :param parent: Reference to the TUI object.
580 def __init__(self
, parent
: App
) -> None:
583 body
= HistoryWindow(parent
)
584 super().__init
__(body
, footer
=footer
)
587 class TUILogHandler(Handler
):
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.
593 :param tui: Reference to the TUI object.
595 def __init__(self
, tui
: App
) -> None:
599 def emit(self
, record
: LogRecord
) -> None:
601 Emits a record to the TUI screen.
603 Appends the log message to the TUI screen
605 level
= record
.levelname
606 msg
= record
.getMessage()
607 self
.tui
.add_to_history(msg
, level
)
612 Driver of the whole script, parses arguments, initialize the TUI and
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()
634 address
= QEMUMonitorProtocol
.parse_address(args
.qmp_server
)
635 except QMPBadPortError
as err
:
636 parser
.error(str(err
))
638 app
= App(address
, args
.num_retries
, args
.retry_delay
)
640 root_logger
= logging
.getLogger()
641 root_logger
.setLevel(logging
.getLevelName(args
.log_level
))
644 root_logger
.addHandler(logging
.FileHandler(args
.log_file
))
646 root_logger
.addHandler(TUILogHandler(app
))
648 app
.run(args
.asyncio_debug
)
651 if __name__
== '__main__':