Coverage for gsb/cli.py: 98%

190 statements  

« 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 

8 

9import click 

10 

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 

20 

21LOGGER = logging.getLogger(__package__) 

22 

23 

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!""" 

29 

30 

31def _subcommand_init(command: Callable) -> Callable: 

32 """Register a subcommand and add some standard CLI handling""" 

33 

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) 

39 

40 log_level = verbosity_to_log_level(verbose - quiet) 

41 

42 cli_handler.setLevel(log_level) 

43 

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) 

51 

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) 

61 

62 wrapped = click.option( 

63 "--verbose", 

64 "-v", 

65 count=True, 

66 help="Increase the amount of information that's printed.", 

67 )(wrapped) 

68 

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) 

76 

77 

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 

113 

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"] 

144 

145 backup_.create_backup(path_as_arg or repo_root, tag, parent=parent_hash) 

146 

147 

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) 

180 

181 

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.""" 

218 

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 

230 

231 history_.show_history( 

232 path_as_arg or repo_root, **kwargs, always_include_latest=True 

233 ) 

234 

235 

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) 

256 

257 

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] 

284 

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 ) 

290 

291 choice: str = click.prompt( 

292 "Select a revision", default="q", show_default=True, type=str 

293 ).lower() 

294 

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 

303 

304 

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) 

324 

325 

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) 

348 

349 choices: str = click.prompt( 

350 "Select a revision or revisions", default="q", show_default=True, type=str 

351 ).lower() 

352 

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(",")) 

357 

358 

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] 

432 

433 if len(specified_formats) > 1: 

434 LOGGER.error("Conflicting values given for archive format") 

435 sys.exit(1) 

436 

437 if revision is None: 

438 revision = _prompt_for_a_recent_revision(repo_root) 

439 

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}") 

452 

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) 

458 

459 

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 

470 

471 pytest.main(["--pyargs", "gsb.test", *pytest_args])