From e82f8d185825414caff1e1ddcd9640ba19d8b621 Mon Sep 17 00:00:00 2001 From: Aeth3r Date: Fri, 16 Oct 2026 13:06:19 -0400 Subject: [PATCH] Make the app cross-platform and add a Windows build - paths: per-platform detection behind an injectable Env. Windows finds Player.log in the user profile and the install via Steam (registry), the standalone installer, Epic, or the uninstall registry. App dirs come from platformdirs (unchanged XDG locations on Linux). - logtail: open Player.log only while reading, so Windows can still rotate it when MTGA restarts. - carddb: on Windows read a private copy of the card database so MTGA's updater can replace the original. - App icon (SVG source + generated ICO/PNG), Windows taskbar identity, and a Windows-safe timer glyph. - PyInstaller spec, launcher and PowerShell build script producing a one-folder windowed build and zip. - Tests for path detection, the log follower and the card-DB copy; README covers Windows usage, building and per-OS locations. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + README.md | 44 ++++++- assets/icon.ico | Bin 0 -> 27768 bytes assets/icon.svg | 11 ++ packaging/build_windows.ps1 | 13 ++ packaging/launch.py | 7 ++ packaging/make_icon.py | 51 ++++++++ packaging/mtga-assistant.spec | 42 +++++++ pyproject.toml | 2 + src/mtga_assistant/app.py | 11 +- src/mtga_assistant/carddb.py | 33 +++++ src/mtga_assistant/icon.png | Bin 0 -> 12822 bytes src/mtga_assistant/logtail.py | 27 +++-- src/mtga_assistant/paths.py | 174 ++++++++++++++++++++++----- src/mtga_assistant/ui/main_window.py | 3 +- src/mtga_assistant/ui/match_view.py | 2 +- tests/test_logtail.py | 75 ++++++++++++ tests/test_paths.py | 135 +++++++++++++++++++++ uv.lock | 107 +++++++++++++++- 19 files changed, 685 insertions(+), 54 deletions(-) create mode 100644 assets/icon.ico create mode 100644 assets/icon.svg create mode 100644 packaging/build_windows.ps1 create mode 100644 packaging/launch.py create mode 100644 packaging/make_icon.py create mode 100644 packaging/mtga-assistant.spec create mode 100644 src/mtga_assistant/icon.png create mode 100644 tests/test_logtail.py create mode 100644 tests/test_paths.py diff --git a/.gitignore b/.gitignore index 17cd2fc..4d53613 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc .pytest_cache/ +build/ +dist/ diff --git a/README.md b/README.md index e3e1c64..86316fe 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,34 @@ Card names, mana costs and types come from MTGA's own local card database, so th ## Requirements -- MTGA installed through Steam (Proton). The log, install and card DB are auto-detected from your - Steam library folders. +Runs on **Linux** and **Windows**. + - **Detailed logs enabled** in MTGA: *Options → Account → Detailed Logs (Plugin Support)*. -- [uv](https://docs.astral.sh/uv/) (`pacman -S uv`). +- MTGA's log, install folder and card database are auto-detected: + - Linux: MTGA through Steam (Proton), found via your Steam library folders. + - Windows: Steam, Epic Games or the standalone Wizards installer. The log is always in + `%USERPROFILE%\AppData\LocalLow\Wizards Of The Coast\MTGA\Player.log`. +- To run from source: [uv](https://docs.astral.sh/uv/) (`pacman -S uv` on Arch; + `winget install astral-sh.uv` on Windows). ## Running +From source (any OS): + ```sh uv run mtga-assistant # follow the live Player.log uv run mtga-assistant --replay path/to/Player-prev.log --rate 40 # replay a saved log uv run pytest # tests ``` +On Windows you can instead use the packaged build: unzip `MTGA-Assistant-windows.zip` and run +`MTGA Assistant.exe`. + Start it before or after MTGA: on startup it reads the existing log to catch up on a match in progress, then follows new lines. When MTGA restarts and replaces the log, the tracker resets. +### Linux desktop integration + On Wayland the window's app id is `mtga-assistant`. For example, to keep it floating on another workspace in Hyprland: @@ -57,18 +69,38 @@ windowrule = float, class:^(mtga-assistant)$ windowrule = workspace 2, class:^(mtga-assistant)$ ``` +### Building the Windows app + +PyInstaller can't cross-compile, so build on Windows (with uv installed): + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1 +``` + +This produces `dist\MTGA Assistant\MTGA Assistant.exe` and `dist\MTGA-Assistant-windows.zip`. +The same spec builds a Linux bundle when run on Linux +(`uv run --group build pyinstaller packaging/mtga-assistant.spec`). After editing +`assets/icon.svg`, regenerate the icons with `uv run python packaging/make_icon.py`. + ## Configuration -Optional: `~/.config/mtga-assistant/config.toml` +Optional `config.toml`: + +| | Config file | History | Image cache | +|---|---|---|---| +| Linux | `~/.config/mtga-assistant/config.toml` | `~/.local/share/mtga-assistant/` | `~/.cache/mtga-assistant/` | +| Windows | `%APPDATA%\mtga-assistant\config.toml` | `%LOCALAPPDATA%\mtga-assistant\` | `%LOCALAPPDATA%\mtga-assistant\Cache\` | ```toml log_path = "/path/to/Player.log" # override auto-detection -mtga_dir = "/path/to/steamapps/common/MTGA" +mtga_dir = "/path/to/MTGA" # the folder containing MTGA_Data cache_max_mb = 500 # image cache cap history_max_mb = 300 # cap for stored game details (summaries are always kept) ``` -Game history lives in `~/.local/share/mtga-assistant/history.db` (roughly 20–100 KB per game). +Game history is stored in `history.db` (roughly 20–100 KB per game). On Windows the app reads a +private copy of MTGA's card database (kept in the cache folder, about 260 MB) so that MTGA's +updater is never blocked by the app holding the file open. ## Image cache diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e3a8ef3fbfda956a22e013eed728a8f12810aa15 GIT binary patch literal 27768 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 DL4QM0 literal 0 HcmV?d00001 diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..3a7106a --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packaging/build_windows.ps1 b/packaging/build_windows.ps1 new file mode 100644 index 0000000..689baac --- /dev/null +++ b/packaging/build_windows.ps1 @@ -0,0 +1,13 @@ +# Build the Windows app folder and a zip of it: dist\MTGA Assistant\ and dist\MTGA-Assistant-windows.zip +# Requires uv (https://docs.astral.sh/uv/). Run from anywhere: +# powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1 +$ErrorActionPreference = "Stop" +Set-Location (Split-Path $PSScriptRoot -Parent) + +uv sync --group build +uv run pyinstaller --noconfirm --clean packaging/mtga-assistant.spec + +$zip = "dist/MTGA-Assistant-windows.zip" +if (Test-Path $zip) { Remove-Item $zip } +Compress-Archive -Path "dist/MTGA Assistant" -DestinationPath $zip +Write-Host "Built dist\MTGA Assistant\MTGA Assistant.exe and $zip" diff --git a/packaging/launch.py b/packaging/launch.py new file mode 100644 index 0000000..aad29ed --- /dev/null +++ b/packaging/launch.py @@ -0,0 +1,7 @@ +"""Entry point for the PyInstaller build (PyInstaller needs a script, not a package).""" + +import sys + +from mtga_assistant.app import main + +sys.exit(main()) diff --git a/packaging/make_icon.py b/packaging/make_icon.py new file mode 100644 index 0000000..b16aa83 --- /dev/null +++ b/packaging/make_icon.py @@ -0,0 +1,51 @@ +"""Render assets/icon.svg to assets/icon.ico (PNG-compressed entries at the usual Windows sizes). + +Run with: uv run python packaging/make_icon.py +""" + +import struct +import sys +from pathlib import Path + +from PySide6.QtCore import QBuffer, QByteArray, QIODevice, Qt +from PySide6.QtGui import QGuiApplication, QImage, QPainter +from PySide6.QtSvg import QSvgRenderer + +SIZES = [16, 24, 32, 48, 64, 128, 256] +ROOT = Path(__file__).resolve().parent.parent + + +def render_png(renderer: QSvgRenderer, size: int) -> bytes: + img = QImage(size, size, QImage.Format.Format_ARGB32) + img.fill(Qt.GlobalColor.transparent) + painter = QPainter(img) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + renderer.render(painter) + painter.end() + data = QByteArray() + buf = QBuffer(data) + buf.open(QIODevice.OpenModeFlag.WriteOnly) + img.save(buf, "PNG") + return bytes(data) + + +def main() -> None: + QGuiApplication(sys.argv[:1]) + renderer = QSvgRenderer(str(ROOT / "assets/icon.svg")) + pngs = [(size, render_png(renderer, size)) for size in SIZES] + # ICONDIR header, then one ICONDIRENTRY per image, then the PNG data. + out = struct.pack("= 256 else size # 0 means 256 + out += struct.pack("=6.8", + "platformdirs>=4", ] [project.scripts] @@ -13,6 +14,7 @@ mtga-assistant = "mtga_assistant.app:main" [dependency-groups] dev = ["pytest>=8"] +build = ["pyinstaller>=6"] [build-system] requires = ["hatchling"] diff --git a/src/mtga_assistant/app.py b/src/mtga_assistant/app.py index 9fcbca1..18e99cb 100644 --- a/src/mtga_assistant/app.py +++ b/src/mtga_assistant/app.py @@ -6,10 +6,11 @@ import argparse import sys from pathlib import Path +from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication from . import paths -from .carddb import CardDB +from .carddb import open_card_db from .history import HistoryStore from .images import ImageCache from .ui import theme @@ -30,14 +31,20 @@ def main() -> int: if args.mtga_dir: cfg.mtga_dir = args.mtga_dir + if sys.platform == "win32": + # Own taskbar identity (icon and grouping) instead of python.exe's. + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("mtga-assistant") app = QApplication([sys.argv[0], *qt_args]) + app.setWindowIcon(QIcon(str(Path(__file__).with_name("icon.png")))) app.setApplicationName("MTGA Assistant") app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules theme.apply(app) mtga_dir = paths.find_mtga_dir(cfg) db_path = paths.find_card_db(mtga_dir) if mtga_dir else None - cards = CardDB(db_path) + cards = open_card_db(db_path, paths.CACHE_DIR / "carddb") images = ImageCache(paths.CACHE_DIR, cards, cfg.cache_max_mb * 1024 * 1024) history = HistoryStore(paths.DATA_DIR / "history.db", cfg.history_max_mb) diff --git a/src/mtga_assistant/carddb.py b/src/mtga_assistant/carddb.py index a4999d9..42c8779 100644 --- a/src/mtga_assistant/carddb.py +++ b/src/mtga_assistant/carddb.py @@ -3,7 +3,9 @@ from __future__ import annotations import re +import shutil import sqlite3 +import sys from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path @@ -84,6 +86,37 @@ class CardInfo: return " ".join(self.types) +def local_copy(db: Path, copy_dir: Path) -> Path: + """Copy MTGA's card DB into our cache (once per DB version) and return the copy's path. + + Used on Windows, where keeping MTGA's own file open would block its updater from replacing it. + The DB file name contains a content hash, so a copy with the same name and size is current. + """ + copy_dir.mkdir(parents=True, exist_ok=True) + target = copy_dir / db.name + if not target.exists() or target.stat().st_size != db.stat().st_size: + tmp = target.with_suffix(".part") + shutil.copyfile(db, tmp) + tmp.replace(target) + for old in copy_dir.glob("Raw_CardDatabase_*.mtga"): + if old != target: + try: + old.unlink() + except OSError: + pass + return target + + +def open_card_db(db: Path | None, copy_dir: Path, platform: str = sys.platform) -> "CardDB": + """Open the card DB; on Windows, via a private copy (see local_copy).""" + if db is not None and platform == "win32": + try: + db = local_copy(db, copy_dir) + except OSError: + pass # fall back to reading MTGA's file directly + return CardDB(db) + + def unknown_card(grp_id: int) -> CardInfo: return CardInfo(grp_id=grp_id, name=f"Unknown card #{grp_id}") diff --git a/src/mtga_assistant/icon.png b/src/mtga_assistant/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b7f47e30abd8f8ec189f1e25f7268867049f1844 GIT binary patch literal 12822 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 AC;$Ke literal 0 HcmV?d00001 diff --git a/src/mtga_assistant/logtail.py b/src/mtga_assistant/logtail.py index 1c5e743..e7adf30 100644 --- a/src/mtga_assistant/logtail.py +++ b/src/mtga_assistant/logtail.py @@ -12,6 +12,7 @@ from .logparse import LogParser POLL_INTERVAL = 0.25 BATCH_MAX = 200 +READ_MAX = 8 * 1024 * 1024 # per poll, so catching up on a big log stays responsive class LogFollower(QThread): @@ -50,9 +51,10 @@ class LogFollower(QThread): self.status.emit(f"Replay of {self.path.name} finished") def _follow(self) -> None: + # The file is only open while reading: on Windows an open handle would stop MTGA from + # renaming Player.log to Player-prev.log when it restarts. parser = LogParser() - f = None - inode = None + inode: int | None = None # None until the first read pos = 0 buf = b"" waiting_reported = False @@ -68,25 +70,30 @@ class LogFollower(QThread): continue waiting_reported = False - if f is None or st.st_ino != inode or st.st_size < pos: - # First open, or MTGA restarted and replaced/truncated the log. - if f is not None: - f.close() + if inode is None or st.st_ino != inode or st.st_size < pos: + # First read, or MTGA restarted and replaced/truncated the log. + if inode is not None: self.reset.emit() - f = open(self.path, "rb") inode, pos, buf = st.st_ino, 0, b"" parser.reset() self.status.emit(f"Reading {self.path.name}") following = False - chunk = f.read() + chunk = b"" + if st.st_size > pos: + try: + with open(self.path, "rb") as f: + f.seek(pos) + chunk = f.read(READ_MAX) + except OSError: + chunk = b"" # e.g. the file is being replaced right now; retry next poll if not chunk: if not following: self.status.emit(f"Following {self.path.name}") following = True time.sleep(POLL_INTERVAL) continue - pos = f.tell() + pos += len(chunk) buf += chunk lines = buf.split(b"\n") buf = lines.pop() # keep a trailing partial line for the next read @@ -99,5 +106,3 @@ class LogFollower(QThread): batch = [] if batch: self.events.emit(batch) - if f is not None: - f.close() diff --git a/src/mtga_assistant/paths.py b/src/mtga_assistant/paths.py index db28b12..24d4288 100644 --- a/src/mtga_assistant/paths.py +++ b/src/mtga_assistant/paths.py @@ -1,29 +1,57 @@ -"""Locate MTGA's Player.log and install directory inside a Steam/Proton setup.""" +"""Locate MTGA's Player.log, install directory and card database, on Linux (Steam/Proton) or Windows. + +Detection functions take an ``Env`` (platform, home, environment variables, registry access) so the +Windows logic can be tested anywhere; ``current_env()`` describes the machine we're running on. +""" from __future__ import annotations import os import re +import sys import tomllib -from dataclasses import dataclass +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field from pathlib import Path -MTGA_APP_ID = "2141910" -LOG_SUBPATH = Path( - "pfx/drive_c/users/steamuser/AppData/LocalLow/Wizards Of The Coast/MTGA" -) +import platformdirs -CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "mtga-assistant" -CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "mtga-assistant" -DATA_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "mtga-assistant" +APP_NAME = "mtga-assistant" +MTGA_APP_ID = "2141910" # Steam app id +# Where MTGA writes Player.log, relative to the Windows user profile (also inside a Proton prefix). +LOG_DIR_IN_PROFILE = Path("AppData/LocalLow/Wizards Of The Coast/MTGA") +PROTON_PROFILE = Path("pfx/drive_c/users/steamuser") + +# On Linux these resolve to the XDG locations (~/.config, ~/.cache, ~/.local/share). +CONFIG_DIR = Path(platformdirs.user_config_dir(APP_NAME, appauthor=False, roaming=True)) +CACHE_DIR = Path(platformdirs.user_cache_dir(APP_NAME, appauthor=False)) +DATA_DIR = Path(platformdirs.user_data_dir(APP_NAME, appauthor=False)) CONFIG_FILE = CONFIG_DIR / "config.toml" -STEAM_ROOTS = [ - Path.home() / ".local/share/Steam", - Path.home() / ".steam/steam", - Path.home() / ".steam/root", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam", -] +# (hive, key, value) -> registry string or None +RegistryReader = Callable[[str, str, str], "str | None"] + + +@dataclass +class Env: + platform: str + home: Path + environ: Mapping[str, str] = field(default_factory=dict) + read_registry: RegistryReader | None = None + # (DisplayName, InstallLocation) pairs from the Windows uninstall registry keys. + uninstall_entries: Callable[[], list[tuple[str, str]]] | None = None + + @property + def windows(self) -> bool: + return self.platform == "win32" + + +def current_env() -> Env: + env = Env(platform=sys.platform, home=Path.home(), environ=os.environ) + if env.windows: + env.read_registry = _winreg_read + env.uninstall_entries = _winreg_uninstall_entries + return env @dataclass @@ -51,10 +79,31 @@ def load_config() -> Config: return cfg -def steam_libraries() -> list[Path]: +# -- Steam ------------------------------------------------------------------------------- + + +def steam_roots(env: Env) -> list[Path]: + if env.windows: + roots = [] + if env.read_registry and (p := env.read_registry("HKCU", r"Software\Valve\Steam", "SteamPath")): + roots.append(Path(p)) + x86 = env.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + roots.append(Path(x86) / "Steam") + return roots + home = env.home + return [ + home / ".local/share/Steam", + home / ".steam/steam", + home / ".steam/root", + home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", + ] + + +def steam_libraries(env: Env | None = None) -> list[Path]: """All Steam library folders listed in any libraryfolders.vdf we can find.""" + env = env or current_env() libs: list[Path] = [] - for root in STEAM_ROOTS: + for root in steam_roots(env): vdf = root / "steamapps/libraryfolders.vdf" try: text = vdf.read_text(errors="replace") @@ -69,40 +118,101 @@ def steam_libraries() -> list[Path]: return libs -def find_mtga_library() -> Path | None: - for lib in steam_libraries(): +def find_mtga_library(env: Env | None = None) -> Path | None: + for lib in steam_libraries(env): if (lib / f"steamapps/appmanifest_{MTGA_APP_ID}.acf").exists(): return lib return None -def find_log_path(cfg: Config) -> Path | None: +# -- log and install ------------------------------------------------------------------------- + + +def find_log_path(cfg: Config, env: Env | None = None) -> Path | None: if cfg.log_path: return cfg.log_path - libs = steam_libraries() - mtga_lib = find_mtga_library() - # The prefix normally lives in the same library as the game, but check all of them. + env = env or current_env() + if env.windows: + # Same place for Steam, Epic and the standalone installer. + profile = Path(env.environ.get("USERPROFILE") or env.home) + d = profile / LOG_DIR_IN_PROFILE + return d / "Player.log" if d.is_dir() else None + libs = steam_libraries(env) + mtga_lib = find_mtga_library(env) + # The Proton prefix normally lives in the same library as the game, but check all of them. if mtga_lib: - libs = [mtga_lib] + [l for l in libs if l != mtga_lib] + libs = [mtga_lib] + [lib for lib in libs if lib != mtga_lib] for lib in libs: - d = lib / "steamapps/compatdata" / MTGA_APP_ID / LOG_SUBPATH + d = lib / "steamapps/compatdata" / MTGA_APP_ID / PROTON_PROFILE / LOG_DIR_IN_PROFILE if d.is_dir(): return d / "Player.log" return None -def find_mtga_dir(cfg: Config) -> Path | None: +def _is_mtga_install(d: Path) -> bool: + return (d / "MTGA_Data").is_dir() + + +def find_mtga_dir(cfg: Config, env: Env | None = None) -> Path | None: if cfg.mtga_dir: return cfg.mtga_dir - lib = find_mtga_library() - if lib: - d = lib / "steamapps/common/MTGA" - if d.is_dir(): - return d - return None + env = env or current_env() + candidates: list[Path] = [] + if lib := find_mtga_library(env): + candidates.append(lib / "steamapps/common/MTGA") + if env.windows: + program_files = Path(env.environ.get("ProgramFiles", r"C:\Program Files")) + candidates += [ + program_files / "Wizards of the Coast/MTGA", # standalone installer + program_files / "Epic Games/MagicTheGathering", # Epic Games Store + ] + if env.uninstall_entries: + candidates += [Path(loc) for name, loc in env.uninstall_entries() + if loc and "magic" in name.lower() and "arena" in name.lower()] + return next((d for d in candidates if _is_mtga_install(d)), None) def find_card_db(mtga_dir: Path) -> Path | None: raw = mtga_dir / "MTGA_Data/Downloads/Raw" dbs = sorted(raw.glob("Raw_CardDatabase_*.mtga"), key=lambda p: p.stat().st_mtime) return dbs[-1] if dbs else None + + +# -- Windows registry ----------------------------------------------------------------------------- + + +def _winreg_read(hive: str, key: str, value: str) -> str | None: + import winreg + + root = {"HKCU": winreg.HKEY_CURRENT_USER, "HKLM": winreg.HKEY_LOCAL_MACHINE}[hive] + try: + with winreg.OpenKey(root, key) as k: + v, _type = winreg.QueryValueEx(k, value) + return str(v) + except OSError: + return None + + +def _winreg_uninstall_entries() -> list[tuple[str, str]]: + import winreg + + out = [] + roots = [ + (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"), + (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"), + (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"), + ] + for root, path in roots: + try: + with winreg.OpenKey(root, path) as parent: + for i in range(winreg.QueryInfoKey(parent)[0]): + try: + with winreg.OpenKey(parent, winreg.EnumKey(parent, i)) as k: + name = winreg.QueryValueEx(k, "DisplayName")[0] + loc = winreg.QueryValueEx(k, "InstallLocation")[0] + out.append((str(name), str(loc))) + except OSError: + continue + except OSError: + continue + return out diff --git a/src/mtga_assistant/ui/main_window.py b/src/mtga_assistant/ui/main_window.py index 032d6f4..ead0134 100644 --- a/src/mtga_assistant/ui/main_window.py +++ b/src/mtga_assistant/ui/main_window.py @@ -24,6 +24,7 @@ from PySide6.QtWidgets import ( QWidget, ) +from .. import paths from ..carddb import CardDB from ..gamestate import MatchTracker from ..history import HistoryRecorder, HistoryStore, import_log, replay_game @@ -405,7 +406,7 @@ class MainWindow(QMainWindow): messages = [] if not self.cards.available: messages.append("MTGA card database not found: card names are unavailable. " - "Set mtga_dir in ~/.config/mtga-assistant/config.toml.") + f"Set mtga_dir in {paths.CONFIG_FILE}.") # MTGA sometimes leaves oversized messages out of the log. Usually later messages fill the # gap in; only flag it (quietly) while some of my cards are still not accounted for. unaccounted = 0 diff --git a/src/mtga_assistant/ui/match_view.py b/src/mtga_assistant/ui/match_view.py index 268d0e8..bb0e4d8 100644 --- a/src/mtga_assistant/ui/match_view.py +++ b/src/mtga_assistant/ui/match_view.py @@ -180,7 +180,7 @@ class PlayerCard(QFrame): else: color = theme.DANGER if remaining <= warn else theme.MINT_TEXT self.timer.setText(f"" - f"⏱ {fmt_seconds(remaining)}") + f"◷ {fmt_seconds(remaining)}") self.timer.setToolTip("Turn timer running" if running else "Time available before the turn timer runs out (starts when the rope appears)") self.timer.setVisible(self.toggles.on("timer") and timer is not None) diff --git a/tests/test_logtail.py b/tests/test_logtail.py new file mode 100644 index 0000000..1c4a8f9 --- /dev/null +++ b/tests/test_logtail.py @@ -0,0 +1,75 @@ +"""The log follower: chunked appends, partial lines, MTGA replacing the log, and no lingering handle.""" + +import os +import time +from pathlib import Path + +import pytest +from PySide6.QtCore import QCoreApplication + +from mtga_assistant.logtail import LogFollower + +FIXTURE = Path(__file__).parent / "fixtures" / "bo3_match.log" + + +@pytest.fixture(scope="module") +def app(): + return QCoreApplication.instance() or QCoreApplication([]) + + +def wait_for(app, cond, timeout=5.0): + end = time.monotonic() + timeout + while time.monotonic() < end: + app.processEvents() + if cond(): + return True + time.sleep(0.02) + return False + + +def open_handles_to(path: Path) -> int: + """Number of this process's file descriptors pointing at path (Linux /proc).""" + fd_dir = Path("/proc/self/fd") + if not fd_dir.exists(): + pytest.skip("needs /proc") + count = 0 + for fd in fd_dir.iterdir(): + try: + if Path(os.readlink(fd)) == path: + count += 1 + except OSError: + pass + return count + + +def test_follow_append_replace_and_no_open_handle(app, tmp_path): + src = FIXTURE.read_bytes() + log = tmp_path / "Player.log" + log.write_bytes(b"") + got, resets = [], [] + f = LogFollower(log) + f.events.connect(lambda evs: got.extend(evs)) + f.reset.connect(lambda: resets.append(1)) + f.start() + try: + cut = len(src) * 3 // 4 # mid-line + with open(log, "ab") as fh: + fh.write(src[:cut]) + assert wait_for(app, lambda: len(got) >= 11) + with open(log, "ab") as fh: + fh.write(src[cut:]) + assert wait_for(app, lambda: len(got) == 22) + + # Between polls the follower must not keep Player.log open (Windows would then block + # MTGA from renaming it). + time.sleep(0.3) + assert open_handles_to(log.resolve()) == 0 + + # MTGA restarts: Player.log is replaced by a new file. + new = tmp_path / "Player.new" + new.write_bytes(src[:cut]) + os.replace(new, log) + assert wait_for(app, lambda: resets and len(got) >= 33) + finally: + f.stop() + f.wait(3000) diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..85d6a50 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,135 @@ +"""Path detection for Linux (Steam/Proton) and Windows, against fake directory trees.""" + +from pathlib import Path + +from mtga_assistant.carddb import local_copy, open_card_db +from mtga_assistant.paths import MTGA_APP_ID, Config, Env, find_card_db, find_log_path, find_mtga_dir + + +def make_install(d: Path) -> Path: + raw = d / "MTGA_Data/Downloads/Raw" + raw.mkdir(parents=True) + (raw / "Raw_CardDatabase_abc.mtga").write_bytes(b"db") + return d + + +def make_log_dir(profile: Path) -> Path: + d = profile / "AppData/LocalLow/Wizards Of The Coast/MTGA" + d.mkdir(parents=True) + return d + + +def steam_library(lib: Path, vdf_root: Path, *, with_mtga: bool = True) -> None: + (vdf_root / "steamapps").mkdir(parents=True, exist_ok=True) + escaped = str(lib).replace("\\", "\\\\") + (vdf_root / "steamapps/libraryfolders.vdf").write_text( + f'"libraryfolders"\n{{\n "0"\n {{\n "path" "{escaped}"\n }}\n}}\n') + if with_mtga: + (lib / "steamapps").mkdir(parents=True, exist_ok=True) + (lib / f"steamapps/appmanifest_{MTGA_APP_ID}.acf").write_text("") + make_install(lib / "steamapps/common/MTGA") + + +# -- Linux --------------------------------------------------------------------------------------- + + +def test_linux_proton(tmp_path): + home, lib = tmp_path / "home", tmp_path / "games" + steam_library(lib, home / ".local/share/Steam") + make_log_dir(lib / f"steamapps/compatdata/{MTGA_APP_ID}/pfx/drive_c/users/steamuser") + env = Env(platform="linux", home=home) + log = find_log_path(Config(), env) + assert log is not None and log.name == "Player.log" and "compatdata" in log.parts + assert find_mtga_dir(Config(), env) == lib / "steamapps/common/MTGA" + + +def test_linux_nothing_installed(tmp_path): + env = Env(platform="linux", home=tmp_path) + assert find_log_path(Config(), env) is None + assert find_mtga_dir(Config(), env) is None + + +# -- Windows --------------------------------------------------------------------------------------- + + +def windows_env(tmp_path, registry=None, uninstall=None) -> Env: + home = tmp_path / "Users/me" + home.mkdir(parents=True, exist_ok=True) + return Env( + platform="win32", home=home, + environ={"USERPROFILE": str(home), "ProgramFiles": str(tmp_path / "Program Files"), + "ProgramFiles(x86)": str(tmp_path / "Program Files (x86)")}, + read_registry=lambda hive, key, value: (registry or {}).get((hive, key, value)), + uninstall_entries=lambda: uninstall or [], + ) + + +def test_windows_log_is_in_user_profile(tmp_path): + env = windows_env(tmp_path) + assert find_log_path(Config(), env) is None # MTGA never ran + d = make_log_dir(Path(env.environ["USERPROFILE"])) + assert find_log_path(Config(), env) == d / "Player.log" + + +def test_windows_steam_found_via_registry(tmp_path): + steam, lib = tmp_path / "Steam", tmp_path / "D/SteamLibrary" + steam_library(lib, steam) + env = windows_env(tmp_path, registry={("HKCU", r"Software\Valve\Steam", "SteamPath"): str(steam)}) + assert find_mtga_dir(Config(), env) == lib / "steamapps/common/MTGA" + + +def test_windows_steam_default_location(tmp_path): + steam, lib = tmp_path / "Program Files (x86)/Steam", tmp_path / "E/Games" + steam_library(lib, steam) + assert find_mtga_dir(Config(), windows_env(tmp_path)) == lib / "steamapps/common/MTGA" + + +def test_windows_standalone_and_epic(tmp_path): + env = windows_env(tmp_path) + epic = make_install(tmp_path / "Program Files/Epic Games/MagicTheGathering") + assert find_mtga_dir(Config(), env) == epic + standalone = make_install(tmp_path / "Program Files/Wizards of the Coast/MTGA") + assert find_mtga_dir(Config(), env) == standalone # preferred over Epic + + +def test_windows_uninstall_registry_fallback(tmp_path): + custom = make_install(tmp_path / "Games/MTGA") + env = windows_env(tmp_path, uninstall=[("Some Other App", str(tmp_path)), + ("Magic: The Gathering Arena", str(custom))]) + assert find_mtga_dir(Config(), env) == custom + assert find_card_db(custom).name == "Raw_CardDatabase_abc.mtga" + + +def test_config_overrides_detection(tmp_path): + cfg = Config(log_path=tmp_path / "x.log", mtga_dir=tmp_path / "mtga") + env = windows_env(tmp_path) + assert find_log_path(cfg, env) == tmp_path / "x.log" + assert find_mtga_dir(cfg, env) == tmp_path / "mtga" + + +# -- card DB copy (Windows) ------------------------------------------------------------------------- + + +def test_card_db_copy_reused_and_old_versions_removed(tmp_path): + src_dir, copies = tmp_path / "Raw", tmp_path / "cache/carddb" + src_dir.mkdir() + v1 = src_dir / "Raw_CardDatabase_v1.mtga" + v1.write_bytes(b"one") + c1 = local_copy(v1, copies) + assert c1.read_bytes() == b"one" + mtime = c1.stat().st_mtime_ns + assert local_copy(v1, copies).stat().st_mtime_ns == mtime # not copied again + + v2 = src_dir / "Raw_CardDatabase_v2.mtga" + v2.write_bytes(b"two!") + c2 = local_copy(v2, copies) + assert c2.read_bytes() == b"two!" and not c1.exists() + + +def test_open_card_db_copies_only_on_windows(tmp_path): + import sqlite3 + db = tmp_path / "Raw_CardDatabase_x.mtga" + sqlite3.connect(db).close() + assert open_card_db(db, tmp_path / "copies", platform="linux").path == db + win = open_card_db(db, tmp_path / "copies", platform="win32") + assert win.path == tmp_path / "copies" / db.name diff --git a/uv.lock b/uv.lock index 656cb75..4e12c10 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 5 requires-python = ">=3.12" +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -20,23 +29,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + [[package]] name = "mtga-assistant" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "platformdirs" }, { name = "pyside6-essentials" }, ] [package.dev-dependencies] +build = [ + { name = "pyinstaller" }, +] dev = [ { name = "pytest" }, ] [package.metadata] -requires-dist = [{ name = "pyside6-essentials", specifier = ">=6.8" }] +requires-dist = [ + { name = "platformdirs", specifier = ">=4" }, + { name = "pyside6-essentials", specifier = ">=6.8" }, +] [package.metadata.requires-dev] +build = [{ name = "pyinstaller", specifier = ">=6" }] dev = [{ name = "pytest", specifier = ">=8" }] [[package]] @@ -48,6 +77,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/91/4a03cfdb03314cfca262921797fff04ff4054bd86a160aed76eddf8aa12b/platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce", size = 61559, upload-time = "2026-10-16T01:16:16.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/b8/fd1af06b079af236f5423f7c1821264419cc8f6b4803f79353acbb8bfa53/platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4", size = 32762, upload-time = "2026-10-16T01:16:15.051Z" }, +] + [[package]] name = "pluggy" version = "1.7.0" @@ -66,6 +113,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.22.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/41/f90302845945abd4ed647933ff5ee7c6ac93983187be67f897b6cb613331/pyinstaller-6.22.3.tar.gz", hash = "sha256:05eb2f5615503e72939a7224d68b4aff572c6b0438ee4a17d0a4b481f399362d", size = 4437180, upload-time = "2026-09-12T21:54:09.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/af/898f70947e27f8e65d55183c780e77112e023c0b4a6fba6255d4d563a27d/pyinstaller-6.22.3-py3-none-macosx_10_13_universal2.whl", hash = "sha256:052f4a1cd4f81092ccb7a18fe8ebc9ecf832a5917317e39fb3775af4e959b253", size = 1071847, upload-time = "2026-09-12T21:53:01.711Z" }, + { url = "https://files.pythonhosted.org/packages/c4/96/99f5184985141c2b024bfa61335e9142ae444555224f303186e59b7bb438/pyinstaller-6.22.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:312da84b4b31ab9750066a823734c11b7c68757fdd67c9f038bf19c330a954e4", size = 758172, upload-time = "2026-09-12T21:53:06.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/9ccb12cc1e6617042cb028fe79f0a756f5f03305bd14b584a4e043fe3844/pyinstaller-6.22.3-py3-none-manylinux2014_i686.whl", hash = "sha256:020d4fe0627f5c73dfbcfad1a6578ef354d5fe4bdccd9d8e5a615528f5a45d2b", size = 772135, upload-time = "2026-09-12T21:53:10.291Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/4b1d5193a88628c052db89f064433681076959a9f41d0d9f660001b49079/pyinstaller-6.22.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:9e4f7bd64b2103f14b4a6240fb9155e9fe7bc61391b4043bbddb7d3ebc8ab0fa", size = 770461, upload-time = "2026-09-12T21:53:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/f173f8757c618d83e116fa2b8e57c5cd796e5ad09792e664eabd6ac5edae/pyinstaller-6.22.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9", size = 764997, upload-time = "2026-09-12T21:53:19.079Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b6/dd21d61fc5e25377be24eabf13e99e4e46548a5abe2bf7b15d8b3d9cc74a/pyinstaller-6.22.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:451a4ae14b719365bf1a2f0a99dae7b3463060061c3a394c70d5264cfb439528", size = 764974, upload-time = "2026-09-12T21:53:23.176Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/d9590942972b1fc205519f7ccffb0d5cd8eb1d6fa61edad319c71d25baed/pyinstaller-6.22.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0", size = 764450, upload-time = "2026-09-12T21:53:27.419Z" }, + { url = "https://files.pythonhosted.org/packages/72/d6/f22c55d1768433b65c460b19a3136dd9769b6c74b786149b829344654c7d/pyinstaller-6.22.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282", size = 763863, upload-time = "2026-09-12T21:53:31.609Z" }, + { url = "https://files.pythonhosted.org/packages/c5/87/cf28cfd42ceca889171e6dd5e7f1553d4372db83d047049ffb7cf95fcbd4/pyinstaller-6.22.3-py3-none-win32.whl", hash = "sha256:46a3e30a118341a1ec20667a5a95aa7e0aa8c4fa129893c0151b3eb3f801a722", size = 1390905, upload-time = "2026-09-12T21:53:38.133Z" }, + { url = "https://files.pythonhosted.org/packages/24/83/c5422204772a27aef643280fdc887b7ef55679989096b21dceb1b9625fc4/pyinstaller-6.22.3-py3-none-win_amd64.whl", hash = "sha256:500bd58c7bf7e584a8435adccbd763a0b918d5c12b08d74ff50fd79b2915458b", size = 1454907, upload-time = "2026-09-12T21:53:44.812Z" }, + { url = "https://files.pythonhosted.org/packages/67/2e/ebc93968bf63f81b122e205da116b16036b653b7ce2cc8ad86e3ddd19586/pyinstaller-6.22.3-py3-none-win_arm64.whl", hash = "sha256:31df3cc4261e804a53d358b7897c293ec8b88b167a7cca06c78018085c855f97", size = 1402956, upload-time = "2026-09-12T21:53:51.369Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3b/fab1a12a21bf9223af012e5dd002b7d4265683d21d9fabbd0ccb347316e4/pyinstaller_hooks_contrib-2026.8.tar.gz", hash = "sha256:4d825786ad7a9b7dbcc52d612748a34562fe273461bcfd88f4d549c594bbf8f9", size = 176955, upload-time = "2026-09-30T07:53:52.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/f1/3eee0263b487e571aed9204257b45bc2a0b6fec34d0a1adade4d8d8a968c/pyinstaller_hooks_contrib-2026.8-py3-none-any.whl", hash = "sha256:6d21d65323ea4467753afc21f52d7d0e69416ae8101dd88269bf273ad48db084", size = 462145, upload-time = "2026-09-30T07:53:51.167Z" }, +] + [[package]] name = "pyside6-essentials" version = "6.12.0" @@ -97,6 +184,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + [[package]] name = "shiboken6" version = "6.12.0"