Merge branch 'main' into libadwaita-1.4

This commit is contained in:
kramo
2023-08-30 10:21:28 +02:00
parent e67977287d
commit 89bc0877fd
73 changed files with 4569 additions and 3197 deletions

View File

@@ -17,10 +17,18 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import Adw
from typing import Optional
from gi.repository import Adw, Gtk
def create_dialog(win, heading, body, extra_option=None, extra_label=None):
def create_dialog(
win: Gtk.Window,
heading: str,
body: str,
extra_option: Optional[str] = None,
extra_label: Optional[str] = None,
) -> Adw.MessageDialog:
dialog = Adw.MessageDialog.new(win, heading, body)
dialog.add_response("dismiss", _("Dismiss"))

View File

@@ -23,14 +23,14 @@ from pathlib import Path
from src import shared
old_data_dir = Path.home() / ".local" / "share"
old_data_dir = shared.home / ".local" / "share"
old_cartridges_data_dir = old_data_dir / "cartridges"
migrated_file_path = old_cartridges_data_dir / ".migrated"
old_games_dir = old_cartridges_data_dir / "games"
old_covers_dir = old_cartridges_data_dir / "covers"
def migrate_game_covers(game_path: Path):
def migrate_game_covers(game_path: Path) -> None:
"""Migrate a game covers from a source game path to the current dir"""
for suffix in (".tiff", ".gif"):
cover_path = old_covers_dir / game_path.with_suffix(suffix).name
@@ -41,7 +41,7 @@ def migrate_game_covers(game_path: Path):
cover_path.rename(destination_cover_path)
def migrate_files_v1_to_v2():
def migrate_files_v1_to_v2() -> None:
"""
Migrate user data from the v1.X locations to the latest location.

View File

@@ -17,11 +17,11 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Optional, Sized
from threading import Lock, Thread, BoundedSemaphore
from time import sleep, time
from collections import deque
from contextlib import AbstractContextManager
from threading import BoundedSemaphore, Lock, Thread
from time import sleep, time
from typing import Any, Sized
class PickHistory(Sized):
@@ -30,22 +30,22 @@ class PickHistory(Sized):
period: int
timestamps: list[int] = None
timestamps_lock: Lock = None
timestamps: list[float]
timestamps_lock: Lock
def __init__(self, period: int) -> None:
self.period = period
self.timestamps = []
self.timestamps_lock = Lock()
def remove_old_entries(self):
def remove_old_entries(self) -> None:
"""Remove history entries older than the period"""
now = time()
cutoff = now - self.period
with self.timestamps_lock:
self.timestamps = [entry for entry in self.timestamps if entry > cutoff]
def add(self, *new_timestamps: Optional[int]):
def add(self, *new_timestamps: float) -> None:
"""Add timestamps to the history.
If none given, will add the current timestamp"""
if len(new_timestamps) == 0:
@@ -60,7 +60,7 @@ class PickHistory(Sized):
return len(self.timestamps)
@property
def start(self) -> int:
def start(self) -> float:
"""Get the time at which the history started"""
self.remove_old_entries()
with self.timestamps_lock:
@@ -70,7 +70,7 @@ class PickHistory(Sized):
entry = time()
return entry
def copy_timestamps(self) -> str:
def copy_timestamps(self) -> list[float]:
"""Get a copy of the timestamps history"""
self.remove_old_entries()
with self.timestamps_lock:
@@ -79,51 +79,55 @@ class PickHistory(Sized):
# pylint: disable=too-many-instance-attributes
class RateLimiter(AbstractContextManager):
"""Rate limiter implementing the token bucket algorithm"""
"""
Base rate limiter implementing the token bucket algorithm.
Do not use directly, create a child class to tailor the rate limiting to the
underlying service's limits.
Subclasses must provide values to the following attributes:
* refill_period_seconds - Period in which we have a max amount of tokens
* refill_period_tokens - Number of tokens allowed in this period
* burst_tokens - Max number of tokens that can be consumed instantly
"""
# Period in which we have a max amount of tokens
refill_period_seconds: int
# Number of tokens allowed in this period
refill_period_tokens: int
# Max number of tokens that can be consumed instantly
burst_tokens: int
pick_history: PickHistory = None
bucket: BoundedSemaphore = None
queue: deque[Lock] = None
queue_lock: Lock = None
pick_history: PickHistory
bucket: BoundedSemaphore
queue: deque[Lock]
queue_lock: Lock
# Protect the number of tokens behind a lock
__n_tokens_lock: Lock = None
__n_tokens_lock: Lock
__n_tokens = 0
@property
def n_tokens(self):
def n_tokens(self) -> int:
with self.__n_tokens_lock:
return self.__n_tokens
@n_tokens.setter
def n_tokens(self, value: int):
def n_tokens(self, value: int) -> None:
with self.__n_tokens_lock:
self.__n_tokens = value
def __init__(
self,
refill_period_seconds: Optional[int] = None,
refill_period_tokens: Optional[int] = None,
burst_tokens: Optional[int] = None,
) -> None:
def _init_pick_history(self) -> None:
"""
Initialize the tocken pick history
(only for use in this class and its children)
By default, creates an empty pick history.
Should be overriden or extended by subclasses.
"""
self.pick_history = PickHistory(self.refill_period_seconds)
def __init__(self) -> None:
"""Initialize the limiter"""
# Initialize default values
if refill_period_seconds is not None:
self.refill_period_seconds = refill_period_seconds
if refill_period_tokens is not None:
self.refill_period_tokens = refill_period_tokens
if burst_tokens is not None:
self.burst_tokens = burst_tokens
if self.pick_history is None:
self.pick_history = PickHistory(self.refill_period_seconds)
self._init_pick_history()
# Create synchronization data
self.__n_tokens_lock = Lock()
@@ -147,8 +151,8 @@ class RateLimiter(AbstractContextManager):
"""
# Compute ideal spacing
tokens_left = self.refill_period_tokens - len(self.pick_history)
seconds_left = self.pick_history.start + self.refill_period_seconds - time()
tokens_left = self.refill_period_tokens - len(self.pick_history) # type: ignore
seconds_left = self.pick_history.start + self.refill_period_seconds - time() # type: ignore
try:
spacing_seconds = seconds_left / tokens_left
except ZeroDivisionError:
@@ -159,7 +163,7 @@ class RateLimiter(AbstractContextManager):
natural_spacing = self.refill_period_seconds / self.refill_period_tokens
return max(natural_spacing, spacing_seconds)
def refill(self):
def refill(self) -> None:
"""Add a token back in the bucket"""
sleep(self.refill_spacing)
try:
@@ -170,7 +174,7 @@ class RateLimiter(AbstractContextManager):
else:
self.n_tokens += 1
def refill_thread_func(self):
def refill_thread_func(self) -> None:
"""Entry point for the daemon thread that is refilling the bucket"""
while True:
self.refill()
@@ -200,18 +204,18 @@ class RateLimiter(AbstractContextManager):
self.queue.appendleft(lock)
return lock
def acquire(self):
def acquire(self) -> None:
"""Acquires a token from the bucket when it's your turn in queue"""
lock = self.add_to_queue()
self.update_queue()
# Wait until our turn in queue
lock.acquire() # pylint: disable=consider-using-with
self.pick_history.add()
self.pick_history.add() # type: ignore
# --- Support for use in with statements
def __enter__(self):
def __enter__(self) -> None:
self.acquire()
def __exit__(self, *_args):
def __exit__(self, *_args: Any) -> None:
pass

View File

@@ -18,11 +18,12 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime
from typing import Any
from gi.repository import GLib
def relative_date(timestamp): # pylint: disable=too-many-return-statements
def relative_date(timestamp: int) -> Any: # pylint: disable=too-many-return-statements
days_no = ((today := datetime.today()) - datetime.fromtimestamp(timestamp)).days
if days_no == 0:

View File

@@ -20,17 +20,30 @@
from pathlib import Path
from shutil import copyfile
from typing import Optional
from gi.repository import Gdk, Gio, GLib
from gi.repository import Gdk, GdkPixbuf, Gio, GLib
from PIL import Image, ImageSequence, UnidentifiedImageError
from src import shared
def resize_cover(cover_path=None, pixbuf=None):
def convert_cover(
cover_path: Optional[Path] = None,
pixbuf: Optional[GdkPixbuf.Pixbuf] = None,
resize: bool = True,
) -> Optional[Path]:
if not cover_path and not pixbuf:
return None
pixbuf_extensions = set()
for pixbuf_format in GdkPixbuf.Pixbuf.get_formats():
for pixbuf_extension in pixbuf_format.get_extensions():
pixbuf_extensions.add(pixbuf_extension)
if not resize and cover_path and cover_path.suffix.lower()[1:] in pixbuf_extensions:
return cover_path
if pixbuf:
cover_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
pixbuf.savev(str(cover_path), "tiff", ["compression"], ["1"])
@@ -39,7 +52,8 @@ def resize_cover(cover_path=None, pixbuf=None):
with Image.open(cover_path) as image:
if getattr(image, "is_animated", False):
frames = tuple(
frame.resize((200, 300)) for frame in ImageSequence.Iterator(image)
frame.resize((200, 300)) if resize else frame
for frame in ImageSequence.Iterator(image)
)
tmp_path = Path(Gio.File.new_tmp("XXXXXX.gif")[0].get_path())
@@ -47,6 +61,7 @@ def resize_cover(cover_path=None, pixbuf=None):
tmp_path,
save_all=True,
append_images=frames[1:],
disposal=2,
)
else:
@@ -56,7 +71,7 @@ def resize_cover(cover_path=None, pixbuf=None):
image = image.convert("RGBA")
tmp_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
image.resize(shared.image_size).save(
(image.resize(shared.image_size) if resize else image).save(
tmp_path,
compression="tiff_adobe_deflate"
if shared.schema.get_boolean("high-quality-images")
@@ -67,14 +82,14 @@ def resize_cover(cover_path=None, pixbuf=None):
Gdk.Texture.new_from_filename(str(cover_path)).save_to_tiff(
tmp_path := Gio.File.new_tmp("XXXXXX.tiff")[0].get_path()
)
return resize_cover(tmp_path)
return convert_cover(tmp_path)
except GLib.GError:
return None
return tmp_path
def save_cover(game_id, cover_path):
def save_cover(game_id: str, cover_path: Path) -> None:
shared.covers_dir.mkdir(parents=True, exist_ok=True)
animated_path = shared.covers_dir / f"{game_id}.gif"

View File

@@ -21,13 +21,14 @@
import json
import logging
import re
from pathlib import Path
from typing import TypedDict
import requests
from requests.exceptions import HTTPError
from src import shared
from src.utils.rate_limiter import PickHistory, RateLimiter
from src.utils.rate_limiter import RateLimiter
class SteamError(Exception):
@@ -71,16 +72,18 @@ class SteamRateLimiter(RateLimiter):
refill_period_tokens = 200
burst_tokens = 100
def __init__(self) -> None:
# Load pick history from schema
# (Remember API limits through restarts of Cartridges)
def _init_pick_history(self) -> None:
"""
Load the pick history from schema.
Allows remembering API limits through restarts of Cartridges.
"""
super()._init_pick_history()
timestamps_str = shared.state_schema.get_string("steam-limiter-tokens-history")
self.pick_history = PickHistory(self.refill_period_seconds)
self.pick_history.add(*json.loads(timestamps_str))
self.pick_history.remove_old_entries()
super().__init__()
def acquire(self):
def acquire(self) -> None:
"""Get a token from the bucket and store the pick history in the schema"""
super().acquire()
timestamps_str = json.dumps(self.pick_history.copy_timestamps())
@@ -88,9 +91,9 @@ class SteamRateLimiter(RateLimiter):
class SteamFileHelper:
"""Helper for steam file formats"""
"""Helper for Steam file formats"""
def get_manifest_data(self, manifest_path) -> SteamManifestData:
def get_manifest_data(self, manifest_path: Path) -> SteamManifestData:
"""Get local data for a game from its manifest"""
with open(manifest_path, "r", encoding="utf-8") as file:
@@ -104,7 +107,11 @@ class SteamFileHelper:
raise SteamInvalidManifestError()
data[key] = match.group(1)
return SteamManifestData(**data)
return SteamManifestData(
name=data["name"],
appid=data["appid"],
stateflags=data["stateflags"],
)
class SteamAPIHelper:
@@ -116,7 +123,7 @@ class SteamAPIHelper:
def __init__(self, rate_limiter: RateLimiter) -> None:
self.rate_limiter = rate_limiter
def get_api_data(self, appid) -> SteamAPIData:
def get_api_data(self, appid: str) -> SteamAPIData:
"""
Get online data for a game from its appid.
May block to satisfy the Steam web API limitations.

View File

@@ -20,13 +20,15 @@
import logging
from pathlib import Path
from typing import Any
import requests
from gi.repository import Gio
from requests.exceptions import HTTPError
from src import shared
from src.utils.save_cover import resize_cover, save_cover
from src.game import Game
from src.utils.save_cover import convert_cover, save_cover
class SGDBError(Exception):
@@ -55,12 +57,12 @@ class SGDBHelper:
base_url = "https://www.steamgriddb.com/api/v2/"
@property
def auth_headers(self):
def auth_headers(self) -> dict[str, str]:
key = shared.schema.get_string("sgdb-key")
headers = {"Authorization": f"Bearer {key}"}
return headers
def get_game_id(self, game):
def get_game_id(self, game: Game) -> Any:
"""Get grid results for a game. Can raise an exception."""
uri = f"{self.base_url}search/autocomplete/{game.name}"
res = requests.get(uri, headers=self.auth_headers, timeout=5)
@@ -74,7 +76,7 @@ class SGDBHelper:
case _:
res.raise_for_status()
def get_image_uri(self, game_id, animated=False):
def get_image_uri(self, game_id: str, animated: bool = False) -> Any:
"""Get the image for a SGDB game id"""
uri = f"{self.base_url}grids/game/{game_id}?dimensions=600x900"
if animated:
@@ -93,7 +95,7 @@ class SGDBHelper:
case _:
res.raise_for_status()
def conditionaly_update_cover(self, game):
def conditionaly_update_cover(self, game: Game) -> None:
"""Update the game's cover if appropriate"""
# Obvious skips
@@ -132,7 +134,7 @@ class SGDBHelper:
tmp_file = Gio.File.new_tmp()[0]
tmp_file_path = tmp_file.get_path()
Path(tmp_file_path).write_bytes(response.content)
save_cover(game.game_id, resize_cover(tmp_file_path))
save_cover(game.game_id, convert_cover(tmp_file_path))
except SGDBAuthError as error:
# Let caller handle auth errors
raise error

View File

@@ -1,86 +0,0 @@
# task.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from functools import wraps
from gi.repository import Gio
def create_task_thread_func_closure(func, data):
"""Wrap a Gio.TaskThreadFunc with the given data in a closure"""
def closure(task, source_object, _data, cancellable):
func(task, source_object, data, cancellable)
return closure
def decorate_set_task_data(task):
"""Decorate Gio.Task.set_task_data to replace it"""
def decorator(original_method):
@wraps(original_method)
def new_method(task_data):
task.task_data = task_data
return new_method
return decorator
def decorate_run_in_thread(task):
"""Decorate Gio.Task.run_in_thread to pass the task data correctly
Creates a closure around task_thread_func with the task data available."""
def decorator(original_method):
@wraps(original_method)
def new_method(task_thread_func):
closure = create_task_thread_func_closure(task_thread_func, task.task_data)
original_method(closure)
return new_method
return decorator
# pylint: disable=too-few-public-methods
class Task:
"""Wrapper around Gio.Task to patch task data not being passed"""
@classmethod
def new(cls, source_object, cancellable, callback, callback_data):
"""Create a new, monkey-patched Gio.Task.
The `set_task_data` and `run_in_thread` methods are decorated.
As of 2023-05-19, pygobject does not work well with Gio.Task, so to pass data
the only viable way it to create a closure with the thread function and its data.
This class is supposed to make Gio.Task comply with its expected behaviour
per the docs:
http://lazka.github.io/pgi-docs/#Gio-2.0/classes/Task.html#Gio.Task.set_task_data
This code may break if pygobject overrides change in the future.
We need to manually pass `self` to the decorators since it's otherwise bound but
not accessible from Python's side.
"""
task = Gio.Task.new(source_object, cancellable, callback, callback_data)
task.set_task_data = decorate_set_task_data(task)(task.set_task_data)
task.run_in_thread = decorate_run_in_thread(task)(task.run_in_thread)
return task