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