Change typing to 3.10 syntax
This commit is contained in:
1 parent
7553997210
commit
92d722fd0c
8 files changed
+154
-174
No files matched your search
+17
-16
@@ -6,15 +6,16 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Any, Optional, List, SupportsInt, TYPE_CHECKING
|
from typing import Any, SupportsInt, TYPE_CHECKING
|
||||||
|
|
||||||
from utils import Game, invalidate_cache
|
from utils import Game, invalidate_cache
|
||||||
from exceptions import MinerException, RequestException
|
from exceptions import MinerException, RequestException
|
||||||
from constants import JsonType, BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, DROPS_ENABLED_TAG
|
from constants import BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, DROPS_ENABLED_TAG
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
from gui import ChannelList
|
from gui import ChannelList
|
||||||
|
from constants import JsonType
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("TwitchDrops")
|
logger = logging.getLogger("TwitchDrops")
|
||||||
@@ -26,16 +27,16 @@ class Stream:
|
|||||||
channel: Channel,
|
channel: Channel,
|
||||||
*,
|
*,
|
||||||
id: SupportsInt,
|
id: SupportsInt,
|
||||||
game: Optional[JsonType],
|
game: JsonType | None,
|
||||||
viewers: int,
|
viewers: int,
|
||||||
title: str,
|
title: str,
|
||||||
tags: List[JsonType],
|
tags: list[JsonType],
|
||||||
):
|
):
|
||||||
self.channel: Channel = channel
|
self.channel: Channel = channel
|
||||||
self.broadcast_id = int(id)
|
self.broadcast_id = int(id)
|
||||||
self.viewers: int = viewers
|
self.viewers: int = viewers
|
||||||
self.drops_enabled: bool = any(t["id"] == DROPS_ENABLED_TAG for t in tags)
|
self.drops_enabled: bool = any(t["id"] == DROPS_ENABLED_TAG for t in tags)
|
||||||
self.game: Optional[Game] = Game(game) if game else None
|
self.game: Game | None = Game(game) if game else None
|
||||||
self.title: str = title
|
self.title: str = title
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -70,18 +71,18 @@ class Channel:
|
|||||||
*,
|
*,
|
||||||
id: SupportsInt,
|
id: SupportsInt,
|
||||||
login: str,
|
login: str,
|
||||||
display_name: Optional[str] = None,
|
display_name: str | None = None,
|
||||||
priority: bool = False,
|
priority: bool = False,
|
||||||
):
|
):
|
||||||
self._twitch: Twitch = twitch
|
self._twitch: Twitch = twitch
|
||||||
self._gui_channels: ChannelList = twitch.gui.channels
|
self._gui_channels: ChannelList = twitch.gui.channels
|
||||||
self.id: int = int(id)
|
self.id: int = int(id)
|
||||||
self._login: str = login
|
self._login: str = login
|
||||||
self._display_name: Optional[str] = display_name
|
self._display_name: str | None = display_name
|
||||||
self._spade_url: Optional[str] = None
|
self._spade_url: str | None = None
|
||||||
self.points: Optional[int] = None
|
self.points: int | None = None
|
||||||
self._stream: Optional[Stream] = None
|
self._stream: Stream | None = None
|
||||||
self._pending_stream_up: Optional[asyncio.Task[Any]] = None
|
self._pending_stream_up: asyncio.Task[Any] | None = None
|
||||||
# Priority channels are:
|
# Priority channels are:
|
||||||
# • considered first when switching channels
|
# • considered first when switching channels
|
||||||
# • if we're watching a non-priority channel, a priority channel going up triggers a switch
|
# • if we're watching a non-priority channel, a priority channel going up triggers a switch
|
||||||
@@ -174,13 +175,13 @@ class Channel:
|
|||||||
return self._pending_stream_up is not None
|
return self._pending_stream_up is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def game(self) -> Optional[Game]:
|
def game(self) -> Game | None:
|
||||||
if self._stream is not None and self._stream.game is not None:
|
if self._stream is not None and self._stream.game is not None:
|
||||||
return self._stream.game
|
return self._stream.game
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def viewers(self) -> Optional[int]:
|
def viewers(self) -> int | None:
|
||||||
if self._stream is not None:
|
if self._stream is not None:
|
||||||
return self._stream.viewers
|
return self._stream.viewers
|
||||||
return None
|
return None
|
||||||
@@ -229,13 +230,13 @@ class Channel:
|
|||||||
raise MinerException("Error while spade_url extraction: step #2")
|
raise MinerException("Error while spade_url extraction: step #2")
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
async def get_stream(self) -> Optional[Stream]:
|
async def get_stream(self) -> Stream | None:
|
||||||
response: Optional[JsonType] = await self._twitch.gql_request(
|
response: JsonType | None = await self._twitch.gql_request(
|
||||||
GQL_OPERATIONS["GetStreamInfo"].with_variables({"channel": self._login})
|
GQL_OPERATIONS["GetStreamInfo"].with_variables({"channel": self._login})
|
||||||
)
|
)
|
||||||
if not response:
|
if not response:
|
||||||
return None
|
return None
|
||||||
stream_data: Optional[JsonType] = response["data"]["user"]
|
stream_data: JsonType | None = response["data"]["user"]
|
||||||
if not stream_data:
|
if not stream_data:
|
||||||
return None
|
return None
|
||||||
# fill in channel_id and display name
|
# fill in channel_id and display name
|
||||||
|
|||||||
+12
-7
@@ -2,16 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from copy import copy
|
from copy import copy
|
||||||
|
from collections import abc
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Optional, Dict, Literal, Callable
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from typing_extensions import TypeAlias
|
||||||
|
|
||||||
from version import __version__
|
from version import __version__
|
||||||
|
|
||||||
|
|
||||||
# Typing
|
# Typing
|
||||||
JsonType = Dict[str, Any]
|
JsonType: TypeAlias = "dict[str, Any]"
|
||||||
TopicProcess = Callable[[int, JsonType], Any]
|
TopicProcess = abc.Callable[[int, JsonType], Any]
|
||||||
# Values
|
# Values
|
||||||
MAX_WEBSOCKETS = 8
|
MAX_WEBSOCKETS = 8
|
||||||
WS_TOPICS_LIMIT = 50
|
WS_TOPICS_LIMIT = 50
|
||||||
@@ -58,7 +61,7 @@ class State(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class GQLOperation(JsonType):
|
class GQLOperation(JsonType):
|
||||||
def __init__(self, name: str, sha256: str, *, variables: Optional[JsonType] = None):
|
def __init__(self, name: str, sha256: str, *, variables: JsonType | None = None):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
operationName=name,
|
operationName=name,
|
||||||
extensions={
|
extensions={
|
||||||
@@ -81,7 +84,7 @@ class GQLOperation(JsonType):
|
|||||||
return modified
|
return modified
|
||||||
|
|
||||||
|
|
||||||
GQL_OPERATIONS: Dict[str, GQLOperation] = {
|
GQL_OPERATIONS: dict[str, GQLOperation] = {
|
||||||
# returns stream information for a particular channel
|
# returns stream information for a particular channel
|
||||||
"GetStreamInfo": GQLOperation(
|
"GetStreamInfo": GQLOperation(
|
||||||
"VideoPlayerStreamInfoOverlayChannel",
|
"VideoPlayerStreamInfoOverlayChannel",
|
||||||
@@ -188,7 +191,9 @@ class WebsocketTopic:
|
|||||||
self._process: TopicProcess = process
|
self._process: TopicProcess = process
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def as_str(cls, category: Literal["User", "Channel"], topic_name: str, target_id: int) -> str:
|
def as_str(
|
||||||
|
cls, category: Literal["User", "Channel"], topic_name: str, target_id: int
|
||||||
|
) -> str:
|
||||||
return f"{WEBSOCKET_TOPICS[category][topic_name]}.{target_id}"
|
return f"{WEBSOCKET_TOPICS[category][topic_name]}.{target_id}"
|
||||||
|
|
||||||
def __call__(self, message: JsonType):
|
def __call__(self, message: JsonType):
|
||||||
@@ -211,7 +216,7 @@ class WebsocketTopic:
|
|||||||
return hash((self.__class__.__name__, self._id))
|
return hash((self.__class__.__name__, self._id))
|
||||||
|
|
||||||
|
|
||||||
WEBSOCKET_TOPICS: Dict[str, Dict[str, str]] = {
|
WEBSOCKET_TOPICS: dict[str, dict[str, str]] = {
|
||||||
"User": { # Using user_id
|
"User": { # Using user_id
|
||||||
"Drops": "user-drop-events",
|
"Drops": "user-drop-events",
|
||||||
"CommunityPoints": "community-points-user-v1",
|
"CommunityPoints": "community-points-user-v1",
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ from math import log10, ceil
|
|||||||
from tkinter.font import Font
|
from tkinter.font import Font
|
||||||
from collections import namedtuple, OrderedDict
|
from collections import namedtuple, OrderedDict
|
||||||
from tkinter import Tk, ttk, StringVar, DoubleVar
|
from tkinter import Tk, ttk, StringVar, DoubleVar
|
||||||
from typing import (
|
from typing import Any, TypedDict, NoReturn, TYPE_CHECKING
|
||||||
Any, Optional, List, Dict, Set, Tuple, TypedDict, Iterable, NoReturn, TYPE_CHECKING
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pystray
|
import pystray
|
||||||
@@ -23,6 +21,7 @@ from constants import FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE,
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
from channel import Channel
|
from channel import Channel
|
||||||
|
from collections import abc
|
||||||
from inventory import Game, TimedDrop
|
from inventory import Game, TimedDrop
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ class PlaceholderEntry(ttk.Entry):
|
|||||||
self.config(foreground=self._ph_color, show='')
|
self.config(foreground=self._ph_color, show='')
|
||||||
self.insert(0, self._ph_text)
|
self.insert(0, self._ph_text)
|
||||||
|
|
||||||
def _store_option(self, options: Dict[str, Any], attr: str, name: str):
|
def _store_option(self, options: dict[str, Any], attr: str, name: str):
|
||||||
value = options.get(name)
|
value = options.get(name)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
setattr(self, attr, value)
|
setattr(self, attr, value)
|
||||||
@@ -157,10 +156,10 @@ class WebsocketStatus:
|
|||||||
justify="right",
|
justify="right",
|
||||||
font=WS_FONT,
|
font=WS_FONT,
|
||||||
).grid(column=2, row=0)
|
).grid(column=2, row=0)
|
||||||
self._items: Dict[int, Optional[_WSEntry]] = {i: None for i in range(MAX_WEBSOCKETS)}
|
self._items: dict[int, _WSEntry | None] = {i: None for i in range(MAX_WEBSOCKETS)}
|
||||||
self._update()
|
self._update()
|
||||||
|
|
||||||
def update(self, idx: int, status: Optional[str] = None, topics: Optional[int] = None):
|
def update(self, idx: int, status: str | None = None, topics: int | None = None):
|
||||||
if status is None and topics is None:
|
if status is None and topics is None:
|
||||||
raise TypeError("You need to provide at least one of: status, topics")
|
raise TypeError("You need to provide at least one of: status, topics")
|
||||||
entry = self._items.get(idx)
|
entry = self._items.get(idx)
|
||||||
@@ -178,8 +177,8 @@ class WebsocketStatus:
|
|||||||
self._update()
|
self._update()
|
||||||
|
|
||||||
def _update(self):
|
def _update(self):
|
||||||
status_lines: List[str] = []
|
status_lines: list[str] = []
|
||||||
topic_lines: List[str] = []
|
topic_lines: list[str] = []
|
||||||
for idx in range(MAX_WEBSOCKETS):
|
for idx in range(MAX_WEBSOCKETS):
|
||||||
item = self._items.get(idx)
|
item = self._items.get(idx)
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -236,7 +235,7 @@ class LoginForm:
|
|||||||
data = LoginData(self._login_entry.get(), self._pass_entry.get(), self._token_entry.get())
|
data = LoginData(self._login_entry.get(), self._pass_entry.get(), self._token_entry.get())
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def update(self, status: str, user_id: Optional[int]):
|
def update(self, status: str, user_id: int | None):
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
user_str = str(user_id)
|
user_str = str(user_id)
|
||||||
else:
|
else:
|
||||||
@@ -260,12 +259,12 @@ 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: str | None = self._manager._twitch.options.game
|
||||||
self._games: OrderedDict[str, Game] = OrderedDict()
|
self._games: OrderedDict[str, Game] = OrderedDict()
|
||||||
self._excluded: Set[int] = set()
|
self._excluded: set[int] = set()
|
||||||
self._list.bind("<<ListboxSelect>>", self._on_select)
|
self._list.bind("<<ListboxSelect>>", self._on_select)
|
||||||
|
|
||||||
def set_games(self, games: Iterable[Game]):
|
def set_games(self, games: abc.Iterable[Game]):
|
||||||
self._games.clear()
|
self._games.clear()
|
||||||
self._games.update((str(g), g) for g in games)
|
self._games.update((str(g), g) for g in games)
|
||||||
self._list.delete(0, "end")
|
self._list.delete(0, "end")
|
||||||
@@ -273,7 +272,7 @@ class GameSelector:
|
|||||||
self._list.config(width=0) # autoadjust listbox width
|
self._list.config(width=0) # autoadjust listbox width
|
||||||
# process excluded games and relink the selection
|
# process excluded games and relink the selection
|
||||||
self._excluded.clear()
|
self._excluded.clear()
|
||||||
selected_index: Optional[int] = None
|
selected_index: int | None = None
|
||||||
exclude = self._manager._twitch.options.exclude
|
exclude = self._manager._twitch.options.exclude
|
||||||
for i, game_name in enumerate(self._list.get(0, "end")):
|
for i, game_name in enumerate(self._list.get(0, "end")):
|
||||||
if game_name in exclude:
|
if game_name in exclude:
|
||||||
@@ -290,7 +289,7 @@ class GameSelector:
|
|||||||
self._selection = None
|
self._selection = None
|
||||||
|
|
||||||
def _on_select(self, event):
|
def _on_select(self, event):
|
||||||
current: Tuple[int, ...] = self._list.curselection()
|
current: tuple[int, ...] = self._list.curselection()
|
||||||
if not current:
|
if not current:
|
||||||
# can happen when the user clicks on an empty list
|
# can happen when the user clicks on an empty list
|
||||||
return
|
return
|
||||||
@@ -311,12 +310,12 @@ class GameSelector:
|
|||||||
self._selection = new_selection
|
self._selection = new_selection
|
||||||
self._manager._twitch.change_state(State.GAME_SELECT)
|
self._manager._twitch.change_state(State.GAME_SELECT)
|
||||||
|
|
||||||
def get_selection(self) -> Optional[Game]:
|
def get_selection(self) -> Game | None:
|
||||||
if self._selection is None:
|
if self._selection is None:
|
||||||
return None
|
return None
|
||||||
return self._games[self._selection]
|
return self._games[self._selection]
|
||||||
|
|
||||||
def set_first(self) -> Optional[Game]:
|
def set_first(self) -> Game | None:
|
||||||
# select and return the first non-excluded game from the list
|
# select and return the first non-excluded game from the list
|
||||||
self._list.selection_clear(0, "end")
|
self._list.selection_clear(0, "end")
|
||||||
for i, game_name in enumerate(self._list.get(0, "end")):
|
for i, game_name in enumerate(self._list.get(0, "end")):
|
||||||
@@ -402,12 +401,12 @@ class CampaignProgress:
|
|||||||
maximum=1,
|
maximum=1,
|
||||||
variable=self._vars["drop"]["progress"],
|
variable=self._vars["drop"]["progress"],
|
||||||
).grid(column=0, row=10, columnspan=2)
|
).grid(column=0, row=10, columnspan=2)
|
||||||
self._drop: Optional[TimedDrop] = None
|
self._drop: TimedDrop | None = None
|
||||||
self._timer_task: Optional[asyncio.Task[None]] = None
|
self._timer_task: asyncio.Task[None] | None = None
|
||||||
self._update_time(0)
|
self._update_time(0)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _divmod(minutes: int, seconds: int) -> Tuple[int, int]:
|
def _divmod(minutes: int, seconds: int) -> tuple[int, int]:
|
||||||
if seconds < 60 and minutes > 0:
|
if seconds < 60 and minutes > 0:
|
||||||
minutes -= 1
|
minutes -= 1
|
||||||
hours, minutes = divmod(minutes, 60)
|
hours, minutes = divmod(minutes, 60)
|
||||||
@@ -560,7 +559,7 @@ class ChannelList:
|
|||||||
table.grid(column=0, row=1, sticky="nsew")
|
table.grid(column=0, row=1, sticky="nsew")
|
||||||
scroll.grid(column=1, row=1, sticky="ns")
|
scroll.grid(column=1, row=1, sticky="ns")
|
||||||
self._font = Font(frame, manager._style.lookup("Treeview", "font"))
|
self._font = Font(frame, manager._style.lookup("Treeview", "font"))
|
||||||
self._const_width: Set[str] = set()
|
self._const_width: set[str] = set()
|
||||||
table.tag_configure("watching", background="gray70")
|
table.tag_configure("watching", background="gray70")
|
||||||
table.bind("<Button-1>", self._disable_column_resize)
|
table.bind("<Button-1>", self._disable_column_resize)
|
||||||
table.bind("<<TreeviewSelect>>", self._selected)
|
table.bind("<<TreeviewSelect>>", self._selected)
|
||||||
@@ -572,7 +571,7 @@ class ChannelList:
|
|||||||
self._add_column("viewers", "Viewers", width_template="1234567")
|
self._add_column("viewers", "Viewers", width_template="1234567")
|
||||||
self._add_column("points", "Points", width_template="1234567")
|
self._add_column("points", "Points", width_template="1234567")
|
||||||
self._add_column("priority", "❗", width_template="✔")
|
self._add_column("priority", "❗", width_template="✔")
|
||||||
self._channel_map: Dict[str, Channel] = {}
|
self._channel_map: dict[str, Channel] = {}
|
||||||
|
|
||||||
def _add_column(
|
def _add_column(
|
||||||
self,
|
self,
|
||||||
@@ -580,13 +579,13 @@ class ChannelList:
|
|||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
anchor: tk._Anchor = "center",
|
anchor: tk._Anchor = "center",
|
||||||
width: Optional[int] = None,
|
width: int | None = None,
|
||||||
width_template: Optional[str] = None,
|
width_template: str | None = None,
|
||||||
):
|
):
|
||||||
table = self._table
|
table = self._table
|
||||||
# we need to save the column settings and headings before modifying the columns...
|
# we need to save the column settings and headings before modifying the columns...
|
||||||
columns: Tuple[str, ...] = table.cget("columns") or ()
|
columns: tuple[str, ...] = table.cget("columns") or ()
|
||||||
column_settings: Dict[str, Tuple[str, tk._Anchor, int, int]] = {}
|
column_settings: dict[str, tuple[str, tk._Anchor, int, int]] = {}
|
||||||
for s_cid in columns:
|
for s_cid in columns:
|
||||||
s_column = table.column(s_cid)
|
s_column = table.column(s_cid)
|
||||||
assert s_column is not None
|
assert s_column is not None
|
||||||
@@ -665,8 +664,8 @@ class ChannelList:
|
|||||||
self._table.set(iid, column, value)
|
self._table.set(iid, column, value)
|
||||||
self._adjust_width(column, value)
|
self._adjust_width(column, value)
|
||||||
|
|
||||||
def _insert(self, iid: str, values: Dict[str, str]):
|
def _insert(self, iid: str, values: dict[str, str]):
|
||||||
to_insert: List[str] = []
|
to_insert: list[str] = []
|
||||||
for cid in self._table.cget("columns"):
|
for cid in self._table.cget("columns"):
|
||||||
value = values[cid]
|
value = values[cid]
|
||||||
to_insert.append(value)
|
to_insert.append(value)
|
||||||
@@ -683,7 +682,7 @@ class ChannelList:
|
|||||||
self._table.item(iid, tags="watching")
|
self._table.item(iid, tags="watching")
|
||||||
self._table.see(iid)
|
self._table.see(iid)
|
||||||
|
|
||||||
def get_selection(self) -> Optional[Channel]:
|
def get_selection(self) -> Channel | None:
|
||||||
if not self._channel_map:
|
if not self._channel_map:
|
||||||
return None
|
return None
|
||||||
selection = self._table.selection()
|
selection = self._table.selection()
|
||||||
@@ -755,7 +754,7 @@ class TrayIcon:
|
|||||||
|
|
||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||||
self._manager = manager
|
self._manager = manager
|
||||||
self.icon: Optional[pystray.Icon] = None
|
self.icon: pystray.Icon | None = 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:
|
||||||
@@ -765,7 +764,7 @@ class TrayIcon:
|
|||||||
def is_tray(self) -> bool:
|
def is_tray(self) -> bool:
|
||||||
return self.icon is not None
|
return self.icon is not None
|
||||||
|
|
||||||
def get_title(self, drop: Optional[TimedDrop]) -> str:
|
def get_title(self, drop: TimedDrop | None) -> str:
|
||||||
if drop is None:
|
if drop is None:
|
||||||
return self.TITLE
|
return self.TITLE
|
||||||
return (
|
return (
|
||||||
@@ -815,7 +814,7 @@ class TrayIcon:
|
|||||||
self.stop()
|
self.stop()
|
||||||
self._manager._root.deiconify()
|
self._manager._root.deiconify()
|
||||||
|
|
||||||
def notify(self, message: str, title: Optional[str] = None, duration: float = 10):
|
def notify(self, message: str, title: str | None = None, duration: float = 10):
|
||||||
if self.icon is not None:
|
if self.icon is not None:
|
||||||
icon = self.icon
|
icon = self.icon
|
||||||
|
|
||||||
@@ -834,7 +833,7 @@ class TrayIcon:
|
|||||||
class GUIManager:
|
class GUIManager:
|
||||||
def __init__(self, twitch: Twitch):
|
def __init__(self, twitch: Twitch):
|
||||||
self._twitch: Twitch = twitch
|
self._twitch: Twitch = twitch
|
||||||
self._poll_task: Optional[asyncio.Task[NoReturn]] = None
|
self._poll_task: asyncio.Task[NoReturn] | None = None
|
||||||
self._closed = asyncio.Event()
|
self._closed = asyncio.Event()
|
||||||
self._root = root = Tk()
|
self._root = root = Tk()
|
||||||
# withdraw immediately to prevent the window from flashing
|
# withdraw immediately to prevent the window from flashing
|
||||||
@@ -978,7 +977,7 @@ if __name__ == "__main__":
|
|||||||
def create_channel(
|
def create_channel(
|
||||||
name: str,
|
name: str,
|
||||||
status: int,
|
status: int,
|
||||||
game: Optional[str],
|
game: str | None,
|
||||||
drops: bool,
|
drops: bool,
|
||||||
viewers: int,
|
viewers: int,
|
||||||
points: int,
|
points: int,
|
||||||
@@ -991,7 +990,7 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
pending = False
|
pending = False
|
||||||
if game is not None:
|
if game is not None:
|
||||||
game_obj: Optional[StrNamespace] = create_game(0, game)
|
game_obj: StrNamespace | None = create_game(0, game)
|
||||||
else:
|
else:
|
||||||
game_obj = None
|
game_obj = None
|
||||||
global iid
|
global iid
|
||||||
|
|||||||
+17
-15
@@ -1,30 +1,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, List, Dict, Iterable, TYPE_CHECKING
|
|
||||||
|
|
||||||
from channel import Channel
|
from constants import GQL_OPERATIONS
|
||||||
from constants import GQL_OPERATIONS, JsonType
|
|
||||||
from utils import timestamp, invalidate_cache, Game
|
from utils import timestamp, invalidate_cache, Game
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
|
from channel import Channel
|
||||||
|
from collections import abc
|
||||||
|
from constants import JsonType
|
||||||
from gui import CampaignProgress
|
from gui import CampaignProgress
|
||||||
|
|
||||||
|
|
||||||
class BaseDrop:
|
class BaseDrop:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, campaign: DropsCampaign, data: JsonType, claimed_benefits: Dict[str, datetime]
|
self, campaign: DropsCampaign, data: JsonType, claimed_benefits: dict[str, datetime]
|
||||||
):
|
):
|
||||||
self._twitch: Twitch = campaign._twitch
|
self._twitch: Twitch = campaign._twitch
|
||||||
self.id: str = data["id"]
|
self.id: str = data["id"]
|
||||||
self.name: str = data["name"]
|
self.name: str = data["name"]
|
||||||
self.campaign: DropsCampaign = campaign
|
self.campaign: DropsCampaign = campaign
|
||||||
self.rewards: List[str] = [b["benefit"]["name"] for b in data["benefitEdges"]]
|
self.rewards: list[str] = [b["benefit"]["name"] for b in data["benefitEdges"]]
|
||||||
self.starts_at: datetime = timestamp(data["startAt"])
|
self.starts_at: datetime = timestamp(data["startAt"])
|
||||||
self.ends_at: datetime = timestamp(data["endAt"])
|
self.ends_at: datetime = timestamp(data["endAt"])
|
||||||
self.claim_id: Optional[str] = None
|
self.claim_id: str | None = None
|
||||||
self.is_claimed: bool = False
|
self.is_claimed: bool = False
|
||||||
if "self" in data:
|
if "self" in data:
|
||||||
self.claim_id = data["self"]["dropInstanceID"]
|
self.claim_id = data["self"]["dropInstanceID"]
|
||||||
@@ -46,7 +48,7 @@ class BaseDrop:
|
|||||||
and all(self.starts_at <= dt < self.ends_at for dt in dts)
|
and all(self.starts_at <= dt < self.ends_at for dt in dts)
|
||||||
):
|
):
|
||||||
self.is_claimed = True
|
self.is_claimed = True
|
||||||
self._precondition_drops: List[str] = [d["id"] for d in (data["preconditionDrops"] or [])]
|
self._precondition_drops: list[str] = [d["id"] for d in (data["preconditionDrops"] or [])]
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
if self.is_claimed:
|
if self.is_claimed:
|
||||||
@@ -62,7 +64,7 @@ class BaseDrop:
|
|||||||
campaign = self.campaign
|
campaign = self.campaign
|
||||||
return all(campaign.timed_drops[pid].is_claimed for pid in self._precondition_drops)
|
return all(campaign.timed_drops[pid].is_claimed for pid in self._precondition_drops)
|
||||||
|
|
||||||
def can_earn(self, channel: Optional[Channel] = None) -> bool:
|
def can_earn(self, channel: Channel | None = None) -> bool:
|
||||||
return (
|
return (
|
||||||
self.preconditions # preconditions are met
|
self.preconditions # preconditions are met
|
||||||
and not self.is_claimed # drop isn't already claimed
|
and not self.is_claimed # drop isn't already claimed
|
||||||
@@ -128,7 +130,7 @@ class BaseDrop:
|
|||||||
|
|
||||||
class TimedDrop(BaseDrop):
|
class TimedDrop(BaseDrop):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, campaign: DropsCampaign, data: JsonType, claimed_benefits: Dict[str, datetime]
|
self, campaign: DropsCampaign, data: JsonType, claimed_benefits: dict[str, datetime]
|
||||||
):
|
):
|
||||||
super().__init__(campaign, data, claimed_benefits)
|
super().__init__(campaign, data, claimed_benefits)
|
||||||
self._gui_progress: CampaignProgress = self._twitch.gui.progress
|
self._gui_progress: CampaignProgress = self._twitch.gui.progress
|
||||||
@@ -185,19 +187,19 @@ class TimedDrop(BaseDrop):
|
|||||||
|
|
||||||
|
|
||||||
class DropsCampaign:
|
class DropsCampaign:
|
||||||
def __init__(self, twitch: Twitch, data: JsonType, claimed_benefits: Dict[str, datetime]):
|
def __init__(self, twitch: Twitch, data: JsonType, claimed_benefits: dict[str, datetime]):
|
||||||
self._twitch: Twitch = twitch
|
self._twitch: Twitch = twitch
|
||||||
self.id: str = data["id"]
|
self.id: str = data["id"]
|
||||||
self.name: str = data["name"]
|
self.name: str = data["name"]
|
||||||
self.game: Game = Game(data["game"])
|
self.game: Game = Game(data["game"])
|
||||||
self.starts_at: datetime = timestamp(data["startAt"])
|
self.starts_at: datetime = timestamp(data["startAt"])
|
||||||
self.ends_at: datetime = timestamp(data["endAt"])
|
self.ends_at: datetime = timestamp(data["endAt"])
|
||||||
allowed = data["allow"]
|
allowed: JsonType = data["allow"]
|
||||||
self.allowed_channels: List[Channel] = (
|
self.allowed_channels: list[Channel] = (
|
||||||
[Channel.from_acl(twitch, channel_data) for channel_data in allowed["channels"]]
|
[Channel.from_acl(twitch, channel_data) for channel_data in allowed["channels"]]
|
||||||
if allowed["channels"] and allowed.get("isEnabled", True) else []
|
if allowed["channels"] and allowed.get("isEnabled", True) else []
|
||||||
)
|
)
|
||||||
self.timed_drops: Dict[str, TimedDrop] = {
|
self.timed_drops: dict[str, TimedDrop] = {
|
||||||
drop_data["id"]: TimedDrop(self, drop_data, claimed_benefits)
|
drop_data["id"]: TimedDrop(self, drop_data, claimed_benefits)
|
||||||
for drop_data in data["timeBasedDrops"]
|
for drop_data in data["timeBasedDrops"]
|
||||||
}
|
}
|
||||||
@@ -206,7 +208,7 @@ class DropsCampaign:
|
|||||||
return f"Campaign({self.name}({self.game!s}), {self.claimed_drops}/{self.total_drops})"
|
return f"Campaign({self.name}({self.game!s}), {self.claimed_drops}/{self.total_drops})"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def drops(self) -> Iterable[TimedDrop]:
|
def drops(self) -> abc.Iterable[TimedDrop]:
|
||||||
return self.timed_drops.values()
|
return self.timed_drops.values()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -249,5 +251,5 @@ class DropsCampaign:
|
|||||||
def _on_minutes_changed(self) -> None:
|
def _on_minutes_changed(self) -> None:
|
||||||
invalidate_cache(self, "progress", "remaining_minutes")
|
invalidate_cache(self, "progress", "remaining_minutes")
|
||||||
|
|
||||||
def get_drop(self, drop_id: str) -> Optional[TimedDrop]:
|
def get_drop(self, drop_id: str) -> TimedDrop | None:
|
||||||
return self.timed_drops.get(drop_id)
|
return self.timed_drops.get(drop_id)
|
||||||
@@ -12,7 +12,7 @@ import tkinter as tk
|
|||||||
from copy import copy
|
from copy import copy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
from typing import Optional, Union, Set, NoReturn, Generic
|
from typing import Generic, NoReturn
|
||||||
|
|
||||||
from utils import _T
|
from utils import _T
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
@@ -21,7 +21,7 @@ from exceptions import CaptchaRequired
|
|||||||
from constants import FORMATTER, LOG_PATH, WINDOW_TITLE
|
from constants import FORMATTER, LOG_PATH, WINDOW_TITLE
|
||||||
|
|
||||||
|
|
||||||
# we need an dummy invisible window for the parser
|
# we need a dummy invisible window for the parser
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.overrideredirect(True)
|
root.overrideredirect(True)
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
@@ -49,9 +49,9 @@ class SetCollectAction(argparse.Action, Generic[_T]):
|
|||||||
option_strings,
|
option_strings,
|
||||||
dest,
|
dest,
|
||||||
*,
|
*,
|
||||||
nargs: Optional[Union[int, str]] = None,
|
const: _T | None = None,
|
||||||
const: Optional[_T] = None,
|
nargs: int | str | None = None,
|
||||||
default: Optional[Set[_T]] = None,
|
default: set[_T] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
if nargs is not None and nargs in ('?', '*') or isinstance(nargs, int) and nargs <= 0:
|
if nargs is not None and nargs in ('?', '*') or isinstance(nargs, int) and nargs <= 0:
|
||||||
@@ -63,7 +63,7 @@ class SetCollectAction(argparse.Action, Generic[_T]):
|
|||||||
super().__init__(option_strings, dest, nargs, const, default, **kwargs)
|
super().__init__(option_strings, dest, nargs, const, default, **kwargs)
|
||||||
|
|
||||||
def __call__(self, parser, namespace, values, option_string=None):
|
def __call__(self, parser, namespace, values, option_string=None):
|
||||||
items: Set[_T] = getattr(namespace, self.dest, self.default)
|
items: set[_T] = getattr(namespace, self.dest, self.default)
|
||||||
items = copy(items)
|
items = copy(items)
|
||||||
for value in values:
|
for value in values:
|
||||||
items.add(value)
|
items.add(value)
|
||||||
@@ -76,9 +76,9 @@ class ParsedArgs(argparse.Namespace):
|
|||||||
_debug_gql: bool
|
_debug_gql: bool
|
||||||
log: bool
|
log: bool
|
||||||
tray: bool
|
tray: bool
|
||||||
exclude: Set[str]
|
game: str | None
|
||||||
|
exclude: set[str]
|
||||||
no_run_check: bool
|
no_run_check: bool
|
||||||
game: Optional[str]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logging_level(self) -> int:
|
def logging_level(self) -> int:
|
||||||
|
|||||||
@@ -6,22 +6,10 @@ import logging
|
|||||||
from yarl import URL
|
from yarl import URL
|
||||||
from time import time
|
from time import time
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from datetime import datetime
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from collections import OrderedDict
|
||||||
from contextlib import suppress, asynccontextmanager
|
from contextlib import suppress, asynccontextmanager
|
||||||
from typing import (
|
|
||||||
Optional,
|
|
||||||
Union,
|
|
||||||
List,
|
|
||||||
Dict,
|
|
||||||
Set,
|
|
||||||
OrderedDict,
|
|
||||||
Callable,
|
|
||||||
AsyncIterator,
|
|
||||||
Final,
|
|
||||||
cast,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -31,9 +19,9 @@ except ModuleNotFoundError as exc:
|
|||||||
from gui import GUIManager
|
from gui import GUIManager
|
||||||
from channel import Channel
|
from channel import Channel
|
||||||
from websocket import WebsocketPool
|
from websocket import WebsocketPool
|
||||||
from inventory import DropsCampaign, TimedDrop
|
from inventory import DropsCampaign
|
||||||
|
from utils import task_wrapper, timestamp, AwaitableValue, OrderedSet
|
||||||
from exceptions import RequestException, LoginException, CaptchaRequired
|
from exceptions import RequestException, LoginException, CaptchaRequired
|
||||||
from utils import task_wrapper, timestamp, Game, AwaitableValue, OrderedSet
|
|
||||||
from constants import (
|
from constants import (
|
||||||
GQL_URL,
|
GQL_URL,
|
||||||
AUTH_URL,
|
AUTH_URL,
|
||||||
@@ -45,15 +33,20 @@ from constants import (
|
|||||||
WATCH_INTERVAL,
|
WATCH_INTERVAL,
|
||||||
WS_TOPICS_LIMIT,
|
WS_TOPICS_LIMIT,
|
||||||
DROPS_ENABLED_TAG,
|
DROPS_ENABLED_TAG,
|
||||||
JsonType,
|
|
||||||
State,
|
State,
|
||||||
GQLOperation,
|
|
||||||
WebsocketTopic,
|
WebsocketTopic,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections import abc
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Final, cast
|
||||||
|
|
||||||
|
from utils import Game
|
||||||
from gui import LoginForm
|
from gui import LoginForm
|
||||||
from main import ParsedArgs
|
from main import ParsedArgs
|
||||||
|
from inventory import TimedDrop
|
||||||
|
from constants import JsonType, GQLOperation
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("TwitchDrops")
|
logger = logging.getLogger("TwitchDrops")
|
||||||
@@ -73,21 +66,21 @@ class Twitch:
|
|||||||
# State management
|
# State management
|
||||||
self._state: State = State.IDLE
|
self._state: State = State.IDLE
|
||||||
self._state_change = asyncio.Event()
|
self._state_change = asyncio.Event()
|
||||||
self.game: Optional[Game] = None
|
self.game: Game | None = None
|
||||||
self.inventory: Dict[Game, List[DropsCampaign]] = {}
|
self.inventory: dict[Game, list[DropsCampaign]] = {}
|
||||||
# GUI
|
# GUI
|
||||||
self.gui = GUIManager(self)
|
self.gui = GUIManager(self)
|
||||||
# Cookies, session and auth
|
# Cookies, session and auth
|
||||||
self._session: Optional[aiohttp.ClientSession] = None
|
self._session: aiohttp.ClientSession | None = None
|
||||||
self._access_token: Optional[str] = None
|
self._access_token: str | None = None
|
||||||
self._user_id: Optional[int] = None
|
self._user_id: int | None = None
|
||||||
self._is_logged_in = asyncio.Event()
|
self._is_logged_in = asyncio.Event()
|
||||||
# Storing and watching channels
|
# Storing and watching channels
|
||||||
self.channels: OrderedDict[int, Channel] = OrderedDict()
|
self.channels: OrderedDict[int, Channel] = OrderedDict()
|
||||||
self.watching_channel: AwaitableValue[Channel] = AwaitableValue()
|
self.watching_channel: AwaitableValue[Channel] = AwaitableValue()
|
||||||
self._watching_task: Optional[asyncio.Task[None]] = None
|
self._watching_task: asyncio.Task[None] | None = None
|
||||||
self._watching_restart = asyncio.Event()
|
self._watching_restart = asyncio.Event()
|
||||||
self._drop_update: Optional[asyncio.Future[bool]] = None
|
self._drop_update: asyncio.Future[bool] | None = None
|
||||||
# Websocket
|
# Websocket
|
||||||
self.websocket = WebsocketPool(self)
|
self.websocket = WebsocketPool(self)
|
||||||
|
|
||||||
@@ -127,7 +120,7 @@ class Twitch:
|
|||||||
self._state = state
|
self._state = state
|
||||||
self._state_change.set()
|
self._state_change.set()
|
||||||
|
|
||||||
def state_change(self, state: State) -> Callable[[], None]:
|
def state_change(self, state: State) -> abc.Callable[[], None]:
|
||||||
# this is identical to change_state, but defers the call
|
# this is identical to change_state, but defers the call
|
||||||
# perfect for GUI usage
|
# perfect for GUI usage
|
||||||
return partial(self.change_state, state)
|
return partial(self.change_state, state)
|
||||||
@@ -192,7 +185,7 @@ class Twitch:
|
|||||||
self.change_state(State.GAMES_UPDATE)
|
self.change_state(State.GAMES_UPDATE)
|
||||||
elif self._state is State.GAMES_UPDATE:
|
elif self._state is State.GAMES_UPDATE:
|
||||||
# Figure out which games to watch, and claim the drops we can
|
# Figure out which games to watch, and claim the drops we can
|
||||||
games: List[Game] = []
|
games: list[Game] = []
|
||||||
for game, campaigns in self.inventory.items():
|
for game, campaigns in self.inventory.items():
|
||||||
add_game = False
|
add_game = False
|
||||||
for campaign in campaigns:
|
for campaign in campaigns:
|
||||||
@@ -233,7 +226,7 @@ class Twitch:
|
|||||||
elif self._state is State.CHANNELS_CLEANUP:
|
elif self._state is State.CHANNELS_CLEANUP:
|
||||||
if self.game is None or full_cleanup:
|
if self.game is None or full_cleanup:
|
||||||
# no game selected or we're doing full cleanup: remove everything
|
# no game selected or we're doing full cleanup: remove everything
|
||||||
to_remove: List[Channel] = list(channels.values())
|
to_remove: list[Channel] = list(channels.values())
|
||||||
else:
|
else:
|
||||||
# remove all channels that:
|
# remove all channels that:
|
||||||
to_remove = [
|
to_remove = [
|
||||||
@@ -291,7 +284,7 @@ class Twitch:
|
|||||||
# sort them descending by viewers,
|
# sort them descending by viewers,
|
||||||
# then by priority so that prioritized ones are first
|
# then by priority so that prioritized ones are first
|
||||||
# NOTE: We can drop OrderedSet now because there's no more channels being added
|
# NOTE: We can drop OrderedSet now because there's no more channels being added
|
||||||
ordered_channels: List[Channel] = sorted(
|
ordered_channels: list[Channel] = sorted(
|
||||||
new_channels, key=viewers_key, reverse=True
|
new_channels, key=viewers_key, reverse=True
|
||||||
)
|
)
|
||||||
ordered_channels.sort(key=lambda ch: ch.priority, reverse=True)
|
ordered_channels.sort(key=lambda ch: ch.priority, reverse=True)
|
||||||
@@ -348,7 +341,7 @@ class Twitch:
|
|||||||
else:
|
else:
|
||||||
# Change into the selected channel, stay in the watching channel,
|
# Change into the selected channel, stay in the watching channel,
|
||||||
# or select a new channel that meets the required conditions
|
# or select a new channel that meets the required conditions
|
||||||
priority_channels: List[Channel] = []
|
priority_channels: list[Channel] = []
|
||||||
selected_channel = self.gui.channels.get_selection()
|
selected_channel = self.gui.channels.get_selection()
|
||||||
if selected_channel is not None:
|
if selected_channel is not None:
|
||||||
self.gui.channels.clear_selection()
|
self.gui.channels.clear_selection()
|
||||||
@@ -409,7 +402,7 @@ class Twitch:
|
|||||||
if not use_active:
|
if not use_active:
|
||||||
# we need to use GQL to get the current progress
|
# we need to use GQL to get the current progress
|
||||||
context = await self.gql_request(GQL_OPERATIONS["CurrentDrop"])
|
context = await self.gql_request(GQL_OPERATIONS["CurrentDrop"])
|
||||||
drop_data: Optional[JsonType] = (
|
drop_data: JsonType | None = (
|
||||||
context["data"]["currentUser"]["dropCurrentSession"]
|
context["data"]["currentUser"]["dropCurrentSession"]
|
||||||
)
|
)
|
||||||
if drop_data is not None:
|
if drop_data is not None:
|
||||||
@@ -535,7 +528,7 @@ class Twitch:
|
|||||||
if msg_type not in ("drop-progress", "drop-claim"):
|
if msg_type not in ("drop-progress", "drop-claim"):
|
||||||
return
|
return
|
||||||
drop_id: str = message["data"]["drop_id"]
|
drop_id: str = message["data"]["drop_id"]
|
||||||
drop: Optional[TimedDrop] = self.get_drop(drop_id)
|
drop: TimedDrop | None = self.get_drop(drop_id)
|
||||||
if msg_type == "drop-claim":
|
if msg_type == "drop-claim":
|
||||||
if drop is None:
|
if drop is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -561,7 +554,7 @@ class Twitch:
|
|||||||
await asyncio.sleep(4)
|
await asyncio.sleep(4)
|
||||||
for attempt in range(8):
|
for attempt in range(8):
|
||||||
context = await self.gql_request(GQL_OPERATIONS["CurrentDrop"])
|
context = await self.gql_request(GQL_OPERATIONS["CurrentDrop"])
|
||||||
drop_data: Optional[JsonType] = (
|
drop_data: JsonType | None = (
|
||||||
context["data"]["currentUser"]["dropCurrentSession"]
|
context["data"]["currentUser"]["dropCurrentSession"]
|
||||||
)
|
)
|
||||||
if drop_data is None or drop_data["dropID"] != drop.id:
|
if drop_data is None or drop_data["dropID"] != drop.id:
|
||||||
@@ -636,7 +629,7 @@ class Twitch:
|
|||||||
msg_type = message["type"]
|
msg_type = message["type"]
|
||||||
if msg_type == "points-earned":
|
if msg_type == "points-earned":
|
||||||
data: JsonType = message["data"]
|
data: JsonType = message["data"]
|
||||||
channel: Optional[Channel] = self.channels.get(int(data["channel_id"]))
|
channel: Channel | None = self.channels.get(int(data["channel_id"]))
|
||||||
points: int = data["point_gain"]["total_points"]
|
points: int = data["point_gain"]["total_points"]
|
||||||
balance: int = data["balance"]["balance"]
|
balance: int = data["balance"]["balance"]
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
@@ -780,13 +773,13 @@ class Twitch:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def request(
|
async def request(
|
||||||
self, method: str, url: str, *, attempts: int = 5, **kwargs
|
self, method: str, url: str, *, attempts: int = 5, **kwargs
|
||||||
) -> AsyncIterator[aiohttp.ClientResponse]:
|
) -> abc.AsyncIterator[aiohttp.ClientResponse]:
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
session = self._session
|
session = self._session
|
||||||
assert session is not None
|
assert session is not None
|
||||||
method = method.upper()
|
method = method.upper()
|
||||||
cause: Optional[Exception] = None
|
cause: Exception | None = None
|
||||||
for attempt in range(attempts):
|
for attempt in range(attempts):
|
||||||
logger.debug(f"Request: ({method=}, {url=}, {attempts=}, {kwargs=})")
|
logger.debug(f"Request: ({method=}, {url=}, {attempts=}, {kwargs=})")
|
||||||
try:
|
try:
|
||||||
@@ -815,7 +808,7 @@ class Twitch:
|
|||||||
return response_json
|
return response_json
|
||||||
|
|
||||||
async def fetch_campaign(
|
async def fetch_campaign(
|
||||||
self, campaign_id: str, claimed_benefits: Dict[str, datetime]
|
self, campaign_id: str, claimed_benefits: dict[str, datetime]
|
||||||
) -> DropsCampaign:
|
) -> DropsCampaign:
|
||||||
response = await self.gql_request(
|
response = await self.gql_request(
|
||||||
GQL_OPERATIONS["CampaignDetails"].with_variables(
|
GQL_OPERATIONS["CampaignDetails"].with_variables(
|
||||||
@@ -829,7 +822,7 @@ class Twitch:
|
|||||||
response = await self.gql_request(GQL_OPERATIONS["Campaigns"])
|
response = await self.gql_request(GQL_OPERATIONS["Campaigns"])
|
||||||
data = response["data"]["currentUser"]["dropCampaigns"] or []
|
data = response["data"]["currentUser"]["dropCampaigns"] or []
|
||||||
applicable_statuses = ("ACTIVE", "UPCOMING")
|
applicable_statuses = ("ACTIVE", "UPCOMING")
|
||||||
available_campaigns: Set[str] = set(
|
available_campaigns: set[str] = set(
|
||||||
c["id"] for c in data
|
c["id"] for c in data
|
||||||
if c["status"] in applicable_statuses and c["self"]["isAccountConnected"]
|
if c["status"] in applicable_statuses and c["self"]["isAccountConnected"]
|
||||||
)
|
)
|
||||||
@@ -838,10 +831,10 @@ class Twitch:
|
|||||||
inventory = response["data"]["currentUser"]["inventory"]
|
inventory = response["data"]["currentUser"]["inventory"]
|
||||||
ongoing_campaigns = inventory["dropCampaignsInProgress"] or []
|
ongoing_campaigns = inventory["dropCampaignsInProgress"] or []
|
||||||
# this contains claimed benefit edge IDs, not drop IDs
|
# this contains claimed benefit edge IDs, not drop IDs
|
||||||
claimed_benefits: Dict[str, datetime] = {
|
claimed_benefits: dict[str, datetime] = {
|
||||||
b["id"]: timestamp(b["lastAwardedAt"]) for b in inventory["gameEventDrops"]
|
b["id"]: timestamp(b["lastAwardedAt"]) for b in inventory["gameEventDrops"]
|
||||||
}
|
}
|
||||||
campaigns: List[DropsCampaign] = [
|
campaigns: list[DropsCampaign] = [
|
||||||
DropsCampaign(self, campaign_data, claimed_benefits)
|
DropsCampaign(self, campaign_data, claimed_benefits)
|
||||||
for campaign_data in ongoing_campaigns
|
for campaign_data in ongoing_campaigns
|
||||||
]
|
]
|
||||||
@@ -862,7 +855,7 @@ class Twitch:
|
|||||||
self.inventory[game] = []
|
self.inventory[game] = []
|
||||||
self.inventory[game].append(campaign)
|
self.inventory[game].append(campaign)
|
||||||
|
|
||||||
def get_drop(self, drop_id: str) -> Optional[TimedDrop]:
|
def get_drop(self, drop_id: str) -> TimedDrop | None:
|
||||||
"""
|
"""
|
||||||
Returns a drop from the inventory, based on it's ID.
|
Returns a drop from the inventory, based on it's ID.
|
||||||
"""
|
"""
|
||||||
@@ -877,7 +870,7 @@ class Twitch:
|
|||||||
return drop
|
return drop
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_active_drop(self, channel: Optional[Channel] = None) -> Optional[TimedDrop]:
|
def get_active_drop(self, channel: Channel | None = None) -> TimedDrop | None:
|
||||||
if self.game is None:
|
if self.game is None:
|
||||||
return None
|
return None
|
||||||
watching_channel = self.watching_channel.get_with_default(channel)
|
watching_channel = self.watching_channel.get_with_default(channel)
|
||||||
@@ -895,7 +888,7 @@ class Twitch:
|
|||||||
return drops[0]
|
return drops[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_live_streams(self) -> List[Channel]:
|
async def get_live_streams(self) -> list[Channel]:
|
||||||
if self.game is None:
|
if self.game is None:
|
||||||
return []
|
return []
|
||||||
limit = 30
|
limit = 30
|
||||||
@@ -914,7 +907,7 @@ class Twitch:
|
|||||||
for stream_channel_data in response["data"]["game"]["streams"]["edges"]
|
for stream_channel_data in response["data"]["game"]["streams"]["edges"]
|
||||||
]
|
]
|
||||||
|
|
||||||
async def claim_points(self, channel_id: Union[str, int], claim_id: str) -> None:
|
async def claim_points(self, channel_id: str | int, claim_id: str) -> None:
|
||||||
await self.gql_request(
|
await self.gql_request(
|
||||||
GQL_OPERATIONS["ClaimCommunityPoints"].with_variables(
|
GQL_OPERATIONS["ClaimCommunityPoints"].with_variables(
|
||||||
{"input": {"channelID": str(channel_id), "claimID": claim_id}}
|
{"input": {"channelID": str(channel_id), "claimID": claim_id}}
|
||||||
|
|||||||
@@ -6,23 +6,9 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from collections import OrderedDict
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import (
|
from collections import abc, OrderedDict
|
||||||
Any,
|
from typing import Any, Literal, Generic, TypeVar, cast, TYPE_CHECKING
|
||||||
Literal,
|
|
||||||
Union,
|
|
||||||
List,
|
|
||||||
MutableSet,
|
|
||||||
Callable,
|
|
||||||
Iterable,
|
|
||||||
Iterator,
|
|
||||||
Coroutine,
|
|
||||||
Generic,
|
|
||||||
TypeVar,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
cast,
|
|
||||||
)
|
|
||||||
|
|
||||||
from constants import JsonType
|
from constants import JsonType
|
||||||
|
|
||||||
@@ -50,13 +36,13 @@ def create_nonce(length: int = 30) -> str:
|
|||||||
return ''.join(random.choices(NONCE_CHARS, k=length))
|
return ''.join(random.choices(NONCE_CHARS, k=length))
|
||||||
|
|
||||||
|
|
||||||
def deduplicate(iterable: Iterable[_T]) -> List[_T]:
|
def deduplicate(iterable: abc.Iterable[_T]) -> list[_T]:
|
||||||
return list(OrderedDict.fromkeys(iterable).keys())
|
return list(OrderedDict.fromkeys(iterable).keys())
|
||||||
|
|
||||||
|
|
||||||
def task_wrapper(
|
def task_wrapper(
|
||||||
afunc: Callable[_P, Coroutine[Any, Any, _T]]
|
afunc: abc.Callable[_P, abc.Coroutine[Any, Any, _T]]
|
||||||
) -> Callable[_P, Coroutine[Any, Any, _T]]:
|
) -> abc.Callable[_P, abc.Coroutine[Any, Any, _T]]:
|
||||||
@wraps(afunc)
|
@wraps(afunc)
|
||||||
async def wrapper(*args: _P.args, **kwargs: _P.kwargs):
|
async def wrapper(*args: _P.args, **kwargs: _P.kwargs):
|
||||||
try:
|
try:
|
||||||
@@ -76,12 +62,12 @@ def invalidate_cache(instance, *attrnames):
|
|||||||
delattr(instance, name)
|
delattr(instance, name)
|
||||||
|
|
||||||
|
|
||||||
class OrderedSet(MutableSet[_T]):
|
class OrderedSet(abc.MutableSet[_T]):
|
||||||
"""
|
"""
|
||||||
Implementation of a set that preserves insertion order,
|
Implementation of a set that preserves insertion order,
|
||||||
based on OrderedDict with values set to None.
|
based on OrderedDict with values set to None.
|
||||||
"""
|
"""
|
||||||
def __init__(self, iterable: Iterable[_T] = [], /):
|
def __init__(self, iterable: abc.Iterable[_T] = [], /):
|
||||||
self._items: OrderedDict[_T, None] = OrderedDict((item, None) for item in iterable)
|
self._items: OrderedDict[_T, None] = OrderedDict((item, None) for item in iterable)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -90,7 +76,7 @@ class OrderedSet(MutableSet[_T]):
|
|||||||
def __contains__(self, item: object, /) -> bool:
|
def __contains__(self, item: object, /) -> bool:
|
||||||
return item in self._items
|
return item in self._items
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[_T]:
|
def __iter__(self) -> abc.Iterator[_T]:
|
||||||
return iter(self._items)
|
return iter(self._items)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
@@ -103,13 +89,13 @@ class OrderedSet(MutableSet[_T]):
|
|||||||
with suppress(KeyError):
|
with suppress(KeyError):
|
||||||
del self._items[item]
|
del self._items[item]
|
||||||
|
|
||||||
def update(self, *others: Iterable[_T]) -> None:
|
def update(self, *others: abc.Iterable[_T]) -> None:
|
||||||
for it in others:
|
for it in others:
|
||||||
for item in it:
|
for item in it:
|
||||||
if item not in self._items:
|
if item not in self._items:
|
||||||
self._items[item] = None
|
self._items[item] = None
|
||||||
|
|
||||||
def difference_update(self, *others: Iterable[_T]) -> None:
|
def difference_update(self, *others: abc.Iterable[_T]) -> None:
|
||||||
for it in others:
|
for it in others:
|
||||||
for item in it:
|
for item in it:
|
||||||
if item in self._items:
|
if item in self._items:
|
||||||
@@ -124,10 +110,10 @@ class AwaitableValue(Generic[_T]):
|
|||||||
def has_value(self) -> bool:
|
def has_value(self) -> bool:
|
||||||
return self._event.is_set()
|
return self._event.is_set()
|
||||||
|
|
||||||
def wait(self) -> Coroutine[Any, Any, Literal[True]]:
|
def wait(self) -> abc.Coroutine[Any, Any, Literal[True]]:
|
||||||
return cast(Coroutine[Any, Any, Literal[True]], self._event.wait())
|
return cast(abc.Coroutine[Any, Any, Literal[True]], self._event.wait())
|
||||||
|
|
||||||
def get_with_default(self, default: _D) -> Union[_T, _D]:
|
def get_with_default(self, default: _D) -> _T | _D:
|
||||||
if self._event.is_set():
|
if self._event.is_set():
|
||||||
return self._value
|
return self._value
|
||||||
return default
|
return default
|
||||||
|
|||||||
+17
-23
@@ -5,7 +5,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from time import time
|
from time import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Optional, List, Dict, Set, Iterable, TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
|
from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
|
||||||
@@ -15,18 +15,12 @@ except ModuleNotFoundError as exc:
|
|||||||
|
|
||||||
from exceptions import MinerException
|
from exceptions import MinerException
|
||||||
from utils import task_wrapper, create_nonce, AwaitableValue
|
from utils import task_wrapper, create_nonce, AwaitableValue
|
||||||
from constants import (
|
from constants import WEBSOCKET_URL, PING_INTERVAL, PING_TIMEOUT, MAX_WEBSOCKETS, WS_TOPICS_LIMIT
|
||||||
JsonType,
|
|
||||||
WebsocketTopic,
|
|
||||||
WEBSOCKET_URL,
|
|
||||||
PING_INTERVAL,
|
|
||||||
PING_TIMEOUT,
|
|
||||||
MAX_WEBSOCKETS,
|
|
||||||
WS_TOPICS_LIMIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
|
from collections import abc
|
||||||
|
from constants import JsonType, WebsocketTopic
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("TwitchDrops")
|
logger = logging.getLogger("TwitchDrops")
|
||||||
@@ -49,10 +43,10 @@ class Websocket:
|
|||||||
self._next_ping: float = time()
|
self._next_ping: float = time()
|
||||||
self._max_pong: float = self._next_ping + PING_TIMEOUT.total_seconds()
|
self._max_pong: float = self._next_ping + PING_TIMEOUT.total_seconds()
|
||||||
# main task, responsible for receiving messages, sending them, and websocket ping
|
# main task, responsible for receiving messages, sending them, and websocket ping
|
||||||
self._handle_task: Optional[asyncio.Task[None]] = None
|
self._handle_task: asyncio.Task[None] | None = None
|
||||||
# topics stuff
|
# topics stuff
|
||||||
self.topics: Dict[str, WebsocketTopic] = {}
|
self.topics: dict[str, WebsocketTopic] = {}
|
||||||
self._submitted: Set[WebsocketTopic] = set()
|
self._submitted: set[WebsocketTopic] = set()
|
||||||
# notify GUI
|
# notify GUI
|
||||||
self.set_status("Disconnected")
|
self.set_status("Disconnected")
|
||||||
|
|
||||||
@@ -63,7 +57,7 @@ class Websocket:
|
|||||||
def wait_until_connected(self):
|
def wait_until_connected(self):
|
||||||
return self._ws.wait()
|
return self._ws.wait()
|
||||||
|
|
||||||
def set_status(self, status: Optional[str] = None, refresh_topics: bool = False):
|
def set_status(self, status: str | None = None, refresh_topics: bool = False):
|
||||||
self._twitch.gui.websockets.update(
|
self._twitch.gui.websockets.update(
|
||||||
self._idx, status=status, topics=(len(self.topics) if refresh_topics else None)
|
self._idx, status=status, topics=(len(self.topics) if refresh_topics else None)
|
||||||
)
|
)
|
||||||
@@ -174,7 +168,7 @@ class Websocket:
|
|||||||
# nothing to do
|
# nothing to do
|
||||||
return
|
return
|
||||||
self._topics_changed.clear()
|
self._topics_changed.clear()
|
||||||
current: Set[WebsocketTopic] = set(self.topics.values())
|
current: set[WebsocketTopic] = set(self.topics.values())
|
||||||
# handle removed topics
|
# handle removed topics
|
||||||
removed = self._submitted.difference(current)
|
removed = self._submitted.difference(current)
|
||||||
if removed:
|
if removed:
|
||||||
@@ -206,7 +200,7 @@ class Websocket:
|
|||||||
)
|
)
|
||||||
self._submitted.update(added)
|
self._submitted.update(added)
|
||||||
|
|
||||||
async def _gather_recv(self, messages: List[JsonType]):
|
async def _gather_recv(self, messages: list[JsonType]):
|
||||||
"""
|
"""
|
||||||
Gather incoming messages over the timeout specified.
|
Gather incoming messages over the timeout specified.
|
||||||
Note that there's no return value - this modifies `messages` in-place.
|
Note that there's no return value - this modifies `messages` in-place.
|
||||||
@@ -231,7 +225,7 @@ class Websocket:
|
|||||||
Handle receiving messages from the websocket.
|
Handle receiving messages from the websocket.
|
||||||
"""
|
"""
|
||||||
# listen over 0.5s for incoming messages
|
# listen over 0.5s for incoming messages
|
||||||
messages: List[JsonType] = []
|
messages: list[JsonType] = []
|
||||||
with suppress(asyncio.TimeoutError):
|
with suppress(asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(self._gather_recv(messages), timeout=0.5)
|
await asyncio.wait_for(self._gather_recv(messages), timeout=0.5)
|
||||||
# process them
|
# process them
|
||||||
@@ -251,7 +245,7 @@ class Websocket:
|
|||||||
else:
|
else:
|
||||||
ws_logger.warning(f"Websocket[{self._idx}] received unknown payload: {message}")
|
ws_logger.warning(f"Websocket[{self._idx}] received unknown payload: {message}")
|
||||||
|
|
||||||
def add_topics(self, topics_set: Set[WebsocketTopic]):
|
def add_topics(self, topics_set: set[WebsocketTopic]):
|
||||||
changed: bool = False
|
changed: bool = False
|
||||||
while topics_set and len(self.topics) < WS_TOPICS_LIMIT:
|
while topics_set and len(self.topics) < WS_TOPICS_LIMIT:
|
||||||
topic = topics_set.pop()
|
topic = topics_set.pop()
|
||||||
@@ -261,7 +255,7 @@ class Websocket:
|
|||||||
self._topics_changed.set()
|
self._topics_changed.set()
|
||||||
self.set_status(refresh_topics=True)
|
self.set_status(refresh_topics=True)
|
||||||
|
|
||||||
def remove_topics(self, topics_set: Set[str]):
|
def remove_topics(self, topics_set: set[str]):
|
||||||
existing = topics_set.intersection(self.topics.keys())
|
existing = topics_set.intersection(self.topics.keys())
|
||||||
if not existing:
|
if not existing:
|
||||||
# nothing to remove from here
|
# nothing to remove from here
|
||||||
@@ -285,7 +279,7 @@ class WebsocketPool:
|
|||||||
def __init__(self, twitch: Twitch):
|
def __init__(self, twitch: Twitch):
|
||||||
self._twitch: Twitch = twitch
|
self._twitch: Twitch = twitch
|
||||||
self._running = asyncio.Event()
|
self._running = asyncio.Event()
|
||||||
self.websockets: List[Websocket] = []
|
self.websockets: list[Websocket] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def running(self) -> bool:
|
def running(self) -> bool:
|
||||||
@@ -307,7 +301,7 @@ class WebsocketPool:
|
|||||||
self._running.clear()
|
self._running.clear()
|
||||||
await asyncio.gather(*(ws.stop() for ws in self.websockets))
|
await asyncio.gather(*(ws.stop() for ws in self.websockets))
|
||||||
|
|
||||||
def add_topics(self, topics: Iterable[WebsocketTopic]):
|
def add_topics(self, topics: abc.Iterable[WebsocketTopic]):
|
||||||
# ensure no topics end up duplicated
|
# ensure no topics end up duplicated
|
||||||
topics_set = set(topics)
|
topics_set = set(topics)
|
||||||
if not topics_set:
|
if not topics_set:
|
||||||
@@ -335,7 +329,7 @@ class WebsocketPool:
|
|||||||
# if we're here, there were leftover topics after filling up all websockets
|
# if we're here, there were leftover topics after filling up all websockets
|
||||||
raise MinerException("Maximum topics limit has been reached")
|
raise MinerException("Maximum topics limit has been reached")
|
||||||
|
|
||||||
def remove_topics(self, topics: Iterable[str]):
|
def remove_topics(self, topics: abc.Iterable[str]):
|
||||||
topics_set = set(topics)
|
topics_set = set(topics)
|
||||||
if not topics_set:
|
if not topics_set:
|
||||||
# nothing to remove
|
# nothing to remove
|
||||||
@@ -344,7 +338,7 @@ class WebsocketPool:
|
|||||||
ws.remove_topics(topics_set)
|
ws.remove_topics(topics_set)
|
||||||
# count up all the topics - if we happen to have more websockets connected than needed,
|
# count up all the topics - if we happen to have more websockets connected than needed,
|
||||||
# stop the last one and recycle topics from it - repeat until we have enough
|
# stop the last one and recycle topics from it - repeat until we have enough
|
||||||
recycled_topics: List[WebsocketTopic] = []
|
recycled_topics: list[WebsocketTopic] = []
|
||||||
while True:
|
while True:
|
||||||
count = sum(len(ws.topics) for ws in self.websockets)
|
count = sum(len(ws.topics) for ws in self.websockets)
|
||||||
if count <= (len(self.websockets) - 1) * WS_TOPICS_LIMIT:
|
if count <= (len(self.websockets) - 1) * WS_TOPICS_LIMIT:
|
||||||
|
|||||||
Reference in new issue
Block a user