Skip to content

Commit

Permalink
refactor: make event handlers asynchronous
Browse files Browse the repository at this point in the history
The event handlers have been made asynchronous to allow them to perform
async I/O as needed. Tasks can also be submitted which are checked for
completion so that their result/exception can be retrieved and reported
back to the user.
  • Loading branch information
P403n1x87 committed Oct 22, 2021
1 parent f688d2e commit 908fbd2
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 49 deletions.
20 changes: 17 additions & 3 deletions austin_tui/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import asyncio
import sys
from textwrap import wrap
from typing import Any, List, Optional
from typing import Any, Callable, List, Optional

from austin import AustinError
from austin.aio import AsyncAustin
Expand Down Expand Up @@ -114,10 +114,15 @@ def on_terminate(self, stats: str) -> None:
def on_view_event(self, event: AustinView.Event, data: Any = None) -> None:
"""View events handler."""

def _unhandled() -> None:
def _unhandled(_: Any) -> None:
raise RuntimeError(f"Unhandled view event: {event}")

{AustinView.Event.QUIT: self.shutdown}.get(event, _unhandled)()
{
AustinView.Event.QUIT: self.on_shutdown,
AustinView.Event.EXCEPTION: self.on_exception,
}.get(event, _unhandled)(
data
) # type: ignore[operator]

async def start(self, args: List[str]) -> None:
"""Start Austin and catch any exceptions."""
Expand Down Expand Up @@ -157,6 +162,15 @@ def shutdown(self) -> None:

asyncio.get_event_loop().stop()

def on_shutdown(self, _: Any = None) -> None:
"""The shutdown view event handler."""
self.shutdown()

def on_exception(self, exc: Exception) -> None:
"""The exception view event handler."""
self.shutdown()
raise exc


def main() -> None:
"""Main function."""
Expand Down
5 changes: 1 addition & 4 deletions austin_tui/controllers/austin.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,6 @@ def _dump_stats() -> None:
except IOError as e:
self.view.notification.set_text(f"Failed to save stats: {e}")

# TODO: This is very bad because the return value is an awaitable that
# we are not awaiting. If an exception is thrown then we never retrieve
# it. What's worse is that this could potentially mess up the output :(.
asyncio.get_event_loop().run_in_executor(None, _dump_stats)
self.view.submit_task(_dump_stats)

return True
72 changes: 68 additions & 4 deletions austin_tui/view/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,23 @@

from abc import ABC
import asyncio
from asyncio.coroutines import coroutine
from asyncio.coroutines import iscoroutine
import curses
import sys
from typing import Any, Callable, Dict, List, Optional, TextIO, Type
from typing import (
Any,
Awaitable,
Callable,
Coroutine,
Dict,
Generator,
List,
Optional,
TextIO,
Type,
Union,
)

from importlib_resources import files
from lxml.etree import _Comment as Comment
Expand Down Expand Up @@ -101,15 +115,27 @@ def __init__(self, name: str) -> None:

def _create_tasks(self) -> None:
loop = asyncio.get_event_loop()
event_handlers = set(self._event_handlers.values())
self._tasks = [
loop.create_task(coro())
for coro in (
attr
for attr in (getattr(self, name) for name in dir(self))
if callable(attr) and asyncio.iscoroutinefunction(attr)
if callable(attr)
and asyncio.iscoroutinefunction(attr)
and attr not in event_handlers
)
]

def on_exception(self, exc: Exception) -> None:
"""Default task exception callback.
This simply closes the view and re-raises the exception. Override in
sub-classes with custom logic.
"""
self.close()
raise exc

async def _input_loop(self) -> None:
if not self.root_widget:
raise RuntimeError("Missing root widget")
Expand All @@ -120,12 +146,29 @@ async def _input_loop(self) -> None:
if not self.root_widget._win:
continue

# Handle user input on the root widget
try:
if self._event_handlers[self.root_widget._win.getkey()]():
if await self._event_handlers[self.root_widget._win.getkey()]():
self.root_widget.refresh()
except (KeyError, curses.error):
pass

# Retrieve the result of finished tasks
finished_tasks = []
running_tasks = []
for task in self._tasks:
if task.done():
finished_tasks.append(task)
else:
running_tasks.append(task)
self._tasks = running_tasks

for task in finished_tasks:
try:
task.result()
except Exception as exc:
self.on_exception(exc)

def _build(self, node: Element) -> Widget:
_validate_ns(node)
widget_class = QName(node).localname
Expand Down Expand Up @@ -168,6 +211,27 @@ def open(self) -> None:

self._create_tasks()

def submit_task(
self,
task: Union[
asyncio.Task,
Coroutine[None, None, None],
Callable[[], Any],
],
) -> None:
"""Submit a task to run concurrently in the event loop.
A task can be an :class:`asyncio.Task` object, a coroutine or a plain
callable object. Any exception thrown within the task can be retrieved
from the ``on_exception`` callback.
"""
if isinstance(task, asyncio.Task):
self._tasks.append(task)
elif iscoroutine(task):
self._tasks.append(asyncio.create_task(task)) # type: ignore[arg-type]
else:
self._tasks.append(asyncio.get_event_loop().run_in_executor(None, task)) # type: ignore[arg-type]

def close(self) -> None:
"""Close the view."""
if not self._open or not self.root_widget:
Expand Down Expand Up @@ -202,7 +266,7 @@ def _parse(view_node: Element) -> View:

root, *rest = view_node
view.root_widget = view._build(root)
view.connect("KEY_RESIZE", view.root_widget.resize)
view.connect("KEY_RESIZE", view.root_widget.on_resize)

def _add_children(widget: Container, node: Element) -> None:
for child in node:
Expand Down
27 changes: 18 additions & 9 deletions austin_tui/view/austin.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class AustinView(View):
class Event(Enum):
"""Austin View Events."""

EXCEPTION = 0
QUIT = 1

def __init__(
Expand All @@ -63,62 +64,70 @@ def __init__(

self._stopped = False

def on_quit(self) -> bool:
def on_exception(self, exc: Exception) -> None:
"""The on exception Austin view handler."""
if not self.callback:
raise RuntimeError(
"AustinTUI requires a callback to handle exception events."
)
self.callback(self.Event.EXCEPTION, exc)

async def on_quit(self) -> bool:
"""Handle Quit event."""
if not self.callback:
raise RuntimeError("AustinTUI requires a callback to handle quit events.")
self.callback(self.Event.QUIT, None)
return False

def on_next_thread(self) -> bool:
async def on_next_thread(self) -> bool:
"""Handle next thread event."""
if self._austin_controller.push(AustinEvent.CHANGE_THREAD, ThreadNav.NEXT):
self.table.draw()
self.stats_view.refresh()
return True
return False

def on_previous_thread(self) -> bool:
async def on_previous_thread(self) -> bool:
"""Handle previous thread event."""
if self._austin_controller.push(AustinEvent.CHANGE_THREAD, ThreadNav.PREV):
self.table.draw()
self.stats_view.refresh()
return True
return False

def on_full_mode_toggled(self) -> bool:
async def on_full_mode_toggled(self) -> bool:
"""Handle Full Mode toggle."""
self.full_mode_cmd.toggle()
self._austin_controller.push(AustinEvent.TOGGLE_FULL_MODE)
self.table.draw()
self.stats_view.refresh()
return True

def on_save(self, data: Any = None) -> bool:
async def on_save(self, data: Any = None) -> bool:
"""Handle Save event."""
self.notification.set_text("Saving collected statistics ...")
self.root_widget.refresh()
return self._austin_controller.push(AustinEvent.SAVE)

def on_table_up(self, data: Any = None) -> bool:
async def on_table_up(self, data: Any = None) -> bool:
"""Handle Up Arrow on the table widget."""
self.stats_view.scroll_up()
self.stats_view.refresh()
return False

def on_table_down(self, data: Any = None) -> bool:
async def on_table_down(self, data: Any = None) -> bool:
"""Handle Down Arrow on the table widget."""
self.stats_view.scroll_down()
self.stats_view.refresh()
return False

def on_table_pgup(self, data: Any = None) -> bool:
async def on_table_pgup(self, data: Any = None) -> bool:
"""Handle Page Up on the table widget."""
self.stats_view.scroll_up(self.table.height - 1)
self.stats_view.refresh()
return False

def on_table_pgdown(self, data: Any = None) -> bool:
async def on_table_pgdown(self, data: Any = None) -> bool:
"""Handle Page Down on the table widget."""
self.stats_view.scroll_down(self.table.height - 1)
self.stats_view.refresh()
Expand Down
4 changes: 4 additions & 0 deletions austin_tui/widgets/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ def __init__(self, name: str) -> None:
super().__init__(name)
self.win = self

async def on_resize(self) -> bool:
"""The resize event handler."""
return self.resize()

def resize(self) -> bool:
"""Resize the window.
Expand Down
4 changes: 2 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ def lint(session):
session.run("flake8", *LINT_LOCATIONS, "--exclude", *LINT_EXCLUDES)


@nox.session(python="3.7")
@nox.session(python="3.9")
def mypy(session):
session.install("mypy")
session.run("mypy", "--show-error-codes", *MYPY_LOCATIONS)


@nox.session(python="3.7")
@nox.session(python="3.9")
def coverage(session):
"""Upload coverage data."""
install_with_constraints(session, "coverage[toml]", "codecov")
Expand Down
55 changes: 29 additions & 26 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ profile = "google"
austin-tui = "austin_tui.__main__:main"

[tool.poetry.dependencies]
austin-python = {version = "^1.0.0*", allow-prereleases = true}
austin-python = {version = "^1.1.0*", allow-prereleases = true}
importlib-resources = "^2.0.1"
lxml = "^4.5.1"
python = "^3.6"
Expand Down

0 comments on commit 908fbd2

Please sign in to comment.