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 <noreply@anthropic.com>
This commit is contained in:
1 parent
171fc64ca9
commit
e82f8d1858
19 files changed
+685
-54
No files matched your search
@@ -2,3 +2,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
build/
|
||||
dist/
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<rect x="8" y="8" width="240" height="240" rx="56" fill="#14171a"/>
|
||||
<rect x="8" y="8" width="240" height="240" rx="56" fill="none" stroke="#2f353b" stroke-width="6"/>
|
||||
<g transform="rotate(-10 128 128)">
|
||||
<rect x="70" y="44" width="116" height="168" rx="14" fill="#1d2125" stroke="#3eb489" stroke-width="10"/>
|
||||
<rect x="86" y="62" width="84" height="58" rx="6" fill="#24443a"/>
|
||||
</g>
|
||||
<circle cx="98" cy="166" r="13" fill="#c1d7e9"/>
|
||||
<circle cx="130" cy="160" r="13" fill="#bab1ab"/>
|
||||
<circle cx="162" cy="154" r="13" fill="#e49977"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 621 B |
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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("<HHH", 0, 1, len(pngs))
|
||||
offset = 6 + 16 * len(pngs)
|
||||
for size, png in pngs:
|
||||
dim = 0 if size >= 256 else size # 0 means 256
|
||||
out += struct.pack("<BBBBHHII", dim, dim, 0, 0, 1, 32, len(png), offset)
|
||||
offset += len(png)
|
||||
out += b"".join(png for _, png in pngs)
|
||||
(ROOT / "assets/icon.ico").write_bytes(out)
|
||||
# The app loads this at runtime (window/taskbar icon), so it lives inside the package.
|
||||
(ROOT / "src/mtga_assistant/icon.png").write_bytes(dict(pngs)[256])
|
||||
print(f"wrote assets/icon.ico ({len(out)} bytes) and src/mtga_assistant/icon.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
# PyInstaller spec for MTGA Assistant: a one-folder, windowed build.
|
||||
# Build on the target OS (PyInstaller can't cross-compile), e.g. on Windows:
|
||||
# powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(SPECPATH).parent
|
||||
SRC = ROOT / "src"
|
||||
|
||||
a = Analysis(
|
||||
[str(ROOT / "packaging" / "launch.py")],
|
||||
pathex=[str(SRC)],
|
||||
datas=[(str(SRC / "mtga_assistant" / "icon.png"), "mtga_assistant")],
|
||||
# Qt modules the app doesn't use; keeps the bundle smaller.
|
||||
excludes=[
|
||||
"tkinter",
|
||||
"PySide6.QtNetwork",
|
||||
"PySide6.QtQml",
|
||||
"PySide6.QtQuick",
|
||||
"PySide6.QtSql",
|
||||
"PySide6.QtTest",
|
||||
"PySide6.QtOpenGL",
|
||||
"PySide6.QtPdf",
|
||||
],
|
||||
noarchive=False,
|
||||
)
|
||||
# Not needed by a Qt Widgets app: the software OpenGL fallback (~20 MB) and Qt's translations.
|
||||
a.binaries = [b for b in a.binaries if Path(b[0]).name.lower() != "opengl32sw.dll"]
|
||||
a.datas = [d for d in a.datas if "translations" not in Path(d[0]).parts]
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="MTGA Assistant",
|
||||
console=False,
|
||||
icon=str(ROOT / "assets" / "icon.ico") if sys.platform == "win32" else None,
|
||||
)
|
||||
coll = COLLECT(exe, a.binaries, a.datas, name="MTGA Assistant")
|
||||
@@ -6,6 +6,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"PySide6-Essentials>=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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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()
|
||||
+142
-32
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -180,7 +180,7 @@ class PlayerCard(QFrame):
|
||||
else:
|
||||
color = theme.DANGER if remaining <= warn else theme.MINT_TEXT
|
||||
self.timer.setText(f"<span style='font-size:{theme.FONT_PT * 2}pt; color:{color}; font-weight:bold'>"
|
||||
f"⏱ {fmt_seconds(remaining)}</span>")
|
||||
f"◷ {fmt_seconds(remaining)}</span>")
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user