From 97d6ac1b294d8e2c1c07240ea0550d4260ba7cc4 Mon Sep 17 00:00:00 2001 From: DevilXD Date: Mon, 17 Jan 2022 17:04:31 +0100 Subject: [PATCH] Move asyncio loop handling to main.py --- constants.py | 1 + gui.py | 27 ++++++++++++------ main.py | 35 +++++++++++++++++++++-- twitch.py | 80 ++++++++++++++++++++++------------------------------ 4 files changed, 86 insertions(+), 57 deletions(-) diff --git a/constants.py b/constants.py index d2e730c..4e4999d 100644 --- a/constants.py +++ b/constants.py @@ -48,6 +48,7 @@ FORMATTER = logging.Formatter( class State(Enum): IDLE = auto() + EXIT = auto() INVENTORY_FETCH = auto() GAMES_UPDATE = auto() GAME_SELECT = auto() diff --git a/gui.py b/gui.py index cf4374c..2b974bf 100644 --- a/gui.py +++ b/gui.py @@ -260,7 +260,7 @@ class GameSelector: highlightthickness=0, ) 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._list.bind("<>", self._on_select) @@ -481,11 +481,11 @@ class ConsoleOutput: yscroll = ttk.Scrollbar(frame, orient="vertical") self._text = tk.Text( frame, - exportselection=False, - height=10, width=52, + height=10, wrap="none", state="disabled", + exportselection=False, xscrollcommand=xscroll.set, yscrollcommand=yscroll.set, ) @@ -689,7 +689,7 @@ class TrayIcon: self.icon: Optional[pystray.Icon] = None self._button = ttk.Button(master, command=self.minimize, text="Minimize to Tray") self._button.grid(column=0, row=0, sticky="e") - if manager._twitch._options.tray: + if manager._twitch.options.tray: # start hidden in tray self._manager._root.after_idle(self.minimize) @@ -799,11 +799,11 @@ class GUIManager: root.update_idletasks() root.minsize(width=0, height=root.winfo_reqheight()) # register logging handler - handler = TKOutputHandler(self) - handler.setFormatter(FORMATTER) - logging.getLogger("TwitchDrops").addHandler(handler) + self._handler = TKOutputHandler(self) + self._handler.setFormatter(FORMATTER) + logging.getLogger("TwitchDrops").addHandler(self._handler) # show the window when ready - if not self._twitch._options.tray: + if not self._twitch.options.tray: self._root.deiconify() # https://stackoverflow.com/questions/56329342/tkinter-treeview-background-tag-not-working @@ -858,10 +858,21 @@ class GUIManager: self._poll_task = None def close(self): + """ + Requests the application to close. + The window itself will be closed in the closing sequence later. + """ self._closed.set() # notify client we're supposed to 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): # support pressing ESC to unfocus self._root.focus_set() diff --git a/main.py b/main.py index a94de2a..940ab37 100644 --- a/main.py +++ b/main.py @@ -2,12 +2,16 @@ from __future__ import annotations import sys import ctypes +import signal +import asyncio import logging import argparse +import traceback from typing import Optional from twitch import Twitch from version import __version__ +from exceptions import CaptchaRequired from constants import FORMATTER, LOG_PATH, WINDOW_TITLE @@ -85,6 +89,33 @@ if options.log: logger.addHandler(handler) logging.getLogger("TwitchDrops.gql").setLevel(options.debug_gql) logging.getLogger("TwitchDrops.websocket").setLevel(options.debug_ws) + # client run -client = Twitch(options) -client.start() +loop = asyncio.get_event_loop() +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() diff --git a/twitch.py b/twitch.py index 3d8e70b..c979e00 100644 --- a/twitch.py +++ b/twitch.py @@ -3,7 +3,6 @@ from __future__ import annotations import os import asyncio import logging -import traceback from yarl import URL from time import time from itertools import chain @@ -75,8 +74,9 @@ class _AwaitableValue(Generic[_V]): class Twitch: - def __init__(self, options: ParsedArgs): - self._options = options + def __init__(self, loop: asyncio.AbstractEventLoop, options: ParsedArgs): + self._loop = loop + self.options = options # GUI self.gui = GUIManager(self) # Cookies, session and auth @@ -84,6 +84,7 @@ class Twitch: if os.path.isfile(COOKIES_PATH): cookie_jar.load(COOKIES_PATH) self._session = aiohttp.ClientSession( + loop=loop, cookie_jar=cookie_jar, headers={"User-Agent": USER_AGENT}, timeout=aiohttp.ClientTimeout(connect=5, total=10), @@ -94,7 +95,7 @@ class Twitch: # State management self._state: State = State.INVENTORY_FETCH self._state_change = asyncio.Event() - self.inventory: List[DropsCampaign] = [] # inventory + self.inventory: List[DropsCampaign] = [] # Storing and watching channels self.channels: Dict[int, Channel] = {} self._watching_channel: _AwaitableValue[Channel] = _AwaitableValue() @@ -103,14 +104,14 @@ class Twitch: self._drop_update: Optional[asyncio.Future[bool]] = None # Websocket self.websocket = WebsocketPool(self) - # Runner task - self._main_task: Optional[asyncio.Task[None]] = None def wait_until_login(self): return self._is_logged_in.wait() def change_state(self, state: State) -> None: - self._state = state + if self._state is not State.EXIT: + # prevent state changing once we switch to exit state + self._state = state self._state_change.set() def state_change(self, state: State) -> Callable[[], None]: @@ -118,58 +119,40 @@ class Twitch: # perfect for GUI usage 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): """ - 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. """ - self.stop() + self.change_state(State.EXIT) - def start(self): - self._loop = loop = asyncio.get_event_loop() - self._main_task = loop.create_task(self._run()) + def prevent_close(self): + """ + 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: - loop.run_until_complete(self._main_task) - except asyncio.CancelledError: - # happens when the user requests close - pass - 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 print(self, *args, **kwargs): + """ + Can be used to print messages within the GUI. + """ + self.gui.print(*args, **kwargs) def stop(self): self.stop_watching() if self._watching_task is not None: self._watching_task.cancel() 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() self.gui.print("Exiting...") self.stop() @@ -186,7 +169,7 @@ class Twitch: watching_channel = self._watching_channel.get_with_default(None) 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. @@ -322,6 +305,9 @@ class Twitch: self.stop_watching() self.gui.print(f"No suitable channel to watch for game: {selected_game}") 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() @task_wrapper