gog-cli 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gog_cli/__init__.py +5 -0
- gog_cli/api.py +143 -0
- gog_cli/aria2c.py +136 -0
- gog_cli/auth.py +217 -0
- gog_cli/backup.py +197 -0
- gog_cli/cli.py +550 -0
- gog_cli/config.py +120 -0
- gog_cli/downloader.py +196 -0
- gog_cli/errors.py +54 -0
- gog_cli/execution.py +1054 -0
- gog_cli/layout.py +72 -0
- gog_cli/listing.py +668 -0
- gog_cli/log.py +19 -0
- gog_cli/metadata.py +212 -0
- gog_cli/output.py +99 -0
- gog_cli/prompt.py +57 -0
- gog_cli/refresh.py +231 -0
- gog_cli/state.py +193 -0
- gog_cli/sync.py +146 -0
- gog_cli-0.2.1.dist-info/METADATA +193 -0
- gog_cli-0.2.1.dist-info/RECORD +25 -0
- gog_cli-0.2.1.dist-info/WHEEL +5 -0
- gog_cli-0.2.1.dist-info/entry_points.txt +2 -0
- gog_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- gog_cli-0.2.1.dist-info/top_level.txt +1 -0
gog_cli/downloader.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Built-in direct downloader with resume and verification support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
_CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
|
17
|
+
_LOG_INTERVAL = 10 * 1024 * 1024 # log every 10 MiB
|
|
18
|
+
|
|
19
|
+
DownloadStatus = Literal["verified", "downloaded", "partial", "failed", "skipped"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DownloadResult:
|
|
24
|
+
status: DownloadStatus
|
|
25
|
+
path: Path | None = None
|
|
26
|
+
temp_path: Path | None = None
|
|
27
|
+
bytes_downloaded: int = 0
|
|
28
|
+
expected_size: int | None = None
|
|
29
|
+
checksum_verified: bool = False
|
|
30
|
+
failure_code: str | None = None
|
|
31
|
+
failure_message: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Downloader:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
session: requests.Session,
|
|
38
|
+
logger: logging.Logger | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._session = session
|
|
41
|
+
self._log = logger or logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
def download(
|
|
44
|
+
self,
|
|
45
|
+
url: str,
|
|
46
|
+
dest: Path,
|
|
47
|
+
*,
|
|
48
|
+
expected_size: int | None = None,
|
|
49
|
+
expected_md5: str | None = None,
|
|
50
|
+
resume: bool = True,
|
|
51
|
+
progress_callback: Callable[[int, int | None], None] | None = None,
|
|
52
|
+
) -> DownloadResult:
|
|
53
|
+
if dest.exists():
|
|
54
|
+
return DownloadResult(
|
|
55
|
+
status="skipped",
|
|
56
|
+
path=dest,
|
|
57
|
+
expected_size=expected_size,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
temp_path = dest.parent / f".{dest.name}.part"
|
|
61
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
# Handle oversized partial file
|
|
64
|
+
if (
|
|
65
|
+
temp_path.exists()
|
|
66
|
+
and expected_size is not None
|
|
67
|
+
and temp_path.stat().st_size > expected_size
|
|
68
|
+
):
|
|
69
|
+
temp_path.unlink()
|
|
70
|
+
|
|
71
|
+
# Determine resume offset
|
|
72
|
+
offset = 0
|
|
73
|
+
if resume and temp_path.exists():
|
|
74
|
+
offset = temp_path.stat().st_size
|
|
75
|
+
if expected_size is not None and offset >= expected_size:
|
|
76
|
+
offset = 0
|
|
77
|
+
temp_path.unlink()
|
|
78
|
+
|
|
79
|
+
headers: dict[str, str] = {}
|
|
80
|
+
if offset > 0:
|
|
81
|
+
headers["Range"] = f"bytes={offset}-"
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
response = self._session.get(url, headers=headers, stream=True, timeout=30)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
except requests.RequestException as exc:
|
|
87
|
+
existing_bytes = temp_path.stat().st_size if temp_path.exists() else 0
|
|
88
|
+
return DownloadResult(
|
|
89
|
+
status="partial" if existing_bytes > 0 else "failed",
|
|
90
|
+
temp_path=temp_path if existing_bytes > 0 else None,
|
|
91
|
+
bytes_downloaded=0,
|
|
92
|
+
expected_size=expected_size,
|
|
93
|
+
failure_code="network_error",
|
|
94
|
+
failure_message=f"Request failed: {type(exc).__name__}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# If we requested a range but got 200, restart from scratch
|
|
98
|
+
if offset > 0 and response.status_code == 200: # noqa: PLR2004
|
|
99
|
+
offset = 0
|
|
100
|
+
if temp_path.exists():
|
|
101
|
+
temp_path.unlink()
|
|
102
|
+
|
|
103
|
+
write_mode = "ab" if offset > 0 else "wb"
|
|
104
|
+
bytes_downloaded = 0
|
|
105
|
+
last_logged = 0
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
with temp_path.open(write_mode) as fh:
|
|
109
|
+
for chunk in response.iter_content(chunk_size=_CHUNK_SIZE):
|
|
110
|
+
if chunk:
|
|
111
|
+
fh.write(chunk)
|
|
112
|
+
bytes_downloaded += len(chunk)
|
|
113
|
+
if progress_callback is not None:
|
|
114
|
+
progress_callback(offset + bytes_downloaded, expected_size)
|
|
115
|
+
if bytes_downloaded - last_logged >= _LOG_INTERVAL:
|
|
116
|
+
self._log.debug(
|
|
117
|
+
"downloaded %d MiB", (offset + bytes_downloaded) // (1024 * 1024)
|
|
118
|
+
)
|
|
119
|
+
last_logged = bytes_downloaded
|
|
120
|
+
except requests.RequestException as exc:
|
|
121
|
+
existing_bytes = temp_path.stat().st_size if temp_path.exists() else 0
|
|
122
|
+
return DownloadResult(
|
|
123
|
+
status="partial" if existing_bytes > 0 else "failed",
|
|
124
|
+
temp_path=temp_path if existing_bytes > 0 else None,
|
|
125
|
+
bytes_downloaded=bytes_downloaded,
|
|
126
|
+
expected_size=expected_size,
|
|
127
|
+
failure_code="network_error",
|
|
128
|
+
failure_message=f"Transfer interrupted: {type(exc).__name__}",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
total_bytes = offset + bytes_downloaded
|
|
132
|
+
self._log.debug("download complete: %d bytes", total_bytes)
|
|
133
|
+
|
|
134
|
+
# Size verification
|
|
135
|
+
if expected_size is not None:
|
|
136
|
+
actual_size = temp_path.stat().st_size
|
|
137
|
+
if actual_size != expected_size:
|
|
138
|
+
return DownloadResult(
|
|
139
|
+
status="failed",
|
|
140
|
+
temp_path=temp_path,
|
|
141
|
+
bytes_downloaded=bytes_downloaded,
|
|
142
|
+
expected_size=expected_size,
|
|
143
|
+
failure_code="size_mismatch",
|
|
144
|
+
failure_message=(f"Expected {expected_size} bytes, got {actual_size}"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# MD5 verification
|
|
148
|
+
if expected_md5 is not None:
|
|
149
|
+
actual_md5 = _md5_file(temp_path)
|
|
150
|
+
if actual_md5 != expected_md5.lower():
|
|
151
|
+
return DownloadResult(
|
|
152
|
+
status="failed",
|
|
153
|
+
temp_path=temp_path,
|
|
154
|
+
bytes_downloaded=bytes_downloaded,
|
|
155
|
+
expected_size=expected_size,
|
|
156
|
+
failure_code="checksum_mismatch",
|
|
157
|
+
failure_message="MD5 checksum did not match expected value",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
os.replace(temp_path, dest)
|
|
161
|
+
return DownloadResult(
|
|
162
|
+
status="verified",
|
|
163
|
+
path=dest,
|
|
164
|
+
bytes_downloaded=bytes_downloaded,
|
|
165
|
+
expected_size=expected_size,
|
|
166
|
+
checksum_verified=expected_md5 is not None,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def fetch_checksum_xml(
|
|
171
|
+
session: requests.Session,
|
|
172
|
+
checksum_url: str,
|
|
173
|
+
) -> tuple[str | None, int | None]:
|
|
174
|
+
"""Fetch GOG checksum XML and return (md5, total_size), or (None, None) on failure."""
|
|
175
|
+
try:
|
|
176
|
+
response = session.get(checksum_url, timeout=15)
|
|
177
|
+
response.raise_for_status()
|
|
178
|
+
root = ET.fromstring(response.text) # noqa: S314
|
|
179
|
+
md5 = root.get("md5")
|
|
180
|
+
size_str = root.get("total_size")
|
|
181
|
+
total_size = int(size_str) if size_str is not None else None
|
|
182
|
+
return (md5 or None, total_size)
|
|
183
|
+
except Exception: # noqa: BLE001
|
|
184
|
+
return (None, None)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _md5_file(path: Path) -> str:
|
|
188
|
+
h = hashlib.md5() # noqa: S324
|
|
189
|
+
with path.open("rb") as fh:
|
|
190
|
+
for chunk in iter(lambda: fh.read(_CHUNK_SIZE), b""):
|
|
191
|
+
h.update(chunk)
|
|
192
|
+
return h.hexdigest()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Expose field for convenience
|
|
196
|
+
__all__ = ["Downloader", "DownloadResult", "DownloadStatus", "fetch_checksum_xml"]
|
gog_cli/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Exit codes and application exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# TASK-0044 plan/dry-run mapping keeps the TASK-0013 values stable:
|
|
9
|
+
# success=0, generated plan but execution would fail=1, invalid args=2,
|
|
10
|
+
# auth required=3, target/disk filesystem failures=6, parser failures=7.
|
|
11
|
+
# Missing local library/download cache is a refresh-required state and uses 8
|
|
12
|
+
# instead of renumbering the existing AUTH=3 code to match the newer plan spec.
|
|
13
|
+
class ExitCode(IntEnum):
|
|
14
|
+
SUCCESS = 0
|
|
15
|
+
FAILURE = 1
|
|
16
|
+
USAGE = 2
|
|
17
|
+
AUTH = 3
|
|
18
|
+
NETWORK = 4
|
|
19
|
+
VERIFICATION = 5
|
|
20
|
+
FILESYSTEM = 6
|
|
21
|
+
PARSER = 7
|
|
22
|
+
CACHE = 8
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GogError(Exception):
|
|
26
|
+
exit_code: ExitCode = ExitCode.FAILURE
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UsageError(GogError):
|
|
30
|
+
exit_code = ExitCode.USAGE
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthError(GogError):
|
|
34
|
+
exit_code = ExitCode.AUTH
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NetworkError(GogError):
|
|
38
|
+
exit_code = ExitCode.NETWORK
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VerificationError(GogError):
|
|
42
|
+
exit_code = ExitCode.VERIFICATION
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FilesystemError(GogError):
|
|
46
|
+
exit_code = ExitCode.FILESYSTEM
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ParserError(GogError):
|
|
50
|
+
exit_code = ExitCode.PARSER
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CacheError(GogError):
|
|
54
|
+
exit_code = ExitCode.CACHE
|