Skip to content

Commit 6bc4146

Browse files
authored
Add --json flag to borg prune for structured output (#9512)
prune: add --json option, fixes #9222 Enable programmatic extraction of prune/keep decisions via structured JSON output, instead of parsing log message text. Follows the repo-list --json pattern: outputs a single JSON object with repository, encryption, and archives array. Each archive includes pruned (bool), rule, and rule_number fields.
1 parent 8eff827 commit 6bc4146

2 files changed

Lines changed: 89 additions & 12 deletions

File tree

src/borg/archiver/prune_cmd.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ..constants import * # NOQA
99
from ..helpers import ArchiveFormatter, interval, sig_int, ProgressIndicatorPercent, CommandError, Error
1010
from ..helpers import archivename_validator
11+
from ..helpers import json_print, basic_json_data
1112
from ..helpers.argparsing import ArgumentParser
1213
from ..manifest import Manifest
1314

@@ -168,8 +169,11 @@ def do_prune(self, args, repository, manifest):
168169
keep += prune_split(archives, rule, num, kept_because)
169170

170171
to_delete = set(archives) - set(keep)
171-
logger.info("Found %d archives.", len(archives))
172-
logger.info("Keeping %d archives, pruning %d archives.", len(keep), len(to_delete))
172+
if not args.json:
173+
logger.info("Found %d archives.", len(archives))
174+
logger.info("Keeping %d archives, pruning %d archives.", len(keep), len(to_delete))
175+
if args.json:
176+
output_data = []
173177
list_logger = logging.getLogger("borg.output.list")
174178
# set up counters for the progress display
175179
to_delete_len = len(to_delete)
@@ -178,29 +182,50 @@ def do_prune(self, args, repository, manifest):
178182
for archive_info in archives:
179183
if sig_int and sig_int.action_done():
180184
break
181-
# format_item may internally load the archive from the repository,
185+
# get_item_data/format_item may internally load the archive from the repository,
182186
# so we must call it before deleting the archive.
183-
archive_formatted = formatter.format_item(archive_info, jsonline=False)
187+
if args.json:
188+
archive_data = formatter.get_item_data(archive_info, jsonline=True)
189+
else:
190+
archive_formatted = formatter.format_item(archive_info, jsonline=False)
184191
if archive_info in to_delete:
185-
pi.show()
192+
if not args.json:
193+
pi.show()
194+
archives_deleted += 1
186195
if args.dry_run:
187196
log_message = "Would prune:"
188197
else:
189198
log_message = "Pruning archive (%d/%d):" % (archives_deleted, to_delete_len)
190199
manifest.archives.delete_by_id(archive_info.id)
191-
archives_deleted += 1
200+
if args.json:
201+
archive_data["kept"] = False
202+
archive_data["deleted_archive_number"] = archives_deleted
192203
else:
193-
log_message = "Keeping archive (rule: {rule} #{num}):".format(
194-
rule=kept_because[archive_info.id][0], num=kept_because[archive_info.id][1]
195-
)
196-
if (
204+
rule, num = kept_because[archive_info.id]
205+
log_message = "Keeping archive (rule: {rule} #{num}):".format(rule=rule, num=num)
206+
if args.json:
207+
archive_data["kept"] = True
208+
archive_data["keep_rule"] = rule
209+
archive_data["kept_archive_number"] = num
210+
if args.json:
211+
if (
212+
args.output_list
213+
or not (args.list_pruned or args.list_kept)
214+
or (args.list_pruned and archive_info in to_delete)
215+
or (args.list_kept and archive_info not in to_delete)
216+
):
217+
output_data.append(archive_data)
218+
elif (
197219
args.output_list
198220
or (args.list_pruned and archive_info in to_delete)
199221
or (args.list_kept and archive_info not in to_delete)
200222
):
201223
list_logger.info(f"{log_message:<44} {archive_formatted}")
202-
pi.finish()
203-
if archives_deleted > 0:
224+
if not args.json:
225+
pi.finish()
226+
if args.json:
227+
json_print(basic_json_data(manifest, extra={"archives": output_data}))
228+
if archives_deleted > 0 and not args.dry_run:
204229
manifest.write()
205230
self.print_warning('Done. Run "borg compact" to free space.', wc=None)
206231
if sig_int:
@@ -295,6 +320,14 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser):
295320
action=Highlander,
296321
help="specify format for the archive part " '(default: "{archive:<36} {time} [{id}]")',
297322
)
323+
subparser.add_argument(
324+
"--json",
325+
action="store_true",
326+
help="Format output as JSON. "
327+
"The form of ``--format`` is ignored, "
328+
"but keys used in it are added to the JSON output. "
329+
"Some keys are always present. Note: JSON can only represent text.",
330+
)
298331
subparser.add_argument(
299332
"--keep-within",
300333
metavar="INTERVAL",

src/borg/testsuite/archiver/prune_cmd_test.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import re
23
from datetime import datetime, timezone, timedelta
34

@@ -416,3 +417,46 @@ def test_prune_list_with_metadata_format(archivers, request, backup_files):
416417
output = cmd(archiver, "prune", "--list", "--keep-daily=1", "--format={name} {hostname}{NL}")
417418
assert "test1" in output
418419
assert "test2" in output
420+
421+
422+
def test_prune_json(archivers, request, backup_files):
423+
archiver = request.getfixturevalue(archivers)
424+
cmd(archiver, "repo-create", RK_ENCRYPTION)
425+
cmd(archiver, "create", "test1", backup_files)
426+
cmd(archiver, "create", "test2", backup_files)
427+
prune_result = json.loads(cmd(archiver, "prune", "--json", "--dry-run", "--keep-daily=1"))
428+
assert "repository" in prune_result
429+
assert "encryption" in prune_result
430+
assert len(prune_result["repository"]["id"]) == 64
431+
archives = prune_result["archives"]
432+
assert len(archives) == 2
433+
kept = [a for a in archives if a["kept"]]
434+
pruned = [a for a in archives if not a["kept"]]
435+
assert len(kept) == 1
436+
assert len(pruned) == 1
437+
assert kept[0]["name"] == "test2"
438+
assert kept[0]["keep_rule"] == "daily"
439+
assert kept[0]["kept_archive_number"] == 1
440+
assert "deleted_archive_number" not in kept[0]
441+
assert pruned[0]["name"] == "test1"
442+
assert pruned[0]["deleted_archive_number"] == 1
443+
assert "keep_rule" not in pruned[0]
444+
assert "kept_archive_number" not in pruned[0]
445+
for archive in archives:
446+
assert "name" in archive
447+
assert "id" in archive
448+
assert "time" in archive
449+
assert "kept" in archive
450+
451+
452+
def test_prune_json_list_pruned(archivers, request, backup_files):
453+
archiver = request.getfixturevalue(archivers)
454+
cmd(archiver, "repo-create", RK_ENCRYPTION)
455+
cmd(archiver, "create", "test1", backup_files)
456+
cmd(archiver, "create", "test2", backup_files)
457+
prune_result = json.loads(cmd(archiver, "prune", "--json", "--dry-run", "--list-pruned", "--keep-daily=1"))
458+
archives = prune_result["archives"]
459+
assert len(archives) == 1
460+
assert archives[0]["name"] == "test1"
461+
assert archives[0]["kept"] is False
462+
assert archives[0]["deleted_archive_number"] == 1

0 commit comments

Comments
 (0)