Move asyncio loop handling to main.py

This commit is contained in:
DevilXD committed 2022-01-17 17:04:31 +01:00
1 parent 2fdbf809e7
commit 97d6ac1b29
4 files changed
+85 -56

No files matched your search

+1
View File
@@ -48,6 +48,7 @@ FORMATTER = logging.Formatter(
class State(Enum): class State(Enum):
IDLE = auto() IDLE = auto()
EXIT = auto()
INVENTORY_FETCH = auto() INVENTORY_FETCH = auto()
GAMES_UPDATE = auto() GAMES_UPDATE = auto()
GAME_SELECT = auto() GAME_SELECT = auto()
+19 -8
View File
@@ -260,7 +260,7 @@ class GameSelector:
highlightthickness=0, highlightthickness=0,
) )
self._list.pack(fill="both", expand=True) self._list.pack(fill="both", expand=True)
self._selection: Optional[str] = self._manager._twitch._options.game self._selection: Optional[str] = self._manager._twitch.options.game
self._games: OrderedDict[str, Game] = OrderedDict() self._games: OrderedDict[str, Game] = OrderedDict()
self._list.bind("<<ListboxSelect>>", self._on_select) self._list.bind("<<ListboxSelect>>", self._on_select)
@@ -481,11 +481,11 @@ class ConsoleOutput:
yscroll = ttk.Scrollbar(frame, orient="vertical") yscroll = ttk.Scrollbar(frame, orient="vertical")
self._text = tk.Text( self._text = tk.Text(
frame, frame,
exportselection=False,
height=10,
width=52, width=52,
height=10,
wrap="none", wrap="none",
state="disabled", state="disabled",
exportselection=False,
xscrollcommand=xscroll.set, xscrollcommand=xscroll.set,
yscrollcommand=yscroll.set, yscrollcommand=yscroll.set,
) )
@@ -689,7 +689,7 @@ class TrayIcon:
self.icon: Optional[pystray.Icon] = None self.icon: Optional[pystray.Icon] = None
self._button = ttk.Button(master, command=self.minimize, text="Minimize to Tray") self._button = ttk.Button(master, command=self.minimize, text="Minimize to Tray")
self._button.grid(column=0, row=0, sticky="e") self._button.grid(column=0, row=0, sticky="e")
if manager._twitch._options.tray: if manager._twitch.options.tray:
# start hidden in tray # start hidden in tray
self._manager._root.after_idle(self.minimize) self._manager._root.after_idle(self.minimize)
@@ -799,11 +799,11 @@ class GUIManager:
root.update_idletasks() root.update_idletasks()
root.minsize(width=0, height=root.winfo_reqheight()) root.minsize(width=0, height=root.winfo_reqheight())
# register logging handler # register logging handler
handler = TKOutputHandler(self) self._handler = TKOutputHandler(self)
handler.setFormatter(FORMATTER) self._handler.setFormatter(FORMATTER)
logging.getLogger("TwitchDrops").addHandler(handler) logging.getLogger("TwitchDrops").addHandler(self._handler)
# show the window when ready # show the window when ready
if not self._twitch._options.tray: if not self._twitch.options.tray:
self._root.deiconify() self._root.deiconify()
# https://stackoverflow.com/questions/56329342/tkinter-treeview-background-tag-not-working # https://stackoverflow.com/questions/56329342/tkinter-treeview-background-tag-not-working
@@ -858,10 +858,21 @@ class GUIManager:
self._poll_task = None self._poll_task = None
def close(self): def close(self):
"""
Requests the application to close.
The window itself will be closed in the closing sequence later.
"""
self._closed.set() self._closed.set()
# notify client we're supposed to close # notify client we're supposed to close
self._twitch.request_close() self._twitch.request_close()
def close_window(self):
"""
Closes the window. Invalidates the logger.
"""
self._root.destroy()
logging.getLogger("TwitchDrops").removeHandler(self._handler)
def unfocus(self, event): def unfocus(self, event):
# support pressing ESC to unfocus # support pressing ESC to unfocus
self._root.focus_set() self._root.focus_set()
+33 -2
View File
@@ -2,12 +2,16 @@ from __future__ import annotations
import sys import sys
import ctypes import ctypes
import signal
import asyncio
import logging import logging
import argparse import argparse
import traceback
from typing import Optional from typing import Optional
from twitch import Twitch from twitch import Twitch
from version import __version__ from version import __version__
from exceptions import CaptchaRequired
from constants import FORMATTER, LOG_PATH, WINDOW_TITLE from constants import FORMATTER, LOG_PATH, WINDOW_TITLE
@@ -85,6 +89,33 @@ if options.log:
logger.addHandler(handler) logger.addHandler(handler)
logging.getLogger("TwitchDrops.gql").setLevel(options.debug_gql) logging.getLogger("TwitchDrops.gql").setLevel(options.debug_gql)
logging.getLogger("TwitchDrops.websocket").setLevel(options.debug_ws) logging.getLogger("TwitchDrops.websocket").setLevel(options.debug_ws)
# client run # client run
client = Twitch(options) loop = asyncio.get_event_loop()
client.start() client = Twitch(loop, options)
signal.signal(signal.SIGINT, lambda *_: client.close())
signal.signal(signal.SIGTERM, lambda *_: client.close())
try:
loop.run_until_complete(client.run())
except CaptchaRequired:
client.prevent_close()
client.print(
"Your login attempt was denied by CAPTCHA.\nPlease try again in +12 hours."
)
except Exception:
client.prevent_close()
client.print("Fatal error encountered:\n")
client.print(traceback.format_exc())
finally:
signal.signal(signal.SIGINT, signal.SIG_DFL)
signal.signal(signal.SIGTERM, signal.SIG_DFL)
loop.run_until_complete(client.shutdown())
if not client.gui.close_requested:
client.print(
"\nApplication Terminated.\nClose the window to exit the application."
)
loop.run_until_complete(client.gui.wait_until_closed())
client.gui.stop()
client.gui.close_window()
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
+32 -46
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import os import os
import asyncio import asyncio
import logging import logging
import traceback
from yarl import URL from yarl import URL
from time import time from time import time
from itertools import chain from itertools import chain
@@ -75,8 +74,9 @@ class _AwaitableValue(Generic[_V]):
class Twitch: class Twitch:
def __init__(self, options: ParsedArgs): def __init__(self, loop: asyncio.AbstractEventLoop, options: ParsedArgs):
self._options = options self._loop = loop
self.options = options
# GUI # GUI
self.gui = GUIManager(self) self.gui = GUIManager(self)
# Cookies, session and auth # Cookies, session and auth
@@ -84,6 +84,7 @@ class Twitch:
if os.path.isfile(COOKIES_PATH): if os.path.isfile(COOKIES_PATH):
cookie_jar.load(COOKIES_PATH) cookie_jar.load(COOKIES_PATH)
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
loop=loop,
cookie_jar=cookie_jar, cookie_jar=cookie_jar,
headers={"User-Agent": USER_AGENT}, headers={"User-Agent": USER_AGENT},
timeout=aiohttp.ClientTimeout(connect=5, total=10), timeout=aiohttp.ClientTimeout(connect=5, total=10),
@@ -94,7 +95,7 @@ class Twitch:
# State management # State management
self._state: State = State.INVENTORY_FETCH self._state: State = State.INVENTORY_FETCH
self._state_change = asyncio.Event() self._state_change = asyncio.Event()
self.inventory: List[DropsCampaign] = [] # inventory self.inventory: List[DropsCampaign] = []
# Storing and watching channels # Storing and watching channels
self.channels: Dict[int, Channel] = {} self.channels: Dict[int, Channel] = {}
self._watching_channel: _AwaitableValue[Channel] = _AwaitableValue() self._watching_channel: _AwaitableValue[Channel] = _AwaitableValue()
@@ -103,13 +104,13 @@ class Twitch:
self._drop_update: Optional[asyncio.Future[bool]] = None self._drop_update: Optional[asyncio.Future[bool]] = None
# Websocket # Websocket
self.websocket = WebsocketPool(self) self.websocket = WebsocketPool(self)
# Runner task
self._main_task: Optional[asyncio.Task[None]] = None
def wait_until_login(self): def wait_until_login(self):
return self._is_logged_in.wait() return self._is_logged_in.wait()
def change_state(self, state: State) -> None: def change_state(self, state: State) -> None:
if self._state is not State.EXIT:
# prevent state changing once we switch to exit state
self._state = state self._state = state
self._state_change.set() self._state_change.set()
@@ -118,58 +119,40 @@ class Twitch:
# perfect for GUI usage # perfect for GUI usage
return partial(self.change_state, state) return partial(self.change_state, state)
def close(self):
"""
Called when the application is requested to close by the operating system,
usually by receiving a SIGINT or SIGTERM.
"""
self.gui.close()
def request_close(self): def request_close(self):
""" """
Called when the application is requested to close, Called when the application is requested to close by the user,
usually by the console or application window being closed. usually by the console or application window being closed.
""" """
self.stop() self.change_state(State.EXIT)
def start(self): def prevent_close(self):
self._loop = loop = asyncio.get_event_loop() """
self._main_task = loop.create_task(self._run()) Called when the application window has to be prevented from closing, even after the user
closes it with X. Usually used solely to display tracebacks drom the closing sequence.
"""
self.gui.prevent_close()
try: def print(self, *args, **kwargs):
loop.run_until_complete(self._main_task) """
except asyncio.CancelledError: Can be used to print messages within the GUI.
# happens when the user requests close """
pass self.gui.print(*args, **kwargs)
except KeyboardInterrupt:
# KeyboardInterrupt causes run_until_complete to exit, but without cancelling the task.
# The loop stops and thus the task gets frozen, until the loop runs again.
# Because we don't want anything from there to actually run during cleanup,
# we need to explicitly cancel the task ourselves here.
self.stop()
except CaptchaRequired:
self.gui.prevent_close()
self.gui.print(
"Your login attempt was denied by CAPTCHA.\nPlease try again in +12 hours."
)
except Exception:
self.gui.prevent_close()
self.gui.print("Fatal error encountered:\n")
self.gui.print(traceback.format_exc())
finally:
loop.run_until_complete(self.close())
loop.run_until_complete(loop.shutdown_asyncgens())
if not self.gui.close_requested:
self.gui.print(
"\nApplication Terminated.\nClose the window to exit the application."
)
loop.run_until_complete(self.gui.wait_until_closed())
self.gui.stop()
loop.close()
def stop(self): def stop(self):
self.stop_watching() self.stop_watching()
if self._watching_task is not None: if self._watching_task is not None:
self._watching_task.cancel() self._watching_task.cancel()
self._watching_task = None self._watching_task = None
if self._main_task is not None:
self._main_task.cancel()
self._main_task = None
async def close(self): async def shutdown(self):
start_time = time() start_time = time()
self.gui.print("Exiting...") self.gui.print("Exiting...")
self.stop() self.stop()
@@ -186,7 +169,7 @@ class Twitch:
watching_channel = self._watching_channel.get_with_default(None) watching_channel = self._watching_channel.get_with_default(None)
return watching_channel is not None and watching_channel == channel return watching_channel is not None and watching_channel == channel
async def _run(self): async def run(self):
""" """
Main method that runs the whole client. Main method that runs the whole client.
@@ -322,6 +305,9 @@ class Twitch:
self.stop_watching() self.stop_watching()
self.gui.print(f"No suitable channel to watch for game: {selected_game}") self.gui.print(f"No suitable channel to watch for game: {selected_game}")
self.change_state(State.IDLE) self.change_state(State.IDLE)
elif self._state is State.EXIT:
# we've been requested to exit the application
break
await self._state_change.wait() await self._state_change.wait()
@task_wrapper @task_wrapper