Coverage for gsb/cli.py: 98%
190 statements
« prev ^ index » next coverage.py v7.2.6, created at 2024-09-08 16:23 -0400
« prev ^ index » next coverage.py v7.2.6, created at 2024-09-08 16:23 -0400
1"""Command-line interface"""
2import datetime as dt
3import functools
4import logging
5import sys
6from pathlib import Path
7from typing import Any, Callable
9import click
11from . import _git, _version
12from . import backup as backup_
13from . import export as export_
14from . import fastforward
15from . import history as history_
16from . import onboard
17from . import rewind as rewind_
18from .logging import IMPORTANT, CLIFormatter, verbosity_to_log_level
19from .manifest import Manifest
21LOGGER = logging.getLogger(__package__)
24@click.group()
25@click.help_option("--help", "-h")
26@click.version_option(_version.get_versions()["version"], "--version", "-v", "-V")
27def gsb():
28 """CLI for managing incremental backups of your save states using Git!"""
31def _subcommand_init(command: Callable) -> Callable:
32 """Register a subcommand and add some standard CLI handling"""
34 @functools.wraps(command)
35 def wrapped(path: Path | None, verbose: int, quiet: int, *args, **kwargs) -> None:
36 cli_handler = logging.StreamHandler()
37 cli_handler.setFormatter(CLIFormatter())
38 LOGGER.addHandler(cli_handler)
40 log_level = verbosity_to_log_level(verbose - quiet)
42 cli_handler.setLevel(log_level)
44 # TODO: when we add log files, set this to minimum log level across all handlers
45 LOGGER.setLevel(log_level)
46 try:
47 command((path or Path()).absolute(), *args, **kwargs)
48 except (OSError, ValueError) as oh_no:
49 LOGGER.error(oh_no)
50 sys.exit(1)
52 wrapped = click.option(
53 "--path",
54 type=Path,
55 metavar="SAVE_PATH",
56 help=(
57 "Optionally specify the root directory containing your save data."
58 " If no path is given, the current working directory will be used."
59 ),
60 )(wrapped)
62 wrapped = click.option(
63 "--verbose",
64 "-v",
65 count=True,
66 help="Increase the amount of information that's printed.",
67 )(wrapped)
69 wrapped = click.option(
70 "--quiet",
71 "-q",
72 count=True,
73 help="Decrease the amount of information that's printed.",
74 )(wrapped)
75 return gsb.command()(wrapped)
78@click.option(
79 "--tag",
80 type=str,
81 help='Specify a description for this backup and "tag" it for future reference.',
82 metavar='"MESSAGE"',
83)
84@click.option(
85 "--combine",
86 "-c",
87 count=True,
88 help=(
89 "Combine this backup and the last backup,"
90 " or use -cc to combine ALL backups since the last tagged backup."
91 ),
92)
93@click.argument(
94 "path_as_arg",
95 type=Path,
96 required=False,
97 metavar="[SAVE_PATH]",
98)
99@_subcommand_init
100def backup(repo_root: Path, path_as_arg: Path | None, tag: str | None, combine: int):
101 """Create a new backup."""
102 parent_hash = None
103 if combine == 1:
104 try:
105 combine_me, parent = history_.get_history(
106 repo_root, tagged_only=False, include_non_gsb=True, limit=2
107 )
108 except ValueError as probably_not_enough_values:
109 if "not enough values to unpack" in str(probably_not_enough_values):
110 LOGGER.error("Cannot combine with the very first backup.")
111 sys.exit(1)
112 raise probably_not_enough_values # pragma: no-cover
114 LOGGER.log(IMPORTANT, "Combining with %s", combine_me["identifier"])
115 if combine_me["tagged"]:
116 LOGGER.warning("Are you sure you want to overwrite a tagged backup?")
117 history_.log_revision(combine_me, None)
118 confirmed: bool = click.confirm(
119 "",
120 default=False,
121 show_default=True,
122 )
123 if not confirmed:
124 LOGGER.error("Aborting.")
125 sys.exit(1)
126 _git.delete_tag(repo_root, combine_me["identifier"])
127 parent_hash = parent["identifier"]
128 if combine > 1:
129 try:
130 last_tag = history_.get_history(repo_root, tagged_only=True, limit=1)[0]
131 except IndexError:
132 LOGGER.error("There are no previous tagged backups.")
133 sys.exit(1)
134 LOGGER.log(IMPORTANT, "Combining with the following backups:")
135 combining = history_.show_history(
136 repo_root,
137 tagged_only=False,
138 include_non_gsb=True,
139 since_last_tagged_backup=True,
140 )
141 if not combining:
142 LOGGER.log(IMPORTANT, "(no backups to combine)")
143 parent_hash = last_tag["identifier"]
145 backup_.create_backup(path_as_arg or repo_root, tag, parent=parent_hash)
148@click.option(
149 "--ignore",
150 type=str,
151 required=False,
152 multiple=True,
153 help=(
154 "Provide a glob pattern to ignore. Each ignore pattern"
155 ' must be prefaced with the "--ignore" flag.'
156 ),
157)
158@click.option(
159 "--track",
160 type=str,
161 required=False,
162 multiple=True,
163 help=(
164 "Provide a glob pattern to track (note: arguments without any flag will"
165 " also be treated as track patterns)."
166 ),
167)
168@click.argument(
169 "track_args", type=str, required=False, nargs=-1, metavar="[TRACK_PATTERN]..."
170)
171@_subcommand_init
172def init(
173 repo_root: Path,
174 track_args: tuple[str, ...],
175 track: tuple[str, ...],
176 ignore: tuple[str, ...],
177):
178 """Start tracking a save."""
179 onboard.create_repo(repo_root, *track_args, *track, ignore=ignore)
182@click.option(
183 "--include_non_gsb",
184 "-g",
185 is_flag=True,
186 help="Include backups created directly with Git / outside of gsb.",
187)
188@click.option("--all", "-a", "all_", is_flag=True, help="Include non-tagged backups.")
189@click.option(
190 "--since",
191 type=click.DateTime(),
192 required=False,
193 help="Only show backups created after the specified date.",
194)
195@click.option(
196 "--limit",
197 "-n",
198 type=int,
199 required=False,
200 help="The maximum number of backups to return.",
201)
202@click.argument(
203 "path_as_arg",
204 type=Path,
205 required=False,
206 metavar="[SAVE_PATH]",
207)
208@_subcommand_init
209def history(
210 repo_root: Path,
211 path_as_arg: Path | None,
212 limit: int | None,
213 since: dt.datetime | None,
214 all_: bool,
215 include_non_gsb: bool,
216):
217 """List the available backups, starting with the most recent."""
219 kwargs: dict[str, Any] = {
220 "tagged_only": not all_,
221 "include_non_gsb": include_non_gsb,
222 }
223 if limit is not None:
224 if limit <= 0:
225 LOGGER.error("Limit must be a positive integer")
226 sys.exit(1)
227 kwargs["limit"] = limit
228 if since is not None:
229 kwargs["since"] = since
231 history_.show_history(
232 path_as_arg or repo_root, **kwargs, always_include_latest=True
233 )
236@click.option(
237 "--include_gsb_settings",
238 is_flag=True,
239 help="Also revert the GSB configuration files (including .gitignore)",
240)
241@click.argument(
242 "revision",
243 type=str,
244 required=False,
245)
246@_subcommand_init
247def rewind(repo_root: Path, revision: str | None, include_gsb_settings: bool):
248 """Restore a backup to the specified REVISION."""
249 if revision is None:
250 revision = _prompt_for_a_recent_revision(repo_root)
251 try:
252 rewind_.restore_backup(repo_root, revision, not include_gsb_settings)
253 except ValueError as whats_that:
254 LOGGER.error(whats_that)
255 sys.exit(1)
258def _prompt_for_a_recent_revision(repo_root) -> str:
259 """Select a recent revision from a prompt"""
260 LOGGER.log(IMPORTANT, "Here is a list of recent backups:")
261 revisions = history_.show_history(repo_root, limit=10)
262 if len(revisions) == 0:
263 LOGGER.info("No tagged revisions found. Trying untagged.")
264 revisions = history_.show_history(
265 repo_root,
266 limit=10,
267 tagged_only=False,
268 )
269 if len(revisions) == 0:
270 LOGGER.warning("No GSB revisions found. Trying Git.")
271 revisions = history_.show_history(
272 repo_root,
273 limit=10,
274 tagged_only=False,
275 include_non_gsb=True,
276 )
277 if len(revisions) == 0:
278 LOGGER.error("No revisions found!")
279 sys.exit(1)
280 LOGGER.log(IMPORTANT, "\nMost recent backup:")
281 most_recent_backup = history_.show_history(
282 repo_root, limit=1, always_include_latest=True, numbering=0
283 )[0]
285 LOGGER.log(
286 IMPORTANT,
287 "\nSelect one by number or identifier (or [q]uit and"
288 " call gsb history yourself to get more revisions).",
289 )
291 choice: str = click.prompt(
292 "Select a revision", default="q", show_default=True, type=str
293 ).lower()
295 if choice.lower().strip() == "q":
296 LOGGER.error("Aborting.")
297 sys.exit(1)
298 if choice.strip() == "0":
299 return most_recent_backup["identifier"]
300 if choice.strip() in [str(i + 1) for i in range(len(revisions))]:
301 return revisions[int(choice.strip()) - 1]["identifier"]
302 return choice
305@click.argument(
306 "revisions", type=str, required=False, nargs=-1, metavar="[REVISION]..."
307)
308@_subcommand_init
309def delete(repo_root: Path, revisions: tuple[str, ...]):
310 """Delete one or more backups by their specified REVISION."""
311 if not revisions:
312 revisions = _prompt_for_revisions_to_delete(repo_root)
313 try:
314 fastforward.delete_backups(repo_root, *revisions)
315 LOGGER.log(
316 IMPORTANT,
317 'Deleted backups are now marked as "loose."'
318 " To delete them immediately, run the command:"
319 "\n git gc --aggressive --prune=now",
320 )
321 except ValueError as whats_that:
322 LOGGER.error(whats_that)
323 sys.exit(1)
326def _prompt_for_revisions_to_delete(repo_root: Path) -> tuple[str, ...]:
327 """Offer a history of revisions to delete. Unlike similar prompts, this
328 is a multiselect and requires the user to type in each entry (in order to guard
329 against accidental deletions)."""
330 LOGGER.log(IMPORTANT, "Here is a list of recent GSB-created backups:")
331 revisions = history_.show_history(
332 repo_root, tagged_only=False, since_last_tagged_backup=True, numbering=None
333 )
334 revisions.extend(history_.show_history(repo_root, limit=3, numbering=None))
335 if len(revisions) == 0:
336 LOGGER.warning("No GSB revisions found. Trying Git.")
337 revisions = history_.show_history(
338 repo_root, limit=10, tagged_only=False, include_non_gsb=True, numbering=None
339 )
340 LOGGER.log(
341 IMPORTANT,
342 "\nSelect a backup to delete by identifier, or multiple separated by commas."
343 "\nAlternatively, [q]uit and call gsb history yourself to get more revisions.",
344 )
345 if len(revisions) == 0:
346 LOGGER.error("No revisions found!")
347 sys.exit(1)
349 choices: str = click.prompt(
350 "Select a revision or revisions", default="q", show_default=True, type=str
351 ).lower()
353 if choices.lower().strip() == "q":
354 LOGGER.error("Aborting.")
355 sys.exit(1)
356 return tuple(choice.strip() for choice in choices.strip().split(","))
359@click.option(
360 "-J",
361 "xz_flag",
362 is_flag=True,
363 flag_value="tar.xz",
364 help="Export as a .tar.xz archive.",
365)
366@click.option(
367 "-j",
368 "bz2_flag",
369 is_flag=True,
370 flag_value="tar.bz2",
371 help="Export as a .tar.bz2 archive.",
372)
373@click.option(
374 "-z",
375 "gz_flag",
376 is_flag=True,
377 flag_value="tar.gz",
378 help="Export as a .tar.gz archive.",
379)
380@click.option(
381 "-t",
382 "tar_flag",
383 is_flag=True,
384 flag_value="tar",
385 help="Export as an uncompressed .tar archive.",
386)
387@click.option(
388 "-p",
389 "zip_flag",
390 is_flag=True,
391 flag_value="zip",
392 help="Export as a .zip archive.",
393)
394@click.option(
395 "--format",
396 "archive_format",
397 type=str,
398 required=False,
399 help=(
400 "Format for the archived backup. If not specified,"
401 " an appropriate one will be chosen based on your OS."
402 ),
403 metavar="FORMAT",
404)
405@click.option(
406 "--output",
407 "-o",
408 type=Path,
409 required=False,
410 help=(
411 "Explicitly specify a filename for the archived backup."
412 " The format of the archive will be inferred from the extension"
413 " unless a format flag is provided."
414 ),
415 metavar="FILENAME",
416)
417@click.argument(
418 "revision",
419 type=str,
420 required=False,
421)
422@_subcommand_init
423def export(
424 repo_root: Path,
425 revision: str | None,
426 output: Path | None,
427 **format_flags,
428):
429 """Create a stand-alone archive of the specified REVISION."""
430 print(format_flags)
431 specified_formats: list[str] = [value for value in format_flags.values() if value]
433 if len(specified_formats) > 1:
434 LOGGER.error("Conflicting values given for archive format")
435 sys.exit(1)
437 if revision is None:
438 revision = _prompt_for_a_recent_revision(repo_root)
440 if len(specified_formats) == 1:
441 archive_format = specified_formats[0]
442 if archive_format.startswith("."):
443 archive_format = archive_format[1:]
444 if output is None:
445 output = Path(
446 export_.generate_archive_name(
447 Manifest.of(repo_root).name, revision, extension=archive_format
448 )
449 )
450 else:
451 output = output.parent / (output.name + f".{archive_format}")
453 try:
454 export_.export_backup(repo_root, revision, output)
455 except ValueError as whats_that:
456 LOGGER.error(whats_that)
457 sys.exit(1)
460@click.argument(
461 "pytest_args",
462 nargs=-1,
463)
464@gsb.command(context_settings={"ignore_unknown_options": True})
465def test(pytest_args: tuple[str, ...]): # pragma: no cover
466 """Run the GSB test suite to ensure that it is running correctly on your system.
467 Requires you to have installed GSB with the test extra
468 (_i.e._ `pipx install gsb[test]`)."""
469 import pytest
471 pytest.main(["--pyargs", "gsb.test", *pytest_args])