diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 19b8426c97..92cf813301 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -8,6 +8,7 @@ from ..constants import * # NOQA from ..helpers import ArchiveFormatter, interval, sig_int, ProgressIndicatorPercent, CommandError, Error from ..helpers import archivename_validator +from ..helpers import json_print, basic_json_data from ..helpers.argparsing import ArgumentParser from ..manifest import Manifest @@ -168,8 +169,11 @@ def do_prune(self, args, repository, manifest): keep += prune_split(archives, rule, num, kept_because) to_delete = set(archives) - set(keep) - logger.info("Found %d archives.", len(archives)) - logger.info("Keeping %d archives, pruning %d archives.", len(keep), len(to_delete)) + if not args.json: + logger.info("Found %d archives.", len(archives)) + logger.info("Keeping %d archives, pruning %d archives.", len(keep), len(to_delete)) + if args.json: + output_data = [] list_logger = logging.getLogger("borg.output.list") # set up counters for the progress display to_delete_len = len(to_delete) @@ -178,29 +182,50 @@ def do_prune(self, args, repository, manifest): for archive_info in archives: if sig_int and sig_int.action_done(): break - # format_item may internally load the archive from the repository, + # get_item_data/format_item may internally load the archive from the repository, # so we must call it before deleting the archive. - archive_formatted = formatter.format_item(archive_info, jsonline=False) + if args.json: + archive_data = formatter.get_item_data(archive_info, jsonline=True) + else: + archive_formatted = formatter.format_item(archive_info, jsonline=False) if archive_info in to_delete: - pi.show() + if not args.json: + pi.show() + archives_deleted += 1 if args.dry_run: log_message = "Would prune:" else: log_message = "Pruning archive (%d/%d):" % (archives_deleted, to_delete_len) manifest.archives.delete_by_id(archive_info.id) - archives_deleted += 1 + if args.json: + archive_data["kept"] = False + archive_data["deleted_archive_number"] = archives_deleted else: - log_message = "Keeping archive (rule: {rule} #{num}):".format( - rule=kept_because[archive_info.id][0], num=kept_because[archive_info.id][1] - ) - if ( + rule, num = kept_because[archive_info.id] + log_message = "Keeping archive (rule: {rule} #{num}):".format(rule=rule, num=num) + if args.json: + archive_data["kept"] = True + archive_data["keep_rule"] = rule + archive_data["kept_archive_number"] = num + if args.json: + if ( + args.output_list + or not (args.list_pruned or args.list_kept) + or (args.list_pruned and archive_info in to_delete) + or (args.list_kept and archive_info not in to_delete) + ): + output_data.append(archive_data) + elif ( args.output_list or (args.list_pruned and archive_info in to_delete) or (args.list_kept and archive_info not in to_delete) ): list_logger.info(f"{log_message:<44} {archive_formatted}") - pi.finish() - if archives_deleted > 0: + if not args.json: + pi.finish() + if args.json: + json_print(basic_json_data(manifest, extra={"archives": output_data})) + if archives_deleted > 0 and not args.dry_run: manifest.write() self.print_warning('Done. Run "borg compact" to free space.', wc=None) if sig_int: @@ -295,6 +320,14 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): action=Highlander, help="specify format for the archive part " '(default: "{archive:<36} {time} [{id}]")', ) + subparser.add_argument( + "--json", + action="store_true", + help="Format output as JSON. " + "The form of ``--format`` is ignored, " + "but keys used in it are added to the JSON output. " + "Some keys are always present. Note: JSON can only represent text.", + ) subparser.add_argument( "--keep-within", metavar="INTERVAL", diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index abf431c1c1..14420a7a5d 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -1,3 +1,4 @@ +import json import re from datetime import datetime, timezone, timedelta @@ -416,3 +417,46 @@ def test_prune_list_with_metadata_format(archivers, request, backup_files): output = cmd(archiver, "prune", "--list", "--keep-daily=1", "--format={name} {hostname}{NL}") assert "test1" in output assert "test2" in output + + +def test_prune_json(archivers, request, backup_files): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test1", backup_files) + cmd(archiver, "create", "test2", backup_files) + prune_result = json.loads(cmd(archiver, "prune", "--json", "--dry-run", "--keep-daily=1")) + assert "repository" in prune_result + assert "encryption" in prune_result + assert len(prune_result["repository"]["id"]) == 64 + archives = prune_result["archives"] + assert len(archives) == 2 + kept = [a for a in archives if a["kept"]] + pruned = [a for a in archives if not a["kept"]] + assert len(kept) == 1 + assert len(pruned) == 1 + assert kept[0]["name"] == "test2" + assert kept[0]["keep_rule"] == "daily" + assert kept[0]["kept_archive_number"] == 1 + assert "deleted_archive_number" not in kept[0] + assert pruned[0]["name"] == "test1" + assert pruned[0]["deleted_archive_number"] == 1 + assert "keep_rule" not in pruned[0] + assert "kept_archive_number" not in pruned[0] + for archive in archives: + assert "name" in archive + assert "id" in archive + assert "time" in archive + assert "kept" in archive + + +def test_prune_json_list_pruned(archivers, request, backup_files): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test1", backup_files) + cmd(archiver, "create", "test2", backup_files) + prune_result = json.loads(cmd(archiver, "prune", "--json", "--dry-run", "--list-pruned", "--keep-daily=1")) + archives = prune_result["archives"] + assert len(archives) == 1 + assert archives[0]["name"] == "test1" + assert archives[0]["kept"] is False + assert archives[0]["deleted_archive_number"] == 1