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/cli.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Command-line interface for gog-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from gog_cli import __version__
|
|
11
|
+
from gog_cli.auth import handle_auth_login, handle_auth_logout, handle_auth_status
|
|
12
|
+
from gog_cli.errors import GogError
|
|
13
|
+
from gog_cli.execution import handle_backup, handle_plan, handle_sync
|
|
14
|
+
from gog_cli.listing import handle_list_backed_up, handle_list_purchased, handle_search_catalog
|
|
15
|
+
from gog_cli.refresh import handle_refresh
|
|
16
|
+
|
|
17
|
+
_TOP_LEVEL_EXAMPLES = """examples:
|
|
18
|
+
gog auth login
|
|
19
|
+
gog refresh
|
|
20
|
+
gog list purchased --search witcher
|
|
21
|
+
gog plan --destination /backups/gog --all --storage
|
|
22
|
+
gog backup --destination /backups/gog --games-from games.txt --downloader aria2c --yes
|
|
23
|
+
gog sync --destination /backups/gog --all --dry-run"""
|
|
24
|
+
|
|
25
|
+
_AUTH_EXAMPLES = """examples:
|
|
26
|
+
gog auth login
|
|
27
|
+
gog auth status
|
|
28
|
+
gog auth logout"""
|
|
29
|
+
|
|
30
|
+
_REFRESH_EXAMPLES = """examples:
|
|
31
|
+
gog refresh
|
|
32
|
+
gog refresh --force
|
|
33
|
+
gog refresh --format json"""
|
|
34
|
+
|
|
35
|
+
_LIST_EXAMPLES = """examples:
|
|
36
|
+
gog list purchased
|
|
37
|
+
gog list purchased --search witcher --platform linux
|
|
38
|
+
gog list backup --destination /backups/gog
|
|
39
|
+
gog list backup --destination /backups/gog --format json"""
|
|
40
|
+
|
|
41
|
+
_LIST_PURCHASED_EXAMPLES = """examples:
|
|
42
|
+
gog list purchased --search witcher
|
|
43
|
+
gog list purchased --platform windows
|
|
44
|
+
gog list purchased --year 1998..2005
|
|
45
|
+
gog list purchased --year 2010..2020 --include-unknown-year
|
|
46
|
+
gog list purchased --genre strategy
|
|
47
|
+
gog list purchased --genre strategy --include-unknown-genre
|
|
48
|
+
gog list purchased --search "baldurs gate" --platform linux --format json"""
|
|
49
|
+
|
|
50
|
+
_LIST_BACKUP_EXAMPLES = """examples:
|
|
51
|
+
gog list backup --destination /backups/gog
|
|
52
|
+
gog list backup --destination /backups/gog --format json"""
|
|
53
|
+
|
|
54
|
+
_SEARCH_EXAMPLES = """examples:
|
|
55
|
+
gog search witcher
|
|
56
|
+
gog search "baldurs gate" --platform windows
|
|
57
|
+
gog search strategy --year 2000..2010
|
|
58
|
+
gog search rpg --genre "role-playing" --format json"""
|
|
59
|
+
|
|
60
|
+
_PLAN_EXAMPLES = """examples:
|
|
61
|
+
gog plan --destination /backups/gog --all --storage
|
|
62
|
+
gog plan --destination /backups/gog --all --check-free-space
|
|
63
|
+
gog plan --destination /backups/gog --games-from games.txt --summary
|
|
64
|
+
gog plan --destination /backups/gog cyberpunk-2077
|
|
65
|
+
gog plan --destination /backups/gog --all --format json"""
|
|
66
|
+
|
|
67
|
+
_BACKUP_EXAMPLES = """examples:
|
|
68
|
+
gog backup --destination /backups/gog --all
|
|
69
|
+
gog backup --destination /backups/gog --all --yes
|
|
70
|
+
gog backup --destination /backups/gog --games-from games.txt --downloader aria2c --yes
|
|
71
|
+
gog backup --destination /backups/gog --platform linux --language en --all --yes
|
|
72
|
+
gog backup --destination /backups/gog --all --format json"""
|
|
73
|
+
|
|
74
|
+
_SYNC_EXAMPLES = """examples:
|
|
75
|
+
gog sync --destination /backups/gog --all
|
|
76
|
+
gog sync --destination /backups/gog --all --dry-run
|
|
77
|
+
gog sync --destination /backups/gog --games-from games.txt --yes"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
81
|
+
parser = argparse.ArgumentParser(
|
|
82
|
+
prog="gog",
|
|
83
|
+
description=(
|
|
84
|
+
"Back up owned DRM-free GOG games. Commands are explicit and "
|
|
85
|
+
"non-destructive by default; backup and sync print a dry-run plan "
|
|
86
|
+
"unless --yes is passed."
|
|
87
|
+
),
|
|
88
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
89
|
+
epilog=_TOP_LEVEL_EXAMPLES,
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--version",
|
|
93
|
+
action="version",
|
|
94
|
+
version=f"%(prog)s {__version__}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
98
|
+
|
|
99
|
+
_add_auth_parser(subcommands)
|
|
100
|
+
_add_refresh_parser(subcommands)
|
|
101
|
+
_add_list_parser(subcommands)
|
|
102
|
+
_add_search_parser(subcommands)
|
|
103
|
+
_add_plan_parser(subcommands)
|
|
104
|
+
_add_backup_parser(subcommands)
|
|
105
|
+
_add_sync_parser(subcommands)
|
|
106
|
+
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _add_auth_parser(subcommands: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
111
|
+
auth = subcommands.add_parser(
|
|
112
|
+
"auth",
|
|
113
|
+
help="Manage GOG credentials.",
|
|
114
|
+
description=(
|
|
115
|
+
"Manage the local GOG session used by refresh and download commands. "
|
|
116
|
+
"Tokens are stored in app state, not inside backup destinations."
|
|
117
|
+
),
|
|
118
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
119
|
+
epilog=_AUTH_EXAMPLES,
|
|
120
|
+
)
|
|
121
|
+
auth_sub = auth.add_subparsers(dest="auth_command", required=True)
|
|
122
|
+
|
|
123
|
+
auth_sub.add_parser(
|
|
124
|
+
"login",
|
|
125
|
+
help="Log in to GOG.",
|
|
126
|
+
description="Start the browser-based GOG login flow and store a local session.",
|
|
127
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
128
|
+
epilog="examples:\n gog auth login",
|
|
129
|
+
).set_defaults(handler=handle_auth_login)
|
|
130
|
+
auth_sub.add_parser(
|
|
131
|
+
"status",
|
|
132
|
+
help="Show authentication status.",
|
|
133
|
+
description="Show whether a local GOG session is available and when it expires.",
|
|
134
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
135
|
+
epilog="examples:\n gog auth status",
|
|
136
|
+
).set_defaults(handler=handle_auth_status)
|
|
137
|
+
auth_sub.add_parser(
|
|
138
|
+
"logout",
|
|
139
|
+
help="Log out and remove credentials.",
|
|
140
|
+
description="Remove the local GOG session from app state.",
|
|
141
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
142
|
+
epilog="examples:\n gog auth logout",
|
|
143
|
+
).set_defaults(
|
|
144
|
+
handler=handle_auth_logout
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _add_refresh_parser(subcommands: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
149
|
+
refresh = subcommands.add_parser(
|
|
150
|
+
"refresh",
|
|
151
|
+
help="Fetch library and download metadata from GOG.",
|
|
152
|
+
description=(
|
|
153
|
+
"Fetch purchased-library and download metadata into the local cache. "
|
|
154
|
+
"This does not download game installers."
|
|
155
|
+
),
|
|
156
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
157
|
+
epilog=_REFRESH_EXAMPLES,
|
|
158
|
+
)
|
|
159
|
+
refresh.add_argument(
|
|
160
|
+
"--force",
|
|
161
|
+
action="store_true",
|
|
162
|
+
help="Re-fetch all download metadata even if recently cached.",
|
|
163
|
+
)
|
|
164
|
+
refresh.add_argument(
|
|
165
|
+
"-f", "--format",
|
|
166
|
+
choices=["human", "json"],
|
|
167
|
+
default="human",
|
|
168
|
+
dest="output_format",
|
|
169
|
+
help="Output format (default: human).",
|
|
170
|
+
)
|
|
171
|
+
refresh.set_defaults(handler=handle_refresh)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _add_list_parser(subcommands: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
175
|
+
list_cmd = subcommands.add_parser(
|
|
176
|
+
"list",
|
|
177
|
+
help="List games.",
|
|
178
|
+
description="List cached purchased games or games already recorded in a backup manifest.",
|
|
179
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
180
|
+
epilog=_LIST_EXAMPLES,
|
|
181
|
+
)
|
|
182
|
+
list_sub = list_cmd.add_subparsers(dest="list_command", required=True)
|
|
183
|
+
|
|
184
|
+
purchased = list_sub.add_parser(
|
|
185
|
+
"purchased",
|
|
186
|
+
help="List owned GOG games.",
|
|
187
|
+
description=(
|
|
188
|
+
"List owned games from the local cache written by `gog refresh`. "
|
|
189
|
+
"This command does not contact GOG."
|
|
190
|
+
),
|
|
191
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
192
|
+
epilog=_LIST_PURCHASED_EXAMPLES,
|
|
193
|
+
)
|
|
194
|
+
purchased.add_argument(
|
|
195
|
+
"-f", "--format",
|
|
196
|
+
choices=["human", "json"],
|
|
197
|
+
default="human",
|
|
198
|
+
dest="output_format",
|
|
199
|
+
help="Output format (default: human).",
|
|
200
|
+
)
|
|
201
|
+
purchased.add_argument(
|
|
202
|
+
"-p", "--platform",
|
|
203
|
+
action="append",
|
|
204
|
+
default=[],
|
|
205
|
+
dest="platforms",
|
|
206
|
+
metavar="PLATFORM",
|
|
207
|
+
help="Filter by platform (windows, mac, linux). Repeatable.",
|
|
208
|
+
)
|
|
209
|
+
purchased.add_argument(
|
|
210
|
+
"-y", "--year",
|
|
211
|
+
metavar="RANGE",
|
|
212
|
+
help="Filter by release year, e.g. 1998..2005, 2020.., or ..2000.",
|
|
213
|
+
)
|
|
214
|
+
purchased.add_argument(
|
|
215
|
+
"--include-unknown-year",
|
|
216
|
+
action="store_true",
|
|
217
|
+
help="Keep games with unknown release years when --year is used.",
|
|
218
|
+
)
|
|
219
|
+
purchased.add_argument(
|
|
220
|
+
"-G", "--genre",
|
|
221
|
+
action="append",
|
|
222
|
+
default=[],
|
|
223
|
+
dest="genres",
|
|
224
|
+
metavar="GENRE",
|
|
225
|
+
help="Filter by genre/category/tag. Repeatable; comma-separated values allowed.",
|
|
226
|
+
)
|
|
227
|
+
purchased.add_argument(
|
|
228
|
+
"--include-unknown-genre",
|
|
229
|
+
action="store_true",
|
|
230
|
+
help="Keep games with unknown genres when --genre is used.",
|
|
231
|
+
)
|
|
232
|
+
purchased.add_argument(
|
|
233
|
+
"-s", "--search",
|
|
234
|
+
metavar="TEXT",
|
|
235
|
+
help="Fuzzy title search.",
|
|
236
|
+
)
|
|
237
|
+
purchased.add_argument(
|
|
238
|
+
"-S", "--sort",
|
|
239
|
+
choices=["title", "year", "size"],
|
|
240
|
+
metavar="COLUMN",
|
|
241
|
+
help="Sort results by column: title (A-Z), year (oldest first), size (largest first).",
|
|
242
|
+
)
|
|
243
|
+
purchased.set_defaults(handler=handle_list_purchased)
|
|
244
|
+
|
|
245
|
+
backed_up = list_sub.add_parser(
|
|
246
|
+
"backup",
|
|
247
|
+
help="List locally backed-up games.",
|
|
248
|
+
description="Read the backup manifest at a destination and summarize recorded games/files.",
|
|
249
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
250
|
+
epilog=_LIST_BACKUP_EXAMPLES,
|
|
251
|
+
)
|
|
252
|
+
backed_up.add_argument(
|
|
253
|
+
"-d", "--destination",
|
|
254
|
+
required=False,
|
|
255
|
+
default=None,
|
|
256
|
+
type=Path,
|
|
257
|
+
help="Backup destination directory to inspect (default: from config).",
|
|
258
|
+
)
|
|
259
|
+
backed_up.add_argument(
|
|
260
|
+
"-f", "--format",
|
|
261
|
+
choices=["human", "json"],
|
|
262
|
+
default="human",
|
|
263
|
+
dest="output_format",
|
|
264
|
+
help="Output format (default: human).",
|
|
265
|
+
)
|
|
266
|
+
backed_up.add_argument(
|
|
267
|
+
"-S", "--sort",
|
|
268
|
+
choices=["title", "size", "status", "files"],
|
|
269
|
+
metavar="COLUMN",
|
|
270
|
+
help="Sort by column: title (A-Z), size (largest first), status (A-Z), files (most first).",
|
|
271
|
+
)
|
|
272
|
+
backed_up.set_defaults(handler=handle_list_backed_up)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _add_search_parser(subcommands: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
276
|
+
search = subcommands.add_parser(
|
|
277
|
+
"search",
|
|
278
|
+
help="Search the public GOG catalog.",
|
|
279
|
+
description=(
|
|
280
|
+
"Search public GOG catalog data. Results are public catalog entries; "
|
|
281
|
+
"use `gog list purchased` for owned-library data."
|
|
282
|
+
),
|
|
283
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
284
|
+
epilog=_SEARCH_EXAMPLES,
|
|
285
|
+
)
|
|
286
|
+
search.add_argument("query", help="Search query (title keywords).")
|
|
287
|
+
search.add_argument(
|
|
288
|
+
"-f", "--format",
|
|
289
|
+
choices=["human", "json"],
|
|
290
|
+
default="human",
|
|
291
|
+
dest="output_format",
|
|
292
|
+
help="Output format (default: human).",
|
|
293
|
+
)
|
|
294
|
+
search.add_argument(
|
|
295
|
+
"-p", "--platform",
|
|
296
|
+
action="append",
|
|
297
|
+
default=[],
|
|
298
|
+
dest="platforms",
|
|
299
|
+
metavar="PLATFORM",
|
|
300
|
+
help="Filter by platform (windows, mac, linux). Repeatable.",
|
|
301
|
+
)
|
|
302
|
+
search.add_argument(
|
|
303
|
+
"-y", "--year",
|
|
304
|
+
metavar="RANGE",
|
|
305
|
+
help="Filter by release year, e.g. 1998..2005, 2020.., or ..2000.",
|
|
306
|
+
)
|
|
307
|
+
search.add_argument(
|
|
308
|
+
"-G", "--genre",
|
|
309
|
+
action="append",
|
|
310
|
+
default=[],
|
|
311
|
+
dest="genres",
|
|
312
|
+
metavar="GENRE",
|
|
313
|
+
help="Filter by genre/category/tag. Repeatable.",
|
|
314
|
+
)
|
|
315
|
+
search.set_defaults(handler=handle_search_catalog)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _add_selector_flags(parser: argparse.ArgumentParser) -> None:
|
|
319
|
+
grp = parser.add_argument_group("game selection")
|
|
320
|
+
grp.add_argument(
|
|
321
|
+
"-g", "--game",
|
|
322
|
+
dest="games",
|
|
323
|
+
metavar="SELECTOR",
|
|
324
|
+
action="append",
|
|
325
|
+
default=[],
|
|
326
|
+
help="Select a game by product id, slug, or exact title. Repeatable.",
|
|
327
|
+
)
|
|
328
|
+
grp.add_argument(
|
|
329
|
+
"-F", "--games-from",
|
|
330
|
+
dest="games_from",
|
|
331
|
+
metavar="PATH",
|
|
332
|
+
action="append",
|
|
333
|
+
default=[],
|
|
334
|
+
type=Path,
|
|
335
|
+
help="Read game selectors from a UTF-8 text file, one per line. Repeatable.",
|
|
336
|
+
)
|
|
337
|
+
grp.add_argument(
|
|
338
|
+
"-x", "--exclude",
|
|
339
|
+
metavar="SELECTOR",
|
|
340
|
+
action="append",
|
|
341
|
+
default=[],
|
|
342
|
+
help="Exclude a game by product id, slug, or exact title. Repeatable.",
|
|
343
|
+
)
|
|
344
|
+
grp.add_argument(
|
|
345
|
+
"-a", "--all",
|
|
346
|
+
dest="all_games",
|
|
347
|
+
action="store_true",
|
|
348
|
+
help="Select all owned games.",
|
|
349
|
+
)
|
|
350
|
+
grp.add_argument(
|
|
351
|
+
"-p", "--platform",
|
|
352
|
+
metavar="PLATFORM",
|
|
353
|
+
action="append",
|
|
354
|
+
default=[],
|
|
355
|
+
dest="platforms",
|
|
356
|
+
help="Limit to this platform (e.g. windows, linux, mac). Repeatable.",
|
|
357
|
+
)
|
|
358
|
+
grp.add_argument(
|
|
359
|
+
"-l", "--language",
|
|
360
|
+
metavar="LANG",
|
|
361
|
+
action="append",
|
|
362
|
+
default=[],
|
|
363
|
+
dest="languages",
|
|
364
|
+
help="Limit to this language code. Repeatable.",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _add_interaction_flags(parser: argparse.ArgumentParser) -> None:
|
|
369
|
+
grp = parser.add_argument_group("interaction")
|
|
370
|
+
grp.add_argument(
|
|
371
|
+
"--yes",
|
|
372
|
+
action="store_true",
|
|
373
|
+
help="Skip confirmation prompts.",
|
|
374
|
+
)
|
|
375
|
+
grp.add_argument(
|
|
376
|
+
"-n", "--no-interactive",
|
|
377
|
+
dest="no_interactive",
|
|
378
|
+
action="store_true",
|
|
379
|
+
help="Fail rather than prompt when selectors are missing.",
|
|
380
|
+
)
|
|
381
|
+
grp.add_argument(
|
|
382
|
+
"-D", "--downloader",
|
|
383
|
+
choices=["direct", "aria2c"],
|
|
384
|
+
default="direct",
|
|
385
|
+
help="Download engine to use (default: direct).",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _add_backup_parser(subcommands: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
390
|
+
backup = subcommands.add_parser(
|
|
391
|
+
"backup",
|
|
392
|
+
help="Back up owned GOG games to a local directory.",
|
|
393
|
+
description=(
|
|
394
|
+
"Plan or execute a local backup. Without --yes this command prints "
|
|
395
|
+
"a dry-run plan and exits without downloading or modifying backup files."
|
|
396
|
+
),
|
|
397
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
398
|
+
epilog=_BACKUP_EXAMPLES,
|
|
399
|
+
)
|
|
400
|
+
backup.add_argument(
|
|
401
|
+
"-d", "--destination",
|
|
402
|
+
type=Path,
|
|
403
|
+
help="Directory where game backups should be stored.",
|
|
404
|
+
)
|
|
405
|
+
backup.add_argument(
|
|
406
|
+
"--dry-run",
|
|
407
|
+
action="store_true",
|
|
408
|
+
help="Show the plan without downloading files.",
|
|
409
|
+
)
|
|
410
|
+
backup.add_argument(
|
|
411
|
+
"-f", "--format",
|
|
412
|
+
choices=["human", "json"],
|
|
413
|
+
default="human",
|
|
414
|
+
dest="output_format",
|
|
415
|
+
help="Output format (default: human).",
|
|
416
|
+
)
|
|
417
|
+
backup.add_argument(
|
|
418
|
+
"--check-free-space",
|
|
419
|
+
action="store_true",
|
|
420
|
+
dest="check_free_space",
|
|
421
|
+
help="Fail if available disk space is less than the estimated download size.",
|
|
422
|
+
)
|
|
423
|
+
backup.add_argument(
|
|
424
|
+
"--storage",
|
|
425
|
+
action="store_true",
|
|
426
|
+
help="Show disk usage section in plan output.",
|
|
427
|
+
)
|
|
428
|
+
backup.add_argument(
|
|
429
|
+
"--summary",
|
|
430
|
+
action="store_true",
|
|
431
|
+
help="Print summary only, omit per-game file detail.",
|
|
432
|
+
)
|
|
433
|
+
backup.add_argument(
|
|
434
|
+
"--changed-only",
|
|
435
|
+
action="store_true",
|
|
436
|
+
dest="changed_only",
|
|
437
|
+
help="Show only games with pending downloads in per-game detail.",
|
|
438
|
+
)
|
|
439
|
+
backup.add_argument(
|
|
440
|
+
"--explain-skips",
|
|
441
|
+
action="store_true",
|
|
442
|
+
dest="explain_skips",
|
|
443
|
+
help="Annotate skipped files with their filter reason.",
|
|
444
|
+
)
|
|
445
|
+
_add_selector_flags(backup)
|
|
446
|
+
_add_interaction_flags(backup)
|
|
447
|
+
backup.set_defaults(handler=handle_backup)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _add_plan_parser(subcommands: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
451
|
+
plan = subcommands.add_parser(
|
|
452
|
+
"plan",
|
|
453
|
+
help="Show the backup plan without downloading files.",
|
|
454
|
+
description=(
|
|
455
|
+
"Show a non-destructive backup plan. This is equivalent to "
|
|
456
|
+
"`gog backup --dry-run` and does not download files or create backup "
|
|
457
|
+
"directories."
|
|
458
|
+
),
|
|
459
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
460
|
+
epilog=_PLAN_EXAMPLES,
|
|
461
|
+
)
|
|
462
|
+
plan.add_argument(
|
|
463
|
+
"selectors",
|
|
464
|
+
nargs="*",
|
|
465
|
+
metavar="GAME",
|
|
466
|
+
help="Game selector by product id, slug, or exact title.",
|
|
467
|
+
)
|
|
468
|
+
plan.add_argument(
|
|
469
|
+
"-d", "--destination",
|
|
470
|
+
type=Path,
|
|
471
|
+
help="Directory where game backups should be stored.",
|
|
472
|
+
)
|
|
473
|
+
plan.add_argument(
|
|
474
|
+
"-f", "--format",
|
|
475
|
+
choices=["human", "json"],
|
|
476
|
+
default="human",
|
|
477
|
+
dest="output_format",
|
|
478
|
+
help="Output format (default: human).",
|
|
479
|
+
)
|
|
480
|
+
plan.add_argument(
|
|
481
|
+
"--check-free-space",
|
|
482
|
+
action="store_true",
|
|
483
|
+
dest="check_free_space",
|
|
484
|
+
help="Fail if available disk space is less than the estimated download size.",
|
|
485
|
+
)
|
|
486
|
+
plan.add_argument(
|
|
487
|
+
"--storage",
|
|
488
|
+
action="store_true",
|
|
489
|
+
help="Show disk usage section in plan output.",
|
|
490
|
+
)
|
|
491
|
+
plan.add_argument(
|
|
492
|
+
"--summary",
|
|
493
|
+
action="store_true",
|
|
494
|
+
help="Print summary only, omit per-game file detail.",
|
|
495
|
+
)
|
|
496
|
+
plan.add_argument(
|
|
497
|
+
"--changed-only",
|
|
498
|
+
action="store_true",
|
|
499
|
+
dest="changed_only",
|
|
500
|
+
help="Show only games with pending downloads in per-game detail.",
|
|
501
|
+
)
|
|
502
|
+
plan.add_argument(
|
|
503
|
+
"--explain-skips",
|
|
504
|
+
action="store_true",
|
|
505
|
+
dest="explain_skips",
|
|
506
|
+
help="Annotate skipped files with their filter reason.",
|
|
507
|
+
)
|
|
508
|
+
_add_selector_flags(plan)
|
|
509
|
+
plan.set_defaults(handler=handle_plan)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _add_sync_parser(subcommands: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
513
|
+
sync = subcommands.add_parser(
|
|
514
|
+
"sync",
|
|
515
|
+
help="Update stale local backups.",
|
|
516
|
+
description=(
|
|
517
|
+
"Compare cached source metadata to a backup manifest and plan updates. "
|
|
518
|
+
"Without --yes this command prints a dry-run plan and exits without "
|
|
519
|
+
"downloading or modifying backup files."
|
|
520
|
+
),
|
|
521
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
522
|
+
epilog=_SYNC_EXAMPLES,
|
|
523
|
+
)
|
|
524
|
+
sync.add_argument(
|
|
525
|
+
"-d", "--destination",
|
|
526
|
+
type=Path,
|
|
527
|
+
help="Backup destination directory to sync.",
|
|
528
|
+
)
|
|
529
|
+
sync.add_argument(
|
|
530
|
+
"--dry-run",
|
|
531
|
+
action="store_true",
|
|
532
|
+
help="Show the plan without downloading files.",
|
|
533
|
+
)
|
|
534
|
+
_add_selector_flags(sync)
|
|
535
|
+
_add_interaction_flags(sync)
|
|
536
|
+
sync.set_defaults(handler=handle_sync)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
540
|
+
parser = build_parser()
|
|
541
|
+
args = parser.parse_args(argv)
|
|
542
|
+
try:
|
|
543
|
+
return args.handler(args)
|
|
544
|
+
except GogError as exc:
|
|
545
|
+
print(str(exc), file=sys.stderr)
|
|
546
|
+
return exc.exit_code
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
if __name__ == "__main__":
|
|
550
|
+
raise SystemExit(main())
|
gog_cli/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Configuration loading with TOML file, env vars, and built-in defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from gog_cli.errors import UsageError
|
|
12
|
+
from gog_cli.state import AppPaths
|
|
13
|
+
|
|
14
|
+
_VALID_DOWNLOADERS = frozenset({"direct", "aria2c"})
|
|
15
|
+
_VALID_FORMATS = frozenset({"human", "json"})
|
|
16
|
+
_KNOWN_DEFAULTS_KEYS = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"destination",
|
|
19
|
+
"downloader",
|
|
20
|
+
"file_roles",
|
|
21
|
+
"format",
|
|
22
|
+
"interactive",
|
|
23
|
+
"languages",
|
|
24
|
+
"platforms",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Config:
|
|
31
|
+
destination: Path | None = None
|
|
32
|
+
downloader: str = "direct"
|
|
33
|
+
platforms: list[str] = field(default_factory=list)
|
|
34
|
+
languages: list[str] = field(default_factory=list)
|
|
35
|
+
file_roles: list[str] = field(default_factory=list)
|
|
36
|
+
output_format: str = "human"
|
|
37
|
+
interactive: bool = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(paths: AppPaths, env: Mapping[str, str] | None = None) -> Config:
|
|
41
|
+
config = Config()
|
|
42
|
+
_apply_toml(config, paths.config_file)
|
|
43
|
+
_apply_env(config, os.environ if env is None else env)
|
|
44
|
+
_validate(config)
|
|
45
|
+
return config
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _apply_toml(config: Config, path: Path) -> None:
|
|
49
|
+
try:
|
|
50
|
+
with path.open("rb") as fh:
|
|
51
|
+
data = tomllib.load(fh)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
return
|
|
54
|
+
except tomllib.TOMLDecodeError as exc:
|
|
55
|
+
raise UsageError(f"Invalid config file {path}: {exc}") from exc
|
|
56
|
+
|
|
57
|
+
unknown_top = set(data) - {"defaults"}
|
|
58
|
+
if unknown_top:
|
|
59
|
+
raise UsageError(f"Unknown top-level keys in {path}: {', '.join(sorted(unknown_top))}")
|
|
60
|
+
|
|
61
|
+
defaults = data.get("defaults", {})
|
|
62
|
+
if not isinstance(defaults, dict):
|
|
63
|
+
raise UsageError(f"[defaults] in {path} must be a TOML table")
|
|
64
|
+
|
|
65
|
+
unknown = set(defaults) - _KNOWN_DEFAULTS_KEYS
|
|
66
|
+
if unknown:
|
|
67
|
+
raise UsageError(f"Unknown config keys in {path}: {', '.join(sorted(unknown))}")
|
|
68
|
+
|
|
69
|
+
if "destination" in defaults:
|
|
70
|
+
config.destination = Path(str(defaults["destination"]))
|
|
71
|
+
if "downloader" in defaults:
|
|
72
|
+
config.downloader = str(defaults["downloader"])
|
|
73
|
+
if "platforms" in defaults:
|
|
74
|
+
config.platforms = [str(v) for v in defaults["platforms"]]
|
|
75
|
+
if "languages" in defaults:
|
|
76
|
+
config.languages = [str(v) for v in defaults["languages"]]
|
|
77
|
+
if "file_roles" in defaults:
|
|
78
|
+
config.file_roles = [str(v) for v in defaults["file_roles"]]
|
|
79
|
+
if "format" in defaults:
|
|
80
|
+
config.output_format = str(defaults["format"])
|
|
81
|
+
if "interactive" in defaults:
|
|
82
|
+
config.interactive = bool(defaults["interactive"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _apply_env(config: Config, env: Mapping[str, str]) -> None:
|
|
86
|
+
if dest := env.get("GOG_CLI_DESTINATION"):
|
|
87
|
+
config.destination = Path(dest)
|
|
88
|
+
if downloader := env.get("GOG_CLI_DOWNLOADER"):
|
|
89
|
+
config.downloader = downloader
|
|
90
|
+
if platforms := env.get("GOG_CLI_PLATFORMS"):
|
|
91
|
+
config.platforms = [p.strip() for p in platforms.split(",") if p.strip()]
|
|
92
|
+
if languages := env.get("GOG_CLI_LANGUAGES"):
|
|
93
|
+
config.languages = [la.strip() for la in languages.split(",") if la.strip()]
|
|
94
|
+
if roles := env.get("GOG_CLI_FILE_ROLES"):
|
|
95
|
+
config.file_roles = [r.strip() for r in roles.split(",") if r.strip()]
|
|
96
|
+
if fmt := env.get("GOG_CLI_FORMAT"):
|
|
97
|
+
config.output_format = fmt
|
|
98
|
+
if interactive_val := env.get("GOG_CLI_INTERACTIVE"):
|
|
99
|
+
config.interactive = _parse_bool(interactive_val, "GOG_CLI_INTERACTIVE")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_bool(value: str, name: str) -> bool:
|
|
103
|
+
if value.lower() in ("1", "true", "yes"):
|
|
104
|
+
return True
|
|
105
|
+
if value.lower() in ("0", "false", "no"):
|
|
106
|
+
return False
|
|
107
|
+
raise UsageError(f"Invalid boolean value for {name}: {value!r}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate(config: Config) -> None:
|
|
111
|
+
if config.downloader not in _VALID_DOWNLOADERS:
|
|
112
|
+
raise UsageError(
|
|
113
|
+
f"Invalid downloader {config.downloader!r}."
|
|
114
|
+
f" Must be one of: {', '.join(sorted(_VALID_DOWNLOADERS))}"
|
|
115
|
+
)
|
|
116
|
+
if config.output_format not in _VALID_FORMATS:
|
|
117
|
+
raise UsageError(
|
|
118
|
+
f"Invalid format {config.output_format!r}."
|
|
119
|
+
f" Must be one of: {', '.join(sorted(_VALID_FORMATS))}"
|
|
120
|
+
)
|