diff --git a/src/borg/archive.py b/src/borg/archive.py index ecb34b06f..aff947bb4 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -291,7 +291,9 @@ class Archive: self.hard_links = {} self.stats = Statistics(output_json=log_json) self.show_progress = progress - self.name = name + self.name = name # overwritten later with name from archive metadata + self.name_in_manifest = name # can differ from .name later (if borg check fixed duplicate archive names) + self.comment = None self.checkpoint_interval = checkpoint_interval self.numeric_owner = numeric_owner self.noatime = noatime @@ -340,6 +342,7 @@ class Archive: self.metadata = self._load_meta(self.id) self.metadata.cmdline = [safe_decode(arg) for arg in self.metadata.cmdline] self.name = self.metadata.name + self.comment = self.metadata.get('comment', '') @property def ts(self): diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 2d00dc7af..607cee0d4 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -1322,7 +1322,7 @@ class Archiver: if args.json_lines: self.print_error('The --json-lines option is only valid for listing archive contents, not archives.') return self.exit_code - return self._list_repository(args, manifest, write) + return self._list_repository(args, repository, manifest, key, write) def _list_archive(self, args, repository, manifest, key, write): matcher = self.build_matcher(args.patterns, args.paths) @@ -1350,14 +1350,14 @@ class Archiver: return self.exit_code - def _list_repository(self, args, manifest, write): + def _list_repository(self, args, repository, manifest, key, write): if args.format is not None: format = args.format elif args.short: format = "{archive}{NL}" else: format = "{archive:<36} {time} [{id}]{NL}" - formatter = ArchiveFormatter(format) + formatter = ArchiveFormatter(format, repository, manifest, key, json=args.json) output_data = [] diff --git a/src/borg/helpers.py b/src/borg/helpers.py index 9a1742f42..7dc62e57b 100644 --- a/src/borg/helpers.py +++ b/src/borg/helpers.py @@ -1589,28 +1589,107 @@ class BaseFormatter: class ArchiveFormatter(BaseFormatter): + KEY_DESCRIPTIONS = { + 'name': 'archive name interpreted as text (might be missing non-text characters, see barchive)', + 'archive': 'archive name interpreted as text (might be missing non-text characters, see barchive)', + 'barchive': 'verbatim archive name, can contain any character except NUL', + 'comment': 'archive comment interpreted as text (might be missing non-text characters, see bcomment)', + 'bcomment': 'verbatim archive comment, can contain any character except NUL', + 'time': 'time (start) of creation of the archive', + # *start* is the key used by borg-info for this timestamp, this makes the formats more compatible + 'start': 'time (start) of creation of the archive', + 'end': 'time (end) of creation of the archive', + 'id': 'internal ID of the archive', + } + KEY_GROUPS = ( + ('name', 'archive', 'barchive', 'comment', 'bcomment', 'id'), + ('time', 'start', 'end'), + ) - def __init__(self, format): - self.format = partial_format(format, self.FIXED_KEYS) + @classmethod + def available_keys(cls): + fake_archive_info = ArchiveInfo('archivename', b'\1'*32, datetime(1970, 1, 1, tzinfo=timezone.utc)) + formatter = cls('', None, None, None) + keys = [] + keys.extend(formatter.call_keys.keys()) + keys.extend(formatter.get_item_data(fake_archive_info).keys()) + return keys - def get_item_data(self, archive): - return { - # *name* is the key used by borg-info for the archive name, this makes the formats more compatible - 'name': remove_surrogates(archive.name), - 'barchive': archive.name, - 'archive': remove_surrogates(archive.name), - 'id': bin_to_hex(archive.id), - 'time': format_time(to_localtime(archive.ts)), - # *start* is the key used by borg-info for this timestamp, this makes the formats more compatible - 'start': format_time(to_localtime(archive.ts)), + @classmethod + def keys_help(cls): + help = [] + keys = cls.available_keys() + for key in cls.FIXED_KEYS: + keys.remove(key) + + for group in cls.KEY_GROUPS: + for key in group: + keys.remove(key) + text = "- " + key + if key in cls.KEY_DESCRIPTIONS: + text += ": " + cls.KEY_DESCRIPTIONS[key] + help.append(text) + help.append("") + assert not keys, str(keys) + return "\n".join(help) + + def __init__(self, format, repository, manifest, key, *, json=False): + self.repository = repository + self.manifest = manifest + self.key = key + self.name = None + self.id = None + self._archive = None + self.json = json + static_keys = {} # here could be stuff on repo level, above archive level + static_keys.update(self.FIXED_KEYS) + self.format = partial_format(format, static_keys) + self.format_keys = {f[1] for f in Formatter().parse(format)} + self.call_keys = { + 'comment': partial(self.get_comment, rs=True), + 'bcomment': partial(self.get_comment, rs=False), + 'end': self.get_ts_end, } + self.used_call_keys = set(self.call_keys) & self.format_keys + if self.json: + self.item_data = {} + self.format_item = self.format_item_json + else: + self.item_data = static_keys - @staticmethod - def keys_help(): - return "- archive, name: archive name interpreted as text (might be missing non-text characters, see barchive)\n" \ - "- barchive: verbatim archive name, can contain any character except NUL\n" \ - "- time: time of creation of the archive\n" \ - "- id: internal ID of the archive" + def format_item_json(self, item): + return json.dumps(self.get_item_data(item)) + '\n' + + def get_item_data(self, archive_info): + self.name = archive_info.name + self.id = archive_info.id + item_data = {} + item_data.update(self.item_data) + item_data.update({ + 'name': remove_surrogates(archive_info.name), + 'archive': remove_surrogates(archive_info.name), + 'barchive': archive_info.name, + 'id': bin_to_hex(archive_info.id), + 'time': format_time(to_localtime(archive_info.ts)), + 'start': format_time(to_localtime(archive_info.ts)), + }) + for key in self.used_call_keys: + item_data[key] = self.call_keys[key]() + return item_data + + @property + def archive(self): + """lazy load / update loaded archive""" + if self._archive is None or self._archive.id != self.id: + from .archive import Archive + self._archive = Archive(self.repository, self.key, self.manifest, self.name) + return self._archive + + def get_comment(self, rs): + return remove_surrogates(self.archive.comment) if rs else self.archive.comment + + def get_ts_end(self): + return format_time(to_localtime(self.archive.ts_end)) class ItemFormatter(BaseFormatter): @@ -1716,9 +1795,10 @@ class ItemFormatter(BaseFormatter): self.used_call_keys = set(self.call_keys) & self.format_keys def get_item_data(self, item): + item_data = {} + item_data.update(self.item_data) mode = stat.filemode(item.mode) item_type = mode[0] - item_data = self.item_data source = item.get('source', '') extra = '' diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 357588159..895799d24 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -1791,8 +1791,8 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_list_repository_format(self): self.cmd('init', '--encryption=repokey', self.repository_location) - self.cmd('create', self.repository_location + '::test-1', src_dir) - self.cmd('create', self.repository_location + '::test-2', src_dir) + self.cmd('create', '--comment', 'comment 1', self.repository_location + '::test-1', src_dir) + self.cmd('create', '--comment', 'comment 2', self.repository_location + '::test-2', src_dir) output_1 = self.cmd('list', self.repository_location) output_2 = self.cmd('list', '--format', '{archive:<36} {time} [{id}]{NL}', self.repository_location) self.assertEqual(output_1, output_2) @@ -1800,6 +1800,9 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.assertEqual(output_1, 'test-1\ntest-2\n') output_1 = self.cmd('list', '--format', '{barchive}/', self.repository_location) self.assertEqual(output_1, 'test-1/test-2/') + output_3 = self.cmd('list', '--format', '{name} {comment}{NL}', self.repository_location) + self.assert_in('test-1 comment 1\n', output_3) + self.assert_in('test-2 comment 2\n', output_3) def test_list_hash(self): self.create_regular_file('empty_file', size=0)