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/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