Compare commits

1 Commits

27 changed files with 4324 additions and 1 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Reference codebase
heroic-gogdl-main/
# Environment and secrets
.env
# AI agent documentation (not for commit per AGENTS.md)
AGENTS.md
CLAUDE.md
DESIGN_DOCUMENT.md
# Python
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
# Type checking
.mypy_cache/
.ruff_cache/
# Testing
.pytest_cache/
# Build
dist/
build/
*.egg-info/
# IDE
.vscode/
.idea/
# Logs
logs/

24
CHANGELOG.md Normal file
View File

@@ -0,0 +1,24 @@
# Changelog
## [1.0.0] — 2026-04-09
### Features
- GOG OAuth2 authentication — browser-based login, paste authorization code
- Game library browser with checkboxes for management, DLCs as sub-items
- Windows / Linux platform switcher in Library and Status tabs (games shown only for platforms where installers exist)
- Per-language installer management — each language tracked independently, version comparison per language
- Language selection tab — choose which languages to download globally
- English-only mode — single toggle to skip language subfolders entirely
- Bonus content support — soundtracks, wallpapers, manuals etc. stored in `GameName/Bonus/`
- Per-game settings override — double-click any game in Library to configure languages, english-only and bonus per game
- Folder name sanitization — strips ®, ™, replaces `:` with ` - `, collapses spaces
- Version comparison — numeric dot-separated comparison with GOG suffix stripping; ambiguous versions ask user via dialog
- Status tab — per-language version display, color-coded status (up to date, update available, not downloaded, unversioned)
- Download with resume support (HTTP Range), real filename extracted from CDN URL
- Prune old versions — keep N most recent, configurable per platform
- Metadata integrity check — stale entries (manually deleted files) cleaned automatically on each check
- Scan existing installers — detect already-downloaded files from correct folder structure and import into metadata
- Version history dialog — double-click game in Status tab to see all downloaded versions with delete options
- DLC installers stored in parent game's folder, not separate folders
- Windows-only games do not download bonus content to Linux path and vice versa

34
GOGUpdater.py Normal file
View File

@@ -0,0 +1,34 @@
"""GOGUpdater GUI entry point."""
import sys
from loguru import logger
from PySide6.QtWidgets import QApplication
from src.api import GogApi
from src.auth import AuthManager
from src.config import AppConfig, DEFAULT_CONFIG_DIR
from src.constants import APP_TITLE
from src.ui.main_window import MainWindow
def main() -> None:
config_dir = DEFAULT_CONFIG_DIR
config_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Starting {APP_TITLE}")
auth = AuthManager(config_dir)
api = GogApi(auth)
config = AppConfig(config_dir)
app = QApplication(sys.argv)
window = MainWindow(auth, api, config)
window.show()
logger.info("Application window shown")
sys.exit(app.exec())
if __name__ == "__main__":
main()

131
PROJECT.md Normal file
View File

@@ -0,0 +1,131 @@
# GOGUpdater
Desktop application (PySide6) for automatic management of GOG.com offline installers.
## Description
GOGUpdater authenticates with a GOG account via OAuth2, lets the user select which games to manage, and automatically downloads/updates offline installers (Windows .exe, Linux .sh) to local directories. The user has a clear overview of installer status and can trigger checks and downloads manually.
## Current version
**1.0.0** (2026-04-09)
## Architecture
### GUI — 5 tabs
1. **Login** — OAuth2 flow, opens login URL in system browser, user pastes authorization code
2. **Library** — owned games with management checkboxes, DLCs as sub-items; Windows/Linux platform switcher; double-click opens per-game settings
3. **Languages** — select which languages to download globally; disabled when English-only mode is on
4. **Settings** — Windows/Linux installer paths, English-only toggle, bonus content toggle, scan existing installers
5. **Status** — installer status per platform (Windows/Linux switcher), check/download/prune controls; double-click opens version history dialog
### Installer folder structure
```
/path/Game Name/1.63/English/setup_game.exe
/path/Game Name/1.63/Czech/setup_game_cs.exe
/path/Game Name/1.52/English/setup_game.exe ← old version kept
/path/Game Name/Bonus/soundtrack.zip ← bonus content (no version subfolder)
```
Single-language games and English-only mode skip the language subfolder:
```
/path/Game Name/1.63/setup_game.exe
```
DLC installers are stored inside the parent game's folder.
### Folder name sanitization
Game titles are sanitized before use as folder names:
- `:`` - `
- Strips `®`, `™`, `©`
- Removes invalid filesystem characters
- Collapses multiple spaces
### Metadata
Each target path (windows/linux) contains `gogupdater.json` tracking downloaded installers and bonus files. Stale entries (files deleted outside the app) are cleaned automatically on each status check.
```json
{
"games": {
"1207658691": {
"name": "Cyberpunk 2077",
"latest_version": "1.63",
"managed": true,
"installers": [
{
"filename": "setup_cyberpunk_2077_1.63.exe",
"size": 12345678,
"version": "1.63",
"language": "en",
"installer_type": "game",
"downloaded_at": "2026-04-09T10:00:00"
}
],
"bonuses": [],
"last_checked": "2026-04-09T10:00:00"
}
}
}
```
### Application configuration
Stored in `~/.config/gogupdater/`:
- `auth.json` — OAuth2 tokens
- `config.json` — paths, languages, managed games, english_only, include_bonus, per-game overrides
### GOG API endpoints
- `GET /user/data/games` — list of owned game IDs
- `GET /products/{id}?expand=downloads,expanded_dlcs` — game info, installers, bonus content, DLCs
- `POST https://auth.gog.com/token` — OAuth2 authentication and refresh
### Download URL resolution
GOG uses a two-level redirect: API downlink → JSON with CDN URL → actual file. The real filename is extracted from the CDN URL path (e.g. `setup_fallout_2.1.0.18.exe`).
### Version comparison
- Strips GOG suffixes like `(gog-3)` before comparing
- Numeric dot-separated comparison
- Ambiguous cases (e.g. `2.2(gog-3)` vs `2.3`) prompt the user via dialog
- Non-version strings like `"bonus"` are treated as unversioned
### Per-game settings
Each game can override global settings:
- Languages (which languages to download)
- English-only (skip language subfolder)
- Include bonus content
Stored as `game_settings` in `config.json`. Default (no override) entries are not saved.
## Modules (src/)
- `auth.py` — authentication, token management
- `api.py` — GOG API client (installers, bonus content, owned games)
- `config.py` — application config, metadata store, scan/verify existing installers
- `downloader.py` — installer and bonus file downloads with resume support
- `models.py` — data structures (GameRecord, InstallerInfo, BonusContent, GameSettings, ...)
- `version_compare.py` — version comparison with ambiguous case detection
- `constants.py` — version, app title, debug mode
- `ui/main_window.py` — main window with tab layout
- `ui/tab_auth.py` — login tab
- `ui/tab_library.py` — library tab with platform switcher
- `ui/tab_languages.py` — language selection tab
- `ui/tab_settings.py` — settings tab
- `ui/tab_status.py` — status tab with platform switcher
- `ui/dialog_game_settings.py` — per-game settings dialog
- `ui/dialog_game_versions.py` — downloaded version history dialog
## Dependencies
- PySide6
- requests
- python-dotenv
- loguru
- tqdm

712
poetry.lock generated Normal file
View File

@@ -0,0 +1,712 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "certifi"
version = "2026.2.25"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.6"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"},
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"},
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"},
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"},
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"},
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"},
{file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"},
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"},
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"},
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"},
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"},
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"},
{file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"},
{file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"},
{file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"},
{file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"},
{file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"},
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"},
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"},
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"},
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"},
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"},
{file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"},
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"},
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"},
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"},
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"},
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"},
{file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"},
{file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"},
{file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"},
{file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"},
{file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"},
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"},
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"},
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"},
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"},
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"},
{file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"},
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"},
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"},
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"},
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"},
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"},
{file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"},
{file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"},
{file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"},
{file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"},
{file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"},
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"},
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"},
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"},
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"},
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"},
{file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"},
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"},
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"},
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"},
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"},
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"},
{file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"},
{file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"},
{file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"},
{file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"},
{file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"},
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"},
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"},
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"},
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"},
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"},
{file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"},
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"},
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"},
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"},
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"},
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"},
{file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"},
{file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"},
{file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"},
{file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"},
{file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"},
{file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"},
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"},
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"},
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"},
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"},
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"},
{file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"},
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"},
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"},
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"},
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"},
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"},
{file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"},
{file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"},
{file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"},
{file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"},
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"},
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"},
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"},
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"},
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"},
{file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"},
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"},
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"},
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"},
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"},
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"},
{file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"},
{file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"},
{file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"},
{file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"},
{file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"},
{file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"},
]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
[[package]]
name = "idna"
version = "3.11"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "librt"
version = "0.8.1"
description = "Mypyc runtime library"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"},
{file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"},
{file = "librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6"},
{file = "librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0"},
{file = "librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b"},
{file = "librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6"},
{file = "librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71"},
{file = "librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7"},
{file = "librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05"},
{file = "librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891"},
{file = "librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7"},
{file = "librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2"},
{file = "librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd"},
{file = "librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965"},
{file = "librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da"},
{file = "librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0"},
{file = "librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e"},
{file = "librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3"},
{file = "librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac"},
{file = "librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596"},
{file = "librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99"},
{file = "librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe"},
{file = "librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb"},
{file = "librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b"},
{file = "librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9"},
{file = "librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a"},
{file = "librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9"},
{file = "librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb"},
{file = "librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d"},
{file = "librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7"},
{file = "librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440"},
{file = "librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9"},
{file = "librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972"},
{file = "librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921"},
{file = "librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0"},
{file = "librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a"},
{file = "librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"},
{file = "librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d"},
{file = "librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35"},
{file = "librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583"},
{file = "librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c"},
{file = "librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04"},
{file = "librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363"},
{file = "librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0"},
{file = "librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012"},
{file = "librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb"},
{file = "librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b"},
{file = "librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d"},
{file = "librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a"},
{file = "librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79"},
{file = "librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0"},
{file = "librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f"},
{file = "librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c"},
{file = "librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc"},
{file = "librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c"},
{file = "librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3"},
{file = "librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14"},
{file = "librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7"},
{file = "librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6"},
{file = "librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071"},
{file = "librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78"},
{file = "librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023"},
{file = "librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730"},
{file = "librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3"},
{file = "librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1"},
{file = "librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee"},
{file = "librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7"},
{file = "librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040"},
{file = "librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e"},
{file = "librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732"},
{file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624"},
{file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4"},
{file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382"},
{file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994"},
{file = "librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a"},
{file = "librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4"},
{file = "librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61"},
{file = "librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac"},
{file = "librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed"},
{file = "librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd"},
{file = "librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851"},
{file = "librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128"},
{file = "librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac"},
{file = "librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551"},
{file = "librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5"},
{file = "librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6"},
{file = "librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed"},
{file = "librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc"},
{file = "librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7"},
{file = "librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73"},
]
[[package]]
name = "loguru"
version = "0.7.3"
description = "Python logging made (stupidly) simple"
optional = false
python-versions = "<4.0,>=3.5"
groups = ["main"]
files = [
{file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"},
{file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"},
]
[package.dependencies]
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
[[package]]
name = "mypy"
version = "1.19.1"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"},
{file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"},
{file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"},
{file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"},
{file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"},
{file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"},
{file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"},
{file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"},
{file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"},
{file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"},
{file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"},
{file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"},
{file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"},
{file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"},
{file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"},
{file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"},
{file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"},
{file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"},
{file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"},
{file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"},
{file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"},
{file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"},
{file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"},
{file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"},
{file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"},
{file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"},
{file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"},
{file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"},
{file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"},
{file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"},
{file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"},
{file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"},
{file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"},
{file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"},
{file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"},
{file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"},
{file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"},
{file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"},
]
[package.dependencies]
librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""}
mypy_extensions = ">=1.0.0"
pathspec = ">=0.9.0"
typing_extensions = ">=4.6.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
faster-cache = ["orjson"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "packaging"
version = "26.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
]
[[package]]
name = "pathspec"
version = "1.0.4"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"},
{file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"},
]
[package.extras]
hyperscan = ["hyperscan (>=0.7)"]
optional = ["typing-extensions (>=4)"]
re2 = ["google-re2 (>=1.1)"]
tests = ["pytest (>=9)", "typing-extensions (>=4.15)"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyside6"
version = "6.11.0"
description = "Python bindings for the Qt cross-platform application and UI framework"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"},
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"},
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"},
{file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"},
{file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"},
]
[package.dependencies]
PySide6_Addons = "6.11.0"
PySide6_Essentials = "6.11.0"
shiboken6 = "6.11.0"
[[package]]
name = "pyside6-addons"
version = "6.11.0"
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"},
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"},
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"},
{file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"},
{file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"},
]
[package.dependencies]
PySide6_Essentials = "6.11.0"
shiboken6 = "6.11.0"
[[package]]
name = "pyside6-essentials"
version = "6.11.0"
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"},
]
[package.dependencies]
shiboken6 = "6.11.0"
[[package]]
name = "pytest"
version = "9.0.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-mock"
version = "3.15.1"
description = "Thin-wrapper around the mock package for easier use with pytest"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"},
{file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"},
]
[package.dependencies]
pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]]
name = "python-dotenv"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "requests"
version = "2.33.0"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"},
{file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"},
]
[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "ruff"
version = "0.15.7"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e"},
{file = "ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477"},
{file = "ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e"},
{file = "ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf"},
{file = "ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85"},
{file = "ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0"},
{file = "ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912"},
{file = "ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036"},
{file = "ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5"},
{file = "ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12"},
{file = "ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c"},
{file = "ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4"},
{file = "ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d"},
{file = "ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580"},
{file = "ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de"},
{file = "ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1"},
{file = "ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2"},
{file = "ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac"},
]
[[package]]
name = "shiboken6"
version = "6.11.0"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"},
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"},
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"},
{file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"},
{file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"},
]
[[package]]
name = "tqdm"
version = "4.67.3"
description = "Fast, Extensible Progress Meter"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf"},
{file = "tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"]
discord = ["requests"]
notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
telegram = ["requests"]
[[package]]
name = "types-requests"
version = "2.32.4.20260324"
description = "Typing stubs for requests"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "types_requests-2.32.4.20260324-py3-none-any.whl", hash = "sha256:f83ef2deb284fe99a249b8b0b0a3e4b9809e01ff456063c4df0aac7670c07ab9"},
{file = "types_requests-2.32.4.20260324.tar.gz", hash = "sha256:33a2a9ccb1de7d4e4da36e347622c35418f6761269014cc32857acabd5df739e"},
]
[package.dependencies]
urllib3 = ">=2"
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "urllib3"
version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "win32-setctime"
version = "1.2.0"
description = "A small Python utility to set file creation time on Windows"
optional = false
python-versions = ">=3.5"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"},
{file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"},
]
[package.extras]
dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.14,<3.15"
content-hash = "feafc9d9aeea619528d2ca9e876b943ca4c42ba21799f580ce473e4f32e2a38c"

66
prebuild.py Normal file
View File

@@ -0,0 +1,66 @@
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from src.constants import VERSION
load_dotenv()
print("=" * 50)
print("PREBUILD CONFIGURATION")
print("=" * 50)
# Check if running in virtual environment
project_root = Path(__file__).parent
expected_venv_path = project_root / ".venv"
current_executable = Path(sys.executable)
print(f"\nPython executable: {sys.executable}")
is_correct_venv = False
try:
current_executable.relative_to(expected_venv_path)
is_correct_venv = True
except ValueError:
is_correct_venv = False
if is_correct_venv:
print("✓ Correct environment selected for building")
else:
print("✗ Wrong environment selected")
print(f" Expected: {expected_venv_path}")
print(f" Current: {current_executable.parent.parent}")
print(f"✓ Version: {VERSION}")
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
console_mode = env_debug
default_spec = Path(__file__).parent.name + ".spec"
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
print(f"\n{'-' * 50}")
print("BUILD SETTINGS")
print(f"{'-' * 50}")
print(f"ENV_DEBUG: {env_debug}")
print(f"Console mode: {console_mode}")
print(f"Spec file: {spec_filename}")
spec_path = Path(__file__).parent / spec_filename
if spec_path.exists():
with open(spec_path, "r", encoding="utf-8") as f:
spec_content = f.read()
if f"console={not console_mode}" in spec_content:
new_spec_content = spec_content.replace(
f"console={not console_mode}",
f"console={console_mode}"
)
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_spec_content)
print(f"✓ Updated {spec_filename}: console={console_mode}")
else:
print(f"{spec_filename} already configured: console={console_mode}")
else:
print(f"{spec_filename} not found!")
print(f"{'-' * 50}\n")

View File

@@ -1,12 +1,17 @@
[project]
name = "gogupdater"
version = "0.1.0"
version = "1.0.0"
description = ""
authors = [
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
]
requires-python = ">=3.14,<3.15"
dependencies = [
"pyside6 (>=6.11.0,<7.0.0)",
"loguru (>=0.7.3,<0.8.0)",
"python-dotenv (>=1.2.2,<2.0.0)",
"requests (>=2.33.0,<3.0.0)",
"tqdm (>=4.67.3,<5.0.0)"
]
[tool.poetry]
@@ -15,3 +20,12 @@ package-mode = false
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"ruff (>=0.15.7,<0.16.0)",
"mypy (>=1.19.1,<2.0.0)",
"pytest (>=9.0.2,<10.0.0)",
"pytest-mock (>=3.15.1,<4.0.0)",
"types-requests (>=2.32.4.20260324,<3.0.0.0)"
]

0
src/__init__.py Normal file
View File

2
src/_version.py Normal file
View File

@@ -0,0 +1,2 @@
"""Auto-generated — do not edit manually."""
__version__ = "0.1.0"

250
src/api.py Normal file
View File

@@ -0,0 +1,250 @@
"""GOG API client for fetching game library and installer info."""
from urllib.parse import unquote, urlparse
import requests
from loguru import logger
from src.auth import AuthManager
from src.models import BonusContent, InstallerInfo, InstallerPlatform, InstallerType, OwnedGame
GOG_API = "https://api.gog.com"
GOG_EMBED = "https://embed.gog.com"
class GogApi:
"""Handles communication with the GOG API."""
def __init__(self, auth: AuthManager) -> None:
self.auth = auth
self.session = requests.Session()
self.session.headers.update({"User-Agent": "GOGUpdater/0.1"})
def _ensure_auth(self) -> bool:
token = self.auth.access_token
if not token:
logger.error("Not authenticated")
return False
self.session.headers["Authorization"] = f"Bearer {token}"
return True
def get_owned_game_ids(self) -> list[int]:
"""Return list of owned game IDs."""
if not self._ensure_auth():
return []
try:
response = self.session.get(f"{GOG_EMBED}/user/data/games", timeout=15)
except (requests.ConnectionError, requests.Timeout):
logger.error("Failed to fetch owned games — network error")
return []
if not response.ok:
logger.error(f"Failed to fetch owned games — HTTP {response.status_code}")
return []
return response.json().get("owned", [])
def get_game_details(self, game_id: int | str) -> dict | None:
"""Fetch game details including download links."""
if not self._ensure_auth():
return None
try:
response = self.session.get(
f"{GOG_EMBED}/account/gameDetails/{game_id}.json",
timeout=15,
)
except (requests.ConnectionError, requests.Timeout):
logger.error(f"Failed to fetch game details for {game_id} — network error")
return None
if not response.ok:
logger.error(f"Failed to fetch game details for {game_id} — HTTP {response.status_code}")
return None
return response.json()
def get_product_info(self, game_id: int | str) -> dict | None:
"""Fetch product info with downloads and DLC expansions."""
if not self._ensure_auth():
return None
try:
response = self.session.get(
f"{GOG_API}/products/{game_id}",
params={"expand": "downloads,expanded_dlcs"},
timeout=15,
)
except (requests.ConnectionError, requests.Timeout):
logger.error(f"Failed to fetch product info for {game_id} — network error")
return None
if not response.ok:
logger.error(f"Failed to fetch product info for {game_id} — HTTP {response.status_code}")
return None
return response.json()
def get_owned_games(self) -> list[OwnedGame]:
"""Fetch list of owned games with titles."""
game_ids = self.get_owned_game_ids()
if not game_ids:
return []
games: list[OwnedGame] = []
for gid in game_ids:
info = self.get_product_info(gid)
if info:
games.append(OwnedGame(game_id=str(gid), title=info.get("title", f"Game {gid}")))
else:
games.append(OwnedGame(game_id=str(gid), title=f"Game {gid}"))
return games
def get_installers(
self,
game_id: int | str,
platforms: list[InstallerPlatform] | None = None,
languages: list[str] | None = None,
) -> list[InstallerInfo]:
"""Fetch available installers for a game, filtered by platform and language.
Uses the /products/{id}?expand=downloads,expanded_dlcs endpoint.
Response format: downloads.installers is a flat list of dicts with
keys: id, name, os, language, version, total_size, files[].
"""
product = self.get_product_info(game_id)
if not product:
return []
if platforms is None:
platforms = [InstallerPlatform.WINDOWS, InstallerPlatform.LINUX]
platform_keys = {p: p.value for p in platforms} # WINDOWS -> "windows", LINUX -> "linux"
result: list[InstallerInfo] = []
# Main game installers
installers = product.get("downloads", {}).get("installers", [])
result.extend(self._parse_installers(installers, str(game_id), platform_keys, languages, InstallerType.GAME))
# DLC installers (only for owned DLCs)
owned_ids = self.get_owned_ids_set()
for dlc in product.get("expanded_dlcs", []):
dlc_id = str(dlc.get("id", ""))
if dlc_id not in owned_ids:
continue
dlc_installers = dlc.get("downloads", {}).get("installers", [])
result.extend(self._parse_installers(dlc_installers, dlc_id, platform_keys, languages, InstallerType.DLC))
return result
def _parse_installers(
self,
installers: list[dict],
game_id: str,
platform_keys: dict[InstallerPlatform, str],
languages: list[str] | None,
installer_type: InstallerType,
) -> list[InstallerInfo]:
"""Parse installer entries from products API response.
Each installer dict has: id, name, os, language, version, total_size, files[{id, size, downlink}].
"""
result: list[InstallerInfo] = []
allowed_os = set(platform_keys.values())
for installer in installers:
os_name = installer.get("os", "")
if os_name not in allowed_os:
continue
lang = installer.get("language", "en")
if languages and lang not in languages:
continue
platform = InstallerPlatform.WINDOWS if os_name == "windows" else InstallerPlatform.LINUX
version = installer.get("version") or ""
name = installer.get("name", "unknown")
for file_entry in installer.get("files", []):
downlink = file_entry.get("downlink", "")
file_id = file_entry.get("id", "")
size = file_entry.get("size", 0)
result.append(
InstallerInfo(
installer_id=f"{game_id}_{os_name}_{lang}_{file_id}",
filename=f"{name}_{file_id}",
size=size,
version=version,
language=lang,
platform=platform,
installer_type=installer_type,
download_url=downlink,
game_id=game_id,
)
)
return result
def get_bonus_content(self, game_id: int | str, product: dict | None = None) -> list[BonusContent]:
"""Fetch bonus content (soundtracks, wallpapers, manuals, etc.) for a game."""
if product is None:
product = self.get_product_info(game_id)
if not product:
return []
bonus_items = product.get("downloads", {}).get("bonus_content", [])
result: list[BonusContent] = []
for item in bonus_items:
name = item.get("name", "unknown")
bonus_type = item.get("type", "")
total_size = item.get("total_size", 0)
for file_entry in item.get("files", []):
downlink = file_entry.get("downlink", "")
file_id = str(file_entry.get("id", ""))
size = file_entry.get("size", total_size)
result.append(BonusContent(
bonus_id=f"{game_id}_bonus_{file_id}",
name=name,
bonus_type=bonus_type,
size=size,
download_url=downlink,
game_id=str(game_id),
))
logger.debug(f"Found {len(result)} bonus item(s) for game {game_id}")
return result
def get_owned_ids_set(self) -> set[str]:
"""Return a set of owned game/DLC IDs as strings."""
return {str(gid) for gid in self.get_owned_game_ids()}
def resolve_download_url(self, downlink: str) -> tuple[str, str] | None:
"""Resolve a GOG downlink to actual download URL and real filename.
GOG downlinks are two-level: first returns JSON with 'downlink' key,
second is the actual CDN URL with the real filename in the path.
Returns (url, filename) or None on failure.
"""
if not self._ensure_auth():
return None
# Step 1: Get CDN URL from API downlink
try:
response = self.session.get(downlink, timeout=15)
except (requests.ConnectionError, requests.Timeout):
logger.error("Failed to resolve download URL — network error")
return None
if not response.ok:
logger.error(f"Failed to resolve download URL — HTTP {response.status_code}")
return None
data = response.json()
cdn_url = data.get("downlink", "")
if not cdn_url:
logger.error("No downlink in API response")
return None
# Extract filename from CDN URL path
path = urlparse(cdn_url).path
filename = unquote(path.rsplit("/", 1)[-1])
return cdn_url, filename

129
src/auth.py Normal file
View File

@@ -0,0 +1,129 @@
"""GOG OAuth2 authentication manager."""
import json
import time
from pathlib import Path
import requests
from loguru import logger
GOG_AUTH_URL = "https://auth.gog.com"
CLIENT_ID = "46899977096215655"
CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client"
LOGIN_URL = (
f"https://login.gog.com/auth?client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
f"&response_type=code&layout=popup"
)
class AuthManager:
"""Manages GOG OAuth2 tokens — exchange, refresh, persistence."""
def __init__(self, config_dir: Path) -> None:
self.config_dir = config_dir
self.auth_file = config_dir / "auth.json"
self.credentials: dict = {}
self.session = requests.Session()
self.session.headers.update({"User-Agent": "GOGUpdater/0.1"})
self._load()
def _load(self) -> None:
if self.auth_file.exists():
try:
self.credentials = json.loads(self.auth_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
logger.warning("Failed to read auth.json, starting fresh")
self.credentials = {}
def _save(self) -> None:
self.config_dir.mkdir(parents=True, exist_ok=True)
self.auth_file.write_text(json.dumps(self.credentials, indent=2), encoding="utf-8")
@property
def is_logged_in(self) -> bool:
return bool(self.credentials.get("access_token"))
@property
def is_expired(self) -> bool:
if not self.is_logged_in:
return True
login_time = self.credentials.get("login_time", 0)
expires_in = self.credentials.get("expires_in", 0)
return time.time() >= login_time + expires_in
@property
def access_token(self) -> str | None:
if not self.is_logged_in:
return None
if self.is_expired:
if not self.refresh():
return None
return self.credentials.get("access_token")
def exchange_code(self, code: str) -> bool:
"""Exchange authorization code for tokens."""
url = (
f"{GOG_AUTH_URL}/token"
f"?client_id={CLIENT_ID}"
f"&client_secret={CLIENT_SECRET}"
f"&grant_type=authorization_code"
f"&redirect_uri={REDIRECT_URI}"
f"&code={code}"
)
try:
response = self.session.get(url, timeout=15)
except (requests.ConnectionError, requests.Timeout):
logger.error("Failed to exchange authorization code — network error")
return False
if not response.ok:
logger.error(f"Failed to exchange authorization code — HTTP {response.status_code}")
return False
data = response.json()
data["login_time"] = time.time()
self.credentials = data
self._save()
logger.info("Successfully authenticated with GOG")
return True
def refresh(self) -> bool:
"""Refresh access token using refresh_token."""
refresh_token = self.credentials.get("refresh_token")
if not refresh_token:
logger.error("No refresh token available")
return False
url = (
f"{GOG_AUTH_URL}/token"
f"?client_id={CLIENT_ID}"
f"&client_secret={CLIENT_SECRET}"
f"&grant_type=refresh_token"
f"&refresh_token={refresh_token}"
)
try:
response = self.session.get(url, timeout=15)
except (requests.ConnectionError, requests.Timeout):
logger.error("Failed to refresh token — network error")
return False
if not response.ok:
logger.error(f"Failed to refresh token — HTTP {response.status_code}")
return False
data = response.json()
data["login_time"] = time.time()
self.credentials = data
self._save()
logger.info("Token refreshed successfully")
return True
def logout(self) -> None:
"""Clear stored credentials."""
self.credentials = {}
if self.auth_file.exists():
self.auth_file.unlink()
logger.info("Logged out")

340
src/config.py Normal file
View File

@@ -0,0 +1,340 @@
"""Application configuration and installer metadata management."""
import json
from pathlib import Path
from loguru import logger
from src.models import DownloadedInstaller, GameRecord, GameSettings, InstallerType, LANGUAGE_NAMES, language_folder_name
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "gogupdater"
METADATA_FILENAME = "gogupdater.json"
class AppConfig:
"""Application settings stored in ~/.config/gogupdater/config.json."""
def __init__(self, config_dir: Path = DEFAULT_CONFIG_DIR) -> None:
self.config_dir = config_dir
self.config_file = config_dir / "config.json"
self.windows_path: str = ""
self.linux_path: str = ""
self.managed_game_ids: list[str] = []
self.languages: list[str] = ["en"]
self.english_only: bool = False
self.include_bonus: bool = False
self.game_settings: dict[str, GameSettings] = {}
self._load()
def _load(self) -> None:
if not self.config_file.exists():
return
try:
data = json.loads(self.config_file.read_text(encoding="utf-8"))
self.windows_path = data.get("windows_path", "")
self.linux_path = data.get("linux_path", "")
self.managed_game_ids = data.get("managed_game_ids", [])
self.languages = data.get("languages", ["en"])
self.english_only = data.get("english_only", False)
self.include_bonus = data.get("include_bonus", False)
self.game_settings = {
gid: GameSettings.from_dict(gs)
for gid, gs in data.get("game_settings", {}).items()
}
except (json.JSONDecodeError, OSError):
logger.warning("Failed to read config.json, using defaults")
def save(self) -> None:
self.config_dir.mkdir(parents=True, exist_ok=True)
data = {
"windows_path": self.windows_path,
"linux_path": self.linux_path,
"managed_game_ids": self.managed_game_ids,
"languages": self.languages,
"english_only": self.english_only,
"include_bonus": self.include_bonus,
"game_settings": {
gid: gs.to_dict()
for gid, gs in self.game_settings.items()
if not gs.is_default()
},
}
self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
def is_game_managed(self, game_id: str) -> bool:
return game_id in self.managed_game_ids
def get_effective_languages(self, game_id: str | None = None) -> list[str]:
"""Return effective language list, checking per-game overrides first."""
english_only = self.english_only
languages = self.languages
if game_id and game_id in self.game_settings:
gs = self.game_settings[game_id]
if gs.english_only is not None:
english_only = gs.english_only
if gs.languages is not None:
languages = gs.languages
if english_only:
return ["en"]
return languages
def get_effective_include_bonus(self, game_id: str | None = None) -> bool:
"""Return include_bonus setting, checking per-game overrides first."""
if game_id and game_id in self.game_settings:
gs = self.game_settings[game_id]
if gs.include_bonus is not None:
return gs.include_bonus
return self.include_bonus
def get_game_settings(self, game_id: str) -> GameSettings:
"""Return per-game settings (may be all defaults)."""
return self.game_settings.get(game_id, GameSettings())
def set_game_settings(self, game_id: str, settings: GameSettings) -> None:
"""Save per-game settings. Removes entry if all defaults."""
if settings.is_default():
self.game_settings.pop(game_id, None)
else:
self.game_settings[game_id] = settings
self.save()
def set_game_managed(self, game_id: str, managed: bool) -> None:
if managed and game_id not in self.managed_game_ids:
self.managed_game_ids.append(game_id)
elif not managed and game_id in self.managed_game_ids:
self.managed_game_ids.remove(game_id)
self.save()
class MetadataStore:
"""Manages gogupdater.json metadata file in installer directories."""
def __init__(self, base_path: str) -> None:
self.base_path = Path(base_path)
self.metadata_file = self.base_path / METADATA_FILENAME
self.games: dict[str, GameRecord] = {}
self._load()
def _load(self) -> None:
if not self.metadata_file.exists():
return
try:
data = json.loads(self.metadata_file.read_text(encoding="utf-8"))
for game_id, game_data in data.get("games", {}).items():
self.games[game_id] = GameRecord.from_dict(game_id, game_data)
except (json.JSONDecodeError, OSError):
logger.warning(f"Failed to read {self.metadata_file}, starting fresh")
def save(self) -> None:
self.base_path.mkdir(parents=True, exist_ok=True)
data = {"games": {gid: record.to_dict() for gid, record in self.games.items()}}
self.metadata_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
def get_game(self, game_id: str) -> GameRecord | None:
return self.games.get(game_id)
def update_game(self, record: GameRecord) -> None:
self.games[record.game_id] = record
self.save()
def get_downloaded_versions(self, game_id: str) -> list[str]:
"""Return list of downloaded versions for a game."""
record = self.games.get(game_id)
if not record:
return []
return list({i.version for i in record.installers})
def prune_old_versions(self, game_id: str, keep_latest: int = 1) -> list[Path]:
"""Remove old version directories, keeping the N most recent. Returns deleted paths."""
record = self.games.get(game_id)
if not record:
return []
versions = sorted({i.version for i in record.installers})
if len(versions) <= keep_latest:
return []
versions_to_remove = versions[:-keep_latest]
deleted: list[Path] = []
for version in versions_to_remove:
version_dir = self.base_path / record.name / version
if version_dir.exists():
import shutil
shutil.rmtree(version_dir)
deleted.append(version_dir)
logger.info(f"Pruned {version_dir}")
record.installers = [i for i in record.installers if i.version not in versions_to_remove]
self.save()
return deleted
def verify_metadata(self) -> int:
"""Verify that recorded files still exist on disk.
Removes metadata entries for missing files. If a game has no
remaining installers or bonuses, removes the entire game record.
Returns number of stale entries removed.
"""
if not self.base_path.is_dir():
return 0
removed = 0
game_ids_to_delete: list[str] = []
for game_id, record in self.games.items():
game_dir = self.base_path / record.name
# If the entire game folder is gone, remove the record
if not game_dir.exists():
game_ids_to_delete.append(game_id)
removed += len(record.installers) + len(record.bonuses)
logger.info(f"Verify: game folder missing for '{record.name}', removing record")
continue
# Check individual installer files
valid_installers: list[DownloadedInstaller] = []
for inst in record.installers:
# Try with language subfolder first, then without
lang_path = game_dir / inst.version / language_folder_name(inst.language) / inst.filename
direct_path = game_dir / inst.version / inst.filename
if lang_path.exists() or direct_path.exists():
valid_installers.append(inst)
else:
removed += 1
logger.info(f"Verify: missing installer '{inst.filename}' for '{record.name}'")
# Check bonus files
valid_bonuses = []
for bonus in record.bonuses:
bonus_path = game_dir / "Bonus" / bonus.filename
if bonus_path.exists():
valid_bonuses.append(bonus)
else:
removed += 1
logger.info(f"Verify: missing bonus '{bonus.filename}' for '{record.name}'")
record.installers = valid_installers
record.bonuses = valid_bonuses
# Update latest_version based on remaining installers
if valid_installers:
versions = sorted({i.version for i in valid_installers})
record.latest_version = versions[-1]
else:
record.latest_version = ""
# If nothing remains, mark for deletion
if not valid_installers and not valid_bonuses:
game_ids_to_delete.append(game_id)
for game_id in game_ids_to_delete:
del self.games[game_id]
if removed:
self.save()
logger.info(f"Verify: removed {removed} stale metadata entries")
return removed
def scan_existing_installers(self, title_to_id: dict[str, str]) -> int:
"""Scan directory for existing installers and populate metadata.
Expects structure: base_path/GameName/version/[Language/]installer_file
title_to_id maps game titles (folder names) to GOG game IDs.
Returns number of games detected.
"""
if not self.base_path.is_dir():
logger.warning(f"Scan path does not exist: {self.base_path}")
return 0
# Reverse language map: folder name -> code
lang_name_to_code: dict[str, str] = {v: k for k, v in LANGUAGE_NAMES.items()}
detected = 0
for game_dir in sorted(self.base_path.iterdir()):
if not game_dir.is_dir() or game_dir.name.startswith("."):
continue
if game_dir.name == METADATA_FILENAME:
continue
game_name = game_dir.name
game_id = title_to_id.get(game_name)
if not game_id:
logger.debug(f"Scan: no matching game ID for folder '{game_name}', skipping")
continue
# Already tracked — skip
if game_id in self.games:
logger.debug(f"Scan: '{game_name}' already in metadata, skipping")
continue
installers: list[DownloadedInstaller] = []
latest_version = ""
for version_dir in sorted(game_dir.iterdir()):
if not version_dir.is_dir():
continue
version = version_dir.name
if not latest_version:
latest_version = version
# Check if subdirs are language folders or direct installer files
sub_entries = list(version_dir.iterdir())
has_lang_subdirs = any(
e.is_dir() and e.name in lang_name_to_code for e in sub_entries
)
if has_lang_subdirs:
for lang_dir in version_dir.iterdir():
if not lang_dir.is_dir():
continue
lang_code = lang_name_to_code.get(lang_dir.name, lang_dir.name)
for f in lang_dir.iterdir():
if f.is_file() and not f.name.startswith("."):
installers.append(DownloadedInstaller(
filename=f.name,
size=f.stat().st_size,
version=version,
language=lang_code,
installer_type=InstallerType.GAME,
downloaded_at="",
))
else:
# No language subfolder — files directly in version dir
for f in version_dir.iterdir():
if f.is_file() and not f.name.startswith("."):
installers.append(DownloadedInstaller(
filename=f.name,
size=f.stat().st_size,
version=version,
language="en",
installer_type=InstallerType.GAME,
downloaded_at="",
))
if installers:
# Latest version = last sorted version directory
all_versions = sorted({i.version for i in installers})
latest_version = all_versions[-1] if all_versions else ""
record = GameRecord(
game_id=game_id,
name=game_name,
latest_version=latest_version,
managed=True,
installers=installers,
)
self.games[game_id] = record
detected += 1
logger.info(f"Scan: detected '{game_name}' with {len(installers)} installer(s), version {latest_version}")
if detected:
self.save()
return detected

80
src/constants.py Normal file
View File

@@ -0,0 +1,80 @@
"""
Generic application constants template.
Usage in your project:
1. Copy this file to src/constants.py
2. Fill in APP_NAME and APP_FULL_NAME
3. Import VERSION, APP_TITLE, DEFAULT_DEBUG where needed
Version loading priority:
1. pyproject.toml [project] version (preferred)
2. src/_version.py __version__ (generated fallback for frozen builds)
3. "0.0.0" (last resort)
Debug mode:
Controlled exclusively via .env: ENV_DEBUG=true
Accepted true-values: true, 1, yes (case-insensitive)
"""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# ---------------------------------------------------------------------------
# Version
# ---------------------------------------------------------------------------
_ROOT = Path(__file__).parent.parent
_PYPROJECT = _ROOT / "pyproject.toml"
_VERSION_FILE = Path(__file__).parent / "_version.py"
def _load_version() -> str:
# 1. pyproject.toml
try:
with open(_PYPROJECT, "rb") as f:
version = tomllib.load(f)["project"]["version"]
# Write fallback for frozen/PyInstaller builds
_VERSION_FILE.write_text(
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
encoding="utf-8",
)
return version
except (FileNotFoundError, KeyError):
pass
# 2. _version.py
try:
from src._version import __version__ # type: ignore[import]
return __version__
except ImportError:
pass
# 3. last resort
return "0.0.0"
# ---------------------------------------------------------------------------
# Debug mode
# ---------------------------------------------------------------------------
def _load_debug() -> bool:
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
# ---------------------------------------------------------------------------
# Public constants ← fill in APP_NAME / APP_FULL_NAME for each project
# ---------------------------------------------------------------------------
APP_NAME: str = "GOGUpdater"
APP_FULL_NAME: str = "GOG Updater"
_VERSION_NUMBER: str = _load_version()
DEFAULT_DEBUG: bool = _load_debug()
VERSION: str = f"v{_VERSION_NUMBER}" + ("DEV" if DEFAULT_DEBUG else "")
APP_TITLE: str = f"{APP_FULL_NAME} {VERSION}"

284
src/downloader.py Normal file
View File

@@ -0,0 +1,284 @@
"""Installer file downloader with progress reporting and resume support."""
import time
from datetime import datetime, timezone
from typing import Protocol
import requests
from loguru import logger
from src.api import GogApi
from src.config import MetadataStore
from src.models import (
BonusContent,
DownloadedBonus,
DownloadedInstaller,
GameRecord,
InstallerInfo,
language_folder_name,
sanitize_folder_name,
)
class DownloadProgressCallback(Protocol):
"""Protocol for progress reporting during downloads."""
def on_progress(self, downloaded: int, total: int, speed: float) -> None: ...
def on_finished(self, success: bool, message: str) -> None: ...
def is_cancelled(self) -> bool: ...
class InstallerDownloader:
"""Downloads installer files from GOG with resume support."""
CHUNK_SIZE = 1024 * 1024 # 1 MiB
def __init__(self, api: GogApi, metadata: MetadataStore) -> None:
self.api = api
self.metadata = metadata
def download_installer(
self,
installer: InstallerInfo,
game_name: str,
single_language: bool = False,
callback: DownloadProgressCallback | None = None,
) -> bool:
"""Download a single installer file to the correct directory structure.
If single_language is True, the language subfolder is skipped
(game only available in one language).
"""
game_name = sanitize_folder_name(game_name)
# Resolve actual download URL and real filename
resolved = self.api.resolve_download_url(installer.download_url)
if not resolved:
logger.error(f"Could not resolve download URL for {installer.filename}")
if callback:
callback.on_finished(False, "Failed to resolve download URL")
return False
actual_url, real_filename = resolved
version = installer.version if installer.version else "unknown"
if single_language:
target_dir = self.metadata.base_path / game_name / version
else:
lang_folder = language_folder_name(installer.language)
target_dir = self.metadata.base_path / game_name / version / lang_folder
target_dir.mkdir(parents=True, exist_ok=True)
target_file = target_dir / real_filename
# Check for partial download (resume)
existing_size = target_file.stat().st_size if target_file.exists() else 0
headers: dict[str, str] = {}
if existing_size > 0:
headers["Range"] = f"bytes={existing_size}-"
logger.info(f"Resuming download of {real_filename} from {existing_size} bytes")
try:
response = requests.get(actual_url, headers=headers, stream=True, timeout=30)
except (requests.ConnectionError, requests.Timeout) as e:
logger.error(f"Download failed — {e}")
if callback:
callback.on_finished(False, f"Network error: {e}")
return False
if response.status_code == 416:
logger.info(f"Already fully downloaded: {real_filename}")
self._record_download(installer, game_name, real_filename)
if callback:
callback.on_finished(True, "Already downloaded")
return True
if not response.ok:
logger.error(f"Download failed — HTTP {response.status_code}")
if callback:
callback.on_finished(False, f"HTTP {response.status_code}")
return False
total_size = int(response.headers.get("content-length", 0)) + existing_size
downloaded = existing_size
mode = "ab" if existing_size > 0 else "wb"
last_time = time.monotonic()
last_downloaded = downloaded
try:
with open(target_file, mode) as f:
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
if callback and callback.is_cancelled():
logger.info("Download cancelled by user")
callback.on_finished(False, "Cancelled")
return False
f.write(chunk)
downloaded += len(chunk)
if callback:
now = time.monotonic()
elapsed = now - last_time
if elapsed >= 0.5:
speed = (downloaded - last_downloaded) / elapsed
last_time = now
last_downloaded = downloaded
callback.on_progress(downloaded, total_size, speed)
except OSError as e:
logger.error(f"Write error: {e}")
if callback:
callback.on_finished(False, f"Write error: {e}")
return False
self._record_download(installer, game_name, real_filename)
logger.info(f"Downloaded {real_filename} ({downloaded} bytes)")
if callback:
callback.on_finished(True, "Download complete")
return True
def download_bonus(
self,
bonus: BonusContent,
game_name: str,
callback: DownloadProgressCallback | None = None,
) -> bool:
"""Download a bonus content file to GameName/Bonus/ directory."""
game_name = sanitize_folder_name(game_name)
resolved = self.api.resolve_download_url(bonus.download_url)
if not resolved:
logger.error(f"Could not resolve download URL for bonus '{bonus.name}'")
if callback:
callback.on_finished(False, "Failed to resolve download URL")
return False
actual_url, real_filename = resolved
target_dir = self.metadata.base_path / game_name / "Bonus"
target_dir.mkdir(parents=True, exist_ok=True)
target_file = target_dir / real_filename
# Skip if already downloaded with same size
if target_file.exists() and target_file.stat().st_size == bonus.size:
logger.info(f"Bonus already downloaded: {real_filename}")
self._record_bonus(bonus, game_name, real_filename)
if callback:
callback.on_finished(True, "Already downloaded")
return True
existing_size = target_file.stat().st_size if target_file.exists() else 0
headers: dict[str, str] = {}
if existing_size > 0:
headers["Range"] = f"bytes={existing_size}-"
logger.info(f"Resuming bonus download of {real_filename} from {existing_size} bytes")
try:
response = requests.get(actual_url, headers=headers, stream=True, timeout=30)
except (requests.ConnectionError, requests.Timeout) as e:
logger.error(f"Bonus download failed — {e}")
if callback:
callback.on_finished(False, f"Network error: {e}")
return False
if response.status_code == 416:
logger.info(f"Bonus already fully downloaded: {real_filename}")
self._record_bonus(bonus, game_name, real_filename)
if callback:
callback.on_finished(True, "Already downloaded")
return True
if not response.ok:
logger.error(f"Bonus download failed — HTTP {response.status_code}")
if callback:
callback.on_finished(False, f"HTTP {response.status_code}")
return False
total_size = int(response.headers.get("content-length", 0)) + existing_size
downloaded = existing_size
mode = "ab" if existing_size > 0 else "wb"
last_time = time.monotonic()
last_downloaded = downloaded
try:
with open(target_file, mode) as f:
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
if callback and callback.is_cancelled():
logger.info("Bonus download cancelled by user")
callback.on_finished(False, "Cancelled")
return False
f.write(chunk)
downloaded += len(chunk)
if callback:
now = time.monotonic()
elapsed = now - last_time
if elapsed >= 0.5:
speed = (downloaded - last_downloaded) / elapsed
last_time = now
last_downloaded = downloaded
callback.on_progress(downloaded, total_size, speed)
except OSError as e:
logger.error(f"Bonus write error: {e}")
if callback:
callback.on_finished(False, f"Write error: {e}")
return False
self._record_bonus(bonus, game_name, real_filename)
logger.info(f"Downloaded bonus {real_filename} ({downloaded} bytes)")
if callback:
callback.on_finished(True, "Download complete")
return True
def _record_bonus(self, bonus: BonusContent, game_name: str, real_filename: str) -> None:
"""Record the bonus download in metadata store."""
game_name = sanitize_folder_name(game_name)
record = self.metadata.get_game(bonus.game_id)
if not record:
record = GameRecord(
game_id=bonus.game_id,
name=game_name,
)
downloaded = DownloadedBonus(
filename=real_filename,
name=bonus.name,
bonus_type=bonus.bonus_type,
size=bonus.size,
downloaded_at=datetime.now(timezone.utc).isoformat(),
)
record.bonuses = [b for b in record.bonuses if b.filename != real_filename]
record.bonuses.append(downloaded)
record.last_checked = datetime.now(timezone.utc).isoformat()
self.metadata.update_game(record)
def _record_download(self, installer: InstallerInfo, game_name: str, real_filename: str) -> None:
"""Record the download in metadata store."""
game_name = sanitize_folder_name(game_name)
record = self.metadata.get_game(installer.game_id)
if not record:
record = GameRecord(
game_id=installer.game_id,
name=game_name,
latest_version=installer.version,
)
downloaded = DownloadedInstaller(
filename=real_filename,
size=installer.size,
version=installer.version,
language=installer.language,
installer_type=installer.installer_type,
downloaded_at=datetime.now(timezone.utc).isoformat(),
)
# Replace existing entry for same file, or add new
record.installers = [i for i in record.installers if i.filename != real_filename]
record.installers.append(downloaded)
record.latest_version = installer.version
record.last_checked = datetime.now(timezone.utc).isoformat()
self.metadata.update_game(record)

338
src/models.py Normal file
View File

@@ -0,0 +1,338 @@
"""Data models for GOGUpdater."""
import re
from dataclasses import dataclass, field
from enum import Enum
LANGUAGE_NAMES: dict[str, str] = {
"en": "English",
"fr": "French",
"de": "German",
"es": "Spanish",
"it": "Italian",
"pt-BR": "Portuguese (Brazil)",
"ru": "Russian",
"pl": "Polish",
"nl": "Dutch",
"cs": "Czech",
"sk": "Slovak",
"hu": "Hungarian",
"tr": "Turkish",
"ja": "Japanese",
"ko": "Korean",
"zh-Hans": "Chinese (Simplified)",
"zh-Hant": "Chinese (Traditional)",
"ro": "Romanian",
"da": "Danish",
"fi": "Finnish",
"sv": "Swedish",
"no": "Norwegian",
"ar": "Arabic",
"uk": "Ukrainian",
}
def language_folder_name(code: str) -> str:
"""Return human-readable language name for folder naming."""
return LANGUAGE_NAMES.get(code, code)
# Characters to strip entirely from folder names
_STRIP_CHARS_RE = re.compile(r"[®™©™\u2122\u00AE\u00A9]")
# Characters invalid in folder names on Windows/Linux
_INVALID_CHARS_RE = re.compile(r'[<>"/\\|?*]')
def sanitize_folder_name(name: str) -> str:
"""Convert a game title to a valid, unified folder name.
- Replaces ':' with ' - '
- Strips special characters (®, ™, ©)
- Removes other invalid filesystem characters
- Collapses multiple spaces/dashes
- Strips leading/trailing whitespace and dots
"""
result = name
# Colon -> " - "
result = result.replace(":", " - ")
# Strip special symbols
result = _STRIP_CHARS_RE.sub("", result)
# Remove filesystem-invalid characters
result = _INVALID_CHARS_RE.sub("", result)
# Collapse multiple spaces
result = re.sub(r"\s{2,}", " ", result)
# Collapse " - - " patterns from adjacent replacements
result = re.sub(r"(\s*-\s*){2,}", " - ", result)
# Strip leading/trailing whitespace and dots
result = result.strip().strip(".")
return result
class InstallerType(Enum):
GAME = "game"
DLC = "dlc"
class InstallerPlatform(Enum):
WINDOWS = "windows"
LINUX = "linux"
class GameStatus(Enum):
UP_TO_DATE = "up_to_date"
UPDATE_AVAILABLE = "update_available"
NOT_DOWNLOADED = "not_downloaded"
UNKNOWN = "unknown"
UNVERSIONED = "unversioned"
@dataclass
class InstallerInfo:
"""Installer metadata from GOG API."""
installer_id: str
filename: str
size: int
version: str
language: str
platform: InstallerPlatform
installer_type: InstallerType
download_url: str
game_id: str
def to_dict(self) -> dict:
return {
"installer_id": self.installer_id,
"filename": self.filename,
"size": self.size,
"version": self.version,
"language": self.language,
"platform": self.platform.value,
"installer_type": self.installer_type.value,
"download_url": self.download_url,
"game_id": self.game_id,
}
@classmethod
def from_dict(cls, data: dict) -> "InstallerInfo":
return cls(
installer_id=data["installer_id"],
filename=data["filename"],
size=data["size"],
version=data["version"],
language=data["language"],
platform=InstallerPlatform(data["platform"]),
installer_type=InstallerType(data["installer_type"]),
download_url=data.get("download_url", ""),
game_id=data.get("game_id", ""),
)
@dataclass
class DownloadedInstaller:
"""Record of a downloaded installer file."""
filename: str
size: int
version: str
language: str
installer_type: InstallerType
downloaded_at: str
def to_dict(self) -> dict:
return {
"filename": self.filename,
"size": self.size,
"version": self.version,
"language": self.language,
"installer_type": self.installer_type.value,
"downloaded_at": self.downloaded_at,
}
@classmethod
def from_dict(cls, data: dict) -> "DownloadedInstaller":
return cls(
filename=data["filename"],
size=data["size"],
version=data["version"],
language=data["language"],
installer_type=InstallerType(data.get("installer_type", "game")),
downloaded_at=data.get("downloaded_at", ""),
)
@dataclass
class GameRecord:
"""Record of a managed game in metadata file."""
game_id: str
name: str
latest_version: str = ""
managed: bool = True
installers: list[DownloadedInstaller] = field(default_factory=list)
bonuses: list[DownloadedBonus] = field(default_factory=list)
last_checked: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"latest_version": self.latest_version,
"managed": self.managed,
"installers": [i.to_dict() for i in self.installers],
"bonuses": [b.to_dict() for b in self.bonuses],
"last_checked": self.last_checked,
}
@classmethod
def from_dict(cls, game_id: str, data: dict) -> "GameRecord":
return cls(
game_id=game_id,
name=data["name"],
latest_version=data.get("latest_version", ""),
managed=data.get("managed", True),
installers=[DownloadedInstaller.from_dict(i) for i in data.get("installers", [])],
bonuses=[DownloadedBonus.from_dict(b) for b in data.get("bonuses", [])],
last_checked=data.get("last_checked", ""),
)
@dataclass
class OwnedGame:
"""Game from GOG library."""
game_id: str
title: str
managed: bool = False
@dataclass
class BonusContent:
"""Bonus content item from GOG API (soundtrack, wallpaper, manual, etc.)."""
bonus_id: str
name: str
bonus_type: str
size: int
download_url: str
game_id: str
def to_dict(self) -> dict:
return {
"bonus_id": self.bonus_id,
"name": self.name,
"bonus_type": self.bonus_type,
"size": self.size,
"download_url": self.download_url,
"game_id": self.game_id,
}
@classmethod
def from_dict(cls, data: dict) -> "BonusContent":
return cls(
bonus_id=data["bonus_id"],
name=data["name"],
bonus_type=data.get("bonus_type", ""),
size=data.get("size", 0),
download_url=data.get("download_url", ""),
game_id=data.get("game_id", ""),
)
@dataclass
class DownloadedBonus:
"""Record of a downloaded bonus content file."""
filename: str
name: str
bonus_type: str
size: int
downloaded_at: str
def to_dict(self) -> dict:
return {
"filename": self.filename,
"name": self.name,
"bonus_type": self.bonus_type,
"size": self.size,
"downloaded_at": self.downloaded_at,
}
@classmethod
def from_dict(cls, data: dict) -> "DownloadedBonus":
return cls(
filename=data["filename"],
name=data["name"],
bonus_type=data.get("bonus_type", ""),
size=data.get("size", 0),
downloaded_at=data.get("downloaded_at", ""),
)
@dataclass
class GameSettings:
"""Per-game settings that override global defaults. None = use global."""
languages: list[str] | None = None
english_only: bool | None = None
include_bonus: bool | None = None
def to_dict(self) -> dict:
data: dict = {}
if self.languages is not None:
data["languages"] = self.languages
if self.english_only is not None:
data["english_only"] = self.english_only
if self.include_bonus is not None:
data["include_bonus"] = self.include_bonus
return data
@classmethod
def from_dict(cls, data: dict) -> "GameSettings":
return cls(
languages=data.get("languages"),
english_only=data.get("english_only"),
include_bonus=data.get("include_bonus"),
)
def is_default(self) -> bool:
"""True if all values are None (no overrides)."""
return self.languages is None and self.english_only is None and self.include_bonus is None
@dataclass
class LanguageVersionInfo:
"""Version info for a single language of a game."""
language: str
current_version: str
latest_version: str
status: GameStatus
@dataclass
class GameStatusInfo:
"""Status information for display in status tab."""
game_id: str
name: str
current_version: str
latest_version: str
status: GameStatus
language_versions: list[LanguageVersionInfo] = field(default_factory=list)
last_checked: str = ""
parent_name: str = ""
dlcs: list["GameStatusInfo"] = field(default_factory=list)
bonus_available: int = 0
bonus_downloaded: int = 0
@property
def status_text(self) -> str:
labels = {
GameStatus.UP_TO_DATE: "Up to date",
GameStatus.UPDATE_AVAILABLE: "Update available",
GameStatus.NOT_DOWNLOADED: "Not downloaded",
GameStatus.UNKNOWN: "Unknown",
GameStatus.UNVERSIONED: "Cannot track version",
}
return labels[self.status]

0
src/ui/__init__.py Normal file
View File

View File

@@ -0,0 +1,128 @@
"""Per-game settings dialog — override global language, bonus, and english_only settings."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QGroupBox,
QLabel,
QListWidget,
QListWidgetItem,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.config import AppConfig
from src.models import GameSettings, LANGUAGE_NAMES
class GameSettingsDialog(QDialog):
"""Dialog for configuring per-game settings (languages, bonus, english_only)."""
def __init__(
self,
game_id: str,
game_title: str,
config: AppConfig,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.game_id = game_id
self.config = config
self.settings = config.get_game_settings(game_id)
self.setWindowTitle(f"Settings — {game_title}")
self.setMinimumWidth(400)
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
info = QLabel(
"Override global settings for this game.\n"
"Unchecked overrides use global defaults from Settings/Languages tabs."
)
info.setWordWrap(True)
layout.addWidget(info)
# English only override
self.english_only_group = QGroupBox("Override English only")
self.english_only_group.setCheckable(True)
self.english_only_group.setChecked(self.settings.english_only is not None)
eo_layout = QVBoxLayout(self.english_only_group)
self.english_only_cb = QCheckBox("English only")
self.english_only_cb.setChecked(
self.settings.english_only if self.settings.english_only is not None else self.config.english_only
)
eo_layout.addWidget(self.english_only_cb)
layout.addWidget(self.english_only_group)
# Languages override
self.langs_group = QGroupBox("Override languages")
self.langs_group.setCheckable(True)
self.langs_group.setChecked(self.settings.languages is not None)
langs_layout = QVBoxLayout(self.langs_group)
self.lang_list = QListWidget()
effective_langs = self.settings.languages if self.settings.languages is not None else self.config.languages
for code, name in LANGUAGE_NAMES.items():
item = QListWidgetItem(f"{name} ({code})")
item.setData(Qt.ItemDataRole.UserRole, code)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
checked = code in effective_langs
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
self.lang_list.addItem(item)
langs_layout.addWidget(self.lang_list)
layout.addWidget(self.langs_group)
# Bonus override
self.bonus_group = QGroupBox("Override bonus content")
self.bonus_group.setCheckable(True)
self.bonus_group.setChecked(self.settings.include_bonus is not None)
bonus_layout = QVBoxLayout(self.bonus_group)
self.bonus_cb = QCheckBox("Include bonus content")
self.bonus_cb.setChecked(
self.settings.include_bonus if self.settings.include_bonus is not None else self.config.include_bonus
)
bonus_layout.addWidget(self.bonus_cb)
layout.addWidget(self.bonus_group)
# Buttons
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self._accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _accept(self) -> None:
# English only
english_only: bool | None = None
if self.english_only_group.isChecked():
english_only = self.english_only_cb.isChecked()
# Languages
languages: list[str] | None = None
if self.langs_group.isChecked():
languages = []
for i in range(self.lang_list.count()):
item = self.lang_list.item(i)
if item and item.checkState() == Qt.CheckState.Checked:
code = item.data(Qt.ItemDataRole.UserRole)
languages.append(code)
if not languages:
languages = ["en"]
# Bonus
include_bonus: bool | None = None
if self.bonus_group.isChecked():
include_bonus = self.bonus_cb.isChecked()
new_settings = GameSettings(
languages=languages,
english_only=english_only,
include_bonus=include_bonus,
)
self.config.set_game_settings(self.game_id, new_settings)
logger.info(f"Per-game settings saved for {self.game_id}: {new_settings}")
self.accept()

View File

@@ -0,0 +1,325 @@
"""Dialog showing downloaded versions of a game with management actions."""
import shutil
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.config import MetadataStore
from src.models import DownloadedInstaller, language_folder_name, sanitize_folder_name
COL_FILE = 0
COL_VERSION = 1
COL_LANG = 2
COL_SIZE = 3
COL_DATE = 4
def _fmt_size(size: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if size < 1024:
return f"{size:.1f} {unit}"
size //= 1024
return f"{size:.1f} GB"
def _installer_path(base_path: Path, game_name: str, installer: DownloadedInstaller) -> Path:
"""Reconstruct the path where an installer file lives."""
lang_path = base_path / game_name / installer.version / language_folder_name(installer.language) / installer.filename
direct_path = base_path / game_name / installer.version / installer.filename
return lang_path if lang_path.exists() else direct_path
class GameVersionsDialog(QDialog):
"""Shows all downloaded versions/files for a game and lets user manage them."""
def __init__(
self,
game_id: str,
game_title: str,
metadata: MetadataStore,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.game_id = game_id
self.game_title = game_title
self.metadata = metadata
self.base_path = metadata.base_path
self.game_folder = self.base_path / sanitize_folder_name(game_title)
self.setWindowTitle(f"Downloaded versions — {game_title}")
self.setMinimumSize(700, 450)
self._setup_ui()
self._populate()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
# Header info
self.info_label = QLabel()
layout.addWidget(self.info_label)
# Installers group
inst_group = QGroupBox("Installer files")
inst_layout = QVBoxLayout(inst_group)
self.inst_tree = QTreeWidget()
self.inst_tree.setColumnCount(5)
self.inst_tree.setHeaderLabels(["File", "Version", "Language", "Size", "Downloaded"])
self.inst_tree.header().setStretchLastSection(False)
self.inst_tree.header().resizeSection(COL_FILE, 280)
self.inst_tree.header().resizeSection(COL_VERSION, 100)
self.inst_tree.header().resizeSection(COL_LANG, 100)
self.inst_tree.header().resizeSection(COL_SIZE, 80)
self.inst_tree.header().resizeSection(COL_DATE, 160)
self.inst_tree.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection)
self.inst_tree.setSortingEnabled(True)
inst_layout.addWidget(self.inst_tree)
inst_btn_row = QHBoxLayout()
self.delete_selected_btn = QPushButton("Delete selected files")
self.delete_selected_btn.clicked.connect(self._delete_selected_installers)
inst_btn_row.addWidget(self.delete_selected_btn)
self.delete_version_btn = QPushButton("Delete entire version folder…")
self.delete_version_btn.clicked.connect(self._delete_version_folder)
inst_btn_row.addWidget(self.delete_version_btn)
inst_btn_row.addStretch()
inst_layout.addLayout(inst_btn_row)
layout.addWidget(inst_group)
# Bonus group
bonus_group = QGroupBox("Bonus content")
bonus_layout = QVBoxLayout(bonus_group)
self.bonus_tree = QTreeWidget()
self.bonus_tree.setColumnCount(4)
self.bonus_tree.setHeaderLabels(["File", "Name", "Type", "Size"])
self.bonus_tree.header().setStretchLastSection(False)
self.bonus_tree.header().resizeSection(COL_FILE, 280)
self.bonus_tree.header().resizeSection(1, 180)
self.bonus_tree.header().resizeSection(2, 100)
self.bonus_tree.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection)
bonus_layout.addWidget(self.bonus_tree)
bonus_btn_row = QHBoxLayout()
self.delete_bonus_btn = QPushButton("Delete selected bonus files")
self.delete_bonus_btn.clicked.connect(self._delete_selected_bonuses)
bonus_btn_row.addWidget(self.delete_bonus_btn)
bonus_btn_row.addStretch()
bonus_layout.addLayout(bonus_btn_row)
layout.addWidget(bonus_group)
# Close button
close_btn = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
close_btn.rejected.connect(self.reject)
layout.addWidget(close_btn)
def _populate(self) -> None:
record = self.metadata.get_game(self.game_id)
if not record:
self.info_label.setText("No downloaded data found for this game.")
return
total_size = sum(i.size for i in record.installers) + sum(b.size for b in record.bonuses)
self.info_label.setText(
f"<b>{self.game_title}</b> — {len(record.installers)} installer file(s), "
f"{len(record.bonuses)} bonus file(s) — total {_fmt_size(total_size)}"
)
# Group installers by version for visual clarity
versions: dict[str, list[DownloadedInstaller]] = {}
for inst in record.installers:
versions.setdefault(inst.version, []).append(inst)
self.inst_tree.clear()
for version in sorted(versions):
ver_node = QTreeWidgetItem()
ver_node.setText(COL_FILE, f"Version {version}")
ver_node.setText(COL_VERSION, version)
bold = QFont()
bold.setBold(True)
ver_node.setFont(COL_FILE, bold)
ver_node.setData(COL_FILE, Qt.ItemDataRole.UserRole, ("version", version))
ver_size = sum(i.size for i in versions[version])
ver_node.setText(COL_SIZE, _fmt_size(ver_size))
for inst in versions[version]:
file_node = QTreeWidgetItem()
file_node.setText(COL_FILE, inst.filename)
file_node.setText(COL_VERSION, inst.version)
file_node.setText(COL_LANG, language_folder_name(inst.language))
file_node.setText(COL_SIZE, _fmt_size(inst.size))
file_node.setText(COL_DATE, inst.downloaded_at[:19].replace("T", " ") if inst.downloaded_at else "")
file_node.setData(COL_FILE, Qt.ItemDataRole.UserRole, ("installer", inst))
# Grey out if file no longer on disk
path = _installer_path(self.base_path, sanitize_folder_name(self.game_title), inst)
if not path.exists():
for col in range(5):
file_node.setForeground(col, self.palette().color(self.foregroundRole()).darker(150))
file_node.setToolTip(COL_FILE, "File not found on disk")
ver_node.addChild(file_node)
ver_node.setExpanded(True)
self.inst_tree.addTopLevelItem(ver_node)
# Bonus files
self.bonus_tree.clear()
for bonus in record.bonuses:
node = QTreeWidgetItem()
node.setText(0, bonus.filename)
node.setText(1, bonus.name)
node.setText(2, bonus.bonus_type)
node.setText(3, _fmt_size(bonus.size))
node.setData(0, Qt.ItemDataRole.UserRole, bonus)
bonus_path = self.base_path / sanitize_folder_name(self.game_title) / "Bonus" / bonus.filename
if not bonus_path.exists():
for col in range(4):
node.setForeground(col, self.palette().color(self.foregroundRole()).darker(150))
node.setToolTip(0, "File not found on disk")
self.bonus_tree.addTopLevelItem(node)
def _delete_selected_installers(self) -> None:
selected = [
item for item in self.inst_tree.selectedItems()
if item.data(COL_FILE, Qt.ItemDataRole.UserRole) and
item.data(COL_FILE, Qt.ItemDataRole.UserRole)[0] == "installer"
]
if not selected:
QMessageBox.information(self, "Delete", "Select one or more installer files first.")
return
reply = QMessageBox.question(
self,
"Delete files",
f"Delete {len(selected)} selected file(s) from disk?\nMetadata will be updated.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
record = self.metadata.get_game(self.game_id)
if not record:
return
deleted = 0
for item in selected:
inst: DownloadedInstaller = item.data(COL_FILE, Qt.ItemDataRole.UserRole)[1]
path = _installer_path(self.base_path, sanitize_folder_name(self.game_title), inst)
if path.exists():
path.unlink()
logger.info(f"Deleted {path}")
deleted += 1
record.installers = [i for i in record.installers if i.filename != inst.filename or i.version != inst.version]
# Update latest_version
if record.installers:
record.latest_version = sorted({i.version for i in record.installers})[-1]
else:
record.latest_version = ""
self.metadata.update_game(record)
self._populate()
QMessageBox.information(self, "Done", f"Deleted {deleted} file(s).")
def _delete_version_folder(self) -> None:
"""Delete an entire version directory after user selects which version."""
record = self.metadata.get_game(self.game_id)
if not record:
return
versions = sorted({i.version for i in record.installers})
if not versions:
QMessageBox.information(self, "Delete", "No versions found in metadata.")
return
# Pick version from selected item if any
selected = self.inst_tree.currentItem()
preselected_version = ""
if selected:
data = selected.data(COL_FILE, Qt.ItemDataRole.UserRole)
if data and data[0] == "version":
preselected_version = data[1]
elif data and data[0] == "installer":
preselected_version = data[1].version
version = preselected_version or versions[-1]
reply = QMessageBox.question(
self,
"Delete version folder",
f"Delete entire version folder '{version}' for '{self.game_title}'?\n\n"
f"Path: {self.base_path / sanitize_folder_name(self.game_title) / version}\n\n"
"This will remove all files in that folder from disk.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
version_dir = self.base_path / sanitize_folder_name(self.game_title) / version
if version_dir.exists():
shutil.rmtree(version_dir)
logger.info(f"Deleted version folder {version_dir}")
record.installers = [i for i in record.installers if i.version != version]
if record.installers:
record.latest_version = sorted({i.version for i in record.installers})[-1]
else:
record.latest_version = ""
self.metadata.update_game(record)
self._populate()
def _delete_selected_bonuses(self) -> None:
selected = self.bonus_tree.selectedItems()
if not selected:
QMessageBox.information(self, "Delete", "Select one or more bonus files first.")
return
reply = QMessageBox.question(
self,
"Delete bonus files",
f"Delete {len(selected)} bonus file(s) from disk?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
record = self.metadata.get_game(self.game_id)
if not record:
return
deleted = 0
for item in selected:
bonus = item.data(0, Qt.ItemDataRole.UserRole)
path = self.base_path / sanitize_folder_name(self.game_title) / "Bonus" / bonus.filename
if path.exists():
path.unlink()
logger.info(f"Deleted bonus {path}")
deleted += 1
record.bonuses = [b for b in record.bonuses if b.filename != bonus.filename]
self.metadata.update_game(record)
self._populate()
QMessageBox.information(self, "Done", f"Deleted {deleted} bonus file(s).")

70
src/ui/main_window.py Normal file
View File

@@ -0,0 +1,70 @@
"""Main application window with tab layout."""
from loguru import logger
from PySide6.QtWidgets import QMainWindow, QTabWidget
from src.api import GogApi
from src.auth import AuthManager
from src.config import AppConfig
from src.constants import APP_TITLE
from src.ui.tab_auth import AuthTab
from src.ui.tab_languages import LanguagesTab
from src.ui.tab_library import LibraryTab
from src.ui.tab_settings import SettingsTab
from src.ui.tab_status import StatusTab
class MainWindow(QMainWindow):
"""Main GOGUpdater window with 5 tabs."""
def __init__(self, auth: AuthManager, api: GogApi, config: AppConfig) -> None:
super().__init__()
self.auth = auth
self.api = api
self.config = config
self.setWindowTitle(APP_TITLE)
self.setMinimumSize(900, 600)
self._setup_tabs()
self._connect_signals()
def _setup_tabs(self) -> None:
self.tabs = QTabWidget()
self.setCentralWidget(self.tabs)
self.auth_tab = AuthTab(self.auth)
self.library_tab = LibraryTab(self.api, self.config)
self.languages_tab = LanguagesTab(self.config)
self.settings_tab = SettingsTab(self.api, self.config)
self.status_tab = StatusTab(self.api, self.config)
self.tabs.addTab(self.auth_tab, "Login")
self.tabs.addTab(self.library_tab, "Library")
self.tabs.addTab(self.languages_tab, "Languages")
self.tabs.addTab(self.settings_tab, "Settings")
self.tabs.addTab(self.status_tab, "Status")
self._update_tab_states()
def _connect_signals(self) -> None:
self.auth_tab.login_state_changed.connect(self._on_login_changed)
self.settings_tab.english_only_changed.connect(self._on_english_only_changed)
def _on_login_changed(self, logged_in: bool) -> None:
logger.info(f"Login state changed: logged_in={logged_in}")
self._update_tab_states()
def _on_english_only_changed(self, english_only: bool) -> None:
"""Enable/disable Languages tab based on english_only setting."""
logger.info(f"English only changed: {english_only}")
self._update_tab_states()
def _update_tab_states(self) -> None:
"""Enable/disable tabs based on login state and settings."""
logged_in = self.auth.is_logged_in
for i in range(1, self.tabs.count()):
self.tabs.setTabEnabled(i, logged_in)
# Languages tab (index 2) disabled when english_only
if logged_in and self.config.english_only:
self.tabs.setTabEnabled(2, False)

116
src/ui/tab_auth.py Normal file
View File

@@ -0,0 +1,116 @@
"""Authentication tab — GOG OAuth2 login flow."""
import webbrowser
from loguru import logger
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from src.auth import LOGIN_URL, AuthManager
class AuthTab(QWidget):
"""Tab for GOG account authentication."""
login_state_changed = Signal(bool)
def __init__(self, auth: AuthManager, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.auth = auth
self._setup_ui()
self._update_status()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
# Status
self.status_label = QLabel()
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold;")
layout.addWidget(self.status_label)
layout.addSpacing(20)
# Step 1: Open browser
step1_label = QLabel("1. Open GOG login page in your browser:")
layout.addWidget(step1_label)
self.open_browser_btn = QPushButton("Open GOG Login")
self.open_browser_btn.setFixedWidth(200)
self.open_browser_btn.clicked.connect(self._open_login)
layout.addWidget(self.open_browser_btn)
layout.addSpacing(10)
# Step 2: Paste code
step2_label = QLabel("2. After login, paste the authorization code from the URL:")
layout.addWidget(step2_label)
code_layout = QHBoxLayout()
self.code_input = QLineEdit()
self.code_input.setPlaceholderText("Paste authorization code here...")
self.code_input.returnPressed.connect(self._submit_code)
code_layout.addWidget(self.code_input)
self.submit_btn = QPushButton("Login")
self.submit_btn.clicked.connect(self._submit_code)
code_layout.addWidget(self.submit_btn)
layout.addLayout(code_layout)
layout.addSpacing(20)
# Logout
self.logout_btn = QPushButton("Logout")
self.logout_btn.setFixedWidth(200)
self.logout_btn.clicked.connect(self._logout)
layout.addWidget(self.logout_btn)
layout.addStretch()
def _update_status(self) -> None:
if self.auth.is_logged_in:
self.status_label.setText("Status: Logged in")
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold; color: green;")
self.code_input.setEnabled(False)
self.submit_btn.setEnabled(False)
self.open_browser_btn.setEnabled(False)
self.logout_btn.setEnabled(True)
else:
self.status_label.setText("Status: Not logged in")
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold; color: red;")
self.code_input.setEnabled(True)
self.submit_btn.setEnabled(True)
self.open_browser_btn.setEnabled(True)
self.logout_btn.setEnabled(False)
def _open_login(self) -> None:
logger.info("Opening GOG login page in browser")
webbrowser.open(LOGIN_URL)
def _submit_code(self) -> None:
code = self.code_input.text().strip()
if not code:
return
logger.info("Submitting authorization code")
if self.auth.exchange_code(code):
self._update_status()
self.login_state_changed.emit(True)
self.code_input.clear()
else:
logger.warning("Authorization code exchange failed")
QMessageBox.warning(self, "Login Failed", "Failed to authenticate. Check the code and try again.")
def _logout(self) -> None:
logger.info("User logging out")
self.auth.logout()
self._update_status()
self.login_state_changed.emit(False)

62
src/ui/tab_languages.py Normal file
View File

@@ -0,0 +1,62 @@
"""Languages tab — select which languages to manage installers for."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.config import AppConfig
from src.models import LANGUAGE_NAMES
class LanguagesTab(QWidget):
"""Tab for selecting which languages to download installers for."""
def __init__(self, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.config = config
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
label = QLabel("Select languages for which installers will be downloaded:")
layout.addWidget(label)
self.lang_list = QListWidget()
for code, name in LANGUAGE_NAMES.items():
item = QListWidgetItem(f"{name} ({code})")
item.setData(Qt.ItemDataRole.UserRole, code)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
checked = code in self.config.languages
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
self.lang_list.addItem(item)
self.lang_list.itemChanged.connect(self._on_item_changed)
layout.addWidget(self.lang_list)
save_btn = QPushButton("Save")
save_btn.setFixedWidth(200)
save_btn.clicked.connect(self._save)
layout.addWidget(save_btn)
def _on_item_changed(self, item: QListWidgetItem) -> None:
self._save()
def _save(self) -> None:
selected: list[str] = []
for i in range(self.lang_list.count()):
item = self.lang_list.item(i)
if item and item.checkState() == Qt.CheckState.Checked:
code = item.data(Qt.ItemDataRole.UserRole)
selected.append(code)
self.config.languages = selected if selected else ["en"]
self.config.save()
logger.info(f"Languages updated: {self.config.languages}")

224
src/ui/tab_library.py Normal file
View File

@@ -0,0 +1,224 @@
"""Library tab — owned games with management checkboxes, DLCs as sub-items."""
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHeaderView,
QLabel,
QPushButton,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.api import GogApi
from src.config import AppConfig
from src.models import InstallerPlatform
from src.ui.dialog_game_settings import GameSettingsDialog
COL_TITLE = 0
COL_ID = 1
COL_OVERRIDES = 2
def _make_tree() -> QTreeWidget:
tree = QTreeWidget()
tree.setColumnCount(3)
tree.setHeaderLabels(["Game Title", "Game ID", "Overrides"])
tree.header().setSectionResizeMode(COL_TITLE, QHeaderView.ResizeMode.Stretch)
tree.setRootIsDecorated(True)
tree.setSortingEnabled(True)
return tree
class LibraryTab(QWidget):
"""Tab showing owned games with checkboxes. DLCs appear as children of their parent game."""
managed_games_changed = Signal()
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.api = api
self.config = config
self._updating = False
# store games_data per platform for re-populating after settings change
self._games_data: dict[InstallerPlatform, list[tuple[str, str, list[tuple[str, str]]]]] = {
InstallerPlatform.WINDOWS: [],
InstallerPlatform.LINUX: [],
}
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
top_row = QPushButton("Refresh Library")
top_row.setFixedWidth(200)
top_row.clicked.connect(self.refresh_library)
layout.addWidget(top_row)
self.status_label = QLabel("Click 'Refresh Library' to load your games.")
layout.addWidget(self.status_label)
self.platform_tabs = QTabWidget()
self.tree_windows = _make_tree()
self.tree_linux = _make_tree()
self.platform_tabs.addTab(self.tree_windows, "Windows")
self.platform_tabs.addTab(self.tree_linux, "Linux")
layout.addWidget(self.platform_tabs)
self.tree_windows.itemChanged.connect(self._on_item_changed)
self.tree_linux.itemChanged.connect(self._on_item_changed)
self.tree_windows.itemDoubleClicked.connect(self._on_item_double_clicked)
self.tree_linux.itemDoubleClicked.connect(self._on_item_double_clicked)
self._update_platform_tab_visibility()
def _update_platform_tab_visibility(self) -> None:
self.platform_tabs.setTabVisible(0, bool(self.config.windows_path))
self.platform_tabs.setTabVisible(1, bool(self.config.linux_path))
def refresh_library(self) -> None:
"""Fetch owned games from GOG API and populate both platform trees."""
self.status_label.setText("Loading library...")
self.tree_windows.clear()
self.tree_linux.clear()
self._update_platform_tab_visibility()
logger.info("Refreshing game library")
# TODO: Run in a thread to avoid blocking the GUI
owned_ids = self.api.get_owned_game_ids()
if not owned_ids:
logger.warning("No owned games found or not logged in")
self.status_label.setText("No games found or not logged in.")
return
owned_set = {str(gid) for gid in owned_ids}
dlc_ids: set[str] = set()
# games_data shared for all platforms — platform filtering happens in populate
games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = []
# (game_id, title, [(dlc_id, dlc_title)], available_platforms)
for gid in owned_ids:
product = self.api.get_product_info(gid)
if not product:
games_data.append((str(gid), f"Game {gid}", [], set()))
continue
title = product.get("title", f"Game {gid}")
dlcs: list[tuple[str, str]] = []
for dlc in product.get("expanded_dlcs", []):
dlc_id = str(dlc.get("id", ""))
if dlc_id in owned_set:
dlc_ids.add(dlc_id)
dlcs.append((dlc_id, dlc.get("title", f"DLC {dlc_id}")))
# Detect which platforms have installers
installers = product.get("downloads", {}).get("installers", [])
platforms: set[str] = {inst.get("os", "") for inst in installers}
games_data.append((str(gid), title, dlcs, platforms))
# Filter out DLC top-level entries and sort
games_data = [(gid, title, dlcs, plats) for gid, title, dlcs, plats in games_data if gid not in dlc_ids]
games_data.sort(key=lambda x: x[1].lower())
self._updating = True
self.tree_windows.setSortingEnabled(False)
self.tree_linux.setSortingEnabled(False)
win_count = 0
lin_count = 0
for game_id, title, dlcs, platforms in games_data:
managed = self.config.is_game_managed(game_id)
check = Qt.CheckState.Checked if managed else Qt.CheckState.Unchecked
if "windows" in platforms and self.config.windows_path:
node = self._make_node(game_id, title, check)
self.tree_windows.addTopLevelItem(node)
for dlc_id, dlc_title in dlcs:
child = self._make_node(dlc_id, dlc_title, check)
node.addChild(child)
win_count += 1
if "linux" in platforms and self.config.linux_path:
node = self._make_node(game_id, title, check)
self.tree_linux.addTopLevelItem(node)
for dlc_id, dlc_title in dlcs:
child = self._make_node(dlc_id, dlc_title, check)
node.addChild(child)
lin_count += 1
self.tree_windows.setSortingEnabled(True)
self.tree_linux.setSortingEnabled(True)
self._updating = False
logger.info(f"Library loaded: {win_count} Windows, {lin_count} Linux games")
self.status_label.setText(
f"Loaded {win_count} Windows / {lin_count} Linux games. Double-click to configure per-game settings."
)
def _make_node(self, game_id: str, title: str, check: Qt.CheckState) -> QTreeWidgetItem:
node = QTreeWidgetItem()
node.setText(COL_TITLE, title)
node.setText(COL_ID, game_id)
node.setFlags(node.flags() | Qt.ItemFlag.ItemIsUserCheckable)
node.setCheckState(COL_TITLE, check)
self._update_override_label(node, game_id)
return node
def _on_item_changed(self, item: QTreeWidgetItem, column: int) -> None:
if self._updating or column != COL_TITLE:
return
game_id = item.text(COL_ID)
managed = item.checkState(COL_TITLE) == Qt.CheckState.Checked
self._updating = True
if item.parent() is None:
logger.debug(f"Game {game_id} managed={managed}")
self.config.set_game_managed(game_id, managed)
for i in range(item.childCount()):
child = item.child(i)
if child:
child.setCheckState(COL_TITLE, item.checkState(COL_TITLE))
self.config.set_game_managed(child.text(COL_ID), managed)
else:
logger.debug(f"DLC {game_id} managed={managed}")
self.config.set_game_managed(game_id, managed)
self._updating = False
self.managed_games_changed.emit()
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
game_id = item.text(COL_ID)
game_title = item.text(COL_TITLE)
dialog = GameSettingsDialog(game_id, game_title, self.config, self)
if dialog.exec():
# Update override label in both trees
for tree in (self.tree_windows, self.tree_linux):
for i in range(tree.topLevelItemCount()):
top = tree.topLevelItem(i)
if top and top.text(COL_ID) == game_id:
self._update_override_label(top, game_id)
if top:
for j in range(top.childCount()):
child = top.child(j)
if child and child.text(COL_ID) == game_id:
self._update_override_label(child, game_id)
def _update_override_label(self, item: QTreeWidgetItem, game_id: str) -> None:
gs = self.config.get_game_settings(game_id)
if gs.is_default():
item.setText(COL_OVERRIDES, "")
return
parts: list[str] = []
if gs.english_only is not None:
parts.append("EN only" if gs.english_only else "multilang")
if gs.languages is not None:
parts.append(f"{len(gs.languages)} lang(s)")
if gs.include_bonus is not None:
parts.append("bonus" if gs.include_bonus else "no bonus")
item.setText(COL_OVERRIDES, ", ".join(parts))

167
src/ui/tab_settings.py Normal file
View File

@@ -0,0 +1,167 @@
"""Settings tab — installer paths configuration."""
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QCheckBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.api import GogApi
from src.config import AppConfig, MetadataStore
class SettingsTab(QWidget):
"""Tab for configuring installer storage paths."""
english_only_changed = Signal(bool)
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.api = api
self.config = config
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
form = QFormLayout()
# Windows installers path
win_layout = QHBoxLayout()
self.win_path_input = QLineEdit(self.config.windows_path)
self.win_path_input.setPlaceholderText("Path for Windows installers (.exe)")
win_layout.addWidget(self.win_path_input)
win_browse_btn = QPushButton("Browse...")
win_browse_btn.clicked.connect(lambda: self._browse("windows"))
win_layout.addWidget(win_browse_btn)
form.addRow("Windows installers:", win_layout)
# Linux installers path
linux_layout = QHBoxLayout()
self.linux_path_input = QLineEdit(self.config.linux_path)
self.linux_path_input.setPlaceholderText("Path for Linux installers (.sh)")
linux_layout.addWidget(self.linux_path_input)
linux_browse_btn = QPushButton("Browse...")
linux_browse_btn.clicked.connect(lambda: self._browse("linux"))
linux_layout.addWidget(linux_browse_btn)
form.addRow("Linux installers:", linux_layout)
layout.addLayout(form)
layout.addSpacing(10)
# English only checkbox
self.english_only_cb = QCheckBox("English only (disable multilingual support)")
self.english_only_cb.setChecked(self.config.english_only)
self.english_only_cb.toggled.connect(self._on_english_only_toggled)
layout.addWidget(self.english_only_cb)
# Include bonus content checkbox
self.include_bonus_cb = QCheckBox("Include bonus content (soundtracks, wallpapers, manuals, ...)")
self.include_bonus_cb.setChecked(self.config.include_bonus)
self.include_bonus_cb.toggled.connect(self._on_include_bonus_toggled)
layout.addWidget(self.include_bonus_cb)
layout.addSpacing(20)
# Buttons row
btn_layout = QHBoxLayout()
save_btn = QPushButton("Save Settings")
save_btn.setFixedWidth(200)
save_btn.clicked.connect(self._save)
btn_layout.addWidget(save_btn)
scan_btn = QPushButton("Scan Existing Installers")
scan_btn.setFixedWidth(200)
scan_btn.clicked.connect(self._scan_existing)
btn_layout.addWidget(scan_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
self.status_label = QLabel("")
layout.addWidget(self.status_label)
layout.addStretch()
def _browse(self, target: str) -> None:
path = QFileDialog.getExistingDirectory(self, f"Select {target} installers directory")
if path:
if target == "windows":
self.win_path_input.setText(path)
else:
self.linux_path_input.setText(path)
def _on_english_only_toggled(self, checked: bool) -> None:
self.config.english_only = checked
self.config.save()
self.english_only_changed.emit(checked)
logger.info(f"English only: {checked}")
def _on_include_bonus_toggled(self, checked: bool) -> None:
self.config.include_bonus = checked
self.config.save()
logger.info(f"Include bonus content: {checked}")
def _save(self) -> None:
self.config.windows_path = self.win_path_input.text().strip()
self.config.linux_path = self.linux_path_input.text().strip()
self.config.english_only = self.english_only_cb.isChecked()
self.config.include_bonus = self.include_bonus_cb.isChecked()
self.config.save()
logger.info(f"Settings saved: windows={self.config.windows_path}, linux={self.config.linux_path}")
self.status_label.setText("Settings saved.")
def _scan_existing(self) -> None:
"""Scan configured paths for already downloaded installers."""
# Build title -> game_id mapping from owned games
self.status_label.setText("Fetching game library for matching...")
try:
owned_games = self.api.get_owned_games()
except Exception as e:
logger.error(f"Failed to fetch owned games for scan: {e}")
self.status_label.setText("Failed to fetch game library.")
return
title_to_id: dict[str, str] = {game.title: game.game_id for game in owned_games}
logger.info(f"Scan: loaded {len(title_to_id)} owned games for matching")
total_detected = 0
scanned_paths: list[str] = []
for path in [self.config.windows_path, self.config.linux_path]:
if not path:
continue
scanned_paths.append(path)
self.status_label.setText(f"Scanning {path}...")
metadata = MetadataStore(path)
detected = metadata.scan_existing_installers(title_to_id)
total_detected += detected
if not scanned_paths:
self.status_label.setText("No paths configured. Set Windows/Linux paths first.")
return
msg = f"Scan complete. Detected {total_detected} game(s) with existing installers."
self.status_label.setText(msg)
logger.info(msg)
if total_detected > 0:
QMessageBox.information(
self,
"Scan Complete",
f"Found {total_detected} game(s) with existing installers.\n\n"
"Their metadata has been recorded. Check the Status tab for details.",
)

569
src/ui/tab_status.py Normal file
View File

@@ -0,0 +1,569 @@
"""Status tab — installer status overview, update checks, downloads, pruning."""
from datetime import datetime, timezone
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QProgressBar,
QPushButton,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.api import GogApi
from src.config import AppConfig, MetadataStore
from src.downloader import InstallerDownloader
from src.ui.dialog_game_versions import GameVersionsDialog
from src.models import (
BonusContent,
GameStatus,
GameStatusInfo,
InstallerPlatform,
LanguageVersionInfo,
language_folder_name,
)
from src.version_compare import CompareResult, compare_versions, format_comparison_question
STATUS_COLORS: dict[GameStatus, str] = {
GameStatus.UP_TO_DATE: "#2e7d32",
GameStatus.UPDATE_AVAILABLE: "#e65100",
GameStatus.NOT_DOWNLOADED: "#1565c0",
GameStatus.UNKNOWN: "#757575",
GameStatus.UNVERSIONED: "#f9a825",
}
COL_NAME = 0
COL_CURRENT = 1
COL_LATEST = 2
COL_STATUS = 3
COL_LANGS = 4
COL_BONUS = 5
def _make_tree() -> QTreeWidget:
tree = QTreeWidget()
tree.setColumnCount(6)
tree.setHeaderLabels(["Game", "Current Version", "Latest Version", "Status", "Language", "Bonus"])
tree.header().setSectionResizeMode(COL_NAME, QHeaderView.ResizeMode.Stretch)
tree.setRootIsDecorated(True)
tree.setSortingEnabled(True)
return tree
class StatusTab(QWidget):
"""Tab showing installer status with check/download/prune controls."""
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.api = api
self.config = config
# per-platform status items
self.status_items: dict[InstallerPlatform, list[GameStatusInfo]] = {
InstallerPlatform.WINDOWS: [],
InstallerPlatform.LINUX: [],
}
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
# Controls
controls = QHBoxLayout()
self.check_btn = QPushButton("Check for Updates")
self.check_btn.clicked.connect(self.check_updates)
controls.addWidget(self.check_btn)
self.download_btn = QPushButton("Download Updates")
self.download_btn.clicked.connect(self.download_updates)
self.download_btn.setEnabled(False)
controls.addWidget(self.download_btn)
self.prune_btn = QPushButton("Prune Old Versions")
self.prune_btn.clicked.connect(self.prune_versions)
controls.addWidget(self.prune_btn)
self.prune_keep_combo = QComboBox()
self.prune_keep_combo.addItems(["Keep 1", "Keep 2", "Keep 3"])
controls.addWidget(self.prune_keep_combo)
self.show_unknown_cb = QCheckBox("Show unversioned/unknown")
self.show_unknown_cb.setChecked(False)
self.show_unknown_cb.toggled.connect(self._repopulate_current)
controls.addWidget(self.show_unknown_cb)
controls.addStretch()
layout.addLayout(controls)
# Status label
self.status_label = QLabel("Click 'Check for Updates' to scan managed games.")
layout.addWidget(self.status_label)
# Progress bar
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
# Platform sub-tabs
self.platform_tabs = QTabWidget()
self.tree_windows = _make_tree()
self.tree_linux = _make_tree()
self.platform_tabs.addTab(self.tree_windows, "Windows")
self.platform_tabs.addTab(self.tree_linux, "Linux")
self.tree_windows.itemDoubleClicked.connect(self._on_item_double_clicked)
self.tree_linux.itemDoubleClicked.connect(self._on_item_double_clicked)
layout.addWidget(self.platform_tabs)
self._update_platform_tab_visibility()
def _update_platform_tab_visibility(self) -> None:
"""Show/hide platform tabs based on configured paths."""
has_win = bool(self.config.windows_path)
has_lin = bool(self.config.linux_path)
self.platform_tabs.setTabVisible(0, has_win)
self.platform_tabs.setTabVisible(1, has_lin)
def _current_platform(self) -> InstallerPlatform:
idx = self.platform_tabs.currentIndex()
return InstallerPlatform.WINDOWS if idx == 0 else InstallerPlatform.LINUX
def _tree_for(self, platform: InstallerPlatform) -> QTreeWidget:
return self.tree_windows if platform == InstallerPlatform.WINDOWS else self.tree_linux
def _base_path_for(self, platform: InstallerPlatform) -> str:
return self.config.windows_path if platform == InstallerPlatform.WINDOWS else self.config.linux_path
def check_updates(self) -> None:
"""Check update status for all managed games, per language."""
managed_ids = self.config.managed_game_ids
if not managed_ids:
self.status_label.setText("No managed games. Go to Library tab to select games.")
return
self.check_btn.setEnabled(False)
self.status_label.setText("Checking updates...")
for items in self.status_items.values():
items.clear()
self.tree_windows.clear()
self.tree_linux.clear()
self._update_platform_tab_visibility()
# TODO: Run in a thread to avoid blocking the GUI
# Verify metadata integrity — remove entries for files deleted outside the app
for platform in InstallerPlatform:
base_path = self._base_path_for(platform)
if base_path:
metadata = MetadataStore(base_path)
stale = metadata.verify_metadata()
if stale:
logger.info(f"Cleaned {stale} stale metadata entries from {base_path}")
owned_set = self.api.get_owned_ids_set()
managed_set = set(managed_ids)
# First pass: collect all DLC IDs and cache products
dlc_ids: set[str] = set()
products_cache: dict[str, dict] = {}
for game_id in managed_ids:
product = self.api.get_product_info(game_id)
if product:
products_cache[game_id] = product
for dlc in product.get("expanded_dlcs", []):
dlc_ids.add(str(dlc.get("id", "")))
for platform in InstallerPlatform:
base_path = self._base_path_for(platform)
if not base_path:
continue
metadata = MetadataStore(base_path)
items_list = self.status_items[platform]
for game_id in managed_ids:
if game_id in dlc_ids:
continue
product = products_cache.get(game_id)
if not product:
items_list.append(GameStatusInfo(
game_id=game_id,
name=f"Game {game_id}",
current_version="",
latest_version="",
status=GameStatus.UNKNOWN,
))
continue
game_name = product.get("title", f"Game {game_id}")
game_status = self._build_game_status(game_id, game_name, product, platform, metadata)
for dlc in product.get("expanded_dlcs", []):
dlc_id = str(dlc.get("id", ""))
if dlc_id not in owned_set or dlc_id not in managed_set:
continue
dlc_name = dlc.get("title", f"DLC {dlc_id}")
dlc_status = self._build_game_status(dlc_id, dlc_name, dlc, platform, metadata, parent_name=game_name)
game_status.dlcs.append(dlc_status)
items_list.append(game_status)
self._populate_tree(InstallerPlatform.WINDOWS)
self._populate_tree(InstallerPlatform.LINUX)
self.download_btn.setEnabled(self._has_downloadable_items())
self.check_btn.setEnabled(True)
total = sum(len(items) for items in self.status_items.values())
self.status_label.setText(f"Checked {total} game entries across platforms.")
def _build_game_status(
self,
game_id: str,
game_name: str,
product: dict,
platform: InstallerPlatform,
metadata: MetadataStore,
parent_name: str = "",
) -> GameStatusInfo:
"""Build GameStatusInfo with per-language version tracking."""
platform_key = "windows" if platform == InstallerPlatform.WINDOWS else "linux"
installers = product.get("downloads", {}).get("installers", [])
lang_versions: dict[str, str] = {}
for inst in installers:
if inst.get("os") == platform_key:
lang = inst.get("language", "en")
version = inst.get("version") or ""
effective_langs = self.config.get_effective_languages(game_id)
if lang in effective_langs or not effective_langs:
lang_versions[lang] = version
record = metadata.get_game(game_id)
current_version = record.latest_version if record else ""
language_infos: list[LanguageVersionInfo] = []
for lang, latest_ver in lang_versions.items():
lang_status = self._determine_status_with_compare(current_version, latest_ver, game_name, lang)
language_infos.append(LanguageVersionInfo(
language=lang,
current_version=current_version,
latest_version=latest_ver,
status=lang_status,
))
if language_infos:
overall_status = self._worst_status(language_infos)
representative_latest = language_infos[0].latest_version
else:
overall_status = GameStatus.UNKNOWN
representative_latest = ""
all_same_version = len({lv.latest_version for lv in language_infos}) <= 1
bonus_available = 0
bonus_downloaded = 0
if self.config.get_effective_include_bonus(game_id) and language_infos:
bonus_items = self.api.get_bonus_content(game_id, product)
bonus_available = len(bonus_items)
if record:
bonus_downloaded = len(record.bonuses)
return GameStatusInfo(
game_id=game_id,
name=game_name,
current_version=current_version,
latest_version=representative_latest if all_same_version else "",
status=overall_status,
language_versions=language_infos,
last_checked=datetime.now(timezone.utc).isoformat(),
parent_name=parent_name,
bonus_available=bonus_available,
bonus_downloaded=bonus_downloaded,
)
@staticmethod
def _is_non_version(value: str) -> bool:
return value.strip().lower() in {"bonus", "n/a", "na", "none", ""}
def _determine_status_with_compare(
self, current: str, latest: str, game_name: str, language: str,
) -> GameStatus:
if not latest or self._is_non_version(latest):
return GameStatus.UNVERSIONED
if not current:
return GameStatus.NOT_DOWNLOADED
if self._is_non_version(current):
return GameStatus.NOT_DOWNLOADED
result = compare_versions(current, latest)
if result == CompareResult.EQUAL:
return GameStatus.UP_TO_DATE
elif result == CompareResult.OLDER:
return GameStatus.UPDATE_AVAILABLE
elif result == CompareResult.NEWER:
return GameStatus.UP_TO_DATE
else:
question = format_comparison_question(game_name, language, current, latest)
reply = QMessageBox.question(
self,
"Version Comparison",
question,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
return GameStatus.UPDATE_AVAILABLE
return GameStatus.UP_TO_DATE
def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus:
priority = [
GameStatus.UPDATE_AVAILABLE,
GameStatus.NOT_DOWNLOADED,
GameStatus.UNVERSIONED,
GameStatus.UNKNOWN,
GameStatus.UP_TO_DATE,
]
for status in priority:
if any(lv.status == status for lv in lang_infos):
return status
return GameStatus.UNKNOWN
def _has_downloadable_items(self) -> bool:
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
for items in self.status_items.values():
for item in items:
if item.status in downloadable:
return True
if any(dlc.status in downloadable for dlc in item.dlcs):
return True
if item.bonus_available > item.bonus_downloaded:
return True
return False
def _should_show(self, info: GameStatusInfo) -> bool:
if info.status in (GameStatus.UNKNOWN, GameStatus.UNVERSIONED):
return self.show_unknown_cb.isChecked()
return True
def _repopulate_current(self) -> None:
"""Repopulate only the currently visible platform tree."""
self._populate_tree(self._current_platform())
def _populate_tree(self, platform: InstallerPlatform) -> None:
tree = self._tree_for(platform)
tree.setSortingEnabled(False)
tree.clear()
for item in self.status_items[platform]:
visible_dlcs = [dlc for dlc in item.dlcs if self._should_show(dlc)]
if not self._should_show(item) and not visible_dlcs:
continue
game_node = self._create_game_tree_item(item)
tree.addTopLevelItem(game_node)
for dlc in visible_dlcs:
dlc_node = self._create_game_tree_item(dlc)
game_node.addChild(dlc_node)
if visible_dlcs:
game_node.setExpanded(True)
tree.setSortingEnabled(True)
def _create_game_tree_item(self, info: GameStatusInfo) -> QTreeWidgetItem:
tree_item = QTreeWidgetItem()
tree_item.setText(COL_NAME, info.name)
tree_item.setData(COL_STATUS, Qt.ItemDataRole.UserRole, info.game_id)
has_different_versions = len({lv.latest_version for lv in info.language_versions}) > 1
if has_different_versions:
tree_item.setText(COL_CURRENT, info.current_version or "-")
tree_item.setText(COL_LATEST, "(per language)")
tree_item.setText(COL_STATUS, info.status_text)
tree_item.setText(COL_LANGS, "")
tree_item.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(info.status, "#000000"))))
for lv in info.language_versions:
lang_node = QTreeWidgetItem()
lang_node.setText(COL_NAME, f" {language_folder_name(lv.language)}")
lang_node.setText(COL_CURRENT, lv.current_version or "-")
lang_node.setText(COL_LATEST, lv.latest_version or "-")
lang_node.setText(COL_STATUS, GameStatusInfo(
game_id="", name="", current_version="", latest_version="", status=lv.status,
).status_text)
lang_node.setText(COL_LANGS, lv.language)
lang_node.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(lv.status, "#000000"))))
tree_item.addChild(lang_node)
tree_item.setExpanded(True)
else:
tree_item.setText(COL_CURRENT, info.current_version or "-")
latest = info.language_versions[0].latest_version if info.language_versions else "-"
tree_item.setText(COL_LATEST, latest or "-")
tree_item.setText(COL_STATUS, info.status_text)
langs = ", ".join(language_folder_name(lv.language) for lv in info.language_versions)
tree_item.setText(COL_LANGS, langs)
tree_item.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(info.status, "#000000"))))
if info.bonus_available > 0:
tree_item.setText(COL_BONUS, f"{info.bonus_downloaded}/{info.bonus_available}")
color = "#e65100" if info.bonus_downloaded < info.bonus_available else "#2e7d32"
tree_item.setForeground(COL_BONUS, QBrush(QColor(color)))
return tree_item
def download_updates(self) -> None:
"""Download updates for the currently visible platform."""
platform = self._current_platform()
base_path = self._base_path_for(platform)
if not base_path:
self.status_label.setText("No path configured for this platform.")
return
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
to_download = [
item for item in self.status_items[platform]
if item.status in downloadable
]
for item in self.status_items[platform]:
to_download.extend(dlc for dlc in item.dlcs if dlc.status in downloadable)
if not to_download:
self.status_label.setText("Nothing to download.")
return
self.download_btn.setEnabled(False)
self.check_btn.setEnabled(False)
self.progress_bar.setVisible(True)
self.progress_bar.setMaximum(len(to_download))
self.progress_bar.setValue(0)
# TODO: Run in a thread to avoid blocking the GUI
completed = 0
metadata = MetadataStore(base_path)
downloader = InstallerDownloader(self.api, metadata)
for item in to_download:
self.status_label.setText(f"Downloading: {item.name}...")
folder_name = item.parent_name if item.parent_name else item.name
effective_langs = self.config.get_effective_languages(item.game_id)
installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs)
gs = self.config.get_game_settings(item.game_id)
is_english_only = gs.english_only if gs.english_only is not None else self.config.english_only
single_language = is_english_only or len({i.language for i in installers}) == 1
for installer in installers:
success = downloader.download_installer(installer, folder_name, single_language=single_language)
if success:
logger.info(f"Downloaded {installer.filename}")
else:
logger.error(f"Failed to download {installer.filename}")
completed += 1
self.progress_bar.setValue(completed)
# Bonus content for this platform
bonus_count = 0
if any(item.bonus_available > item.bonus_downloaded for item in self.status_items[platform]):
bonus_items_to_download = self._collect_bonus_downloads(platform, base_path)
if bonus_items_to_download:
self.progress_bar.setMaximum(len(bonus_items_to_download))
self.progress_bar.setValue(0)
for game_name, bonus in bonus_items_to_download:
self.status_label.setText(f"Downloading bonus: {bonus.name}...")
b_metadata = MetadataStore(base_path)
b_downloader = InstallerDownloader(self.api, b_metadata)
if b_downloader.download_bonus(bonus, game_name):
bonus_count += 1
self.progress_bar.setValue(bonus_count)
self.progress_bar.setVisible(False)
self.check_btn.setEnabled(True)
parts = [f"Downloaded {completed} items"]
if bonus_count:
parts.append(f"{bonus_count} bonus files")
self.status_label.setText(f"{', '.join(parts)}. Run 'Check for Updates' to refresh.")
def _collect_bonus_downloads(
self, platform: InstallerPlatform, base_path: str,
) -> list[tuple[str, BonusContent]]:
"""Collect bonus content not yet downloaded for this platform."""
result: list[tuple[str, BonusContent]] = []
metadata = MetadataStore(base_path)
platform_key = "windows" if platform == InstallerPlatform.WINDOWS else "linux"
for item in self.status_items[platform]:
if item.bonus_available <= item.bonus_downloaded:
continue
record = metadata.get_game(item.game_id)
if not record or not record.installers:
product = self.api.get_product_info(item.game_id)
if not product:
continue
has_platform = any(
inst.get("os") == platform_key
for inst in product.get("downloads", {}).get("installers", [])
)
if not has_platform:
continue
downloaded_names = {b.name for b in record.bonuses} if record else set()
folder_name = item.parent_name if item.parent_name else item.name
for bonus in self.api.get_bonus_content(item.game_id):
if bonus.name not in downloaded_names:
result.append((folder_name, bonus))
return result
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Open version management dialog for the clicked game."""
game_id = item.data(COL_STATUS, Qt.ItemDataRole.UserRole)
if not game_id:
return # language sub-row, no game_id stored
game_name = item.text(COL_NAME)
base_path = self._base_path_for(self._current_platform())
if not base_path:
return
metadata = MetadataStore(base_path)
dialog = GameVersionsDialog(game_id, game_name, metadata, self)
dialog.exec()
def prune_versions(self) -> None:
"""Remove old installer versions for the currently visible platform."""
platform = self._current_platform()
base_path = self._base_path_for(platform)
if not base_path:
self.status_label.setText("No path configured for this platform.")
return
keep = self.prune_keep_combo.currentIndex() + 1
reply = QMessageBox.question(
self,
"Prune Old Versions",
f"This will delete all but the {keep} most recent version(s) of each managed game "
f"in the {platform.value.capitalize()} path.\n\nContinue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
metadata = MetadataStore(base_path)
total_deleted = sum(
len(metadata.prune_old_versions(game_id, keep_latest=keep))
for game_id in self.config.managed_game_ids
)
self.status_label.setText(f"Pruned {total_deleted} old version(s).")

81
src/version_compare.py Normal file
View File

@@ -0,0 +1,81 @@
"""Version comparison with fallback to user prompt for ambiguous cases."""
import re
from enum import Enum
from loguru import logger
class CompareResult(Enum):
OLDER = "older" # current < latest → update available
EQUAL = "equal" # current == latest → up to date
NEWER = "newer" # current > latest → somehow ahead
AMBIGUOUS = "ambiguous" # can't determine → ask user
# Pattern to strip GOG-specific suffixes like "(gog-3)", "(Galaxy)", etc.
_GOG_SUFFIX_RE = re.compile(r"\s*\(.*?\)\s*$")
def _normalize(version: str) -> str:
"""Strip GOG-specific suffixes and whitespace."""
return _GOG_SUFFIX_RE.sub("", version).strip()
def _parse_numeric_parts(version: str) -> list[int] | None:
"""Try to parse version as dot-separated integers. Returns None if not possible."""
normalized = _normalize(version)
if not normalized:
return None
parts = normalized.split(".")
try:
return [int(p) for p in parts]
except ValueError:
return None
def compare_versions(current: str, latest: str) -> CompareResult:
"""Compare two version strings.
Tries numeric comparison first. If both versions can be parsed as
dot-separated integers (after stripping GOG suffixes), compares numerically.
If versions are identical strings, returns EQUAL.
Otherwise returns AMBIGUOUS — caller should ask the user.
"""
if not current or not latest:
return CompareResult.AMBIGUOUS
# Exact string match
if current == latest:
return CompareResult.EQUAL
# Normalized string match
norm_current = _normalize(current)
norm_latest = _normalize(latest)
if norm_current == norm_latest:
return CompareResult.EQUAL
# Try numeric comparison
current_parts = _parse_numeric_parts(current)
latest_parts = _parse_numeric_parts(latest)
if current_parts is not None and latest_parts is not None:
# Pad shorter list with zeros for fair comparison
max_len = max(len(current_parts), len(latest_parts))
current_padded = current_parts + [0] * (max_len - len(current_parts))
latest_padded = latest_parts + [0] * (max_len - len(latest_parts))
if current_padded < latest_padded:
return CompareResult.OLDER
elif current_padded > latest_padded:
return CompareResult.NEWER
else:
return CompareResult.EQUAL
logger.debug(f"Ambiguous version comparison: '{current}' vs '{latest}'")
return CompareResult.AMBIGUOUS
def format_comparison_question(game_name: str, language: str, current: str, latest: str) -> str:
"""Format a human-readable question for ambiguous version comparison."""
return f"{game_name} [{language}]\n\nIs '{latest}' newer than '{current}'?"

0
tests/__init__.py Normal file
View File

141
tests/test_constants.py Normal file
View File

@@ -0,0 +1,141 @@
"""Tests for constants module."""
import re
from pathlib import Path
from unittest.mock import mock_open, patch
import pytest
from src.constants import (
APP_NAME,
APP_TITLE,
APP_VERSION,
ENV_DEBUG,
get_debug_mode,
get_version,
)
# ---------------------------------------------------------------------------
# get_version()
# ---------------------------------------------------------------------------
def test_get_version_returns_string() -> None:
"""get_version() should return a string."""
assert isinstance(get_version(), str)
def test_get_version_semver_format() -> None:
"""get_version() should return a semver-like string X.Y.Z."""
version = get_version()
assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}"
def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
"""get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing."""
missing = tmp_path / "nonexistent.toml"
with patch("src.constants._PYPROJECT_PATH", missing):
result = get_version()
# Either fallback _version.py exists (from previous run) or returns unknown
assert isinstance(result, str)
assert len(result) > 0
def test_get_version_unknown_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
missing = tmp_path / "nonexistent.toml"
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
# Patch _version import to also fail
with patch("src.constants.Path.write_text", side_effect=OSError):
with patch.dict("sys.modules", {"src._version": None}):
result = get_version()
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# get_debug_mode()
# ---------------------------------------------------------------------------
def test_get_debug_mode_returns_bool() -> None:
"""get_debug_mode() should always return a bool."""
assert isinstance(get_debug_mode(), bool)
def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns True when ENV_DEBUG=true."""
monkeypatch.setenv("ENV_DEBUG", "true")
assert get_debug_mode() is True
def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() accepts '1' and 'yes' as truthy values."""
for value in ("1", "yes", "YES", "True", "TRUE"):
monkeypatch.setenv("ENV_DEBUG", value)
assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}"
def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns False when ENV_DEBUG=false."""
monkeypatch.setenv("ENV_DEBUG", "false")
assert get_debug_mode() is False
def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns False when ENV_DEBUG is not set."""
monkeypatch.delenv("ENV_DEBUG", raising=False)
assert get_debug_mode() is False
# ---------------------------------------------------------------------------
# Module-level constants
# ---------------------------------------------------------------------------
def test_env_debug_is_bool() -> None:
"""ENV_DEBUG should be a bool."""
assert isinstance(ENV_DEBUG, bool)
def test_app_version_is_string() -> None:
"""APP_VERSION should be a string."""
assert isinstance(APP_VERSION, str)
def test_app_version_semver_format() -> None:
"""APP_VERSION should follow semver format X.Y.Z."""
assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}"
def test_app_name_value() -> None:
"""APP_NAME should be 'X4 SavEd'."""
assert APP_NAME == "X4 SavEd"
def test_app_title_contains_name_and_version() -> None:
"""APP_TITLE should contain APP_NAME and APP_VERSION."""
assert APP_NAME in APP_TITLE
assert APP_VERSION in APP_TITLE
def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None:
"""APP_TITLE ends with '-DEV' when ENV_DEBUG is True."""
import importlib
import src.constants as consts
monkeypatch.setenv("ENV_DEBUG", "true")
monkeypatch.setattr(consts, "ENV_DEBUG", True)
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "")
assert title.endswith("-DEV")
def test_app_title_no_dev_suffix_when_not_debug(monkeypatch: pytest.MonkeyPatch) -> None:
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
import src.constants as consts
monkeypatch.setattr(consts, "ENV_DEBUG", False)
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "")
assert not title.endswith("-DEV")