diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9f3d1b --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7707c41 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/GOGUpdater.py b/GOGUpdater.py new file mode 100644 index 0000000..f876843 --- /dev/null +++ b/GOGUpdater.py @@ -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() diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..862200e --- /dev/null +++ b/PROJECT.md @@ -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 diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..d15b218 --- /dev/null +++ b/poetry.lock @@ -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" diff --git a/prebuild.py b/prebuild.py new file mode 100644 index 0000000..84de3ae --- /dev/null +++ b/prebuild.py @@ -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") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 21d1469..b9cfc5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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)" +] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/_version.py b/src/_version.py new file mode 100644 index 0000000..941897d --- /dev/null +++ b/src/_version.py @@ -0,0 +1,2 @@ +"""Auto-generated — do not edit manually.""" +__version__ = "0.1.0" diff --git a/src/api.py b/src/api.py new file mode 100644 index 0000000..0777c8c --- /dev/null +++ b/src/api.py @@ -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 diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..1a0da0c --- /dev/null +++ b/src/auth.py @@ -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") diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..f30c187 --- /dev/null +++ b/src/config.py @@ -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 diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..0f11c6e --- /dev/null +++ b/src/constants.py @@ -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}" diff --git a/src/downloader.py b/src/downloader.py new file mode 100644 index 0000000..3ac0a2e --- /dev/null +++ b/src/downloader.py @@ -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) diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..7a2a019 --- /dev/null +++ b/src/models.py @@ -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] diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/dialog_game_settings.py b/src/ui/dialog_game_settings.py new file mode 100644 index 0000000..363e611 --- /dev/null +++ b/src/ui/dialog_game_settings.py @@ -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() diff --git a/src/ui/dialog_game_versions.py b/src/ui/dialog_game_versions.py new file mode 100644 index 0000000..0071178 --- /dev/null +++ b/src/ui/dialog_game_versions.py @@ -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"{self.game_title} — {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).") diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..a3b01ef --- /dev/null +++ b/src/ui/main_window.py @@ -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) diff --git a/src/ui/tab_auth.py b/src/ui/tab_auth.py new file mode 100644 index 0000000..84a353f --- /dev/null +++ b/src/ui/tab_auth.py @@ -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) diff --git a/src/ui/tab_languages.py b/src/ui/tab_languages.py new file mode 100644 index 0000000..a73441d --- /dev/null +++ b/src/ui/tab_languages.py @@ -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}") diff --git a/src/ui/tab_library.py b/src/ui/tab_library.py new file mode 100644 index 0000000..477be1b --- /dev/null +++ b/src/ui/tab_library.py @@ -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)) diff --git a/src/ui/tab_settings.py b/src/ui/tab_settings.py new file mode 100644 index 0000000..67b550d --- /dev/null +++ b/src/ui/tab_settings.py @@ -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.", + ) diff --git a/src/ui/tab_status.py b/src/ui/tab_status.py new file mode 100644 index 0000000..a41b45a --- /dev/null +++ b/src/ui/tab_status.py @@ -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).") diff --git a/src/version_compare.py b/src/version_compare.py new file mode 100644 index 0000000..ec8a7c5 --- /dev/null +++ b/src/version_compare.py @@ -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}'?" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..7156474 --- /dev/null +++ b/tests/test_constants.py @@ -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")