On Tue, Aug 24, 2021 at 6:15 AM Niteesh G. S. <niteesh...@gmail.com> wrote:
> > > On Tue, Aug 24, 2021 at 12:30 AM John Snow <js...@redhat.com> wrote: > >> >> >> On Mon, Aug 23, 2021 at 12:31 PM G S Niteesh Babu <niteesh...@gmail.com> >> wrote: >> >>> Added AQMP TUI. >>> >>> Implements the follwing basic features: >>> 1) Command transmission/reception. >>> 2) Shows events asynchronously. >>> 3) Shows server status in the bottom status bar. >>> 4) Automatic retries on disconnects and error conditions. >>> >>> Also added type annotations and necessary pylint/mypy configurations. >>> >>> Signed-off-by: G S Niteesh Babu <niteesh...@gmail.com> >>> --- >>> python/qemu/aqmp/aqmp_tui.py | 637 +++++++++++++++++++++++++++++++++++ >>> python/setup.cfg | 13 +- >>> 2 files changed, 649 insertions(+), 1 deletion(-) >>> create mode 100644 python/qemu/aqmp/aqmp_tui.py >>> >>> diff --git a/python/qemu/aqmp/aqmp_tui.py b/python/qemu/aqmp/aqmp_tui.py >>> new file mode 100644 >>> index 0000000000..d3180e38bf >>> --- /dev/null >>> +++ b/python/qemu/aqmp/aqmp_tui.py >>> @@ -0,0 +1,637 @@ >>> +# Copyright (c) 2021 >>> +# >>> +# Authors: >>> +# Niteesh Babu G S <niteesh...@gmail.com> >>> +# >>> +# This work is licensed under the terms of the GNU GPL, version 2 or >>> +# later. See the COPYING file in the top-level directory. >>> +""" >>> +AQMP TUI >>> + >>> +AQMP TUI is an asynchronous interface built on top the of the AQMP >>> library. >>> +It is the successor of QMP-shell and is bought-in as a replacement for >>> it. >>> + >>> +Example Usage: aqmp-tui <SOCKET | TCP IP:PORT> >>> +Full Usage: aqmp-tui --help >>> +""" >>> + >>> +import argparse >>> +import asyncio >>> +import logging >>> +from logging import Handler, LogRecord >>> +import signal >>> +from typing import ( >>> + List, >>> + Optional, >>> + Tuple, >>> + Type, >>> + Union, >>> + cast, >>> +) >>> + >>> +import urwid >>> +import urwid_readline >>> + >>> +from ..qmp import QEMUMonitorProtocol, QMPBadPortError >>> +from .error import ProtocolError >>> +from .message import DeserializationError, Message, UnexpectedTypeError >>> +from .protocol import ConnectError, Runstate >>> +from .qmp_client import ExecInterruptedError, QMPClient >>> +from .util import create_task, pretty_traceback >>> + >>> + >>> +# The name of the signal that is used to update the history list >>> +UPDATE_MSG: str = 'UPDATE_MSG' >>> + >>> + >>> +def format_json(msg: str) -> str: >>> + """ >>> + Formats given multi-line JSON message into a single-line message. >>> + Converting into single line is more asthetically pleasing when >>> looking >>> + along with error messages. >>> + >>> + Eg: >>> + Input: >>> + [ 1, >>> + true, >>> + 3 ] >>> + The above input is not a valid QMP message and produces the >>> following error >>> + "QMP message is not a JSON object." >>> + When displaying this in TUI in multiline mode we get >>> + >>> + [ 1, >>> + true, >>> + 3 ]: QMP message is not a JSON object. >>> + >>> + whereas in singleline mode we get the following >>> + >>> + [1, true, 3]: QMP message is not a JSON object. >>> + >>> + The single line mode is more asthetically pleasing. >>> + >>> + :param msg: >>> + The message to formatted into single line. >>> + >>> + :return: Formatted singleline message. >>> + >>> + NOTE: We cannot use the JSON module here because it is only capable >>> of >>> + format valid JSON messages. But here the goal is to also format >>> invalid >>> + JSON messages. >>> + """ >>> + msg = msg.replace('\n', '') >>> + words = msg.split(' ') >>> + words = [word for word in words if word != ''] >>> >> >> try list(filter(None, words)) -- it's a little easier to read. >> > Thanks. Fixed. > >> >> >>> + return ' '.join(words) >>> + >>> + >>> +def has_tui_handler(logger: logging.Logger, >>> + handler_type: Type[Handler]) -> bool: >>> >> >> maybe has_handler_type(...), since you wrote something a bit more generic >> than just checking for the TUI handler. >> > Ahh yes. First I had hardcoded the TUILogHandler type but then decided to > make it more generic. > >> >> >>> + """ >>> + The Logger class has no interface to check if a certain type of >>> handler is >>> + installed or not. So we provide an interface to do so. >>> + >>> + :param logger: >>> + Logger object >>> + :param handler_type: >>> + The type of the handler to be checked. >>> + >>> + :return: returns True if handler of type `handler_type` is >>> installed else >>> + False. >>> >> >> If you wanted to fit this on one line, the "else False" is implied and >> could be omitted. >> > Omitted > >> >> >>> + """ >>> + handlers = logger.handlers >>> + for handler in handlers: >>> >> >> You could combine these lines if you wanted: for handler in >> logger.handlers: ... >> > Fixed. > >> >> >>> + if isinstance(handler, handler_type): >>> + return True >>> + return False >>> + >>> + >>> +class App(QMPClient): >>> + """ >>> + Implements the AQMP TUI. >>> + >>> + Initializes the widgets and starts the urwid event loop. >>> + """ >>> + def __init__(self, address: Union[str, Tuple[str, int]], >>> num_retries: int, >>> + retry_delay: Optional[int]) -> None: >>> + """ >>> + Initializes the TUI. >>> + >>> + :param address: >>> + Address of the server to connect to. >>> + :param num_retries: >>> + The number of times to retry before stopping to reconnect. >>> + :param retry_delay: >>> + The delay(sec) before each retry >>> + """ >>> >> >> Here and elsewhere, the init documentation can actually go into the class >> docstring. So you don't have to write stuff like "Initializes the TUI" >> everywhere. Take a look at how I do it in the rest of AQMP as a guide. >> > Changed everywhere. > >> >> >>> + urwid.register_signal(type(self), UPDATE_MSG) >>> + self.window = Window(self) >>> + self.address = address >>> + self.aloop: Optional[asyncio.AbstractEventLoop] = None >>> + self.num_retries = num_retries >>> + self.retry_delay = retry_delay if retry_delay else 2 >>> + self.retry: bool = False >>> + self.exiting: bool = False >>> + super().__init__() >>> + >>> + def add_to_history(self, msg: str, level: Optional[str] = None) -> >>> None: >>> + """ >>> + Appends the msg to the history list. >>> + >>> + :param msg: >>> + The raw message to be appended in string type. >>> + """ >>> + urwid.emit_signal(self, UPDATE_MSG, msg, level) >>> + >>> + def _cb_outbound(self, msg: Message) -> Message: >>> + """ >>> + Callback: outbound message hook. >>> + >>> + Appends the outgoing messages to the history box. >>> + >>> + :param msg: raw outbound message. >>> + :return: final outbound message. >>> + """ >>> + str_msg = str(msg) >>> + >>> + if not has_tui_handler(logging.getLogger(), TUILogHandler): >>> + logging.debug('Request: %s', str_msg) >>> + self.add_to_history('<-- ' + str_msg) >>> + return msg >>> + >>> + def _cb_inbound(self, msg: Message) -> Message: >>> + """ >>> + Callback: outbound message hook. >>> + >>> + Appends the incoming messages to the history box. >>> + >>> + :param msg: raw inbound message. >>> + :return: final inbound message. >>> + """ >>> + str_msg = str(msg) >>> + >>> + if not has_tui_handler(logging.getLogger(), TUILogHandler): >>> + logging.debug('Request: %s', str_msg) >>> + self.add_to_history('--> ' + str_msg) >>> + return msg >>> + >>> + async def _send_to_server(self, msg: Message) -> None: >>> + """ >>> + This coroutine sends the message to the server. >>> + The message has to be pre-validated. >>> + >>> + :param msg: >>> + Pre-validated message to be to sent to the server. >>> + >>> + :raise Exception: When an unhandled exception is caught. >>> + """ >>> + try: >>> + await self._raw(msg, assign_id='id' not in msg) >>> + except ExecInterruptedError as err: >>> + logging.info('Error server disconnected before reply %s', >>> str(err)) >>> + self.add_to_history('Server disconnected before reply', >>> 'ERROR') >>> + await self.disconnect() >>> >> >> In this case, the connection manager will probably already have noticed >> that we were disconnected, so you can probably omit the call to disconnect >> here. >> > Omitted. > >> >> >>> + except Exception as err: >>> + logging.error('Exception from _send_to_server: %s', >>> str(err)) >>> + raise err >>> + >>> + def cb_send_to_server(self, raw_msg: str) -> None: >>> + """ >>> + Validates and sends the message to the server. >>> + The raw string message is first converted into a Message object >>> + and is then sent to the server. >>> + >>> + :param raw_msg: >>> + The raw string message to be sent to the server. >>> + >>> + :raise Exception: When an unhandled exception is caught. >>> + """ >>> + try: >>> + raw_msg = format_json(raw_msg) >>> >> >> Technically you're processing the message -- just a little bit. I'd >> prefer to pass the raw input straight to Message(...) if we could. >> > Fixed. > >> >> >>> + msg = Message(bytes(raw_msg, encoding='utf-8')) >>> + create_task(self._send_to_server(msg)) >>> + except (ValueError, TypeError) as err: >>> + logging.info('Invalid message: %s', str(err)) >>> + self.add_to_history(f'{raw_msg}: {err}', 'ERROR') >>> + except (DeserializationError, UnexpectedTypeError) as err: >>> + logging.info('Invalid message: %s', err.error_message) >>> + self.add_to_history(f'{raw_msg}: {err.error_message}', >>> 'ERROR') >>> >> >> I see what you wanted to do here. You'd like to show a nice error message >> even when the message isn't a valid QMP message, or even valid JSON. >> > >> In the case of UnexpectedTypeError, we know it was valid JSON but not >> valid QMP -- we can still use the JSON library to format this message. >> In the case of DeserializationError, it wasn't valid JSON at all -- and >> if you want nice formatting, you need to get creative. >> >> You could probably apply your format_json() function only in the >> DeserializationError case -- that way it's only being used for a fairly >> specific purpose, and if it isn't quite so rigorously good at formatting >> JSON, it doesn't matter. You could name it format_malformed_input to >> suggest what it's used for a bit more clearly, perhaps? >> > I did a couple of more changes than what you have mentioned. > 1) Removed ValueError and TypeError - since these are possible to occur > because we use the deserialization interface of the message class. > 2) Refactored the format_json method to use the standard JSON module to > format the message incase of a valid JSON message and use the simple string > manipulation method incase of an invalid JSON message. > > >> >>> + >>> + def unhandled_input(self, key: str) -> None: >>> + """ >>> + Handle's keys which haven't been handled by the child widgets. >>> + >>> + :param key: >>> + Unhandled key >>> + """ >>> + if key == 'esc': >>> + self.kill_app() >>> + >>> + def kill_app(self) -> None: >>> + """ >>> + Initiates killing of app. A bridge between asynchronous and >>> synchronous >>> + code. >>> + """ >>> + create_task(self._kill_app()) >>> + >>> + async def _kill_app(self) -> None: >>> + """ >>> + This coroutine initiates the actual disconnect process and calls >>> + urwid.ExitMainLoop() to kill the TUI. >>> + >>> + :raise Exception: When an unhandled exception is caught. >>> + """ >>> + self.exiting = True >>> + await self.disconnect() >>> + logging.debug('Disconnect finished. Exiting app') >>> + raise urwid.ExitMainLoop() >>> + >>> + async def disconnect(self) -> None: >>> + """ >>> + Overrides the disconnect method to handle the errors locally. >>> + """ >>> + try: >>> + await super().disconnect() >>> + self.retry = False >>> + except EOFError as err: >>> + logging.info('disconnect: %s', str(err)) >>> + self.retry = True >>> + except ProtocolError as err: >>> + logging.info('disconnect: %s', str(err)) >>> + self.retry = False >>> + except Exception as err: >>> + logging.error('disconnect: Unhandled exception %s', >>> str(err)) >>> + self.retry = False >>> + raise err >>> >> >> What about for OSError problems, like ConnectionResetByPeer and so forth? >> You could probably rewrite this to be retry False by default, and then >> select the handful of cases where you know you want to retry. >> > Addressed both these comments. > >> >> >>> + >>> + def _set_status(self, msg: str) -> None: >>> + """ >>> + Sets the message as the status. >>> + >>> + :param msg: >>> + The message to be displayed in the status bar. >>> + """ >>> + self.window.footer.set_text(msg) >>> + >>> + def _get_formatted_address(self) -> str: >>> + """ >>> + Returns a formatted version of the server's address. >>> + >>> + :return: formatted address >>> + """ >>> + if isinstance(self.address, tuple): >>> + host, port = self.address >>> + addr = f'{host}:{port}' >>> + else: >>> + addr = f'{self.address}' >>> + return addr >>> + >>> + async def _initiate_connection(self) -> Optional[ConnectError]: >>> + """ >>> + Tries connecting to a server a number of times with a delay >>> between >>> + each try. If all retries failed then return the error faced >>> during >>> + the last retry. >>> + >>> + :return: Error faced during last retry. >>> + """ >>> + current_retries = 0 >>> + err = None >>> + >>> + # initial try >>> + await self.connect_server() >>> + while self.retry and current_retries < self.num_retries: >>> + logging.info('Connection Failed, retrying in %d', >>> self.retry_delay) >>> + status = f'[Retry #{current_retries} ({self.retry_delay}s)]' >>> + self._set_status(status) >>> + >>> + await asyncio.sleep(self.retry_delay) >>> + >>> + err = await self.connect_server() >>> + current_retries += 1 >>> + # If all retries failed report the last error >>> + if err: >>> + logging.info('All retries failed: %s', err) >>> + return err >>> + return None >>> + >>> + async def manage_connection(self) -> None: >>> + """ >>> + Manage the connection based on the current run state. >>> + >>> + A reconnect is issued when the current state is IDLE and the >>> number >>> + of retries is not exhausted. >>> + A disconnect is issued when the current state is DISCONNECTING. >>> + """ >>> + while not self.exiting: >>> + if self.runstate == Runstate.IDLE: >>> + err = await self._initiate_connection() >>> + # If retry is still true then, we have exhausted all >>> our tries. >>> + if err: >>> + self._set_status(f'[Error: {err.error_message}]') >>> + else: >>> + addr = self._get_formatted_address() >>> + self._set_status(f'[Connected {addr}]') >>> + elif self.runstate == Runstate.DISCONNECTING: >>> + self._set_status('[Disconnected]') >>> + await self.disconnect() >>> + # check if a retry is needed >>> + if self.runstate == Runstate.IDLE: >>> + continue >>> + await self.runstate_changed() >>> + >>> + async def connect_server(self) -> Optional[ConnectError]: >>> + """ >>> + Initiates a connection to the server at address `self.address` >>> + and in case of a failure, sets the status to the respective >>> error. >>> + """ >>> + try: >>> + await self.connect(self.address) >>> + self.retry = False >>> + except ConnectError as err: >>> + logging.info('connect_server: ConnectError %s', str(err)) >>> + self.retry = True >>> + return err >>> + return None >>> + >>> + def run(self, debug: bool = False) -> None: >>> + """ >>> + Starts the long running co-routines and the urwid event loop. >>> + >>> + :param debug: >>> + Enables/Disables asyncio event loop debugging >>> + """ >>> + self.aloop = asyncio.get_event_loop() >>> + self.aloop.set_debug(debug) >>> + >>> + # Gracefully handle SIGTERM and SIGINT signals >>> + cancel_signals = [signal.SIGTERM, signal.SIGINT] >>> + for sig in cancel_signals: >>> + self.aloop.add_signal_handler(sig, self.kill_app) >>> + >>> + event_loop = urwid.AsyncioEventLoop(loop=self.aloop) >>> + main_loop = urwid.MainLoop(urwid.AttrMap(self.window, >>> 'background'), >>> + unhandled_input=self.unhandled_input, >>> + handle_mouse=True, >>> + event_loop=event_loop) >>> + >>> + create_task(self.manage_connection(), self.aloop) >>> + try: >>> + main_loop.run() >>> + except Exception as err: >>> + logging.error('%s\n%s\n', str(err), pretty_traceback()) >>> + raise err >>> + >>> + >>> +class StatusBar(urwid.Text): >>> + """ >>> + A simple statusbar modelled using the Text widget. The status can be >>> + set using the set_text function. All text set is aligned to right. >>> + """ >>> + def __init__(self, text: str = ''): >>> + super().__init__(text, align='right') >>> + >>> + >>> +class Editor(urwid_readline.ReadlineEdit): >>> + """ >>> + A simple editor modelled using the urwid_readline.ReadlineEdit >>> widget. >>> + Mimcs GNU readline shortcuts and provides history support. >>> + >>> + The readline shortcuts can be found below: >>> + https://github.com/rr-/urwid_readline#features >>> + >>> + Along with the readline features, this editor also has support for >>> + history. Pressing the 'up' arrow key with empty message box, lists >>> the >>> + previous message inplace. >>> + >>> + Currently there is no support to save the history to a file. The >>> history of >>> + previous commands is lost on exit. >>> + """ >>> + def __init__(self, parent: App) -> None: >>> + """ >>> + Initializes the editor widget >>> + >>> + :param parent: Reference to the TUI object. >>> + """ >>> + super().__init__(caption='> ', multiline=True) >>> + self.parent = parent >>> + self.history: List[str] = [] >>> + self.last_index: int = 0 >>> + self.show_history: bool = False >>> + >>> + def keypress(self, size: Tuple[int, int], key: str) -> >>> Optional[str]: >>> + """ >>> + Handles the keypress on this widget. >>> + >>> + :param size: >>> + The current size of the widget. >>> + :param key: >>> + The key to be handled. >>> + >>> + :return: Unhandled key if any. >>> + """ >>> + msg = self.get_edit_text() >>> + if key == 'up' and not msg: >>> + # Show the history when 'up arrow' is pressed with no input >>> text. >>> + # NOTE: The show_history logic is necessary because in >>> 'multiline' >>> + # mode (which we use) 'up arrow' is used to move between >>> lines. >>> + if not self.history: >>> + return None >>> + self.show_history = True >>> + last_msg = self.history[self.last_index] >>> + self.set_edit_text(last_msg) >>> + self.edit_pos = len(last_msg) >>> + elif key == 'up' and self.show_history: >>> + self.last_index = min(self.last_index + 1, >>> len(self.history) - 1) >>> + self.set_edit_text(self.history[self.last_index]) >>> + self.edit_pos = len(self.history[self.last_index]) >>> + elif key == 'down' and self.show_history: >>> + if self.last_index == 0: >>> + self.set_edit_text('') >>> + self.show_history = False >>> + else: >>> + self.last_index -= 1 >>> + self.set_edit_text(self.history[self.last_index]) >>> + self.edit_pos = len(self.history[self.last_index]) >>> + elif key == 'meta enter': >>> + # When using multiline, enter inserts a new line into the >>> editor >>> + # send the input to the server on alt + enter >>> + self.parent.cb_send_to_server(msg) >>> + self.history.insert(0, msg) >>> >> >> Why not append to the end? >> > A dumb reason, I was more comfortable using positive indices. > Not a dumb reason, just a stylistic preference. I only asked in case there was something I was missing. > > You can count backwards with list indices too, so you can look at >> history[-1], -2, -3, etc to go further backwards. >> > I have now refactored this to append the messages to the end and count > backward using negative indices. > > >> >> >>> + self.set_edit_text('') >>> + self.last_index = 0 >>> + self.show_history = False >>> + else: >>> + self.show_history = False >>> + self.last_index = 0 >>> + return cast(Optional[str], super().keypress(size, key)) >>> + return None >>> + >>> + >>> +class EditorWidget(urwid.Filler): >>> + """ >>> + The Editor is a flow widget and has to wrapped inside a box widget. >>> + This class wraps the Editor inside filler widget. >>> + """ >>> + def __init__(self, parent: App) -> None: >>> + super().__init__(Editor(parent), valign='top') >>> + >>> + >>> +class HistoryBox(urwid.ListBox): >>> + """ >>> + This widget is modelled using the ListBox widget, contains the list >>> of >>> + all messages both QMP messages and log messsages to be shown in the >>> TUI. >>> + >>> + The messages are urwid.Text widgets. On every append of a message, >>> the >>> + focus is shifted to the last appended message. >>> + """ >>> + def __init__(self, parent: App) -> None: >>> + """ >>> + Initializes the historybox widget >>> + >>> + :param parent: Reference to the TUI object. >>> + """ >>> + self.parent = parent >>> + self.history = urwid.SimpleFocusListWalker([]) >>> + super().__init__(self.history) >>> + >>> + def add_to_history(self, history: str) -> None: >>> + """ >>> + Appends a message to the list and set the focus to the last >>> appended >>> + message. >>> + >>> + :param history: >>> + The history item(message/event) to be appended to the list. >>> + """ >>> + self.history.append(urwid.Text(history)) >>> + if self.history: >>> + self.history.set_focus(len(self.history) - 1) >>> >> >> I assume this is something to work around a mypy error? if we've appended >> something to a list, then it should be impossible for the list to be empty, >> right? >> > > I was really dumb and did not see this simple logic. > It's not an error, just something we can simplify. No need to call yourself dumb. Mistakes and oversights happen. > >>> >>> + >>> + def mouse_event(self, size: Tuple[int, int], _event: str, button: >>> float, >>> + _x: int, _y: int, focus: bool) -> None: >>> + # Unfortunately there are no urwid constants that represent the >>> below >>> + # events. >>> + if button == 4: # Scroll up event >>> + super().keypress(size, 'up') >>> + elif button == 5: # Scroll down event >>> + super().keypress(size, 'down') >>> + >>> + >>> +class HistoryWindow(urwid.Frame): >>> + """ >>> + This window composes the HistoryBox and EditorWidget in a >>> horizontal split. >>> + By default the first focus is given to the history box. >>> + """ >>> + def __init__(self, parent: App) -> None: >>> + """ >>> + Initializes this widget and its child widgets. >>> + >>> + :param parent: Reference to the TUI object. >>> + """ >>> + self.parent = parent >>> + self.editor_widget = EditorWidget(parent) >>> + self.editor = urwid.LineBox(self.editor_widget) >>> + self.history = HistoryBox(parent) >>> + self.body = urwid.Pile([('weight', 80, self.history), >>> + ('weight', 20, self.editor)]) >>> + super().__init__(self.body) >>> + urwid.connect_signal(self.parent, UPDATE_MSG, >>> self.cb_add_to_history) >>> + >>> + def cb_add_to_history(self, msg: str, level: Optional[str] = None) >>> -> None: >>> + """ >>> + Appends a message to the history box >>> + >>> + :param msg: >>> + The message to be appended to the history box. >>> + """ >>> + if level: >>> + msg = f'[{level}]: {msg}' >>> + self.history.add_to_history(msg) >>> + >>> + >>> +class Window(urwid.Frame): >>> + """ >>> + This window is the top most widget of the TUI and will contain other >>> + windows. Each child of this widget is responsible for displaying a >>> specific >>> + functionality. >>> + """ >>> + def __init__(self, parent: App) -> None: >>> + """ >>> + Initializes this widget and its child windows. >>> + >>> + :param parent: Reference to the TUI object. >>> + """ >>> + self.parent = parent >>> + footer = StatusBar() >>> + body = HistoryWindow(parent) >>> + super().__init__(body, footer=footer) >>> + >>> + >>> +class TUILogHandler(Handler): >>> + """ >>> + This handler routes all the log messages to the TUI screen. >>> + It is installed to the root logger to so that the log message from >>> all >>> + libraries begin used is routed to the screen. >>> + """ >>> + def __init__(self, tui: App) -> None: >>> + """ >>> + Initializes the handler class. >>> + >>> + :param tui: >>> + Reference to the TUI object. >>> + """ >>> + super().__init__() >>> + self.tui = tui >>> + >>> + def emit(self, record: LogRecord) -> None: >>> + """ >>> + Emits a record to the TUI screen. >>> + >>> + Appends the log message to the TUI screen >>> + """ >>> + level = record.levelname >>> + msg = record.getMessage() >>> + self.tui.add_to_history(msg, level) >>> + >>> + >>> +def main() -> None: >>> + """ >>> + Driver of the whole script, parses arguments, initialize the TUI and >>> + the logger. >>> + """ >>> + parser = argparse.ArgumentParser(description='AQMP TUI') >>> + parser.add_argument('qmp_server', help='Address of the QMP server. ' >>> + 'Format <UNIX socket path | TCP addr:port>') >>> + parser.add_argument('--num-retries', type=int, default=10, >>> + help='Number of times to reconnect before >>> giving up.') >>> + parser.add_argument('--retry-delay', type=int, >>> + help='Time(s) to wait before next retry. ' >>> + 'Default action is to wait 2s between each >>> retry.') >>> + parser.add_argument('--log-file', help='The Log file name') >>> + parser.add_argument('--log-level', default='WARNING', >>> + help='Log level >>> <CRITICAL|ERROR|WARNING|INFO|DEBUG|>') >>> + parser.add_argument('--asyncio-debug', action='store_true', >>> + help='Enable debug mode for asyncio loop. ' >>> + 'Generates lot of output, makes TUI unusable >>> when ' >>> + 'logs are logged in the TUI. ' >>> + 'Use only when logging to a file.') >>> + args = parser.parse_args() >>> + >>> + try: >>> + address = QEMUMonitorProtocol.parse_address(args.qmp_server) >>> + except QMPBadPortError as err: >>> + parser.error(str(err)) >>> + >>> + app = App(address, args.num_retries, args.retry_delay) >>> + >>> + root_logger = logging.getLogger() >>> + root_logger.setLevel(logging.getLevelName(args.log_level)) >>> + >>> + if args.log_file: >>> + root_logger.addHandler(logging.FileHandler(args.log_file)) >>> + else: >>> + root_logger.addHandler(TUILogHandler(app)) >>> + >>> + app.run(args.asyncio_debug) >>> + >>> + >>> +if __name__ == '__main__': >>> + main() >>> diff --git a/python/setup.cfg b/python/setup.cfg >>> index 589a90be21..e9ceaea637 100644 >>> --- a/python/setup.cfg >>> +++ b/python/setup.cfg >>> @@ -81,8 +81,19 @@ namespace_packages = True >>> # fusepy has no type stubs: >>> allow_subclassing_any = True >>> >>> +[mypy-qemu.aqmp.aqmp_tui] >>> +# urwid and urwid_readline have no type stubs: >>> +allow_subclassing_any = True >>> + >>> +# The following missing import directives are because these libraries >>> do not >>> +# provide type stubs. Allow them on an as-needed basis for mypy. >>> [mypy-fuse] >>> -# fusepy has no type stubs: >>> +ignore_missing_imports = True >>> + >>> +[mypy-urwid] >>> +ignore_missing_imports = True >>> + >>> +[mypy-urwid_readline] >>> ignore_missing_imports = True >>> >>> [pylint.messages control] >>> -- >>> 2.17.1 >>> >>> I know a good bit of time has passed, but I'm getting ready to stage everything (The base AQMP patches + your project!) -- if it passes the CI again today, I'll be sending the PR along. If you have any additional work you'd like to submit for consideration, I kindly ask that you rebase your "WIP" branch on top of origin/master after the project is merged and submit an [RFC] series so that your work doesn't go to waste! Hope you enjoyed your summer, and thanks for your effort! --js