On Tue, Aug 24, 2021 at 12:30 AM John Snow <[email protected]> wrote:
>
>
> On Mon, Aug 23, 2021 at 12:31 PM G S Niteesh Babu <[email protected]>
> 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 <[email protected]>
>> ---
>> 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 <[email protected]>
>> +#
>> +# 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.
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.
>
>>
>> +
>> + 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
>>
>>