From e339ce2fd18dd77b18c97091173eb66f97c38c63 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 14 May 2026 22:24:06 -0700 Subject: [PATCH 01/63] Initial work on a browse action. --- binary_requirements.in | 2 + binary_requirements.txt | 2 + borgmatic/actions/browse.py | 369 ++++++++++++++++++++++++++++ borgmatic/commands/arguments.py | 20 +- borgmatic/commands/borgmatic.py | 10 +- docs/how-to/develop-on-borgmatic.md | 4 +- pyproject.toml | 2 + test_requirements.txt | 2 + 8 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 borgmatic/actions/browse.py diff --git a/binary_requirements.in b/binary_requirements.in index 985a50f8..5df75d93 100644 --- a/binary_requirements.in +++ b/binary_requirements.in @@ -14,6 +14,8 @@ pyyaml referencing requests requests-oauthlib +rich rpds-py ruamel-yaml +textual urllib3 diff --git a/binary_requirements.txt b/binary_requirements.txt index ed52f8e1..01e14035 100644 --- a/binary_requirements.txt +++ b/binary_requirements.txt @@ -16,6 +16,8 @@ pyyaml==6.0.3 # via apprise, -r binary_requirements.in referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r binary_requirements.in requests==2.34.0 # via apprise, borgmatic, requests-oauthlib, -r binary_requirements.in requests-oauthlib==2.0.0 # via apprise, -r binary_requirements.in +rich==15.0.0 rpds-py==0.30.0 # via jsonschema, referencing, -r binary_requirements.in ruamel-yaml==0.19.1 # via borgmatic, -r binary_requirements.in +textual==8.2.5 urllib3==2.7.0 # via requests, -r binary_requirements.in diff --git a/borgmatic/actions/browse.py b/borgmatic/actions/browse.py new file mode 100644 index 00000000..6fee5ad0 --- /dev/null +++ b/borgmatic/actions/browse.py @@ -0,0 +1,369 @@ +import argparse +import contextlib +import enum +import functools +import json +import os +import logging + +import borgmatic.actions.pattern +import borgmatic.borg.diff +import borgmatic.borg.repo_list +import borgmatic.borg.version +import borgmatic.logger + +import rich.text +import textual._context +import textual.app +import textual.binding +import textual.reactive +import textual.widgets + +logger = logging.getLogger(__name__) + + +class Node_type(enum.Enum): + CONFIGURATION = 0 + REPOSITORY = 1 + ARCHIVE = 2 + DIRECTORY = 3 + FILE = 4 + DUMP = 5 + BOOTSTRAP = 6 + LOADING = 7 + + +PATH_TYPE_ICONS = { + Node_type.CONFIGURATION: '⚙️ ', + Node_type.REPOSITORY: '🗃️ ', + Node_type.ARCHIVE: '🗂️ ', + Node_type.DIRECTORY: '📁', + Node_type.FILE: '📄', + Node_type.DUMP: '🗄️', + Node_type.BOOTSTRAP: '🥾', + Node_type.LOADING: '⏳', +} + + +PATH_TYPE_EXPANDED_ICONS = {Node_type.DIRECTORY: '📂'} + + +@textual.work(thread=True) +async def get_repository_archives(browse_tree, repository_node, config, repository, timer): + with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): + logger.answer('Listing repository') + repo_list_arguments = argparse.Namespace( + repository=repository['path'], + short=None, + format=None, + json=True, + prefix=None, + match_archives=None, + sort_by=None, + first=None, + last=None, + ) + global_arguments = argparse.Namespace() + local_path = config.get('local_path', 'borg') + remote_path = config.get('remote_path') + local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) + + archives_data = json.loads( + borgmatic.borg.repo_list.list_repository( + repository['path'], + config, + local_borg_version, + repo_list_arguments, + global_arguments, + local_path, + remote_path, + ) + ) + + browse_tree.app.call_from_thread(add_repository_archives, repository_node, archives_data, timer) + + +def add_repository_archives(repository_node, archives_data, timer): + timer.stop() + + for child in repository_node.children: + child.remove() + + # Reverse the archives, so the common case of accessing the latest archive is easy because it's + # at the top. + for archive in reversed(archives_data['archives']): + repository_node.add( + archive['archive'], + data={ + 'type': Node_type.ARCHIVE, + 'config': repository_node.data['config'], + 'repository': repository_node.data['repository'], + 'archive': archive, + }, + ) + + +@textual.work(thread=True) +def get_archive_files(browse_tree, archive_node, config, repository, archive, timer): + with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): + logger.answer(f"Listing archive {archive['archive']}") + + global_arguments = argparse.Namespace() + local_path = config.get('local_path', 'borg') + remote_path = config.get('remote_path') + local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) + + file_data = borgmatic.borg.list.capture_archive_listing( + repository['path'], + archive['archive'], + config, + local_borg_version, + global_arguments, + local_path=local_path, + remote_path=remote_path, + ) + + browse_tree.app.call_from_thread(add_archive_files, archive_node, file_data, timer) + + +def add_archive_files(archive_node, file_data, timer): + config = archive_node.data['config'] + repository = archive_node.data['repository'] + archive = archive_node.data['archive'] + + timer.stop() + + for child in archive_node.children: + child.remove() + + for file in file_data: + archive_node.add( + file['path'], + data={ + 'type': Node_type.DIRECTORY if file['type'] == 'd' else Node_type.FILE, + 'config': config, + 'repository': repository, + 'archive': archive, + 'file': file, + }, + ) + + +class Browse_tree(textual.widgets.Tree): + COMPONENT_CLASSES = textual.widgets.DirectoryTree.COMPONENT_CLASSES + DEFAULT_CSS = textual.widgets.DirectoryTree.DEFAULT_CSS + LOADING_DOT_INTERVAL_SECONDS = 0.3 + + def __init__(self): + super().__init__('root') + + self.show_root = False + + def render_label(self, node, base_style, style): + node_label = node._label.copy() + node_label.stylize(style) + + if not self.is_mounted: + return node_label + + if node._allow_expand: + icon = PATH_TYPE_ICONS.get(node.data['type'] if node.data else Node_type.FILE) + expanded_icon = PATH_TYPE_EXPANDED_ICONS.get( + node.data['type'] if node.data else Node_type.FILE, icon + ) + prefix = (f'{expanded_icon} ' if node.is_expanded else f'{icon} ', base_style) + node_label.stylize_before( + self.get_component_rich_style("directory-tree--folder", partial=True) + ) + else: + prefix = (f'{PATH_TYPE_ICONS.get(node.data["type"])} ', base_style) + node_label.stylize_before( + self.get_component_rich_style("directory-tree--file", partial=True), + ) + node_label.highlight_regex( + r"\..+$", + self.get_component_rich_style("directory-tree--extension", partial=True), + ) + + if node_label.plain.startswith('.'): + node_label.stylize_before( + self.get_component_rich_style("directory-tree--hidden", partial=True) + ) + + return rich.text.Text.assemble(prefix, node_label) + + def update_loading_dots(self, loading_node): + loading_node.label = (str(loading_node.label) + '.').replace('....', '') + + def on_tree_node_expanded(self, event): + if event.node.data['type'] == Node_type.REPOSITORY and len(event.node.children) == 0: + loading_node = event.node.add_leaf( + 'Loading archives...', data={'type': Node_type.LOADING} + ) + timer = self.set_interval( + self.LOADING_DOT_INTERVAL_SECONDS, + functools.partial(self.update_loading_dots, loading_node=loading_node), + ) + self.call_after_refresh( + functools.partial( + get_repository_archives, + self, + repository_node=event.node, + config=event.node.data['config'], + repository=event.node.data['repository'], + timer=timer, + ) + ) + elif event.node.data['type'] == Node_type.ARCHIVE and len(event.node.children) == 0: + loading_node = event.node.add_leaf('Loading files...', data={'type': Node_type.LOADING}) + timer = self.set_interval( + self.LOADING_DOT_INTERVAL_SECONDS, + functools.partial(self.update_loading_dots, loading_node=loading_node), + ) + self.call_after_refresh( + functools.partial( + get_archive_files, + self, + archive_node=event.node, + config=event.node.data['config'], + repository=event.node.data['repository'], + archive=event.node.data['archive'], + timer=timer, + ) + ) + + +def unexpand_path(config_path): + home_directory = os.path.expanduser('~') + + if config_path.startswith(home_directory): + return config_path.replace(home_directory, '~') + + return config_path + + +class Browse_log_handler(logging.Handler): + def __init__(self, app, log_widget): + self.app = app + self.log_widget = log_widget + + super().__init__() + + def emit(self, record): + message = self.format(record) + + try: + worker = textual.worker.get_current_worker() + self.app.call_from_thread(self.log_widget.write, message) + except textual.worker.NoActiveWorker: + with contextlib.suppress(textual._context.NoActiveAppError): + self.log_widget.write(message) + + +class Rich_color_formatter(logging.Formatter): + def __init__(self, *args, **kwargs): + self.prefix = None + super().__init__( + '{prefix}{message}', + *args, + style='{', + **kwargs, + ) + + def format(self, record): + borgmatic.logger.add_custom_log_levels() + + color = { + logging.CRITICAL: 'bright_red', + logging.ERROR: 'bright_red', + logging.WARNING: 'bright_yellow', + logging.ANSWER: 'bright_magenta', + logging.INFO: 'bright_green', + logging.DEBUG: 'bright_cyan', + }.get(record.levelno) + record.prefix = f'{self.prefix}: ' if self.prefix else '' + + return f'[{color}]{super().format(record)}[/{color}]' + + +class Browse_app(textual.app.App): + BINDINGS = [ + textual.binding.Binding(key='escape', action='quit', description='quit'), + textual.binding.Binding( + key='alt+m', action='command_palette', description='menu', show=False + ), + ] + COMMAND_PALETTE_BINDING = 'alt+m' + + def __init__(self, configs): + self.configs = configs + + super().__init__() + + def compose(self): + tree = Browse_tree() + tree.styles.border = ('round', 'gray') + + for config_path, config in self.configs.items(): + config_node = tree.root.add( + unexpand_path(config_path), data={'type': Node_type.CONFIGURATION, 'config': config} + ) + + for repository in config['repositories']: + config_node.add( + repository['path'], + data={'type': Node_type.REPOSITORY, 'config': config, 'repository': repository}, + ) + + first_child = tree.root.children[0] + tree.select_node(first_child) + tree.scroll_to_node(first_child) + + yield textual.widgets.Header() + yield tree + log_widget = textual.widgets.RichLog(markup=True) + log_widget.styles.border = ('round', 'gray') + yield log_widget + yield textual.widgets.Footer() + + logger = logging.getLogger() + + handler = Browse_log_handler(self, log_widget) + handler.setFormatter(Rich_color_formatter()) + logger.setLevel(min(handler.level for handler in logger.handlers)) + logger.addHandler(handler) + + # Remove the console log handler so it doesn't try to log all over our UI; we have our own + # log handler for surfacing logs within the UI. + with contextlib.suppress(StopIteration): + console_handler = next( + handler + for handler in logging.getLogger().handlers + if isinstance(handler, borgmatic.logger.Multi_stream_handler) + ) + logger.removeHandler(console_handler) + + def on_mount(self): + self.title = 'borgmatic browse' + self.theme = 'ansi-light' + tree = self.query_one(textual.widgets.Tree) + + for child in tree.root.children: + child.expand() + + +def run_browse( + diff_arguments, + global_arguments, + configs, +): + ''' + Run the "browse" action for the given repository. + ''' + if not configs: + return + + logging.getLogger('asyncio').setLevel(logging.WARNING) + + app = Browse_app(configs) + app.run() diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index bce78774..9247ab28 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -31,9 +31,10 @@ ACTION_ALIASES = { 'transfer': [], 'break-lock': [], 'key': [], - 'borg': [], 'recreate': [], 'diff': [], + 'browse': [], + 'borg': [], } @@ -1997,8 +1998,9 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 diff_parser = action_parsers.add_parser( 'diff', aliases=ACTION_ALIASES['diff'], - help='This command finds differences (file contents, user/group/mode) between archives', - description='This command finds differences (file contents, user/group/mode) between archives', + help='Find differences (file contents, user/group/mode) between archives', + description='Find differences (file contents, user/group/mode) between archives', + add_help=False, ) diff_group = diff_parser.add_argument_group('diff arguments') diff_group.add_argument( @@ -2035,6 +2037,18 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 action='store_true', help='Run the diff according to borgmatic configured patterns (ie do not diff entire archives)', ) + diff_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') + + browse_parser = action_parsers.add_parser( + 'browse', + aliases=ACTION_ALIASES['browse'], + help='Browse repositories, archives, and files in a console UI', + description='Browse repositories, archives, and files in a console UI', + add_help=False, + ) + browse_group = browse_parser.add_argument_group('browse arguments') + browse_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') + borg_parser = action_parsers.add_parser( 'borg', aliases=ACTION_ALIASES['borg'], diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 83c77829..10fdfad4 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -12,6 +12,7 @@ import ruamel.yaml import borgmatic.actions.borg import borgmatic.actions.break_lock +import borgmatic.actions.browse import borgmatic.actions.change_passphrase import borgmatic.actions.check import borgmatic.actions.compact @@ -825,8 +826,8 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par try: if 'bootstrap' in arguments: try: - # No configuration file is needed for bootstrap. local_borg_version = borg_version.local_borg_version( + # No configuration file is needed for bootstrap. {}, arguments['bootstrap'].local_path, ) @@ -897,6 +898,13 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par return + if 'browse' in arguments: + borgmatic.actions.browse.run_browse( + arguments['browse'], + arguments['global'], + configs, + ) + except ( CalledProcessError, ValueError, diff --git a/docs/how-to/develop-on-borgmatic.md b/docs/how-to/develop-on-borgmatic.md index effa2354..5e6264a2 100644 --- a/docs/how-to/develop-on-borgmatic.md +++ b/docs/how-to/develop-on-borgmatic.md @@ -31,7 +31,7 @@ changes work: ```bash cd borgmatic uv tool update-shell -uv tool install --editable . +uv tool install --editable .[dev] ``` Or to work on the [Apprise @@ -39,7 +39,7 @@ hook](https://torsion.org/borgmatic/reference/configuration/monitoring/apprise/) change that last line to: ```bash -uv tool install --editable .[Apprise] +uv tool install --editable .[dev,Apprise] ``` To get oriented with the borgmatic source code, have a look at the [source diff --git a/pyproject.toml b/pyproject.toml index 3bf5818c..03e542f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "packaging", "requests", "ruamel.yaml>0.15.0", + "textual", ] [project.scripts] @@ -30,6 +31,7 @@ validate-borgmatic-config = "borgmatic.commands.validate_config:main" [project.optional-dependencies] Apprise = ["apprise"] +dev = ["textual-dev"] [project.urls] Homepage = "https://torsion.org/borgmatic" diff --git a/test_requirements.txt b/test_requirements.txt index 4b9aff2b..ec4e11e3 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -24,7 +24,9 @@ pyyaml>5.0.0 referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r test_requirements.in requests==2.34.0 # via apprise, requests-oauthlib, -r test_requirements.in requests-oauthlib==2.0.0 # via apprise, -r test_requirements.in +rich==15.0.0 rpds-py==0.30.0 # via jsonschema, referencing, -r test_requirements.in ruamel-yaml>0.15.0 +textual==8.2.5 typing-extensions==4.15.0 # via -r test_requirements.in urllib3==2.7.0 # via requests, -r test_requirements.in From 1286ccce46e8e17c970a6eff9b30adf816a8bdee Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 14 May 2026 22:53:59 -0700 Subject: [PATCH 02/63] Browse action MVC refactoring into multiple files. --- borgmatic/actions/browse/__init__.py | 0 borgmatic/actions/browse/controller.py | 61 ++++++ .../actions/{browse.py => browse/view.py} | 197 +++++++----------- borgmatic/commands/borgmatic.py | 4 +- 4 files changed, 135 insertions(+), 127 deletions(-) create mode 100644 borgmatic/actions/browse/__init__.py create mode 100644 borgmatic/actions/browse/controller.py rename borgmatic/actions/{browse.py => browse/view.py} (81%) diff --git a/borgmatic/actions/browse/__init__.py b/borgmatic/actions/browse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/borgmatic/actions/browse/controller.py b/borgmatic/actions/browse/controller.py new file mode 100644 index 00000000..0b0bd25d --- /dev/null +++ b/borgmatic/actions/browse/controller.py @@ -0,0 +1,61 @@ +import argparse +import json +import logging + +import borgmatic.borg.repo_list +import borgmatic.borg.version + + +logger = logging.getLogger(__name__) + + +def get_repository_archives(config, repository): + with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): + logger.answer('Listing repository') + repo_list_arguments = argparse.Namespace( + repository=repository['path'], + short=None, + format=None, + json=True, + prefix=None, + match_archives=None, + sort_by=None, + first=None, + last=None, + ) + global_arguments = argparse.Namespace() + local_path = config.get('local_path', 'borg') + remote_path = config.get('remote_path') + local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) + + return json.loads( + borgmatic.borg.repo_list.list_repository( + repository['path'], + config, + local_borg_version, + repo_list_arguments, + global_arguments, + local_path, + remote_path, + ) + ) + + +def get_archive_files(config, repository, archive): + with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): + logger.answer(f"Listing archive {archive['archive']}") + + global_arguments = argparse.Namespace() + local_path = config.get('local_path', 'borg') + remote_path = config.get('remote_path') + local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) + + return borgmatic.borg.list.capture_archive_listing( + repository['path'], + archive['archive'], + config, + local_borg_version, + global_arguments, + local_path=local_path, + remote_path=remote_path, + ) diff --git a/borgmatic/actions/browse.py b/borgmatic/actions/browse/view.py similarity index 81% rename from borgmatic/actions/browse.py rename to borgmatic/actions/browse/view.py index 6fee5ad0..eac0d45a 100644 --- a/borgmatic/actions/browse.py +++ b/borgmatic/actions/browse/view.py @@ -1,16 +1,10 @@ -import argparse import contextlib import enum import functools -import json import os import logging -import borgmatic.actions.pattern -import borgmatic.borg.diff -import borgmatic.borg.repo_list -import borgmatic.borg.version -import borgmatic.logger +import borgmatic.actions.browse.controller import rich.text import textual._context @@ -19,8 +13,6 @@ import textual.binding import textual.reactive import textual.widgets -logger = logging.getLogger(__name__) - class Node_type(enum.Enum): CONFIGURATION = 0 @@ -50,37 +42,9 @@ PATH_TYPE_EXPANDED_ICONS = {Node_type.DIRECTORY: '📂'} @textual.work(thread=True) async def get_repository_archives(browse_tree, repository_node, config, repository, timer): - with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): - logger.answer('Listing repository') - repo_list_arguments = argparse.Namespace( - repository=repository['path'], - short=None, - format=None, - json=True, - prefix=None, - match_archives=None, - sort_by=None, - first=None, - last=None, - ) - global_arguments = argparse.Namespace() - local_path = config.get('local_path', 'borg') - remote_path = config.get('remote_path') - local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) + archives_data = borgmatic.actions.browse.controller.get_repository_archives(config, repository) - archives_data = json.loads( - borgmatic.borg.repo_list.list_repository( - repository['path'], - config, - local_borg_version, - repo_list_arguments, - global_arguments, - local_path, - remote_path, - ) - ) - - browse_tree.app.call_from_thread(add_repository_archives, repository_node, archives_data, timer) + browse_tree.app.call_from_thread(add_repository_archives, repository_node, archives_data, timer) def add_repository_archives(repository_node, archives_data, timer): @@ -105,28 +69,12 @@ def add_repository_archives(repository_node, archives_data, timer): @textual.work(thread=True) def get_archive_files(browse_tree, archive_node, config, repository, archive, timer): - with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): - logger.answer(f"Listing archive {archive['archive']}") + files_data = borgmatic.actions.browse.controller.get_archive_files(config, repository, archive) - global_arguments = argparse.Namespace() - local_path = config.get('local_path', 'borg') - remote_path = config.get('remote_path') - local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) - - file_data = borgmatic.borg.list.capture_archive_listing( - repository['path'], - archive['archive'], - config, - local_borg_version, - global_arguments, - local_path=local_path, - remote_path=remote_path, - ) - - browse_tree.app.call_from_thread(add_archive_files, archive_node, file_data, timer) + browse_tree.app.call_from_thread(add_archive_files, archive_node, files_data, timer) -def add_archive_files(archive_node, file_data, timer): +def add_archive_files(archive_node, files_data, timer): config = archive_node.data['config'] repository = archive_node.data['repository'] archive = archive_node.data['archive'] @@ -136,7 +84,7 @@ def add_archive_files(archive_node, file_data, timer): for child in archive_node.children: child.remove() - for file in file_data: + for file in files_data: archive_node.add( file['path'], data={ @@ -242,6 +190,71 @@ def unexpand_path(config_path): return config_path +class Browse_app(textual.app.App): + BINDINGS = [ + textual.binding.Binding(key='escape', action='quit', description='quit'), + textual.binding.Binding( + key='alt+m', action='command_palette', description='menu', show=False + ), + ] + COMMAND_PALETTE_BINDING = 'alt+m' + + def __init__(self, configs): + self.configs = configs + + super().__init__() + + def compose(self): + tree = Browse_tree() + tree.styles.border = ('round', 'gray') + + for config_path, config in self.configs.items(): + config_node = tree.root.add( + unexpand_path(config_path), data={'type': Node_type.CONFIGURATION, 'config': config} + ) + + for repository in config['repositories']: + config_node.add( + repository['path'], + data={'type': Node_type.REPOSITORY, 'config': config, 'repository': repository}, + ) + + first_child = tree.root.children[0] + tree.select_node(first_child) + tree.scroll_to_node(first_child) + + yield textual.widgets.Header() + yield tree + log_widget = textual.widgets.RichLog(markup=True) + log_widget.styles.border = ('round', 'gray') + yield log_widget + yield textual.widgets.Footer() + + handler = Browse_log_handler(self, log_widget) + handler.setFormatter(Rich_color_formatter()) + logger = logging.getLogger() + logger.setLevel(min(handler.level for handler in logger.handlers)) + logger.addHandler(handler) + + # Remove the console log handler so it doesn't try to log all over our UI; we have our own + # log handler for surfacing logs within the UI. + with contextlib.suppress(StopIteration): + console_handler = next( + handler + for handler in logging.getLogger().handlers + if isinstance(handler, borgmatic.logger.Multi_stream_handler) + ) + logger.removeHandler(console_handler) + + def on_mount(self): + self.title = 'borgmatic browse' + self.theme = 'ansi-light' + tree = self.query_one(textual.widgets.Tree) + + for child in tree.root.children: + child.expand() + + class Browse_log_handler(logging.Handler): def __init__(self, app, log_widget): self.app = app @@ -286,72 +299,6 @@ class Rich_color_formatter(logging.Formatter): return f'[{color}]{super().format(record)}[/{color}]' -class Browse_app(textual.app.App): - BINDINGS = [ - textual.binding.Binding(key='escape', action='quit', description='quit'), - textual.binding.Binding( - key='alt+m', action='command_palette', description='menu', show=False - ), - ] - COMMAND_PALETTE_BINDING = 'alt+m' - - def __init__(self, configs): - self.configs = configs - - super().__init__() - - def compose(self): - tree = Browse_tree() - tree.styles.border = ('round', 'gray') - - for config_path, config in self.configs.items(): - config_node = tree.root.add( - unexpand_path(config_path), data={'type': Node_type.CONFIGURATION, 'config': config} - ) - - for repository in config['repositories']: - config_node.add( - repository['path'], - data={'type': Node_type.REPOSITORY, 'config': config, 'repository': repository}, - ) - - first_child = tree.root.children[0] - tree.select_node(first_child) - tree.scroll_to_node(first_child) - - yield textual.widgets.Header() - yield tree - log_widget = textual.widgets.RichLog(markup=True) - log_widget.styles.border = ('round', 'gray') - yield log_widget - yield textual.widgets.Footer() - - logger = logging.getLogger() - - handler = Browse_log_handler(self, log_widget) - handler.setFormatter(Rich_color_formatter()) - logger.setLevel(min(handler.level for handler in logger.handlers)) - logger.addHandler(handler) - - # Remove the console log handler so it doesn't try to log all over our UI; we have our own - # log handler for surfacing logs within the UI. - with contextlib.suppress(StopIteration): - console_handler = next( - handler - for handler in logging.getLogger().handlers - if isinstance(handler, borgmatic.logger.Multi_stream_handler) - ) - logger.removeHandler(console_handler) - - def on_mount(self): - self.title = 'borgmatic browse' - self.theme = 'ansi-light' - tree = self.query_one(textual.widgets.Tree) - - for child in tree.root.children: - child.expand() - - def run_browse( diff_arguments, global_arguments, diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 10fdfad4..0db5a7fe 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -12,7 +12,7 @@ import ruamel.yaml import borgmatic.actions.borg import borgmatic.actions.break_lock -import borgmatic.actions.browse +import borgmatic.actions.browse.view import borgmatic.actions.change_passphrase import borgmatic.actions.check import borgmatic.actions.compact @@ -899,7 +899,7 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par return if 'browse' in arguments: - borgmatic.actions.browse.run_browse( + borgmatic.actions.browse.view.run_browse( arguments['browse'], arguments['global'], configs, From b22a068a6079adc46aa3aff408d6acefccceff3a Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 14 May 2026 23:26:09 -0700 Subject: [PATCH 03/63] Put browse action archive files into an actual tree hierarchy. --- borgmatic/actions/browse/view.py | 46 ++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index eac0d45a..c05ab2c6 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -74,6 +74,39 @@ def get_archive_files(browse_tree, archive_node, config, repository, archive, ti browse_tree.app.call_from_thread(add_archive_files, archive_node, files_data, timer) +def add_path(archive_node, config, repository, archive, file_data): + path_pieces = file_data['path'].split(os.path.sep) + path_leftover = path_pieces[1:] + + # Get or add a child with a particular label. + try: + path_node = next( + child for child in archive_node.children if str(child.label) == path_pieces[0] + ) + except StopIteration: + path_node = archive_node.add( + path_pieces[0], + data={ + 'type': Node_type.DIRECTORY + if file_data['type'] == 'd' or path_leftover + else Node_type.FILE, + 'config': config, + 'repository': repository, + 'archive': archive, + 'file': file_data, + }, + ) + + if path_leftover: + add_path( + path_node, + config, + repository, + archive, + dict(file_data, **{'path': os.path.sep.join(path_leftover)}), + ) + + def add_archive_files(archive_node, files_data, timer): config = archive_node.data['config'] repository = archive_node.data['repository'] @@ -84,17 +117,8 @@ def add_archive_files(archive_node, files_data, timer): for child in archive_node.children: child.remove() - for file in files_data: - archive_node.add( - file['path'], - data={ - 'type': Node_type.DIRECTORY if file['type'] == 'd' else Node_type.FILE, - 'config': config, - 'repository': repository, - 'archive': archive, - 'file': file, - }, - ) + for file_data in files_data: + add_path(archive_node, config, repository, archive, file_data) class Browse_tree(textual.widgets.Tree): From dfb3c0830ed43744dacca1c603e560c986ed0c5a Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 16 May 2026 22:06:39 -0700 Subject: [PATCH 04/63] Convert browse action UI from a tree to a series of option lists. --- borgmatic/actions/browse/controller.py | 1 + borgmatic/actions/browse/view.py | 327 +++++++++++-------------- borgmatic/borg/list.py | 6 +- 3 files changed, 145 insertions(+), 189 deletions(-) diff --git a/borgmatic/actions/browse/controller.py b/borgmatic/actions/browse/controller.py index 0b0bd25d..57299aa1 100644 --- a/borgmatic/actions/browse/controller.py +++ b/borgmatic/actions/browse/controller.py @@ -56,6 +56,7 @@ def get_archive_files(config, repository, archive): config, local_borg_version, global_arguments, + list_paths=(r're:^([^/]+/){1,2}[^/]+$',), # TODO local_path=local_path, remote_path=remote_path, ) diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index c05ab2c6..7d5c7309 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -9,15 +9,13 @@ import borgmatic.actions.browse.controller import rich.text import textual._context import textual.app +import textual.color import textual.binding import textual.reactive import textual.widgets -class Node_type(enum.Enum): - CONFIGURATION = 0 - REPOSITORY = 1 - ARCHIVE = 2 +class Path_type(enum.Enum): DIRECTORY = 3 FILE = 4 DUMP = 5 @@ -26,183 +24,110 @@ class Node_type(enum.Enum): PATH_TYPE_ICONS = { - Node_type.CONFIGURATION: '⚙️ ', - Node_type.REPOSITORY: '🗃️ ', - Node_type.ARCHIVE: '🗂️ ', - Node_type.DIRECTORY: '📁', - Node_type.FILE: '📄', - Node_type.DUMP: '🗄️', - Node_type.BOOTSTRAP: '🥾', - Node_type.LOADING: '⏳', + Path_type.DIRECTORY: '📁', + Path_type.FILE: '📄', + Path_type.DUMP: '🗄️', + Path_type.BOOTSTRAP: '🥾', } +PATH_TYPE_EXPANDED_ICONS = {Path_type.DIRECTORY: '📂'} +LOADING_DOT_INTERVAL_SECONDS = 0.3 -PATH_TYPE_EXPANDED_ICONS = {Node_type.DIRECTORY: '📂'} +def update_inline_loading_indicator(option_list, loading_option): + option_list.replace_option_prompt( + 'loading-indicator', (str(loading_option.prompt) + '.').replace('....', '') + ) + + +def add_inline_loading_indicator(option_list): + option_list.clear_options() + loading_option = textual.widgets.option_list.Option(f'⏳ Loading...', id='loading-indicator') + option_list.add_option(loading_option) + + return option_list.set_interval( + LOADING_DOT_INTERVAL_SECONDS, + functools.partial(update_inline_loading_indicator, option_list, loading_option), + ) @textual.work(thread=True) -async def get_repository_archives(browse_tree, repository_node, config, repository, timer): +async def add_repository_archives( + browse_app, archives_list, config, repository, timer +): archives_data = borgmatic.actions.browse.controller.get_repository_archives(config, repository) - browse_tree.app.call_from_thread(add_repository_archives, repository_node, archives_data, timer) - - -def add_repository_archives(repository_node, archives_data, timer): - timer.stop() - - for child in repository_node.children: - child.remove() - # Reverse the archives, so the common case of accessing the latest archive is easy because it's # at the top. - for archive in reversed(archives_data['archives']): - repository_node.add( - archive['archive'], - data={ - 'type': Node_type.ARCHIVE, - 'config': repository_node.data['config'], - 'repository': repository_node.data['repository'], - 'archive': archive, - }, - ) + browse_app.call_from_thread( + archives_list.add_options, + ( + textual.widgets.option_list.Option(archive['archive'], id=archive['archive']) + for archive in reversed(archives_data['archives']) + ), + ) + + browse_app.call_from_thread(timer.stop) + browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') +# FIXME: Revamp for option list instead of tree. @textual.work(thread=True) -def get_archive_files(browse_tree, archive_node, config, repository, archive, timer): +def add_archive_files(browse_tree, archive_node, config, repository, archive, timer, loading_node): files_data = borgmatic.actions.browse.controller.get_archive_files(config, repository, archive) - - browse_tree.app.call_from_thread(add_archive_files, archive_node, files_data, timer) - - -def add_path(archive_node, config, repository, archive, file_data): - path_pieces = file_data['path'].split(os.path.sep) - path_leftover = path_pieces[1:] - - # Get or add a child with a particular label. - try: - path_node = next( - child for child in archive_node.children if str(child.label) == path_pieces[0] - ) - except StopIteration: - path_node = archive_node.add( - path_pieces[0], - data={ - 'type': Node_type.DIRECTORY - if file_data['type'] == 'd' or path_leftover - else Node_type.FILE, - 'config': config, - 'repository': repository, - 'archive': archive, - 'file': file_data, - }, - ) - - if path_leftover: - add_path( - path_node, - config, - repository, - archive, - dict(file_data, **{'path': os.path.sep.join(path_leftover)}), - ) - - -def add_archive_files(archive_node, files_data, timer): config = archive_node.data['config'] repository = archive_node.data['repository'] archive = archive_node.data['archive'] - timer.stop() - - for child in archive_node.children: - child.remove() - for file_data in files_data: - add_path(archive_node, config, repository, archive, file_data) + add_path( + browse_tree, archive_node, config, repository, archive, timer, loading_node, file_data + ) + + browse_tree.app.call_from_thread(loading_node.remove) + browse_tree.app.call_from_thread(timer.stop) -class Browse_tree(textual.widgets.Tree): - COMPONENT_CLASSES = textual.widgets.DirectoryTree.COMPONENT_CLASSES - DEFAULT_CSS = textual.widgets.DirectoryTree.DEFAULT_CSS - LOADING_DOT_INTERVAL_SECONDS = 0.3 +class Configuration_files_list(textual.widgets.OptionList): + def __init__(self, configs): + super().__init__( + *( + textual.widgets.option_list.Option(f'{config_path}', id=config_path) + for config_path in configs.keys() + ), + id='configuration-files-list', + classes='panel', + ) + self.configs = configs + self.border_title = 'configuration files' + +class Repositories_list(textual.widgets.OptionList): def __init__(self): - super().__init__('root') + super().__init__(id='repositories-list', classes='panel') + self.border_title = 'repositories' - self.show_root = False + def set_repositories(self, config): + self.config = config + self.repositories = config['repositories'] - def render_label(self, node, base_style, style): - node_label = node._label.copy() - node_label.stylize(style) + self.clear_options() + self.add_options( + textual.widgets.option_list.Option(label, id=index) + for index, repository in enumerate(self.repositories) + for label in (repository.get('label', repository.get('path')),) + ) - if not self.is_mounted: - return node_label - if node._allow_expand: - icon = PATH_TYPE_ICONS.get(node.data['type'] if node.data else Node_type.FILE) - expanded_icon = PATH_TYPE_EXPANDED_ICONS.get( - node.data['type'] if node.data else Node_type.FILE, icon - ) - prefix = (f'{expanded_icon} ' if node.is_expanded else f'{icon} ', base_style) - node_label.stylize_before( - self.get_component_rich_style("directory-tree--folder", partial=True) - ) - else: - prefix = (f'{PATH_TYPE_ICONS.get(node.data["type"])} ', base_style) - node_label.stylize_before( - self.get_component_rich_style("directory-tree--file", partial=True), - ) - node_label.highlight_regex( - r"\..+$", - self.get_component_rich_style("directory-tree--extension", partial=True), - ) +class Archives_list(textual.widgets.OptionList): + def __init__(self): + super().__init__(id='archives-list', classes='panel') + self.border_title = 'archives' - if node_label.plain.startswith('.'): - node_label.stylize_before( - self.get_component_rich_style("directory-tree--hidden", partial=True) - ) - return rich.text.Text.assemble(prefix, node_label) - - def update_loading_dots(self, loading_node): - loading_node.label = (str(loading_node.label) + '.').replace('....', '') - - def on_tree_node_expanded(self, event): - if event.node.data['type'] == Node_type.REPOSITORY and len(event.node.children) == 0: - loading_node = event.node.add_leaf( - 'Loading archives...', data={'type': Node_type.LOADING} - ) - timer = self.set_interval( - self.LOADING_DOT_INTERVAL_SECONDS, - functools.partial(self.update_loading_dots, loading_node=loading_node), - ) - self.call_after_refresh( - functools.partial( - get_repository_archives, - self, - repository_node=event.node, - config=event.node.data['config'], - repository=event.node.data['repository'], - timer=timer, - ) - ) - elif event.node.data['type'] == Node_type.ARCHIVE and len(event.node.children) == 0: - loading_node = event.node.add_leaf('Loading files...', data={'type': Node_type.LOADING}) - timer = self.set_interval( - self.LOADING_DOT_INTERVAL_SECONDS, - functools.partial(self.update_loading_dots, loading_node=loading_node), - ) - self.call_after_refresh( - functools.partial( - get_archive_files, - self, - archive_node=event.node, - config=event.node.data['config'], - repository=event.node.data['repository'], - archive=event.node.data['archive'], - timer=timer, - ) - ) +class Logs(textual.widgets.RichLog): + def __init__(self): + super().__init__(markup=True, classes='panel') + self.border_title = 'logs' def unexpand_path(config_path): @@ -216,12 +141,29 @@ def unexpand_path(config_path): class Browse_app(textual.app.App): BINDINGS = [ - textual.binding.Binding(key='escape', action='quit', description='quit'), + textual.binding.Binding(key='q', action='quit', description='quit'), + textual.binding.Binding(key='l', action='toggle_logs', description='logs'), textual.binding.Binding( - key='alt+m', action='command_palette', description='menu', show=False + key='c', action='command_palette', description='commands', show=False ), ] - COMMAND_PALETTE_BINDING = 'alt+m' + COMMAND_PALETTE_BINDING = 'c' + CSS = ''' + .panel { + border: round $primary; + border-title-color: $text-primary; + height: 100%; + } + + #archives-list { + display: none; + } + + #logs-container { + height: 50%; + display: none; + } + ''' def __init__(self, configs): self.configs = configs @@ -229,32 +171,22 @@ class Browse_app(textual.app.App): super().__init__() def compose(self): - tree = Browse_tree() - tree.styles.border = ('round', 'gray') - - for config_path, config in self.configs.items(): - config_node = tree.root.add( - unexpand_path(config_path), data={'type': Node_type.CONFIGURATION, 'config': config} - ) - - for repository in config['repositories']: - config_node.add( - repository['path'], - data={'type': Node_type.REPOSITORY, 'config': config, 'repository': repository}, - ) - - first_child = tree.root.children[0] - tree.select_node(first_child) - tree.scroll_to_node(first_child) + self.configuration_files_list = Configuration_files_list(self.configs) + self.repositories_list = Repositories_list() + self.archives_list = Archives_list() yield textual.widgets.Header() - yield tree - log_widget = textual.widgets.RichLog(markup=True) - log_widget.styles.border = ('round', 'gray') - yield log_widget + with textual.containers.Horizontal(): + yield self.configuration_files_list + yield self.repositories_list + yield self.archives_list + + with textual.containers.Horizontal(id='logs-container'): + logs_widget = Logs() + yield logs_widget yield textual.widgets.Footer() - handler = Browse_log_handler(self, log_widget) + handler = Browse_log_handler(self, logs_widget) handler.setFormatter(Rich_color_formatter()) logger = logging.getLogger() logger.setLevel(min(handler.level for handler in logger.handlers)) @@ -272,17 +204,40 @@ class Browse_app(textual.app.App): def on_mount(self): self.title = 'borgmatic browse' - self.theme = 'ansi-light' - tree = self.query_one(textual.widgets.Tree) - for child in tree.root.children: - child.expand() + def on_option_list_option_highlighted(self, event): + if event.option_list == self.configuration_files_list: + config = self.configuration_files_list.configs[event.option_id] + self.repositories_list.set_repositories(config) + elif event.option_list == self.repositories_list: + timer = add_inline_loading_indicator(self.archives_list) + + add_repository_archives( + self, + archives_list=self.archives_list, + config=self.repositories_list.config, + repository=self.repositories_list.repositories[event.option_id], + timer=timer, + ) + + def on_option_list_option_selected(self, event): + if event.option_list == self.configuration_files_list: + self.configuration_files_list.styles.display = 'none' + self.archives_list.styles.display = 'block' + self.repositories_list.focus() + self.repositories_list.highlighted = 0 + + def action_toggle_logs(self): + logs_container = self.query_one('#logs-container') + logs_container.styles.display = ( + 'none' if logs_container.styles.display == 'block' else 'block' + ) class Browse_log_handler(logging.Handler): - def __init__(self, app, log_widget): + def __init__(self, app, logs_widget): self.app = app - self.log_widget = log_widget + self.logs_widget = logs_widget super().__init__() @@ -291,10 +246,10 @@ class Browse_log_handler(logging.Handler): try: worker = textual.worker.get_current_worker() - self.app.call_from_thread(self.log_widget.write, message) - except textual.worker.NoActiveWorker: + self.app.call_from_thread(self.logs_widget.write, message) + except (RuntimeError, textual.worker.NoActiveWorker): with contextlib.suppress(textual._context.NoActiveAppError): - self.log_widget.write(message) + self.logs_widget.write(message) class Rich_color_formatter(logging.Formatter): diff --git a/borgmatic/borg/list.py b/borgmatic/borg/list.py index 308a6f31..45391548 100644 --- a/borgmatic/borg/list.py +++ b/borgmatic/borg/list.py @@ -115,10 +115,10 @@ def capture_archive_listing( Given a local or remote repository path, an archive name, a configuration dict, the local Borg version, global arguments as an argparse.Namespace, the archive paths (or Borg patterns) in which to list files, the Borg path format indicating keys to include in the output, and local - and remote Borg paths, capture the output of listing that archive and return it as a sequence of - dicts, one per path. + and remote Borg paths, capture the output of listing that archive and return it as a generator + of dicts, one per path. ''' - return tuple( + return ( json.loads(entry) for entry in execute_command_and_capture_output( make_list_command( From 9729809f6f0b20046bec16f6bf801ca08d92543c Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 16 May 2026 23:04:30 -0700 Subject: [PATCH 05/63] Add the first level of file loading. --- borgmatic/actions/browse/controller.py | 29 ++++++++----- borgmatic/actions/browse/view.py | 59 ++++++++++++++++++-------- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/borgmatic/actions/browse/controller.py b/borgmatic/actions/browse/controller.py index 57299aa1..17a2addb 100644 --- a/borgmatic/actions/browse/controller.py +++ b/borgmatic/actions/browse/controller.py @@ -1,3 +1,4 @@ +import os import argparse import json import logging @@ -41,22 +42,28 @@ def get_repository_archives(config, repository): ) -def get_archive_files(config, repository, archive): +def get_archive_files(config, repository, archive_name): with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): - logger.answer(f"Listing archive {archive['archive']}") + logger.answer(f"Listing archive {archive_name}") global_arguments = argparse.Namespace() local_path = config.get('local_path', 'borg') remote_path = config.get('remote_path') local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) - return borgmatic.borg.list.capture_archive_listing( - repository['path'], - archive['archive'], - config, - local_borg_version, - global_arguments, - list_paths=(r're:^([^/]+/){1,2}[^/]+$',), # TODO - local_path=local_path, - remote_path=remote_path, + return sorted( + dict.fromkeys( + base_path + for archive in borgmatic.borg.list.capture_archive_listing( + repository['path'], + archive_name, + config, + local_borg_version, + global_arguments, + list_paths=(r're:^([^/]+/){1,2}[^/]+$',), # TODO + local_path=local_path, + remote_path=remote_path, + ) + for base_path in (archive['path'].split(os.path.sep)[0],) + ), ) diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index 7d5c7309..c3a15efa 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -58,33 +58,28 @@ async def add_repository_archives( # Reverse the archives, so the common case of accessing the latest archive is easy because it's # at the top. - browse_app.call_from_thread( - archives_list.add_options, - ( - textual.widgets.option_list.Option(archive['archive'], id=archive['archive']) - for archive in reversed(archives_data['archives']) - ), - ) + for archive in reversed(archives_data['archives']): + browse_app.call_from_thread( + archives_list.add_option, + textual.widgets.option_list.Option(archive['archive'], id=archive['archive']), + ) browse_app.call_from_thread(timer.stop) browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') -# FIXME: Revamp for option list instead of tree. @textual.work(thread=True) -def add_archive_files(browse_tree, archive_node, config, repository, archive, timer, loading_node): - files_data = borgmatic.actions.browse.controller.get_archive_files(config, repository, archive) - config = archive_node.data['config'] - repository = archive_node.data['repository'] - archive = archive_node.data['archive'] +def add_archive_files(browse_app, files_list, config, repository, archive_name, timer): + paths = borgmatic.actions.browse.controller.get_archive_files(config, repository, archive_name) - for file_data in files_data: - add_path( - browse_tree, archive_node, config, repository, archive, timer, loading_node, file_data + for path in paths: + browse_app.call_from_thread( + files_list.add_option, + textual.widgets.option_list.Option(path, id=path), ) - browse_tree.app.call_from_thread(loading_node.remove) - browse_tree.app.call_from_thread(timer.stop) + browse_app.call_from_thread(timer.stop) + browse_app.call_from_thread(files_list.remove_option, 'loading-indicator') class Configuration_files_list(textual.widgets.OptionList): @@ -124,6 +119,12 @@ class Archives_list(textual.widgets.OptionList): self.border_title = 'archives' +class Files_list(textual.widgets.OptionList): + def __init__(self): + super().__init__(id='files-list', classes='panel') + self.border_title = 'files' + + class Logs(textual.widgets.RichLog): def __init__(self): super().__init__(markup=True, classes='panel') @@ -159,6 +160,10 @@ class Browse_app(textual.app.App): display: none; } + #files-list { + display: none; + } + #logs-container { height: 50%; display: none; @@ -174,12 +179,14 @@ class Browse_app(textual.app.App): self.configuration_files_list = Configuration_files_list(self.configs) self.repositories_list = Repositories_list() self.archives_list = Archives_list() + self.files_list = Files_list() yield textual.widgets.Header() with textual.containers.Horizontal(): yield self.configuration_files_list yield self.repositories_list yield self.archives_list + yield self.files_list with textual.containers.Horizontal(id='logs-container'): logs_widget = Logs() @@ -219,6 +226,17 @@ class Browse_app(textual.app.App): repository=self.repositories_list.repositories[event.option_id], timer=timer, ) + elif event.option_list == self.archives_list: + timer = add_inline_loading_indicator(self.files_list) + + add_archive_files( + self, + files_list=self.files_list, + config=self.repositories_list.config, + repository=self.repositories_list.repositories[self.repositories_list.highlighted_option.id], + archive_name=event.option_id, + timer=timer, + ) def on_option_list_option_selected(self, event): if event.option_list == self.configuration_files_list: @@ -226,6 +244,11 @@ class Browse_app(textual.app.App): self.archives_list.styles.display = 'block' self.repositories_list.focus() self.repositories_list.highlighted = 0 + elif event.option_list == self.repositories_list: + self.repositories_list.styles.display = 'none' + self.files_list.styles.display = 'block' + self.archives_list.focus() + self.archives_list.highlighted = 0 def action_toggle_logs(self): logs_container = self.query_one('#logs-container') From f170ef19dfc4e49818e123e13507683c4c228ce7 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sun, 17 May 2026 16:10:58 -0700 Subject: [PATCH 06/63] Switch to a generalized "carousel" for the borgmatic browse panels. --- borgmatic/actions/browse/view.py | 194 +++++++++++++++++-------------- 1 file changed, 109 insertions(+), 85 deletions(-) diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index c3a15efa..409cd9dd 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -55,6 +55,8 @@ async def add_repository_archives( browse_app, archives_list, config, repository, timer ): archives_data = borgmatic.actions.browse.controller.get_repository_archives(config, repository) + browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') + browse_app.call_from_thread(timer.stop) # Reverse the archives, so the common case of accessing the latest archive is easy because it's # at the top. @@ -64,13 +66,12 @@ async def add_repository_archives( textual.widgets.option_list.Option(archive['archive'], id=archive['archive']), ) - browse_app.call_from_thread(timer.stop) - browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') - @textual.work(thread=True) def add_archive_files(browse_app, files_list, config, repository, archive_name, timer): paths = borgmatic.actions.browse.controller.get_archive_files(config, repository, archive_name) + browse_app.call_from_thread(timer.stop) + browse_app.call_from_thread(files_list.remove_option, 'loading-indicator') for path in paths: browse_app.call_from_thread( @@ -78,52 +79,139 @@ def add_archive_files(browse_app, files_list, config, repository, archive_name, textual.widgets.option_list.Option(path, id=path), ) - browse_app.call_from_thread(timer.stop) - browse_app.call_from_thread(files_list.remove_option, 'loading-indicator') - class Configuration_files_list(textual.widgets.OptionList): def __init__(self, configs): + self.configs = configs + home_directory = os.path.expanduser('~') + super().__init__( *( - textual.widgets.option_list.Option(f'{config_path}', id=config_path) + textual.widgets.option_list.Option(f'{unexpanded_path}', id=config_path) for config_path in configs.keys() + for unexpanded_path in (config_path.replace(home_directory, '~'),) + ), id='configuration-files-list', classes='panel', ) - self.configs = configs self.border_title = 'configuration files' + def make_preview(self, option_id): + return Repositories_list(config=self.configs[option_id]) + class Repositories_list(textual.widgets.OptionList): - def __init__(self): - super().__init__(id='repositories-list', classes='panel') - self.border_title = 'repositories' - - def set_repositories(self, config): + def __init__(self, config): self.config = config self.repositories = config['repositories'] - self.clear_options() - self.add_options( - textual.widgets.option_list.Option(label, id=index) - for index, repository in enumerate(self.repositories) - for label in (repository.get('label', repository.get('path')),) + super().__init__( + *( + textual.widgets.option_list.Option(label, id=index) + for index, repository in enumerate(self.repositories) + for label in (repository.get('label', repository.get('path')),) + ), + id='repositories-list', + classes='panel', ) + self.border_title = 'repositories' + + def make_preview(self, option_id): + return Archives_list(config=self.config, repository=self.repositories[option_id]) class Archives_list(textual.widgets.OptionList): - def __init__(self): + def __init__(self, config, repository): + self.config = config + self.repository = repository + super().__init__(id='archives-list', classes='panel') self.border_title = 'archives' + timer = add_inline_loading_indicator(self) + + add_repository_archives( + self.app, + archives_list=self, + config=self.config, + repository=self.repository, + timer=timer, + ) + + def make_preview(self, option_id): + return Files_list(config=self.config, repository=self.repository, archive_name=option_id) + class Files_list(textual.widgets.OptionList): - def __init__(self): + def __init__(self, config, repository, archive_name): + self.config = config + self.repository = repository + self.archive_name = archive_name + super().__init__(id='files-list', classes='panel') self.border_title = 'files' + timer = add_inline_loading_indicator(self) + + add_archive_files( + self.app, + files_list=self, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + timer=timer, + ) + + +class Carousel(textual.containers.Horizontal): + def __init__(self, option_lists): + self.option_lists = option_lists + self.focused_option_list = option_lists[0] + self.preview_option_list = None + + super().__init__() + + def compose(self): + for option_list in self.option_lists: + yield option_list + + self.focused_option_list.focus() + + def on_option_list_option_highlighted(self, event): + if event.option_list != self.focused_option_list: + return + + # Remove any existing preview from the option list. + focused_index = self.option_lists.index(event.option_list) + del(self.option_lists[(focused_index + 1):]) + + # Add a fresh preview. + self.preview_option_list = event.option_list.make_preview(event.option_id) + self.option_lists.append(self.preview_option_list) + self.refresh(recompose=True) + + def on_option_list_option_selected(self, event): + if event.option_list != self.focused_option_list or not self.preview_option_list: + return + + self.focused_option_list.styles.display = 'none' + self.focused_option_list = self.preview_option_list + self.preview_option_list = None + + self.focused_option_list.styles.display = 'block' + self.focused_option_list.focus() + self.focused_option_list.highlighted = 0 + + # Trigger on_option_list_option_highlighted() for the newly focused option list. + self.focused_option_list.post_message( + self.focused_option_list.OptionHighlighted( + self.focused_option_list, + self.focused_option_list.options[0], + 0, + ) + ) + class Logs(textual.widgets.RichLog): def __init__(self): @@ -131,15 +219,6 @@ class Logs(textual.widgets.RichLog): self.border_title = 'logs' -def unexpand_path(config_path): - home_directory = os.path.expanduser('~') - - if config_path.startswith(home_directory): - return config_path.replace(home_directory, '~') - - return config_path - - class Browse_app(textual.app.App): BINDINGS = [ textual.binding.Binding(key='q', action='quit', description='quit'), @@ -156,14 +235,6 @@ class Browse_app(textual.app.App): height: 100%; } - #archives-list { - display: none; - } - - #files-list { - display: none; - } - #logs-container { height: 50%; display: none; @@ -176,17 +247,8 @@ class Browse_app(textual.app.App): super().__init__() def compose(self): - self.configuration_files_list = Configuration_files_list(self.configs) - self.repositories_list = Repositories_list() - self.archives_list = Archives_list() - self.files_list = Files_list() - yield textual.widgets.Header() - with textual.containers.Horizontal(): - yield self.configuration_files_list - yield self.repositories_list - yield self.archives_list - yield self.files_list + yield Carousel([Configuration_files_list(self.configs)]) with textual.containers.Horizontal(id='logs-container'): logs_widget = Logs() @@ -212,44 +274,6 @@ class Browse_app(textual.app.App): def on_mount(self): self.title = 'borgmatic browse' - def on_option_list_option_highlighted(self, event): - if event.option_list == self.configuration_files_list: - config = self.configuration_files_list.configs[event.option_id] - self.repositories_list.set_repositories(config) - elif event.option_list == self.repositories_list: - timer = add_inline_loading_indicator(self.archives_list) - - add_repository_archives( - self, - archives_list=self.archives_list, - config=self.repositories_list.config, - repository=self.repositories_list.repositories[event.option_id], - timer=timer, - ) - elif event.option_list == self.archives_list: - timer = add_inline_loading_indicator(self.files_list) - - add_archive_files( - self, - files_list=self.files_list, - config=self.repositories_list.config, - repository=self.repositories_list.repositories[self.repositories_list.highlighted_option.id], - archive_name=event.option_id, - timer=timer, - ) - - def on_option_list_option_selected(self, event): - if event.option_list == self.configuration_files_list: - self.configuration_files_list.styles.display = 'none' - self.archives_list.styles.display = 'block' - self.repositories_list.focus() - self.repositories_list.highlighted = 0 - elif event.option_list == self.repositories_list: - self.repositories_list.styles.display = 'none' - self.files_list.styles.display = 'block' - self.archives_list.focus() - self.archives_list.highlighted = 0 - def action_toggle_logs(self): logs_container = self.query_one('#logs-container') logs_container.styles.display = ( From 0396a89f727de1c565daf287fa84f38088cdc621 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 16:38:51 -0700 Subject: [PATCH 07/63] Various browse action improvements. --- borgmatic/actions/browse/controller.py | 60 +++-- borgmatic/actions/browse/view.py | 326 +++++++++++++++++++------ 2 files changed, 292 insertions(+), 94 deletions(-) diff --git a/borgmatic/actions/browse/controller.py b/borgmatic/actions/browse/controller.py index 17a2addb..35273c5a 100644 --- a/borgmatic/actions/browse/controller.py +++ b/borgmatic/actions/browse/controller.py @@ -3,6 +3,7 @@ import argparse import json import logging +import borgmatic.borg.extract import borgmatic.borg.repo_list import borgmatic.borg.version @@ -42,7 +43,7 @@ def get_repository_archives(config, repository): ) -def get_archive_files(config, repository, archive_name): +def get_archive_files(config, repository, archive_name, list_path=None): with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): logger.answer(f"Listing archive {archive_name}") @@ -50,20 +51,47 @@ def get_archive_files(config, repository, archive_name): local_path = config.get('local_path', 'borg') remote_path = config.get('remote_path') local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) + seen = set() - return sorted( - dict.fromkeys( - base_path - for archive in borgmatic.borg.list.capture_archive_listing( - repository['path'], - archive_name, - config, - local_borg_version, - global_arguments, - list_paths=(r're:^([^/]+/){1,2}[^/]+$',), # TODO - local_path=local_path, - remote_path=remote_path, - ) - for base_path in (archive['path'].split(os.path.sep)[0],) - ), + return ( + (path_data['type'] if os.path.join(list_path, base_path) == path_data['path'] else 'd', base_path, path_data.get('linktarget')) + for path_data in borgmatic.borg.list.capture_archive_listing( + repository['path'], + archive_name, + config, + local_borg_version, + global_arguments, + list_paths=(fr're:^{list_path}/[^/]+',) if list_path else (r're:^[^/]+',), + local_path=local_path, + remote_path=remote_path, + ) + for base_path in (os.path.relpath(path_data['path'], list_path).split(os.path.sep)[0],) + if base_path not in seen and not seen.add(base_path) ) + + +READLINES_HINT_BYTES = 1000 + + +def get_archive_file_content(config, repository, archive_name, file_path): + local_path = config.get('local_path', 'borg') + remote_path = config.get('remote_path') + + lines = borgmatic.borg.extract.extract_archive( + dry_run=False, + repository=repository['path'], + archive=archive_name, + paths=(file_path,), + config=config, + local_borg_version=borgmatic.borg.version.local_borg_version(config, local_path), + global_arguments=argparse.Namespace(), + local_path=local_path, + remote_path=remote_path, + destination_path=None, + strip_components=None, + extract_to_stdout=True, + ).stdout.readlines(READLINES_HINT_BYTES) + + content = ''.join(line.decode() for line in lines) + + return content if len(content) < READLINES_HINT_BYTES else f'{content}[...]' diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index 409cd9dd..dfc54d89 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -16,37 +16,52 @@ import textual.widgets class Path_type(enum.Enum): - DIRECTORY = 3 - FILE = 4 - DUMP = 5 - BOOTSTRAP = 6 - LOADING = 7 + DIRECTORY = 'd' + LINK = 'l' + FILE = '-' PATH_TYPE_ICONS = { - Path_type.DIRECTORY: '📁', - Path_type.FILE: '📄', - Path_type.DUMP: '🗄️', - Path_type.BOOTSTRAP: '🥾', + Path_type.DIRECTORY.value: '📁', + Path_type.LINK.value: '🔗', + Path_type.FILE.value: '📄', } -PATH_TYPE_EXPANDED_ICONS = {Path_type.DIRECTORY: '📂'} LOADING_DOT_INTERVAL_SECONDS = 0.3 -def update_inline_loading_indicator(option_list, loading_option): - option_list.replace_option_prompt( - 'loading-indicator', (str(loading_option.prompt) + '.').replace('....', '') - ) +logger = logging.getLogger('__name__') -def add_inline_loading_indicator(option_list): - option_list.clear_options() - loading_option = textual.widgets.option_list.Option(f'⏳ Loading...', id='loading-indicator') - option_list.add_option(loading_option) +def update_inline_loading_indicator(widget): + if isinstance(widget, textual.widgets.OptionList): + try: + widget.replace_option_prompt( + 'loading-indicator', (str(widget.get_option('loading-indicator').prompt) + '.').replace('....', '') + ) + except textual.widgets.option_list.OptionDoesNotExist: + pass + elif isinstance(widget, textual.widgets.Static): + widget.update((str(widget.content) + '.').replace('....', '')) + else: + raise ValueError(f'Unsupported widget type: {type(widget)}') - return option_list.set_interval( + +def add_inline_loading_indicator(widget): + loading_message = f'⏳ loading...' + + if isinstance(widget, textual.widgets.OptionList): + widget.clear_options() + loading_option = textual.widgets.option_list.Option(loading_message, id='loading-indicator') + widget.add_option(loading_option) + widget.highlighted = None + elif isinstance(widget, textual.widgets.Static): + widget.update(loading_message) + else: + raise ValueError(f'Unsupported widget type: {type(widget)}') + + return widget.set_interval( LOADING_DOT_INTERVAL_SECONDS, - functools.partial(update_inline_loading_indicator, option_list, loading_option), + functools.partial(update_inline_loading_indicator, widget), ) @@ -55,30 +70,63 @@ async def add_repository_archives( browse_app, archives_list, config, repository, timer ): archives_data = borgmatic.actions.browse.controller.get_repository_archives(config, repository) - browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') - browse_app.call_from_thread(timer.stop) + loading_option = archives_list.get_option('loading-indicator') # Reverse the archives, so the common case of accessing the latest archive is easy because it's # at the top. - for archive in reversed(archives_data['archives']): + for index, archive in enumerate(reversed(archives_data['archives'])): + label_pieces = (archive['archive'], '[dim](latest)[/dim]') if index == 0 else (archive['archive'],) + browse_app.call_from_thread( - archives_list.add_option, - textual.widgets.option_list.Option(archive['archive'], id=archive['archive']), + archives_list.remove_option, + 'loading-indicator' ) + browse_app.call_from_thread( + archives_list.add_options, + ( + textual.widgets.option_list.Option(' '.join(label_pieces), id=archive['archive']), + loading_option, + ) + ) + + browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') + browse_app.call_from_thread(timer.stop) @textual.work(thread=True) -def add_archive_files(browse_app, files_list, config, repository, archive_name, timer): - paths = borgmatic.actions.browse.controller.get_archive_files(config, repository, archive_name) - browse_app.call_from_thread(timer.stop) - browse_app.call_from_thread(files_list.remove_option, 'loading-indicator') +def add_archive_files(browse_app, directory_list, config, repository, archive_name, list_path, root_directory, timer): + file_type_paths = borgmatic.actions.browse.controller.get_archive_files(config, repository, archive_name, list_path) + loading_option = directory_list.get_option('loading-indicator') - for path in paths: + if not root_directory: + browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') browse_app.call_from_thread( - files_list.add_option, - textual.widgets.option_list.Option(path, id=path), + directory_list.add_options, + ( + textual.widgets.option_list.Option(f'{PATH_TYPE_ICONS[Path_type.DIRECTORY.value]} ..', id='..'), + loading_option, + ) ) + for (path_type, file_path, link_target) in file_type_paths: + pieces = (PATH_TYPE_ICONS.get(path_type, '?'), file_path) + (('→', link_target) if link_target else ()) + sorted_options = sorted( + directory_list.options + [textual.widgets.option_list.Option(' '.join(pieces), id=file_path)], + key=lambda option: ((option.id == 'loading-indicator'), option.prompt) + ) + browse_app.call_from_thread(directory_list.set_options, sorted_options) + + browse_app.call_from_thread(timer.stop) + browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') + + +@textual.work(thread=True) +def load_file_preview(browse_app, file_preview, config, repository, archive_name, file_path, timer): + file_content = borgmatic.actions.browse.controller.get_archive_file_content(config, repository, archive_name, file_path) + + browse_app.call_from_thread(timer.stop) + browse_app.call_from_thread(file_preview.update, file_content) + class Configuration_files_list(textual.widgets.OptionList): def __init__(self, configs): @@ -92,11 +140,16 @@ class Configuration_files_list(textual.widgets.OptionList): for unexpanded_path in (config_path.replace(home_directory, '~'),) ), - id='configuration-files-list', classes='panel', ) self.border_title = 'configuration files' + def focus(self): + super().focus() + + if self.highlighted is None and self.options: + self.highlighted = 0 + def make_preview(self, option_id): return Repositories_list(config=self.configs[option_id]) @@ -112,10 +165,22 @@ class Repositories_list(textual.widgets.OptionList): for index, repository in enumerate(self.repositories) for label in (repository.get('label', repository.get('path')),) ), - id='repositories-list', classes='panel', ) self.border_title = 'repositories' + self.highlighted = None + + def add_option(self, option): + super().add_option(option) + + if self.highlighted is None and self.options: + self.highlighted = 0 + + def focus(self): + super().focus() + + if self.highlighted is None and self.options: + self.highlighted = 0 def make_preview(self, option_id): return Archives_list(config=self.config, repository=self.repositories[option_id]) @@ -126,7 +191,7 @@ class Archives_list(textual.widgets.OptionList): self.config = config self.repository = repository - super().__init__(id='archives-list', classes='panel') + super().__init__(classes='panel') self.border_title = 'archives' timer = add_inline_loading_indicator(self) @@ -139,83 +204,187 @@ class Archives_list(textual.widgets.OptionList): timer=timer, ) + def add_option(self, option): + super().add_option(option) + + if self.highlighted is None and self.options and self.can_focus: + self.highlighted = 0 + + def focus(self): + super().focus() + + if self.highlighted is None and self.options: + self.highlighted = 0 + def make_preview(self, option_id): - return Files_list(config=self.config, repository=self.repository, archive_name=option_id) + return Directory_list(config=self.config, repository=self.repository, archive_name=option_id) -class Files_list(textual.widgets.OptionList): - def __init__(self, config, repository, archive_name): +class Directory_list(textual.widgets.OptionList): + def __init__(self, config, repository, archive_name, path_components=None): self.config = config self.repository = repository self.archive_name = archive_name + self.path_components = path_components or () - super().__init__(id='files-list', classes='panel') - self.border_title = 'files' + super().__init__(classes='panel') + self.border_title = os.path.sep.join(self.path_components) if self.path_components else f'{archive_name}' timer = add_inline_loading_indicator(self) add_archive_files( self.app, - files_list=self, + directory_list=self, config=self.config, repository=self.repository, archive_name=self.archive_name, + list_path=os.path.sep.join(self.path_components), + root_directory=not bool(self.path_components), timer=timer, ) + def add_option(self, option): + super().add_option(option) + + if self.highlighted is None and self.options and self.can_focus: + self.highlighted = 0 + + def focus(self): + super().focus() + + if self.highlighted is None and self.options: + self.highlighted = 0 + + def make_preview(self, option_id): + option = self.get_option(option_id) + + if option_id == '..': + return Null_list() + + if option.prompt.startswith(PATH_TYPE_ICONS[Path_type.DIRECTORY.value]): + return Directory_list(self.config, self.repository, self.archive_name, path_components=self.path_components + (option_id,)) + + return File_preview(self.config, self.repository, self.archive_name, file_path=os.path.sep.join(self.path_components + (option_id,))) + + +class Null_list(textual.widgets.OptionList): + def __init__(self): + super().__init__(classes='panel') + + +class File_preview(textual.widgets.Static): + def __init__(self, config, repository, archive_name, file_path): + self.config = config + self.repository = repository + self.archive_name = archive_name + self.file_path = file_path + + super().__init__(classes='panel') + self.border_title = f'{PATH_TYPE_ICONS[Path_type.FILE.value]} {self.file_path} preview' + + timer = add_inline_loading_indicator(self) + load_file_preview( + self.app, + file_preview=self, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + file_path=self.file_path, + timer=timer, + ) + + def make_preview(self, option_id): + return None + class Carousel(textual.containers.Horizontal): - def __init__(self, option_lists): - self.option_lists = option_lists - self.focused_option_list = option_lists[0] - self.preview_option_list = None + BINDINGS = [ + textual.binding.Binding(key='left,h', action='previous', description='previous', priority=True), + ] + + def __init__(self, panels): + self.panels = panels + self.focused_panel = panels[0] + self.preview_panel = None super().__init__() def compose(self): - for option_list in self.option_lists: - yield option_list + for panel in self.panels: + yield panel - self.focused_option_list.focus() + self.focused_panel.focus() - def on_option_list_option_highlighted(self, event): - if event.option_list != self.focused_option_list: + def action_previous(self): + ''' + Hide the preview panel, demote the current focused panel to be the new preview, and make the + previous panel into the focused panel. + ''' + previous_panel_index = self.panels.index(self.focused_panel) - 1 + + if previous_panel_index < 0: return - # Remove any existing preview from the option list. - focused_index = self.option_lists.index(event.option_list) - del(self.option_lists[(focused_index + 1):]) + self.focused_panel.highlighted = None + + if self.preview_panel: + self.preview_panel.styles.display = 'none' + + self.preview_panel = self.focused_panel + + self.focused_panel = self.panels[previous_panel_index] + self.focused_panel.styles.display = 'block' + self.focused_panel.can_focus = True + self.focused_panel.focus() + self.focused_panel.highlighted = 0 + + def action_next(self): + ''' + Hide the current focused panel. Then promote the preview panel to be the new focused panel. + ''' + if not self.preview_panel: + return + + self.focused_panel.styles.display = 'none' + self.focused_panel = self.preview_panel + self.preview_panel = None + + self.focused_panel.styles.display = 'block' + self.focused_panel.can_focus = True + self.focused_panel.focus() + self.focused_panel.highlighted = 0 + + def on_option_list_option_highlighted(self, event): + if event.option_list != self.focused_panel: + return + + # Remove any existing preview from the carousel. + focused_index = self.panels.index(event.option_list) + del(self.panels[(focused_index + 1):]) # Add a fresh preview. - self.preview_option_list = event.option_list.make_preview(event.option_id) - self.option_lists.append(self.preview_option_list) + if event.option_id == 'loading-indicator': + self.preview_panel = Null_list() + else: + self.preview_panel = event.option_list.make_preview(event.option_id) + + self.preview_panel.can_focus = False + self.panels.append(self.preview_panel) self.refresh(recompose=True) def on_option_list_option_selected(self, event): - if event.option_list != self.focused_option_list or not self.preview_option_list: + if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': return - self.focused_option_list.styles.display = 'none' - self.focused_option_list = self.preview_option_list - self.preview_option_list = None - - self.focused_option_list.styles.display = 'block' - self.focused_option_list.focus() - self.focused_option_list.highlighted = 0 - - # Trigger on_option_list_option_highlighted() for the newly focused option list. - self.focused_option_list.post_message( - self.focused_option_list.OptionHighlighted( - self.focused_option_list, - self.focused_option_list.options[0], - 0, - ) - ) + if event.option_id == '..': + self.action_previous() + else: + self.action_next() class Logs(textual.widgets.RichLog): def __init__(self): - super().__init__(markup=True, classes='panel') + super().__init__(markup=True, id='logs', classes='panel') self.border_title = 'logs' @@ -232,10 +401,12 @@ class Browse_app(textual.app.App): .panel { border: round $primary; border-title-color: $text-primary; + width: 50%; height: 100%; } - #logs-container { + #logs { + width: 100%; height: 50%; display: none; } @@ -250,9 +421,8 @@ class Browse_app(textual.app.App): yield textual.widgets.Header() yield Carousel([Configuration_files_list(self.configs)]) - with textual.containers.Horizontal(id='logs-container'): - logs_widget = Logs() - yield logs_widget + logs_widget = Logs() + yield logs_widget yield textual.widgets.Footer() handler = Browse_log_handler(self, logs_widget) @@ -275,7 +445,7 @@ class Browse_app(textual.app.App): self.title = 'borgmatic browse' def action_toggle_logs(self): - logs_container = self.query_one('#logs-container') + logs_container = self.query_one('#logs') logs_container.styles.display = ( 'none' if logs_container.styles.display == 'block' else 'block' ) From a2dcd4747fc99dd5c4fb27377681a585db5983fd Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 17:10:00 -0700 Subject: [PATCH 08/63] Get rid of automatic preview panels in the browse action; they're just too slow and annoying in real-world use. --- borgmatic/actions/browse/view.py | 60 ++++++++++---------------------- 1 file changed, 19 insertions(+), 41 deletions(-) diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index dfc54d89..1993ce3e 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -76,6 +76,7 @@ async def add_repository_archives( # at the top. for index, archive in enumerate(reversed(archives_data['archives'])): label_pieces = (archive['archive'], '[dim](latest)[/dim]') if index == 0 else (archive['archive'],) + highlighted_option = archives_list.highlighted_option browse_app.call_from_thread( archives_list.remove_option, @@ -88,6 +89,7 @@ async def add_repository_archives( loading_option, ) ) + archives_list.highlighted = archives_list.get_option_index(highlighted_option.id) if highlighted_option and archives_list.highlighted != 0 else 0 browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') browse_app.call_from_thread(timer.stop) @@ -110,11 +112,13 @@ def add_archive_files(browse_app, directory_list, config, repository, archive_na for (path_type, file_path, link_target) in file_type_paths: pieces = (PATH_TYPE_ICONS.get(path_type, '?'), file_path) + (('→', link_target) if link_target else ()) + highlighted_option = directory_list.highlighted_option sorted_options = sorted( directory_list.options + [textual.widgets.option_list.Option(' '.join(pieces), id=file_path)], key=lambda option: ((option.id == 'loading-indicator'), option.prompt) ) browse_app.call_from_thread(directory_list.set_options, sorted_options) + directory_list.highlighted = directory_list.get_option_index(highlighted_option.id) if highlighted_option and directory_list.highlighted != 0 else 0 browse_app.call_from_thread(timer.stop) browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') @@ -150,7 +154,7 @@ class Configuration_files_list(textual.widgets.OptionList): if self.highlighted is None and self.options: self.highlighted = 0 - def make_preview(self, option_id): + def make_next_panel(self, option_id): return Repositories_list(config=self.configs[option_id]) @@ -182,7 +186,7 @@ class Repositories_list(textual.widgets.OptionList): if self.highlighted is None and self.options: self.highlighted = 0 - def make_preview(self, option_id): + def make_next_panel(self, option_id): return Archives_list(config=self.config, repository=self.repositories[option_id]) @@ -216,7 +220,7 @@ class Archives_list(textual.widgets.OptionList): if self.highlighted is None and self.options: self.highlighted = 0 - def make_preview(self, option_id): + def make_next_panel(self, option_id): return Directory_list(config=self.config, repository=self.repository, archive_name=option_id) @@ -255,7 +259,7 @@ class Directory_list(textual.widgets.OptionList): if self.highlighted is None and self.options: self.highlighted = 0 - def make_preview(self, option_id): + def make_next_panel(self, option_id): option = self.get_option(option_id) if option_id == '..': @@ -293,7 +297,7 @@ class File_preview(textual.widgets.Static): timer=timer, ) - def make_preview(self, option_id): + def make_next_panel(self, option_id): return None @@ -305,7 +309,6 @@ class Carousel(textual.containers.Horizontal): def __init__(self, panels): self.panels = panels self.focused_panel = panels[0] - self.preview_panel = None super().__init__() @@ -317,8 +320,7 @@ class Carousel(textual.containers.Horizontal): def action_previous(self): ''' - Hide the preview panel, demote the current focused panel to be the new preview, and make the - previous panel into the focused panel. + Make the previous panel into the focused panel. ''' previous_panel_index = self.panels.index(self.focused_panel) - 1 @@ -326,11 +328,7 @@ class Carousel(textual.containers.Horizontal): return self.focused_panel.highlighted = None - - if self.preview_panel: - self.preview_panel.styles.display = 'none' - - self.preview_panel = self.focused_panel + self.focused_panel.styles.display = 'none' self.focused_panel = self.panels[previous_panel_index] self.focused_panel.styles.display = 'block' @@ -338,38 +336,18 @@ class Carousel(textual.containers.Horizontal): self.focused_panel.focus() self.focused_panel.highlighted = 0 - def action_next(self): + def action_next(self, option_id): ''' - Hide the current focused panel. Then promote the preview panel to be the new focused panel. + Hide the current focused panel and create the next one. ''' - if not self.preview_panel: - return - self.focused_panel.styles.display = 'none' - self.focused_panel = self.preview_panel - self.preview_panel = None - self.focused_panel.styles.display = 'block' - self.focused_panel.can_focus = True + # TODO: If a panel already exists after this one, use it instead of creating a new instance. + + self.focused_panel = self.focused_panel.make_next_panel(option_id) + self.panels.append(self.focused_panel) self.focused_panel.focus() self.focused_panel.highlighted = 0 - - def on_option_list_option_highlighted(self, event): - if event.option_list != self.focused_panel: - return - - # Remove any existing preview from the carousel. - focused_index = self.panels.index(event.option_list) - del(self.panels[(focused_index + 1):]) - - # Add a fresh preview. - if event.option_id == 'loading-indicator': - self.preview_panel = Null_list() - else: - self.preview_panel = event.option_list.make_preview(event.option_id) - - self.preview_panel.can_focus = False - self.panels.append(self.preview_panel) self.refresh(recompose=True) def on_option_list_option_selected(self, event): @@ -379,7 +357,7 @@ class Carousel(textual.containers.Horizontal): if event.option_id == '..': self.action_previous() else: - self.action_next() + self.action_next(event.option_id) class Logs(textual.widgets.RichLog): @@ -401,7 +379,7 @@ class Browse_app(textual.app.App): .panel { border: round $primary; border-title-color: $text-primary; - width: 50%; + width: 100%; height: 100%; } From 3f4a87517b0eb8ba2fc923268e2745080ec07b47 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 19:40:47 -0700 Subject: [PATCH 09/63] Fix browse action option list highlights. --- borgmatic/actions/browse/view.py | 79 +++++++++++--------------------- 1 file changed, 28 insertions(+), 51 deletions(-) diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index 1993ce3e..675d3fbd 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -89,7 +89,7 @@ async def add_repository_archives( loading_option, ) ) - archives_list.highlighted = archives_list.get_option_index(highlighted_option.id) if highlighted_option and archives_list.highlighted != 0 else 0 + archives_list.highlighted = archives_list.get_option_index(highlighted_option.id) if highlighted_option and archives_list.highlighted_option_changed else 0 browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') browse_app.call_from_thread(timer.stop) @@ -118,7 +118,7 @@ def add_archive_files(browse_app, directory_list, config, repository, archive_na key=lambda option: ((option.id == 'loading-indicator'), option.prompt) ) browse_app.call_from_thread(directory_list.set_options, sorted_options) - directory_list.highlighted = directory_list.get_option_index(highlighted_option.id) if highlighted_option and directory_list.highlighted != 0 else 0 + directory_list.highlighted = directory_list.get_option_index(highlighted_option.id) if highlighted_option and directory_list.highlighted_option_changed else 0 browse_app.call_from_thread(timer.stop) browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') @@ -148,12 +148,6 @@ class Configuration_files_list(textual.widgets.OptionList): ) self.border_title = 'configuration files' - def focus(self): - super().focus() - - if self.highlighted is None and self.options: - self.highlighted = 0 - def make_next_panel(self, option_id): return Repositories_list(config=self.configs[option_id]) @@ -172,19 +166,6 @@ class Repositories_list(textual.widgets.OptionList): classes='panel', ) self.border_title = 'repositories' - self.highlighted = None - - def add_option(self, option): - super().add_option(option) - - if self.highlighted is None and self.options: - self.highlighted = 0 - - def focus(self): - super().focus() - - if self.highlighted is None and self.options: - self.highlighted = 0 def make_next_panel(self, option_id): return Archives_list(config=self.config, repository=self.repositories[option_id]) @@ -197,6 +178,7 @@ class Archives_list(textual.widgets.OptionList): super().__init__(classes='panel') self.border_title = 'archives' + self.highlighted_option_changed = False timer = add_inline_loading_indicator(self) @@ -208,21 +190,13 @@ class Archives_list(textual.widgets.OptionList): timer=timer, ) - def add_option(self, option): - super().add_option(option) - - if self.highlighted is None and self.options and self.can_focus: - self.highlighted = 0 - - def focus(self): - super().focus() - - if self.highlighted is None and self.options: - self.highlighted = 0 - def make_next_panel(self, option_id): return Directory_list(config=self.config, repository=self.repository, archive_name=option_id) + def on_option_list_option_highlighted(self, event): + if self.highlighted not in (None, 0): + self.highlighted_option_changed = True + class Directory_list(textual.widgets.OptionList): def __init__(self, config, repository, archive_name, path_components=None): @@ -230,6 +204,7 @@ class Directory_list(textual.widgets.OptionList): self.repository = repository self.archive_name = archive_name self.path_components = path_components or () + self.highlighted_option_changed = False super().__init__(classes='panel') self.border_title = os.path.sep.join(self.path_components) if self.path_components else f'{archive_name}' @@ -247,18 +222,6 @@ class Directory_list(textual.widgets.OptionList): timer=timer, ) - def add_option(self, option): - super().add_option(option) - - if self.highlighted is None and self.options and self.can_focus: - self.highlighted = 0 - - def focus(self): - super().focus() - - if self.highlighted is None and self.options: - self.highlighted = 0 - def make_next_panel(self, option_id): option = self.get_option(option_id) @@ -270,6 +233,10 @@ class Directory_list(textual.widgets.OptionList): return File_preview(self.config, self.repository, self.archive_name, file_path=os.path.sep.join(self.path_components + (option_id,))) + def on_option_list_option_highlighted(self, event): + if self.highlighted not in (None, 0): + self.highlighted_option_changed = True + class Null_list(textual.widgets.OptionList): def __init__(self): @@ -327,29 +294,39 @@ class Carousel(textual.containers.Horizontal): if previous_panel_index < 0: return - self.focused_panel.highlighted = None self.focused_panel.styles.display = 'none' self.focused_panel = self.panels[previous_panel_index] self.focused_panel.styles.display = 'block' self.focused_panel.can_focus = True self.focused_panel.focus() - self.focused_panel.highlighted = 0 def action_next(self, option_id): ''' Hide the current focused panel and create the next one. ''' self.focused_panel.styles.display = 'none' + next_panel_index = self.panels.index(self.focused_panel) + 1 - # TODO: If a panel already exists after this one, use it instead of creating a new instance. + if next_panel_index < len(self.panels): + self.focused_panel = self.panels[next_panel_index] + self.focused_panel.styles.display = 'block' + else: + self.focused_panel = self.focused_panel.make_next_panel(option_id) + self.panels.append(self.focused_panel) + self.focused_panel.highlighted = 0 - self.focused_panel = self.focused_panel.make_next_panel(option_id) - self.panels.append(self.focused_panel) self.focused_panel.focus() - self.focused_panel.highlighted = 0 self.refresh(recompose=True) + def on_option_list_option_highlighted(self, event): + ''' + The highlighted option has changed, so truncate any next panels. + ''' + next_panel_index = self.panels.index(self.focused_panel) + 1 + + del(self.panels[next_panel_index:]) + def on_option_list_option_selected(self, event): if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': return From d1d757a5d17db36db42cf7d083d427b8912cc8c7 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 20:01:03 -0700 Subject: [PATCH 10/63] Fix focus on file preview widgets so key bindings for them work in the browse action. --- borgmatic/actions/browse/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index 675d3fbd..7b049e4c 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -249,6 +249,7 @@ class File_preview(textual.widgets.Static): self.repository = repository self.archive_name = archive_name self.file_path = file_path + self.can_focus = True super().__init__(classes='panel') self.border_title = f'{PATH_TYPE_ICONS[Path_type.FILE.value]} {self.file_path} preview' @@ -298,7 +299,6 @@ class Carousel(textual.containers.Horizontal): self.focused_panel = self.panels[previous_panel_index] self.focused_panel.styles.display = 'block' - self.focused_panel.can_focus = True self.focused_panel.focus() def action_next(self, option_id): From 9310b42d9f2d29428223b2cec656f69c6812382c Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 20:16:30 -0700 Subject: [PATCH 11/63] Add a few more browse action bindings. --- borgmatic/actions/browse/view.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index 7b049e4c..b665e84f 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -132,7 +132,16 @@ def load_file_preview(browse_app, file_preview, config, repository, archive_name browse_app.call_from_thread(file_preview.update, file_content) +OPTION_LIST_BINDINGS = textual.widgets.OptionList.BINDINGS + [ + textual.binding.Binding(key='j', action='cursor_down', description='down', show=False), + textual.binding.Binding(key='k', action='cursor_up', description='up', show=False), + textual.binding.Binding(key='right,l', action='select', description='select', show=False), +] + + class Configuration_files_list(textual.widgets.OptionList): + BINDINGS = OPTION_LIST_BINDINGS + def __init__(self, configs): self.configs = configs home_directory = os.path.expanduser('~') @@ -153,6 +162,8 @@ class Configuration_files_list(textual.widgets.OptionList): class Repositories_list(textual.widgets.OptionList): + BINDINGS = OPTION_LIST_BINDINGS + def __init__(self, config): self.config = config self.repositories = config['repositories'] @@ -172,6 +183,8 @@ class Repositories_list(textual.widgets.OptionList): class Archives_list(textual.widgets.OptionList): + BINDINGS = OPTION_LIST_BINDINGS + def __init__(self, config, repository): self.config = config self.repository = repository @@ -199,6 +212,8 @@ class Archives_list(textual.widgets.OptionList): class Directory_list(textual.widgets.OptionList): + BINDINGS = OPTION_LIST_BINDINGS + def __init__(self, config, repository, archive_name, path_components=None): self.config = config self.repository = repository @@ -346,7 +361,7 @@ class Logs(textual.widgets.RichLog): class Browse_app(textual.app.App): BINDINGS = [ textual.binding.Binding(key='q', action='quit', description='quit'), - textual.binding.Binding(key='l', action='toggle_logs', description='logs'), + textual.binding.Binding(key='v', action='toggle_logs', description='view logs'), textual.binding.Binding( key='c', action='command_palette', description='commands', show=False ), From 9be16987624511eb48c99d2e4585761bb725872e Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 21:00:16 -0700 Subject: [PATCH 12/63] Add syntax highlighting to previews. --- borgmatic/actions/browse/controller.py | 2 +- borgmatic/actions/browse/view.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/borgmatic/actions/browse/controller.py b/borgmatic/actions/browse/controller.py index 35273c5a..6716febe 100644 --- a/borgmatic/actions/browse/controller.py +++ b/borgmatic/actions/browse/controller.py @@ -70,7 +70,7 @@ def get_archive_files(config, repository, archive_name, list_path=None): ) -READLINES_HINT_BYTES = 1000 +READLINES_HINT_BYTES = 2000 def get_archive_file_content(config, repository, archive_name, file_path): diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index b665e84f..fe9fb722 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -6,6 +6,7 @@ import logging import borgmatic.actions.browse.controller +import rich.syntax import rich.text import textual._context import textual.app @@ -129,7 +130,8 @@ def load_file_preview(browse_app, file_preview, config, repository, archive_name file_content = borgmatic.actions.browse.controller.get_archive_file_content(config, repository, archive_name, file_path) browse_app.call_from_thread(timer.stop) - browse_app.call_from_thread(file_preview.update, file_content) + syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) + browse_app.call_from_thread(file_preview.update, rich.syntax.Syntax(file_content, syntax_lexer)) OPTION_LIST_BINDINGS = textual.widgets.OptionList.BINDINGS + [ From 8e91af6820cc19ad840d3c713fecde8a27e18c83 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 22:17:19 -0700 Subject: [PATCH 13/63] Make Textual an optional dependency. --- borgmatic/actions/browse/run.py | 25 +++++++++++++++++++++++++ borgmatic/actions/browse/view.py | 17 ----------------- borgmatic/commands/borgmatic.py | 4 ++-- borgmatic/hooks/monitoring/apprise.py | 2 +- pyproject.toml | 4 ++-- 5 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 borgmatic/actions/browse/run.py diff --git a/borgmatic/actions/browse/run.py b/borgmatic/actions/browse/run.py new file mode 100644 index 00000000..133f4beb --- /dev/null +++ b/borgmatic/actions/browse/run.py @@ -0,0 +1,25 @@ +import logging + + +def run_browse( + diff_arguments, + global_arguments, + configs, +): + ''' + Run the "browse" action for the given repository. + ''' + if not configs: + return + + logging.getLogger('asyncio').setLevel(logging.WARNING) + + try: + import textual + except ImportError: # pragma: no cover + raise ValueError('Unable to import the Textual library for the browse action; try installing "borgmatic[browse]"') + + import borgmatic.actions.browse.view + + app = borgmatic.actions.browse.view.Browse_app(configs) + app.run() diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index fe9fb722..cde120e9 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -465,20 +465,3 @@ class Rich_color_formatter(logging.Formatter): record.prefix = f'{self.prefix}: ' if self.prefix else '' return f'[{color}]{super().format(record)}[/{color}]' - - -def run_browse( - diff_arguments, - global_arguments, - configs, -): - ''' - Run the "browse" action for the given repository. - ''' - if not configs: - return - - logging.getLogger('asyncio').setLevel(logging.WARNING) - - app = Browse_app(configs) - app.run() diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 0db5a7fe..f2a3b613 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -12,7 +12,7 @@ import ruamel.yaml import borgmatic.actions.borg import borgmatic.actions.break_lock -import borgmatic.actions.browse.view +import borgmatic.actions.browse.run import borgmatic.actions.change_passphrase import borgmatic.actions.check import borgmatic.actions.compact @@ -899,7 +899,7 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par return if 'browse' in arguments: - borgmatic.actions.browse.view.run_browse( + borgmatic.actions.browse.run.run_browse( arguments['browse'], arguments['global'], configs, diff --git a/borgmatic/hooks/monitoring/apprise.py b/borgmatic/hooks/monitoring/apprise.py index 0280758a..59578d44 100644 --- a/borgmatic/hooks/monitoring/apprise.py +++ b/borgmatic/hooks/monitoring/apprise.py @@ -44,7 +44,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev import apprise # noqa: PLC0415 from apprise import NotifyFormat, NotifyType # noqa: PLC0415 except ImportError: # pragma: no cover - logger.warning('Unable to import Apprise in monitoring hook') + logger.warning('Unable to import Apprise in its monitoring hook; try installing "borgmatic[Apprise]"') return state_to_notify_type = { diff --git a/pyproject.toml b/pyproject.toml index 03e542f8..b9a4de86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ dependencies = [ "packaging", "requests", "ruamel.yaml>0.15.0", - "textual", ] [project.scripts] @@ -31,7 +30,8 @@ validate-borgmatic-config = "borgmatic.commands.validate_config:main" [project.optional-dependencies] Apprise = ["apprise"] -dev = ["textual-dev"] +browse = ["textual"] +browse-dev = ["textual-dev"] [project.urls] Homepage = "https://torsion.org/borgmatic" From 4b4851e78acc20534f9ec109bed29ba638a2e776 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 22:57:52 -0700 Subject: [PATCH 14/63] Add browse action documentation. --- NEWS | 3 ++ docs/Dockerfile | 2 +- docs/_includes/snippet/command-line/sample.md | 6 +-- docs/how-to/inspect-your-backups.md | 39 +++++++++++++++++++ docs/reference/command-line/actions/browse.md | 17 ++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 docs/reference/command-line/actions/browse.md diff --git a/NEWS b/NEWS index fe7a74a3..078d25de 100644 --- a/NEWS +++ b/NEWS @@ -10,6 +10,9 @@ * #1303: For the MariaDB hook, include only a subset of system data when dumping the "mysql" system database (or "all" databases), so the dump is actually restorable. See the documentation for more information: https://torsion.org/borgmatic/reference/configuration/data-sources/mariadb/ + * Add an experimental "borgmatic browse" action, a console UI for browsing your backups. See the + documentation for more information: + https://torsion.org/borgmatic/how-to/inspect-your-backups/#browsing-backups * Update the KeePassXC credential hook to support KeePassXC's secret service integration. See the documentation for more information: https://torsion.org/borgmatic/reference/configuration/credentials/keepassxc/ diff --git a/docs/Dockerfile b/docs/Dockerfile index 2f2f0e40..389be793 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -5,7 +5,7 @@ RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib RUN pip install --break-system-packages --no-cache /app && borgmatic config generate && borgmatic config generate --destination /etc/borgmatic --split && chmod +r /etc/borgmatic/*.yaml RUN mkdir /command-line \ && borgmatic --help > /command-line/global.txt \ - && for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" "config show" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key import" "key change-passphrase" recreate diff borg; do \ + && for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" "config show" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key import" "key change-passphrase" recreate diff browse borg; do \ borgmatic $action --help > /command-line/${action/ /-}.txt; done RUN /app/docs/fetch-contributors >> /contributors.html diff --git a/docs/_includes/snippet/command-line/sample.md b/docs/_includes/snippet/command-line/sample.md index 91013a84..8250a378 100644 --- a/docs/_includes/snippet/command-line/sample.md +++ b/docs/_includes/snippet/command-line/sample.md @@ -1,5 +1,5 @@ Here's the command-line help for this action in the [most recent version of borgmatic](https://projects.torsion.org/borgmatic-collective/borgmatic/releases). -If you're using an older version, some of these flags may not work, and you -should instead run the action with `--help` to see the flags specific to your -borgmatic version. +If you're using an older version, some of these flags may not work (or the action +may be missing entirely). You should instead run the action with `--help` to see +the flags and actions specific to your borgmatic version. diff --git a/docs/how-to/inspect-your-backups.md b/docs/how-to/inspect-your-backups.md index 02997c9f..3f1517f6 100644 --- a/docs/how-to/inspect-your-backups.md +++ b/docs/how-to/inspect-your-backups.md @@ -135,3 +135,42 @@ is, patterns are _not_ taken into consideration. If you require this, supply the See the [Borg](https://borgbackup.readthedocs.io/en/stable/usage/diff.html) documentation for information on output format, what is compared, and more. + + +## Browsing backups + +New in version 2.1.6 Experimental feature borgmatic has an +experimental console UI for browsing your repositories, archives, and files. +This is handy if you want to quickly look at the contents of your backups—but +without messing with borgmatic's command-line. + +Depending on how you installed borgmatic, it may not have come with the +necessary Python libraries to support the browse action. For instance, if you +originally [installed borgmatic with +uv](https://torsion.org/borgmatic/how-to/install-borgmatic/), run the following +to install the libraries needed for the browse action: + +```bash +sudo uv tool install borgmatic[browse] +``` + +Omit `sudo` if borgmatic is installed as a non-root user. + +Once the libraries are installed, run the following to access the browse action: + +```bash +borgmatic browse +``` + +This launches a console UI where you can select a borgmatic configuration file, +select a Borg repository from it, select an archive in that repository, and even +browse the backed up files in that archive. + +Use the keyboard or the mouse to navigate the UI. The footer at the bottom of +the screen shows some of the available keys. Logs shows up directly in the UI, +although hidden by default. + +Please [provide +feedback](https://torsion.org/borgmatic/#support-and-contributing) if you find +this feature useful—or even if you don't, but would like it to become useful. diff --git a/docs/reference/command-line/actions/browse.md b/docs/reference/command-line/actions/browse.md new file mode 100644 index 00000000..c9fee5d7 --- /dev/null +++ b/docs/reference/command-line/actions/browse.md @@ -0,0 +1,17 @@ +--- +title: browse +eleventyNavigation: + key: browse + parent: 🎬 Actions +--- + +Experimental feature {% include snippet/command-line/sample.md %} + +``` +{% include borgmatic/command-line/browse.txt %} +``` + + +## Related documentation + + * [Inspect your backups](https://torsion.org/borgmatic/how-to/inspect-your-backups/) From 5075c1e539e727d82c59801b1e2dfbf96840c1fe Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 20 May 2026 23:10:29 -0700 Subject: [PATCH 15/63] Skip the browse action's configuration list if there's only one config file. --- borgmatic/actions/browse/controller.py | 10 ++- borgmatic/actions/browse/run.py | 4 +- borgmatic/actions/browse/view.py | 94 ++++++++++++++++++-------- borgmatic/hooks/monitoring/apprise.py | 4 +- 4 files changed, 79 insertions(+), 33 deletions(-) diff --git a/borgmatic/actions/browse/controller.py b/borgmatic/actions/browse/controller.py index 6716febe..e9fe61d9 100644 --- a/borgmatic/actions/browse/controller.py +++ b/borgmatic/actions/browse/controller.py @@ -54,14 +54,20 @@ def get_archive_files(config, repository, archive_name, list_path=None): seen = set() return ( - (path_data['type'] if os.path.join(list_path, base_path) == path_data['path'] else 'd', base_path, path_data.get('linktarget')) + ( + path_data['type'] + if os.path.join(list_path, base_path) == path_data['path'] + else 'd', + base_path, + path_data.get('linktarget'), + ) for path_data in borgmatic.borg.list.capture_archive_listing( repository['path'], archive_name, config, local_borg_version, global_arguments, - list_paths=(fr're:^{list_path}/[^/]+',) if list_path else (r're:^[^/]+',), + list_paths=(rf're:^{list_path}/[^/]+',) if list_path else (r're:^[^/]+',), local_path=local_path, remote_path=remote_path, ) diff --git a/borgmatic/actions/browse/run.py b/borgmatic/actions/browse/run.py index 133f4beb..b60ee74c 100644 --- a/borgmatic/actions/browse/run.py +++ b/borgmatic/actions/browse/run.py @@ -17,7 +17,9 @@ def run_browse( try: import textual except ImportError: # pragma: no cover - raise ValueError('Unable to import the Textual library for the browse action; try installing "borgmatic[browse]"') + raise ValueError( + 'Unable to import the Textual library for the browse action; try installing "borgmatic[browse]"' + ) import borgmatic.actions.browse.view diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py index cde120e9..aa17afb5 100644 --- a/borgmatic/actions/browse/view.py +++ b/borgmatic/actions/browse/view.py @@ -37,7 +37,8 @@ def update_inline_loading_indicator(widget): if isinstance(widget, textual.widgets.OptionList): try: widget.replace_option_prompt( - 'loading-indicator', (str(widget.get_option('loading-indicator').prompt) + '.').replace('....', '') + 'loading-indicator', + (str(widget.get_option('loading-indicator').prompt) + '.').replace('....', ''), ) except textual.widgets.option_list.OptionDoesNotExist: pass @@ -67,38 +68,43 @@ def add_inline_loading_indicator(widget): @textual.work(thread=True) -async def add_repository_archives( - browse_app, archives_list, config, repository, timer -): +async def add_repository_archives(browse_app, archives_list, config, repository, timer): archives_data = borgmatic.actions.browse.controller.get_repository_archives(config, repository) loading_option = archives_list.get_option('loading-indicator') # Reverse the archives, so the common case of accessing the latest archive is easy because it's # at the top. for index, archive in enumerate(reversed(archives_data['archives'])): - label_pieces = (archive['archive'], '[dim](latest)[/dim]') if index == 0 else (archive['archive'],) + label_pieces = ( + (archive['archive'], '[dim](latest)[/dim]') if index == 0 else (archive['archive'],) + ) highlighted_option = archives_list.highlighted_option - browse_app.call_from_thread( - archives_list.remove_option, - 'loading-indicator' - ) + browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') browse_app.call_from_thread( archives_list.add_options, ( textual.widgets.option_list.Option(' '.join(label_pieces), id=archive['archive']), loading_option, - ) + ), + ) + archives_list.highlighted = ( + archives_list.get_option_index(highlighted_option.id) + if highlighted_option and archives_list.highlighted_option_changed + else 0 ) - archives_list.highlighted = archives_list.get_option_index(highlighted_option.id) if highlighted_option and archives_list.highlighted_option_changed else 0 browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') browse_app.call_from_thread(timer.stop) @textual.work(thread=True) -def add_archive_files(browse_app, directory_list, config, repository, archive_name, list_path, root_directory, timer): - file_type_paths = borgmatic.actions.browse.controller.get_archive_files(config, repository, archive_name, list_path) +def add_archive_files( + browse_app, directory_list, config, repository, archive_name, list_path, root_directory, timer +): + file_type_paths = borgmatic.actions.browse.controller.get_archive_files( + config, repository, archive_name, list_path + ) loading_option = directory_list.get_option('loading-indicator') if not root_directory: @@ -106,20 +112,29 @@ def add_archive_files(browse_app, directory_list, config, repository, archive_na browse_app.call_from_thread( directory_list.add_options, ( - textual.widgets.option_list.Option(f'{PATH_TYPE_ICONS[Path_type.DIRECTORY.value]} ..', id='..'), + textual.widgets.option_list.Option( + f'{PATH_TYPE_ICONS[Path_type.DIRECTORY.value]} ..', id='..' + ), loading_option, - ) + ), ) - for (path_type, file_path, link_target) in file_type_paths: - pieces = (PATH_TYPE_ICONS.get(path_type, '?'), file_path) + (('→', link_target) if link_target else ()) + for path_type, file_path, link_target in file_type_paths: + pieces = (PATH_TYPE_ICONS.get(path_type, '?'), file_path) + ( + ('→', link_target) if link_target else () + ) highlighted_option = directory_list.highlighted_option sorted_options = sorted( - directory_list.options + [textual.widgets.option_list.Option(' '.join(pieces), id=file_path)], - key=lambda option: ((option.id == 'loading-indicator'), option.prompt) + directory_list.options + + [textual.widgets.option_list.Option(' '.join(pieces), id=file_path)], + key=lambda option: ((option.id == 'loading-indicator'), option.prompt), ) browse_app.call_from_thread(directory_list.set_options, sorted_options) - directory_list.highlighted = directory_list.get_option_index(highlighted_option.id) if highlighted_option and directory_list.highlighted_option_changed else 0 + directory_list.highlighted = ( + directory_list.get_option_index(highlighted_option.id) + if highlighted_option and directory_list.highlighted_option_changed + else 0 + ) browse_app.call_from_thread(timer.stop) browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') @@ -127,7 +142,9 @@ def add_archive_files(browse_app, directory_list, config, repository, archive_na @textual.work(thread=True) def load_file_preview(browse_app, file_preview, config, repository, archive_name, file_path, timer): - file_content = borgmatic.actions.browse.controller.get_archive_file_content(config, repository, archive_name, file_path) + file_content = borgmatic.actions.browse.controller.get_archive_file_content( + config, repository, archive_name, file_path + ) browse_app.call_from_thread(timer.stop) syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) @@ -153,7 +170,6 @@ class Configuration_files_list(textual.widgets.OptionList): textual.widgets.option_list.Option(f'{unexpanded_path}', id=config_path) for config_path in configs.keys() for unexpanded_path in (config_path.replace(home_directory, '~'),) - ), classes='panel', ) @@ -206,7 +222,9 @@ class Archives_list(textual.widgets.OptionList): ) def make_next_panel(self, option_id): - return Directory_list(config=self.config, repository=self.repository, archive_name=option_id) + return Directory_list( + config=self.config, repository=self.repository, archive_name=option_id + ) def on_option_list_option_highlighted(self, event): if self.highlighted not in (None, 0): @@ -224,7 +242,9 @@ class Directory_list(textual.widgets.OptionList): self.highlighted_option_changed = False super().__init__(classes='panel') - self.border_title = os.path.sep.join(self.path_components) if self.path_components else f'{archive_name}' + self.border_title = ( + os.path.sep.join(self.path_components) if self.path_components else f'{archive_name}' + ) timer = add_inline_loading_indicator(self) @@ -246,9 +266,19 @@ class Directory_list(textual.widgets.OptionList): return Null_list() if option.prompt.startswith(PATH_TYPE_ICONS[Path_type.DIRECTORY.value]): - return Directory_list(self.config, self.repository, self.archive_name, path_components=self.path_components + (option_id,)) + return Directory_list( + self.config, + self.repository, + self.archive_name, + path_components=self.path_components + (option_id,), + ) - return File_preview(self.config, self.repository, self.archive_name, file_path=os.path.sep.join(self.path_components + (option_id,))) + return File_preview( + self.config, + self.repository, + self.archive_name, + file_path=os.path.sep.join(self.path_components + (option_id,)), + ) def on_option_list_option_highlighted(self, event): if self.highlighted not in (None, 0): @@ -288,7 +318,9 @@ class File_preview(textual.widgets.Static): class Carousel(textual.containers.Horizontal): BINDINGS = [ - textual.binding.Binding(key='left,h', action='previous', description='previous', priority=True), + textual.binding.Binding( + key='left,h', action='previous', description='previous', priority=True + ), ] def __init__(self, panels): @@ -342,7 +374,7 @@ class Carousel(textual.containers.Horizontal): ''' next_panel_index = self.panels.index(self.focused_panel) + 1 - del(self.panels[next_panel_index:]) + del self.panels[next_panel_index:] def on_option_list_option_selected(self, event): if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': @@ -391,7 +423,11 @@ class Browse_app(textual.app.App): def compose(self): yield textual.widgets.Header() - yield Carousel([Configuration_files_list(self.configs)]) + yield Carousel( + [Configuration_files_list(self.configs)] + if len(self.configs) > 1 + else [Repositories_list(tuple(self.configs.values())[0])] + ) logs_widget = Logs() yield logs_widget diff --git a/borgmatic/hooks/monitoring/apprise.py b/borgmatic/hooks/monitoring/apprise.py index 59578d44..ab1ec356 100644 --- a/borgmatic/hooks/monitoring/apprise.py +++ b/borgmatic/hooks/monitoring/apprise.py @@ -44,7 +44,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev import apprise # noqa: PLC0415 from apprise import NotifyFormat, NotifyType # noqa: PLC0415 except ImportError: # pragma: no cover - logger.warning('Unable to import Apprise in its monitoring hook; try installing "borgmatic[Apprise]"') + logger.warning( + 'Unable to import Apprise in its monitoring hook; try installing "borgmatic[Apprise]"' + ) return state_to_notify_type = { From da0698f6e5c8a052909dae0504474ce8790a874c Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 21 May 2026 10:08:42 -0700 Subject: [PATCH 16/63] Browse action logging improvements. --- borgmatic/actions/browse/controller.py | 45 ++++++++++++++------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/borgmatic/actions/browse/controller.py b/borgmatic/actions/browse/controller.py index e9fe61d9..834c34b9 100644 --- a/borgmatic/actions/browse/controller.py +++ b/borgmatic/actions/browse/controller.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) def get_repository_archives(config, repository): with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): - logger.answer('Listing repository') + logger.info('Listing repository') repo_list_arguments = argparse.Namespace( repository=repository['path'], short=None, @@ -45,7 +45,10 @@ def get_repository_archives(config, repository): def get_archive_files(config, repository, archive_name, list_path=None): with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): - logger.answer(f"Listing archive {archive_name}") + if list_path: + logger.info(f"Listing archive {archive_name} at path {list_path}") + else: + logger.info(f"Listing archive {archive_name}") global_arguments = argparse.Namespace() local_path = config.get('local_path', 'borg') @@ -80,24 +83,26 @@ READLINES_HINT_BYTES = 2000 def get_archive_file_content(config, repository, archive_name, file_path): - local_path = config.get('local_path', 'borg') - remote_path = config.get('remote_path') + with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): + logger.info(f'Getting archive content of file {file_path}') + local_path = config.get('local_path', 'borg') + remote_path = config.get('remote_path') - lines = borgmatic.borg.extract.extract_archive( - dry_run=False, - repository=repository['path'], - archive=archive_name, - paths=(file_path,), - config=config, - local_borg_version=borgmatic.borg.version.local_borg_version(config, local_path), - global_arguments=argparse.Namespace(), - local_path=local_path, - remote_path=remote_path, - destination_path=None, - strip_components=None, - extract_to_stdout=True, - ).stdout.readlines(READLINES_HINT_BYTES) + lines = borgmatic.borg.extract.extract_archive( + dry_run=False, + repository=repository['path'], + archive=archive_name, + paths=(file_path,), + config=config, + local_borg_version=borgmatic.borg.version.local_borg_version(config, local_path), + global_arguments=argparse.Namespace(), + local_path=local_path, + remote_path=remote_path, + destination_path=None, + strip_components=None, + extract_to_stdout=True, + ).stdout.readlines(READLINES_HINT_BYTES) - content = ''.join(line.decode() for line in lines) + content = ''.join(line.decode() for line in lines) - return content if len(content) < READLINES_HINT_BYTES else f'{content}[...]' + return content if len(content) < READLINES_HINT_BYTES else f'{content}[...]' From e93fe4cc1560d2ef794ab44ae620dec33ec69c77 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 21 May 2026 11:33:05 -0700 Subject: [PATCH 17/63] Browse action refactoring into separate files. --- borgmatic/actions/browse/app.py | 62 +++ .../browse/{controller.py => archive.py} | 0 borgmatic/actions/browse/loading.py | 41 ++ borgmatic/actions/browse/logs.py | 72 +++ borgmatic/actions/browse/paths.py | 14 + borgmatic/actions/browse/run.py | 4 +- borgmatic/actions/browse/view.py | 503 ------------------ borgmatic/actions/browse/widgets.py | 261 +++++++++ borgmatic/actions/browse/workers.py | 91 ++++ 9 files changed, 543 insertions(+), 505 deletions(-) create mode 100644 borgmatic/actions/browse/app.py rename borgmatic/actions/browse/{controller.py => archive.py} (100%) create mode 100644 borgmatic/actions/browse/loading.py create mode 100644 borgmatic/actions/browse/logs.py create mode 100644 borgmatic/actions/browse/paths.py delete mode 100644 borgmatic/actions/browse/view.py create mode 100644 borgmatic/actions/browse/widgets.py create mode 100644 borgmatic/actions/browse/workers.py diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py new file mode 100644 index 00000000..76c90095 --- /dev/null +++ b/borgmatic/actions/browse/app.py @@ -0,0 +1,62 @@ +import textual.app +import textual.binding +import textual.widgets + +import borgmatic.actions.browse.logs +import borgmatic.actions.browse.widgets + + +class Browse_app(textual.app.App): + BINDINGS = [ + textual.binding.Binding(key='q', action='quit', description='quit'), + textual.binding.Binding(key='v', action='toggle_logs', description='view logs'), + textual.binding.Binding( + key='c', action='command_palette', description='commands', show=False + ), + ] + COMMAND_PALETTE_BINDING = 'c' + CSS = ''' + .panel { + border: round $primary; + border-title-color: $text-primary; + width: 100%; + height: 100%; + } + + #logs { + width: 100%; + height: 50%; + display: none; + } + ''' + + def __init__(self, configs): + self.configs = configs + + super().__init__() + + def compose(self): + yield textual.widgets.Header() + yield borgmatic.actions.browse.widgets.Carousel( + [borgmatic.actions.browse.widgets.Configuration_files_list(self.configs)] + if len(self.configs) > 1 + else [ + borgmatic.actions.browse.widgets.Repositories_list(tuple(self.configs.values())[0]) + ] + ) + + logs_widget = borgmatic.actions.browse.widgets.Logs() + yield logs_widget + yield textual.widgets.Footer() + + borgmatic.actions.browse.logs.log_to_widget(logs_widget) + + def on_mount(self): + self.title = 'borgmatic browse' + + def action_toggle_logs(self): + logs_container = self.query_one('#logs') + logs_container.styles.display = ( + 'none' if logs_container.styles.display == 'block' else 'block' + ) + diff --git a/borgmatic/actions/browse/controller.py b/borgmatic/actions/browse/archive.py similarity index 100% rename from borgmatic/actions/browse/controller.py rename to borgmatic/actions/browse/archive.py diff --git a/borgmatic/actions/browse/loading.py b/borgmatic/actions/browse/loading.py new file mode 100644 index 00000000..c7ba7435 --- /dev/null +++ b/borgmatic/actions/browse/loading.py @@ -0,0 +1,41 @@ +import functools + +import textual.widgets +import textual.widgets.option_list + + +LOADING_DOT_INTERVAL_SECONDS = 0.3 + + +def update_inline_loading_indicator(widget): + if isinstance(widget, textual.widgets.OptionList): + try: + widget.replace_option_prompt( + 'loading-indicator', + (str(widget.get_option('loading-indicator').prompt) + '.').replace('....', ''), + ) + except textual.widgets.option_list.OptionDoesNotExist: + pass + elif isinstance(widget, textual.widgets.Static): + widget.update((str(widget.content) + '.').replace('....', '')) + else: + raise ValueError(f'Unsupported widget type: {type(widget)}') + + +def add_inline_loading_indicator(widget): + loading_message = f'⏳ loading...' + + if isinstance(widget, textual.widgets.OptionList): + widget.clear_options() + loading_option = textual.widgets.option_list.Option(loading_message, id='loading-indicator') + widget.add_option(loading_option) + widget.highlighted = None + elif isinstance(widget, textual.widgets.Static): + widget.update(loading_message) + else: + raise ValueError(f'Unsupported widget type: {type(widget)}') + + return widget.set_interval( + LOADING_DOT_INTERVAL_SECONDS, + functools.partial(update_inline_loading_indicator, widget), + ) diff --git a/borgmatic/actions/browse/logs.py b/borgmatic/actions/browse/logs.py new file mode 100644 index 00000000..dbe6231f --- /dev/null +++ b/borgmatic/actions/browse/logs.py @@ -0,0 +1,72 @@ +import contextlib +import logging + +import borgmatic.logger + +import textual._context +import textual.worker + + +class Rich_color_formatter(logging.Formatter): + def __init__(self, *args, **kwargs): + self.prefix = None + super().__init__( + '{prefix}{message}', + *args, + style='{', + **kwargs, + ) + + def format(self, record): + borgmatic.logger.add_custom_log_levels() + + color = { + logging.CRITICAL: 'bright_red', + logging.ERROR: 'bright_red', + logging.WARNING: 'bright_yellow', + logging.ANSWER: 'bright_magenta', + logging.INFO: 'bright_green', + logging.DEBUG: 'bright_cyan', + }.get(record.levelno) + record.prefix = f'{self.prefix}: ' if self.prefix else '' + + return f'[{color}]{super().format(record)}[/{color}]' + + +class Browse_log_handler(logging.Handler): + def __init__(self, logs_widget): + self.logs_widget = logs_widget + + super().__init__() + + def emit(self, record): + message = self.format(record) + + try: + worker = textual.worker.get_current_worker() + self.logs_widget.app.call_from_thread(self.logs_widget.write, message) + except (RuntimeError, textual.worker.NoActiveWorker): + with contextlib.suppress(textual._context.NoActiveAppError): + self.logs_widget.write(message) + + +def log_to_widget(logs_widget): + ''' + Given a Textual RichLog logs widget, add a log handler and formatter that logs to it. Also + remove the default borgmatic console log handler so it doesn't try to log all over our UI. + ''' + handler = Browse_log_handler(logs_widget) + handler.setFormatter(Rich_color_formatter()) + logger = logging.getLogger() + logger.setLevel(min(handler.level for handler in logger.handlers)) + logger.addHandler(handler) + + # Remove the console log handler so it doesn't try to log all over our UI; we have our own + # log handler for surfacing logs within the UI. + with contextlib.suppress(StopIteration): + console_handler = next( + handler + for handler in logging.getLogger().handlers + if isinstance(handler, borgmatic.logger.Multi_stream_handler) + ) + logger.removeHandler(console_handler) diff --git a/borgmatic/actions/browse/paths.py b/borgmatic/actions/browse/paths.py new file mode 100644 index 00000000..08ebf996 --- /dev/null +++ b/borgmatic/actions/browse/paths.py @@ -0,0 +1,14 @@ +import enum + + +class Path_type(enum.Enum): + DIRECTORY = 'd' + LINK = 'l' + FILE = '-' + + +PATH_TYPE_ICONS = { + Path_type.DIRECTORY.value: '📁', + Path_type.LINK.value: '🔗', + Path_type.FILE.value: '📄', +} diff --git a/borgmatic/actions/browse/run.py b/borgmatic/actions/browse/run.py index b60ee74c..d781399c 100644 --- a/borgmatic/actions/browse/run.py +++ b/borgmatic/actions/browse/run.py @@ -21,7 +21,7 @@ def run_browse( 'Unable to import the Textual library for the browse action; try installing "borgmatic[browse]"' ) - import borgmatic.actions.browse.view + import borgmatic.actions.browse.app - app = borgmatic.actions.browse.view.Browse_app(configs) + app = borgmatic.actions.browse.app.Browse_app(configs) app.run() diff --git a/borgmatic/actions/browse/view.py b/borgmatic/actions/browse/view.py deleted file mode 100644 index aa17afb5..00000000 --- a/borgmatic/actions/browse/view.py +++ /dev/null @@ -1,503 +0,0 @@ -import contextlib -import enum -import functools -import os -import logging - -import borgmatic.actions.browse.controller - -import rich.syntax -import rich.text -import textual._context -import textual.app -import textual.color -import textual.binding -import textual.reactive -import textual.widgets - - -class Path_type(enum.Enum): - DIRECTORY = 'd' - LINK = 'l' - FILE = '-' - - -PATH_TYPE_ICONS = { - Path_type.DIRECTORY.value: '📁', - Path_type.LINK.value: '🔗', - Path_type.FILE.value: '📄', -} -LOADING_DOT_INTERVAL_SECONDS = 0.3 - - -logger = logging.getLogger('__name__') - - -def update_inline_loading_indicator(widget): - if isinstance(widget, textual.widgets.OptionList): - try: - widget.replace_option_prompt( - 'loading-indicator', - (str(widget.get_option('loading-indicator').prompt) + '.').replace('....', ''), - ) - except textual.widgets.option_list.OptionDoesNotExist: - pass - elif isinstance(widget, textual.widgets.Static): - widget.update((str(widget.content) + '.').replace('....', '')) - else: - raise ValueError(f'Unsupported widget type: {type(widget)}') - - -def add_inline_loading_indicator(widget): - loading_message = f'⏳ loading...' - - if isinstance(widget, textual.widgets.OptionList): - widget.clear_options() - loading_option = textual.widgets.option_list.Option(loading_message, id='loading-indicator') - widget.add_option(loading_option) - widget.highlighted = None - elif isinstance(widget, textual.widgets.Static): - widget.update(loading_message) - else: - raise ValueError(f'Unsupported widget type: {type(widget)}') - - return widget.set_interval( - LOADING_DOT_INTERVAL_SECONDS, - functools.partial(update_inline_loading_indicator, widget), - ) - - -@textual.work(thread=True) -async def add_repository_archives(browse_app, archives_list, config, repository, timer): - archives_data = borgmatic.actions.browse.controller.get_repository_archives(config, repository) - loading_option = archives_list.get_option('loading-indicator') - - # Reverse the archives, so the common case of accessing the latest archive is easy because it's - # at the top. - for index, archive in enumerate(reversed(archives_data['archives'])): - label_pieces = ( - (archive['archive'], '[dim](latest)[/dim]') if index == 0 else (archive['archive'],) - ) - highlighted_option = archives_list.highlighted_option - - browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') - browse_app.call_from_thread( - archives_list.add_options, - ( - textual.widgets.option_list.Option(' '.join(label_pieces), id=archive['archive']), - loading_option, - ), - ) - archives_list.highlighted = ( - archives_list.get_option_index(highlighted_option.id) - if highlighted_option and archives_list.highlighted_option_changed - else 0 - ) - - browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') - browse_app.call_from_thread(timer.stop) - - -@textual.work(thread=True) -def add_archive_files( - browse_app, directory_list, config, repository, archive_name, list_path, root_directory, timer -): - file_type_paths = borgmatic.actions.browse.controller.get_archive_files( - config, repository, archive_name, list_path - ) - loading_option = directory_list.get_option('loading-indicator') - - if not root_directory: - browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') - browse_app.call_from_thread( - directory_list.add_options, - ( - textual.widgets.option_list.Option( - f'{PATH_TYPE_ICONS[Path_type.DIRECTORY.value]} ..', id='..' - ), - loading_option, - ), - ) - - for path_type, file_path, link_target in file_type_paths: - pieces = (PATH_TYPE_ICONS.get(path_type, '?'), file_path) + ( - ('→', link_target) if link_target else () - ) - highlighted_option = directory_list.highlighted_option - sorted_options = sorted( - directory_list.options - + [textual.widgets.option_list.Option(' '.join(pieces), id=file_path)], - key=lambda option: ((option.id == 'loading-indicator'), option.prompt), - ) - browse_app.call_from_thread(directory_list.set_options, sorted_options) - directory_list.highlighted = ( - directory_list.get_option_index(highlighted_option.id) - if highlighted_option and directory_list.highlighted_option_changed - else 0 - ) - - browse_app.call_from_thread(timer.stop) - browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') - - -@textual.work(thread=True) -def load_file_preview(browse_app, file_preview, config, repository, archive_name, file_path, timer): - file_content = borgmatic.actions.browse.controller.get_archive_file_content( - config, repository, archive_name, file_path - ) - - browse_app.call_from_thread(timer.stop) - syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) - browse_app.call_from_thread(file_preview.update, rich.syntax.Syntax(file_content, syntax_lexer)) - - -OPTION_LIST_BINDINGS = textual.widgets.OptionList.BINDINGS + [ - textual.binding.Binding(key='j', action='cursor_down', description='down', show=False), - textual.binding.Binding(key='k', action='cursor_up', description='up', show=False), - textual.binding.Binding(key='right,l', action='select', description='select', show=False), -] - - -class Configuration_files_list(textual.widgets.OptionList): - BINDINGS = OPTION_LIST_BINDINGS - - def __init__(self, configs): - self.configs = configs - home_directory = os.path.expanduser('~') - - super().__init__( - *( - textual.widgets.option_list.Option(f'{unexpanded_path}', id=config_path) - for config_path in configs.keys() - for unexpanded_path in (config_path.replace(home_directory, '~'),) - ), - classes='panel', - ) - self.border_title = 'configuration files' - - def make_next_panel(self, option_id): - return Repositories_list(config=self.configs[option_id]) - - -class Repositories_list(textual.widgets.OptionList): - BINDINGS = OPTION_LIST_BINDINGS - - def __init__(self, config): - self.config = config - self.repositories = config['repositories'] - - super().__init__( - *( - textual.widgets.option_list.Option(label, id=index) - for index, repository in enumerate(self.repositories) - for label in (repository.get('label', repository.get('path')),) - ), - classes='panel', - ) - self.border_title = 'repositories' - - def make_next_panel(self, option_id): - return Archives_list(config=self.config, repository=self.repositories[option_id]) - - -class Archives_list(textual.widgets.OptionList): - BINDINGS = OPTION_LIST_BINDINGS - - def __init__(self, config, repository): - self.config = config - self.repository = repository - - super().__init__(classes='panel') - self.border_title = 'archives' - self.highlighted_option_changed = False - - timer = add_inline_loading_indicator(self) - - add_repository_archives( - self.app, - archives_list=self, - config=self.config, - repository=self.repository, - timer=timer, - ) - - def make_next_panel(self, option_id): - return Directory_list( - config=self.config, repository=self.repository, archive_name=option_id - ) - - def on_option_list_option_highlighted(self, event): - if self.highlighted not in (None, 0): - self.highlighted_option_changed = True - - -class Directory_list(textual.widgets.OptionList): - BINDINGS = OPTION_LIST_BINDINGS - - def __init__(self, config, repository, archive_name, path_components=None): - self.config = config - self.repository = repository - self.archive_name = archive_name - self.path_components = path_components or () - self.highlighted_option_changed = False - - super().__init__(classes='panel') - self.border_title = ( - os.path.sep.join(self.path_components) if self.path_components else f'{archive_name}' - ) - - timer = add_inline_loading_indicator(self) - - add_archive_files( - self.app, - directory_list=self, - config=self.config, - repository=self.repository, - archive_name=self.archive_name, - list_path=os.path.sep.join(self.path_components), - root_directory=not bool(self.path_components), - timer=timer, - ) - - def make_next_panel(self, option_id): - option = self.get_option(option_id) - - if option_id == '..': - return Null_list() - - if option.prompt.startswith(PATH_TYPE_ICONS[Path_type.DIRECTORY.value]): - return Directory_list( - self.config, - self.repository, - self.archive_name, - path_components=self.path_components + (option_id,), - ) - - return File_preview( - self.config, - self.repository, - self.archive_name, - file_path=os.path.sep.join(self.path_components + (option_id,)), - ) - - def on_option_list_option_highlighted(self, event): - if self.highlighted not in (None, 0): - self.highlighted_option_changed = True - - -class Null_list(textual.widgets.OptionList): - def __init__(self): - super().__init__(classes='panel') - - -class File_preview(textual.widgets.Static): - def __init__(self, config, repository, archive_name, file_path): - self.config = config - self.repository = repository - self.archive_name = archive_name - self.file_path = file_path - self.can_focus = True - - super().__init__(classes='panel') - self.border_title = f'{PATH_TYPE_ICONS[Path_type.FILE.value]} {self.file_path} preview' - - timer = add_inline_loading_indicator(self) - load_file_preview( - self.app, - file_preview=self, - config=self.config, - repository=self.repository, - archive_name=self.archive_name, - file_path=self.file_path, - timer=timer, - ) - - def make_next_panel(self, option_id): - return None - - -class Carousel(textual.containers.Horizontal): - BINDINGS = [ - textual.binding.Binding( - key='left,h', action='previous', description='previous', priority=True - ), - ] - - def __init__(self, panels): - self.panels = panels - self.focused_panel = panels[0] - - super().__init__() - - def compose(self): - for panel in self.panels: - yield panel - - self.focused_panel.focus() - - def action_previous(self): - ''' - Make the previous panel into the focused panel. - ''' - previous_panel_index = self.panels.index(self.focused_panel) - 1 - - if previous_panel_index < 0: - return - - self.focused_panel.styles.display = 'none' - - self.focused_panel = self.panels[previous_panel_index] - self.focused_panel.styles.display = 'block' - self.focused_panel.focus() - - def action_next(self, option_id): - ''' - Hide the current focused panel and create the next one. - ''' - self.focused_panel.styles.display = 'none' - next_panel_index = self.panels.index(self.focused_panel) + 1 - - if next_panel_index < len(self.panels): - self.focused_panel = self.panels[next_panel_index] - self.focused_panel.styles.display = 'block' - else: - self.focused_panel = self.focused_panel.make_next_panel(option_id) - self.panels.append(self.focused_panel) - self.focused_panel.highlighted = 0 - - self.focused_panel.focus() - self.refresh(recompose=True) - - def on_option_list_option_highlighted(self, event): - ''' - The highlighted option has changed, so truncate any next panels. - ''' - next_panel_index = self.panels.index(self.focused_panel) + 1 - - del self.panels[next_panel_index:] - - def on_option_list_option_selected(self, event): - if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': - return - - if event.option_id == '..': - self.action_previous() - else: - self.action_next(event.option_id) - - -class Logs(textual.widgets.RichLog): - def __init__(self): - super().__init__(markup=True, id='logs', classes='panel') - self.border_title = 'logs' - - -class Browse_app(textual.app.App): - BINDINGS = [ - textual.binding.Binding(key='q', action='quit', description='quit'), - textual.binding.Binding(key='v', action='toggle_logs', description='view logs'), - textual.binding.Binding( - key='c', action='command_palette', description='commands', show=False - ), - ] - COMMAND_PALETTE_BINDING = 'c' - CSS = ''' - .panel { - border: round $primary; - border-title-color: $text-primary; - width: 100%; - height: 100%; - } - - #logs { - width: 100%; - height: 50%; - display: none; - } - ''' - - def __init__(self, configs): - self.configs = configs - - super().__init__() - - def compose(self): - yield textual.widgets.Header() - yield Carousel( - [Configuration_files_list(self.configs)] - if len(self.configs) > 1 - else [Repositories_list(tuple(self.configs.values())[0])] - ) - - logs_widget = Logs() - yield logs_widget - yield textual.widgets.Footer() - - handler = Browse_log_handler(self, logs_widget) - handler.setFormatter(Rich_color_formatter()) - logger = logging.getLogger() - logger.setLevel(min(handler.level for handler in logger.handlers)) - logger.addHandler(handler) - - # Remove the console log handler so it doesn't try to log all over our UI; we have our own - # log handler for surfacing logs within the UI. - with contextlib.suppress(StopIteration): - console_handler = next( - handler - for handler in logging.getLogger().handlers - if isinstance(handler, borgmatic.logger.Multi_stream_handler) - ) - logger.removeHandler(console_handler) - - def on_mount(self): - self.title = 'borgmatic browse' - - def action_toggle_logs(self): - logs_container = self.query_one('#logs') - logs_container.styles.display = ( - 'none' if logs_container.styles.display == 'block' else 'block' - ) - - -class Browse_log_handler(logging.Handler): - def __init__(self, app, logs_widget): - self.app = app - self.logs_widget = logs_widget - - super().__init__() - - def emit(self, record): - message = self.format(record) - - try: - worker = textual.worker.get_current_worker() - self.app.call_from_thread(self.logs_widget.write, message) - except (RuntimeError, textual.worker.NoActiveWorker): - with contextlib.suppress(textual._context.NoActiveAppError): - self.logs_widget.write(message) - - -class Rich_color_formatter(logging.Formatter): - def __init__(self, *args, **kwargs): - self.prefix = None - super().__init__( - '{prefix}{message}', - *args, - style='{', - **kwargs, - ) - - def format(self, record): - borgmatic.logger.add_custom_log_levels() - - color = { - logging.CRITICAL: 'bright_red', - logging.ERROR: 'bright_red', - logging.WARNING: 'bright_yellow', - logging.ANSWER: 'bright_magenta', - logging.INFO: 'bright_green', - logging.DEBUG: 'bright_cyan', - }.get(record.levelno) - record.prefix = f'{self.prefix}: ' if self.prefix else '' - - return f'[{color}]{super().format(record)}[/{color}]' diff --git a/borgmatic/actions/browse/widgets.py b/borgmatic/actions/browse/widgets.py new file mode 100644 index 00000000..c8e87c03 --- /dev/null +++ b/borgmatic/actions/browse/widgets.py @@ -0,0 +1,261 @@ +import os + +import textual.binding +import textual.widgets + +import borgmatic.actions.browse.loading +import borgmatic.actions.browse.paths +import borgmatic.actions.browse.workers + + +OPTION_LIST_BINDINGS = textual.widgets.OptionList.BINDINGS + [ + textual.binding.Binding(key='j', action='cursor_down', description='down', show=False), + textual.binding.Binding(key='k', action='cursor_up', description='up', show=False), + textual.binding.Binding(key='right,l', action='select', description='select', show=False), +] + + +class Configuration_files_list(textual.widgets.OptionList): + BINDINGS = OPTION_LIST_BINDINGS + + def __init__(self, configs): + self.configs = configs + home_directory = os.path.expanduser('~') + + super().__init__( + *( + textual.widgets.option_list.Option(f'{unexpanded_path}', id=config_path) + for config_path in configs.keys() + for unexpanded_path in (config_path.replace(home_directory, '~'),) + ), + classes='panel', + ) + self.border_title = 'configuration files' + + def make_next_panel(self, option_id): + return Repositories_list(config=self.configs[option_id]) + + +class Repositories_list(textual.widgets.OptionList): + BINDINGS = OPTION_LIST_BINDINGS + + def __init__(self, config): + self.config = config + self.repositories = config['repositories'] + + super().__init__( + *( + textual.widgets.option_list.Option(label, id=index) + for index, repository in enumerate(self.repositories) + for label in (repository.get('label', repository.get('path')),) + ), + classes='panel', + ) + self.border_title = 'repositories' + + def make_next_panel(self, option_id): + return Archives_list(config=self.config, repository=self.repositories[option_id]) + + +class Archives_list(textual.widgets.OptionList): + BINDINGS = OPTION_LIST_BINDINGS + + def __init__(self, config, repository): + self.config = config + self.repository = repository + + super().__init__(classes='panel') + self.border_title = 'archives' + self.highlighted_option_changed = False + + timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + + borgmatic.actions.browse.workers.add_repository_archives( + self.app, + archives_list=self, + config=self.config, + repository=self.repository, + timer=timer, + ) + + def make_next_panel(self, option_id): + return Directory_list( + config=self.config, repository=self.repository, archive_name=option_id + ) + + def on_option_list_option_highlighted(self, event): + if self.highlighted not in (None, 0): + self.highlighted_option_changed = True + + +class Directory_list(textual.widgets.OptionList): + BINDINGS = OPTION_LIST_BINDINGS + + def __init__(self, config, repository, archive_name, path_components=None): + self.config = config + self.repository = repository + self.archive_name = archive_name + self.path_components = path_components or () + self.highlighted_option_changed = False + + super().__init__(classes='panel') + self.border_title = ' '.join( + ( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ + borgmatic.actions.browse.paths.Path_type.DIRECTORY.value + ], + os.path.sep.join(self.path_components) + if self.path_components + else f'{archive_name}', + ) + ) + + timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + + borgmatic.actions.browse.workers.add_archive_files( + self.app, + directory_list=self, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + list_path=os.path.sep.join(self.path_components), + root_directory=not bool(self.path_components), + timer=timer, + ) + + def make_next_panel(self, option_id): + option = self.get_option(option_id) + + if option.prompt.startswith( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ + borgmatic.actions.browse.paths.Path_type.DIRECTORY.value + ] + ): + return Directory_list( + self.config, + self.repository, + self.archive_name, + path_components=self.path_components + (option_id,), + ) + + return File_preview( + self.config, + self.repository, + self.archive_name, + file_path=os.path.sep.join(self.path_components + (option_id,)), + ) + + def on_option_list_option_highlighted(self, event): + if self.highlighted not in (None, 0): + self.highlighted_option_changed = True + + +class File_preview(textual.widgets.Static): + def __init__(self, config, repository, archive_name, file_path): + self.config = config + self.repository = repository + self.archive_name = archive_name + self.file_path = file_path + self.can_focus = True + + super().__init__(classes='panel') + self.border_title = ' '.join( + ( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ + borgmatic.actions.browse.paths.Path_type.FILE.value + ], + self.file_path, + 'preview', + ) + ) + + timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + + borgmatic.actions.browse.workers.load_file_preview( + self.app, + file_preview=self, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + file_path=self.file_path, + timer=timer, + ) + + def make_next_panel(self, option_id): + return None + + +class Carousel(textual.containers.Horizontal): + BINDINGS = [ + textual.binding.Binding( + key='left,h', action='previous', description='previous', priority=True + ), + ] + + def __init__(self, panels): + self.panels = panels + self.focused_panel = panels[0] + + super().__init__() + + def compose(self): + for panel in self.panels: + yield panel + + self.focused_panel.focus() + + def action_previous(self): + ''' + Make the previous panel into the focused panel. + ''' + previous_panel_index = self.panels.index(self.focused_panel) - 1 + + if previous_panel_index < 0: + return + + self.focused_panel.styles.display = 'none' + + self.focused_panel = self.panels[previous_panel_index] + self.focused_panel.styles.display = 'block' + self.focused_panel.focus() + + def action_next(self, option_id): + ''' + Hide the current focused panel and create the next one. + ''' + self.focused_panel.styles.display = 'none' + next_panel_index = self.panels.index(self.focused_panel) + 1 + + if next_panel_index < len(self.panels): + self.focused_panel = self.panels[next_panel_index] + self.focused_panel.styles.display = 'block' + else: + self.focused_panel = self.focused_panel.make_next_panel(option_id) + self.panels.append(self.focused_panel) + self.focused_panel.highlighted = 0 + + self.focused_panel.focus() + self.refresh(recompose=True) + + def on_option_list_option_highlighted(self, event): + ''' + The highlighted option has changed, so truncate any next panels. + ''' + next_panel_index = self.panels.index(self.focused_panel) + 1 + + del self.panels[next_panel_index:] + + def on_option_list_option_selected(self, event): + if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': + return + + if event.option_id == '..': + self.action_previous() + else: + self.action_next(event.option_id) + + +class Logs(textual.widgets.RichLog): + def __init__(self): + super().__init__(markup=True, id='logs', classes='panel') + self.border_title = 'logs' diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py new file mode 100644 index 00000000..1de36bbb --- /dev/null +++ b/borgmatic/actions/browse/workers.py @@ -0,0 +1,91 @@ +import borgmatic.actions.browse.archive + +import rich.syntax +import rich.text +import textual +import textual.widgets.option_list + + +@textual.work(thread=True) +async def add_repository_archives(browse_app, archives_list, config, repository, timer): + archives_data = borgmatic.actions.browse.archive.get_repository_archives(config, repository) + loading_option = archives_list.get_option('loading-indicator') + + # Reverse the archives, so the common case of accessing the latest archive is easy because it's + # at the top. + for index, archive in enumerate(reversed(archives_data['archives'])): + label_pieces = ( + (archive['archive'], '[dim](latest)[/dim]') if index == 0 else (archive['archive'],) + ) + highlighted_option = archives_list.highlighted_option + + browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') + browse_app.call_from_thread( + archives_list.add_options, + ( + textual.widgets.option_list.Option(' '.join(label_pieces), id=archive['archive']), + loading_option, + ), + ) + archives_list.highlighted = ( + archives_list.get_option_index(highlighted_option.id) + if highlighted_option and archives_list.highlighted_option_changed + else 0 + ) + + browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') + browse_app.call_from_thread(timer.stop) + + +@textual.work(thread=True) +def add_archive_files( + browse_app, directory_list, config, repository, archive_name, list_path, root_directory, timer +): + file_type_paths = borgmatic.actions.browse.archive.get_archive_files( + config, repository, archive_name, list_path + ) + loading_option = directory_list.get_option('loading-indicator') + + if not root_directory: + browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') + browse_app.call_from_thread( + directory_list.add_options, + ( + textual.widgets.option_list.Option( + f'{borgmatic.actions.browse.paths.PATH_TYPE_ICONS[borgmatic.actions.browse.paths.Path_type.DIRECTORY.value]} ..', + id='..', + ), + loading_option, + ), + ) + + for path_type, file_path, link_target in file_type_paths: + pieces = (borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get(path_type, '?'), file_path) + ( + ('→', link_target) if link_target else () + ) + highlighted_option = directory_list.highlighted_option + sorted_options = sorted( + directory_list.options + + [textual.widgets.option_list.Option(' '.join(pieces), id=file_path)], + key=lambda option: ((option.id == 'loading-indicator'), option.prompt), + ) + browse_app.call_from_thread(directory_list.set_options, sorted_options) + directory_list.highlighted = ( + directory_list.get_option_index(highlighted_option.id) + if highlighted_option and directory_list.highlighted_option_changed + else 0 + ) + + browse_app.call_from_thread(timer.stop) + browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') + + +@textual.work(thread=True) +def load_file_preview(browse_app, file_preview, config, repository, archive_name, file_path, timer): + file_content = borgmatic.actions.browse.archive.get_archive_file_content( + config, repository, archive_name, file_path + ) + + browse_app.call_from_thread(timer.stop) + syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) + browse_app.call_from_thread(file_preview.update, rich.syntax.Syntax(file_content, syntax_lexer)) From 16e743d8d908a5a4aa75613bfd93a6adec2d162a Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 21 May 2026 11:41:44 -0700 Subject: [PATCH 18/63] More browse action option list keys. --- borgmatic/actions/browse/widgets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/borgmatic/actions/browse/widgets.py b/borgmatic/actions/browse/widgets.py index c8e87c03..61c9f4df 100644 --- a/borgmatic/actions/browse/widgets.py +++ b/borgmatic/actions/browse/widgets.py @@ -9,8 +9,9 @@ import borgmatic.actions.browse.workers OPTION_LIST_BINDINGS = textual.widgets.OptionList.BINDINGS + [ - textual.binding.Binding(key='j', action='cursor_down', description='down', show=False), - textual.binding.Binding(key='k', action='cursor_up', description='up', show=False), + textual.binding.Binding(key='up,k', action='cursor_up', description='up', show=True, priority=True), + textual.binding.Binding(key='down,j', action='cursor_down', description='down', show=True, priority=True), + textual.binding.Binding(key='enter', action='select', description='select', show=True, priority=True), textual.binding.Binding(key='right,l', action='select', description='select', show=False), ] From fd98f4fdd1e5480d8b3698508fe0b9cf4be4794c Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 21 May 2026 14:10:49 -0700 Subject: [PATCH 19/63] Browse action documentation tweaks. --- docs/how-to/inspect-your-backups.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/how-to/inspect-your-backups.md b/docs/how-to/inspect-your-backups.md index 3f1517f6..4995b2f8 100644 --- a/docs/how-to/inspect-your-backups.md +++ b/docs/how-to/inspect-your-backups.md @@ -142,8 +142,11 @@ documentation for information on output format, what is compared, and more. New in version 2.1.6 Experimental feature borgmatic has an experimental console UI for browsing your repositories, archives, and files. -This is handy if you want to quickly look at the contents of your backups—but -without messing with borgmatic's command-line. + +This feature is not intended to be a general-purpose Borg UI with every +borgmatic feature, but rather it's for use cases like quickly looking at the +contents of your backups when you're feeling too lazy to type out a full +borgmatic command-line. Depending on how you installed borgmatic, it may not have come with the necessary Python libraries to support the browse action. For instance, if you @@ -163,13 +166,15 @@ Once the libraries are installed, run the following to access the browse action: borgmatic browse ``` -This launches a console UI where you can select a borgmatic configuration file, -select a Borg repository from it, select an archive in that repository, and even -browse the backed up files in that archive. +This launches a console UI where you can select a borgmatic configuration file +(if there's more than one), select a Borg repository, select an archive in that +repository, and even browse the backed up files in that archive. Use the keyboard or the mouse to navigate the UI. The footer at the bottom of -the screen shows some of the available keys. Logs shows up directly in the UI, -although hidden by default. +the screen shows some of the available keys. Logs shows up directly in the UI at +the [selected +verbosity](https://torsion.org/borgmatic/reference/command-line/logging/), +although logs are hidden by default. Please [provide feedback](https://torsion.org/borgmatic/#support-and-contributing) if you find From 73222e7b5b2d6316528be767abc0a835b941d865 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 21 May 2026 15:50:34 -0700 Subject: [PATCH 20/63] Factor out make_next_panel() out of browse action widgets into a plain function. --- borgmatic/actions/browse/widgets.py | 73 +++++++++++++++-------------- docs/how-to/inspect-your-backups.md | 2 +- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/borgmatic/actions/browse/widgets.py b/borgmatic/actions/browse/widgets.py index 61c9f4df..402ba935 100644 --- a/borgmatic/actions/browse/widgets.py +++ b/borgmatic/actions/browse/widgets.py @@ -33,9 +33,6 @@ class Configuration_files_list(textual.widgets.OptionList): ) self.border_title = 'configuration files' - def make_next_panel(self, option_id): - return Repositories_list(config=self.configs[option_id]) - class Repositories_list(textual.widgets.OptionList): BINDINGS = OPTION_LIST_BINDINGS @@ -54,9 +51,6 @@ class Repositories_list(textual.widgets.OptionList): ) self.border_title = 'repositories' - def make_next_panel(self, option_id): - return Archives_list(config=self.config, repository=self.repositories[option_id]) - class Archives_list(textual.widgets.OptionList): BINDINGS = OPTION_LIST_BINDINGS @@ -79,11 +73,6 @@ class Archives_list(textual.widgets.OptionList): timer=timer, ) - def make_next_panel(self, option_id): - return Directory_list( - config=self.config, repository=self.repository, archive_name=option_id - ) - def on_option_list_option_highlighted(self, event): if self.highlighted not in (None, 0): self.highlighted_option_changed = True @@ -124,28 +113,6 @@ class Directory_list(textual.widgets.OptionList): timer=timer, ) - def make_next_panel(self, option_id): - option = self.get_option(option_id) - - if option.prompt.startswith( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ - borgmatic.actions.browse.paths.Path_type.DIRECTORY.value - ] - ): - return Directory_list( - self.config, - self.repository, - self.archive_name, - path_components=self.path_components + (option_id,), - ) - - return File_preview( - self.config, - self.repository, - self.archive_name, - file_path=os.path.sep.join(self.path_components + (option_id,)), - ) - def on_option_list_option_highlighted(self, event): if self.highlighted not in (None, 0): self.highlighted_option_changed = True @@ -182,8 +149,42 @@ class File_preview(textual.widgets.Static): timer=timer, ) - def make_next_panel(self, option_id): - return None + +def make_next_panel(focused_panel, option_id): + if isinstance(focused_panel, Configuration_files_list): + return Repositories_list(config=focused_panel.configs[option_id]) + + if isinstance(focused_panel, Repositories_list): + return Archives_list(config=focused_panel.config, repository=focused_panel.repositories[option_id]) + + if isinstance(focused_panel, Archives_list): + return Directory_list( + config=focused_panel.config, repository=focused_panel.repository, archive_name=option_id + ) + + if isinstance(focused_panel, Directory_list): + option = focused_panel.get_option(option_id) + + if option.prompt.startswith( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ + borgmatic.actions.browse.paths.Path_type.DIRECTORY.value + ] + ): + return Directory_list( + focused_panel.config, + focused_panel.repository, + focused_panel.archive_name, + path_components=focused_panel.path_components + (option_id,), + ) + + return File_preview( + focused_panel.config, + focused_panel.repository, + focused_panel.archive_name, + file_path=os.path.sep.join(focused_panel.path_components + (option_id,)), + ) + + return None class Carousel(textual.containers.Horizontal): @@ -231,7 +232,7 @@ class Carousel(textual.containers.Horizontal): self.focused_panel = self.panels[next_panel_index] self.focused_panel.styles.display = 'block' else: - self.focused_panel = self.focused_panel.make_next_panel(option_id) + self.focused_panel = make_next_panel(self.focused_panel, option_id) self.panels.append(self.focused_panel) self.focused_panel.highlighted = 0 diff --git a/docs/how-to/inspect-your-backups.md b/docs/how-to/inspect-your-backups.md index 4995b2f8..9d1a20fa 100644 --- a/docs/how-to/inspect-your-backups.md +++ b/docs/how-to/inspect-your-backups.md @@ -171,7 +171,7 @@ This launches a console UI where you can select a borgmatic configuration file repository, and even browse the backed up files in that archive. Use the keyboard or the mouse to navigate the UI. The footer at the bottom of -the screen shows some of the available keys. Logs shows up directly in the UI at +the screen shows some of the available keys. Logs show up directly in the UI at the [selected verbosity](https://torsion.org/borgmatic/reference/command-line/logging/), although logs are hidden by default. From ec11e8817d76b8c6fd9c74779904acaafed9fa76 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 21 May 2026 16:08:10 -0700 Subject: [PATCH 21/63] Code formatting. --- borgmatic/actions/browse/app.py | 1 - borgmatic/actions/browse/widgets.py | 16 ++++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py index 76c90095..d3ef6c3c 100644 --- a/borgmatic/actions/browse/app.py +++ b/borgmatic/actions/browse/app.py @@ -59,4 +59,3 @@ class Browse_app(textual.app.App): logs_container.styles.display = ( 'none' if logs_container.styles.display == 'block' else 'block' ) - diff --git a/borgmatic/actions/browse/widgets.py b/borgmatic/actions/browse/widgets.py index 402ba935..db722e55 100644 --- a/borgmatic/actions/browse/widgets.py +++ b/borgmatic/actions/browse/widgets.py @@ -9,9 +9,15 @@ import borgmatic.actions.browse.workers OPTION_LIST_BINDINGS = textual.widgets.OptionList.BINDINGS + [ - textual.binding.Binding(key='up,k', action='cursor_up', description='up', show=True, priority=True), - textual.binding.Binding(key='down,j', action='cursor_down', description='down', show=True, priority=True), - textual.binding.Binding(key='enter', action='select', description='select', show=True, priority=True), + textual.binding.Binding( + key='up,k', action='cursor_up', description='up', show=True, priority=True + ), + textual.binding.Binding( + key='down,j', action='cursor_down', description='down', show=True, priority=True + ), + textual.binding.Binding( + key='enter', action='select', description='select', show=True, priority=True + ), textual.binding.Binding(key='right,l', action='select', description='select', show=False), ] @@ -155,7 +161,9 @@ def make_next_panel(focused_panel, option_id): return Repositories_list(config=focused_panel.configs[option_id]) if isinstance(focused_panel, Repositories_list): - return Archives_list(config=focused_panel.config, repository=focused_panel.repositories[option_id]) + return Archives_list( + config=focused_panel.config, repository=focused_panel.repositories[option_id] + ) if isinstance(focused_panel, Archives_list): return Directory_list( From 9935d0ef8d1cfee877a661edc623059c96ed0077 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 21 May 2026 17:01:07 -0700 Subject: [PATCH 22/63] Linting and more file refactoring for the browse action. --- borgmatic/actions/browse/app.py | 14 +- borgmatic/actions/browse/archive.py | 7 +- borgmatic/actions/browse/carousel.py | 117 +++++++++++++++++ borgmatic/actions/browse/loading.py | 8 +- borgmatic/actions/browse/logs.py | 6 +- .../actions/browse/{widgets.py => panels.py} | 121 +----------------- borgmatic/actions/browse/run.py | 4 +- borgmatic/actions/browse/workers.py | 13 +- 8 files changed, 148 insertions(+), 142 deletions(-) create mode 100644 borgmatic/actions/browse/carousel.py rename borgmatic/actions/browse/{widgets.py => panels.py} (57%) diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py index d3ef6c3c..ee860288 100644 --- a/borgmatic/actions/browse/app.py +++ b/borgmatic/actions/browse/app.py @@ -2,18 +2,18 @@ import textual.app import textual.binding import textual.widgets +import borgmatic.actions.browse.carousel import borgmatic.actions.browse.logs -import borgmatic.actions.browse.widgets class Browse_app(textual.app.App): - BINDINGS = [ + BINDINGS = ( textual.binding.Binding(key='q', action='quit', description='quit'), textual.binding.Binding(key='v', action='toggle_logs', description='view logs'), textual.binding.Binding( key='c', action='command_palette', description='commands', show=False ), - ] + ) COMMAND_PALETTE_BINDING = 'c' CSS = ''' .panel { @@ -37,15 +37,15 @@ class Browse_app(textual.app.App): def compose(self): yield textual.widgets.Header() - yield borgmatic.actions.browse.widgets.Carousel( - [borgmatic.actions.browse.widgets.Configuration_files_list(self.configs)] + yield borgmatic.actions.browse.carousel.Carousel( + [borgmatic.actions.browse.panels.Configuration_files_list(self.configs)] if len(self.configs) > 1 else [ - borgmatic.actions.browse.widgets.Repositories_list(tuple(self.configs.values())[0]) + borgmatic.actions.browse.panels.Repositories_list(next(iter(self.configs.values()))) ] ) - logs_widget = borgmatic.actions.browse.widgets.Logs() + logs_widget = borgmatic.actions.browse.panels.Logs() yield logs_widget yield textual.widgets.Footer() diff --git a/borgmatic/actions/browse/archive.py b/borgmatic/actions/browse/archive.py index 834c34b9..d188915a 100644 --- a/borgmatic/actions/browse/archive.py +++ b/borgmatic/actions/browse/archive.py @@ -1,13 +1,12 @@ -import os import argparse import json import logging +import os import borgmatic.borg.extract import borgmatic.borg.repo_list import borgmatic.borg.version - logger = logging.getLogger(__name__) @@ -46,9 +45,9 @@ def get_repository_archives(config, repository): def get_archive_files(config, repository, archive_name, list_path=None): with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): if list_path: - logger.info(f"Listing archive {archive_name} at path {list_path}") + logger.info(f'Listing archive {archive_name} at path {list_path}') else: - logger.info(f"Listing archive {archive_name}") + logger.info(f'Listing archive {archive_name}') global_arguments = argparse.Namespace() local_path = config.get('local_path', 'borg') diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py new file mode 100644 index 00000000..5b4b3849 --- /dev/null +++ b/borgmatic/actions/browse/carousel.py @@ -0,0 +1,117 @@ +import os + +import textual.binding +import textual.containers + +import borgmatic.actions.browse.panels +import borgmatic.actions.browse.paths + + +def make_next_panel(focused_panel, option_id): + if isinstance(focused_panel, borgmatic.actions.browse.panels.Configuration_files_list): + return borgmatic.actions.browse.panels.Repositories_list( + config=focused_panel.configs[option_id] + ) + + if isinstance(focused_panel, borgmatic.actions.browse.panels.Repositories_list): + return borgmatic.actions.browse.panels.Archives_list( + config=focused_panel.config, repository=focused_panel.repositories[option_id] + ) + + if isinstance(focused_panel, borgmatic.actions.browse.panels.Archives_list): + return borgmatic.actions.browse.panels.Directory_list( + config=focused_panel.config, repository=focused_panel.repository, archive_name=option_id + ) + + if isinstance(focused_panel, borgmatic.actions.browse.panels.Directory_list): + option = focused_panel.get_option(option_id) + + if option.prompt.startswith( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ + borgmatic.actions.browse.paths.Path_type.DIRECTORY.value + ] + ): + return borgmatic.actions.browse.panels.Directory_list( + focused_panel.config, + focused_panel.repository, + focused_panel.archive_name, + path_components=(*focused_panel.path_components, option_id), + ) + + return borgmatic.actions.browse.panels.File_preview( + focused_panel.config, + focused_panel.repository, + focused_panel.archive_name, + file_path=os.path.sep.join((*focused_panel.path_components, option_id)), + ) + + return None + + +class Carousel(textual.containers.Horizontal): + BINDINGS = ( + textual.binding.Binding( + key='left,h', action='previous', description='previous', priority=True + ), + ) + + def __init__(self, panels): + self.panels = panels + self.focused_panel = panels[0] + + super().__init__() + + def compose(self): + yield from self.panels + + self.focused_panel.focus() + + def action_previous(self): + ''' + Make the previous panel into the focused panel. + ''' + previous_panel_index = self.panels.index(self.focused_panel) - 1 + + if previous_panel_index < 0: + return + + self.focused_panel.styles.display = 'none' + + self.focused_panel = self.panels[previous_panel_index] + self.focused_panel.styles.display = 'block' + self.focused_panel.focus() + + def action_next(self, option_id): + ''' + Hide the current focused panel and create the next one. + ''' + self.focused_panel.styles.display = 'none' + next_panel_index = self.panels.index(self.focused_panel) + 1 + + if next_panel_index < len(self.panels): + self.focused_panel = self.panels[next_panel_index] + self.focused_panel.styles.display = 'block' + else: + self.focused_panel = make_next_panel(self.focused_panel, option_id) + self.panels.append(self.focused_panel) + self.focused_panel.highlighted = 0 + + self.focused_panel.focus() + self.refresh(recompose=True) + + def on_option_list_option_highlighted(self, event): + ''' + The highlighted option has changed, so truncate any next panels. + ''' + next_panel_index = self.panels.index(self.focused_panel) + 1 + + del self.panels[next_panel_index:] + + def on_option_list_option_selected(self, event): + if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': + return + + if event.option_id == '..': + self.action_previous() + else: + self.action_next(event.option_id) diff --git a/borgmatic/actions/browse/loading.py b/borgmatic/actions/browse/loading.py index c7ba7435..60438553 100644 --- a/borgmatic/actions/browse/loading.py +++ b/borgmatic/actions/browse/loading.py @@ -1,21 +1,19 @@ +import contextlib import functools import textual.widgets import textual.widgets.option_list - LOADING_DOT_INTERVAL_SECONDS = 0.3 def update_inline_loading_indicator(widget): if isinstance(widget, textual.widgets.OptionList): - try: + with contextlib.suppress(textual.widgets.option_list.OptionDoesNotExist): widget.replace_option_prompt( 'loading-indicator', (str(widget.get_option('loading-indicator').prompt) + '.').replace('....', ''), ) - except textual.widgets.option_list.OptionDoesNotExist: - pass elif isinstance(widget, textual.widgets.Static): widget.update((str(widget.content) + '.').replace('....', '')) else: @@ -23,7 +21,7 @@ def update_inline_loading_indicator(widget): def add_inline_loading_indicator(widget): - loading_message = f'⏳ loading...' + loading_message = '⏳ loading...' if isinstance(widget, textual.widgets.OptionList): widget.clear_options() diff --git a/borgmatic/actions/browse/logs.py b/borgmatic/actions/browse/logs.py index dbe6231f..aa4bd962 100644 --- a/borgmatic/actions/browse/logs.py +++ b/borgmatic/actions/browse/logs.py @@ -1,11 +1,11 @@ import contextlib import logging -import borgmatic.logger - import textual._context import textual.worker +import borgmatic.logger + class Rich_color_formatter(logging.Formatter): def __init__(self, *args, **kwargs): @@ -43,7 +43,7 @@ class Browse_log_handler(logging.Handler): message = self.format(record) try: - worker = textual.worker.get_current_worker() + textual.worker.get_current_worker() self.logs_widget.app.call_from_thread(self.logs_widget.write, message) except (RuntimeError, textual.worker.NoActiveWorker): with contextlib.suppress(textual._context.NoActiveAppError): diff --git a/borgmatic/actions/browse/widgets.py b/borgmatic/actions/browse/panels.py similarity index 57% rename from borgmatic/actions/browse/widgets.py rename to borgmatic/actions/browse/panels.py index db722e55..024cde1c 100644 --- a/borgmatic/actions/browse/widgets.py +++ b/borgmatic/actions/browse/panels.py @@ -7,8 +7,8 @@ import borgmatic.actions.browse.loading import borgmatic.actions.browse.paths import borgmatic.actions.browse.workers - -OPTION_LIST_BINDINGS = textual.widgets.OptionList.BINDINGS + [ +OPTION_LIST_BINDINGS = ( + *textual.widgets.OptionList.BINDINGS, textual.binding.Binding( key='up,k', action='cursor_up', description='up', show=True, priority=True ), @@ -19,7 +19,7 @@ OPTION_LIST_BINDINGS = textual.widgets.OptionList.BINDINGS + [ key='enter', action='select', description='select', show=True, priority=True ), textual.binding.Binding(key='right,l', action='select', description='select', show=False), -] +) class Configuration_files_list(textual.widgets.OptionList): @@ -32,7 +32,7 @@ class Configuration_files_list(textual.widgets.OptionList): super().__init__( *( textual.widgets.option_list.Option(f'{unexpanded_path}', id=config_path) - for config_path in configs.keys() + for config_path in configs for unexpanded_path in (config_path.replace(home_directory, '~'),) ), classes='panel', @@ -80,7 +80,7 @@ class Archives_list(textual.widgets.OptionList): ) def on_option_list_option_highlighted(self, event): - if self.highlighted not in (None, 0): + if self.highlighted not in {None, 0}: self.highlighted_option_changed = True @@ -120,7 +120,7 @@ class Directory_list(textual.widgets.OptionList): ) def on_option_list_option_highlighted(self, event): - if self.highlighted not in (None, 0): + if self.highlighted not in {None, 0}: self.highlighted_option_changed = True @@ -156,115 +156,6 @@ class File_preview(textual.widgets.Static): ) -def make_next_panel(focused_panel, option_id): - if isinstance(focused_panel, Configuration_files_list): - return Repositories_list(config=focused_panel.configs[option_id]) - - if isinstance(focused_panel, Repositories_list): - return Archives_list( - config=focused_panel.config, repository=focused_panel.repositories[option_id] - ) - - if isinstance(focused_panel, Archives_list): - return Directory_list( - config=focused_panel.config, repository=focused_panel.repository, archive_name=option_id - ) - - if isinstance(focused_panel, Directory_list): - option = focused_panel.get_option(option_id) - - if option.prompt.startswith( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ - borgmatic.actions.browse.paths.Path_type.DIRECTORY.value - ] - ): - return Directory_list( - focused_panel.config, - focused_panel.repository, - focused_panel.archive_name, - path_components=focused_panel.path_components + (option_id,), - ) - - return File_preview( - focused_panel.config, - focused_panel.repository, - focused_panel.archive_name, - file_path=os.path.sep.join(focused_panel.path_components + (option_id,)), - ) - - return None - - -class Carousel(textual.containers.Horizontal): - BINDINGS = [ - textual.binding.Binding( - key='left,h', action='previous', description='previous', priority=True - ), - ] - - def __init__(self, panels): - self.panels = panels - self.focused_panel = panels[0] - - super().__init__() - - def compose(self): - for panel in self.panels: - yield panel - - self.focused_panel.focus() - - def action_previous(self): - ''' - Make the previous panel into the focused panel. - ''' - previous_panel_index = self.panels.index(self.focused_panel) - 1 - - if previous_panel_index < 0: - return - - self.focused_panel.styles.display = 'none' - - self.focused_panel = self.panels[previous_panel_index] - self.focused_panel.styles.display = 'block' - self.focused_panel.focus() - - def action_next(self, option_id): - ''' - Hide the current focused panel and create the next one. - ''' - self.focused_panel.styles.display = 'none' - next_panel_index = self.panels.index(self.focused_panel) + 1 - - if next_panel_index < len(self.panels): - self.focused_panel = self.panels[next_panel_index] - self.focused_panel.styles.display = 'block' - else: - self.focused_panel = make_next_panel(self.focused_panel, option_id) - self.panels.append(self.focused_panel) - self.focused_panel.highlighted = 0 - - self.focused_panel.focus() - self.refresh(recompose=True) - - def on_option_list_option_highlighted(self, event): - ''' - The highlighted option has changed, so truncate any next panels. - ''' - next_panel_index = self.panels.index(self.focused_panel) + 1 - - del self.panels[next_panel_index:] - - def on_option_list_option_selected(self, event): - if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': - return - - if event.option_id == '..': - self.action_previous() - else: - self.action_next(event.option_id) - - class Logs(textual.widgets.RichLog): def __init__(self): super().__init__(markup=True, id='logs', classes='panel') diff --git a/borgmatic/actions/browse/run.py b/borgmatic/actions/browse/run.py index d781399c..984cb4e2 100644 --- a/borgmatic/actions/browse/run.py +++ b/borgmatic/actions/browse/run.py @@ -15,13 +15,13 @@ def run_browse( logging.getLogger('asyncio').setLevel(logging.WARNING) try: - import textual + import textual # noqa: F401, PLC0415 except ImportError: # pragma: no cover raise ValueError( 'Unable to import the Textual library for the browse action; try installing "borgmatic[browse]"' ) - import borgmatic.actions.browse.app + import borgmatic.actions.browse.app # noqa: PLC0415 app = borgmatic.actions.browse.app.Browse_app(configs) app.run() diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index 1de36bbb..26267123 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -1,13 +1,12 @@ -import borgmatic.actions.browse.archive - import rich.syntax -import rich.text import textual import textual.widgets.option_list +import borgmatic.actions.browse.archive + @textual.work(thread=True) -async def add_repository_archives(browse_app, archives_list, config, repository, timer): +def add_repository_archives(browse_app, archives_list, config, repository, timer): archives_data = borgmatic.actions.browse.archive.get_repository_archives(config, repository) loading_option = archives_list.get_option('loading-indicator') @@ -65,8 +64,10 @@ def add_archive_files( ) highlighted_option = directory_list.highlighted_option sorted_options = sorted( - directory_list.options - + [textual.widgets.option_list.Option(' '.join(pieces), id=file_path)], + [ + *directory_list.options, + textual.widgets.option_list.Option(' '.join(pieces), id=file_path), + ], key=lambda option: ((option.id == 'loading-indicator'), option.prompt), ) browse_app.call_from_thread(directory_list.set_options, sorted_options) From c4170bb12633ac259a037f6880f872dad6e0f4ee Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 21 May 2026 23:23:19 -0700 Subject: [PATCH 23/63] Several browse action unit and integration tests. --- borgmatic/actions/browse/app.py | 30 +- borgmatic/actions/browse/carousel.py | 20 +- borgmatic/actions/browse/panels.py | 2 +- borgmatic/actions/browse/run.py | 4 +- borgmatic/config/schema.yaml | 9 +- pyproject.toml | 2 +- test_requirements.txt | 1 + tests/integration/actions/browse/__init__.py | 0 tests/integration/actions/browse/test_app.py | 60 ++++ .../actions/browse/test_carousel.py | 265 ++++++++++++++++++ tests/unit/actions/browse/__init__.py | 0 tests/unit/actions/browse/test_run.py | 29 ++ 12 files changed, 403 insertions(+), 19 deletions(-) create mode 100644 tests/integration/actions/browse/__init__.py create mode 100644 tests/integration/actions/browse/test_app.py create mode 100644 tests/integration/actions/browse/test_carousel.py create mode 100644 tests/unit/actions/browse/__init__.py create mode 100644 tests/unit/actions/browse/test_run.py diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py index ee860288..30035f9c 100644 --- a/borgmatic/actions/browse/app.py +++ b/borgmatic/actions/browse/app.py @@ -7,6 +7,10 @@ import borgmatic.actions.browse.logs class Browse_app(textual.app.App): + ''' + The main app / entry point for the browse action UI. + ''' + BINDINGS = ( textual.binding.Binding(key='q', action='quit', description='quit'), textual.binding.Binding(key='v', action='toggle_logs', description='view logs'), @@ -36,6 +40,14 @@ class Browse_app(textual.app.App): super().__init__() def compose(self): + ''' + Compose a UI consisting of: + + * a header with the application name + * a carousel container that contains the main UI panels + * a logs panel where Python logs show up (panel hidden by default) + * a footer with available keys listed + ''' yield textual.widgets.Header() yield borgmatic.actions.browse.carousel.Carousel( [borgmatic.actions.browse.panels.Configuration_files_list(self.configs)] @@ -45,17 +57,21 @@ class Browse_app(textual.app.App): ] ) - logs_widget = borgmatic.actions.browse.panels.Logs() - yield logs_widget + logs_panel = borgmatic.actions.browse.panels.Logs() + yield logs_panel yield textual.widgets.Footer() - borgmatic.actions.browse.logs.log_to_widget(logs_widget) + borgmatic.actions.browse.logs.log_to_widget(logs_panel) def on_mount(self): + ''' + Set the application title, which ends up in the header. + ''' self.title = 'borgmatic browse' def action_toggle_logs(self): - logs_container = self.query_one('#logs') - logs_container.styles.display = ( - 'none' if logs_container.styles.display == 'block' else 'block' - ) + ''' + Toggle the show/hide status of the logs panel. + ''' + logs_panel = self.query_one('#logs') + logs_panel.styles.display = 'none' if logs_panel.styles.display == 'block' else 'block' diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index 5b4b3849..acbc29c8 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -62,6 +62,9 @@ class Carousel(textual.containers.Horizontal): super().__init__() def compose(self): + ''' + Compose with each of the contained panels and focus the first one. + ''' yield from self.panels self.focused_panel.focus() @@ -85,17 +88,22 @@ class Carousel(textual.containers.Horizontal): ''' Hide the current focused panel and create the next one. ''' - self.focused_panel.styles.display = 'none' next_panel_index = self.panels.index(self.focused_panel) + 1 if next_panel_index < len(self.panels): - self.focused_panel = self.panels[next_panel_index] - self.focused_panel.styles.display = 'block' + next_panel = self.panels[next_panel_index] + next_panel.styles.display = 'block' else: - self.focused_panel = make_next_panel(self.focused_panel, option_id) - self.panels.append(self.focused_panel) - self.focused_panel.highlighted = 0 + next_panel = make_next_panel(self.focused_panel, option_id) + if next_panel is None: + return + + self.panels.append(next_panel) + next_panel.highlighted = 0 + + self.focused_panel.styles.display = 'none' + self.focused_panel = next_panel self.focused_panel.focus() self.refresh(recompose=True) diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index 024cde1c..d09ccf2b 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -31,7 +31,7 @@ class Configuration_files_list(textual.widgets.OptionList): super().__init__( *( - textual.widgets.option_list.Option(f'{unexpanded_path}', id=config_path) + textual.widgets.option_list.Option(unexpanded_path, id=config_path) for config_path in configs for unexpanded_path in (config_path.replace(home_directory, '~'),) ), diff --git a/borgmatic/actions/browse/run.py b/borgmatic/actions/browse/run.py index 984cb4e2..26786120 100644 --- a/borgmatic/actions/browse/run.py +++ b/borgmatic/actions/browse/run.py @@ -7,7 +7,9 @@ def run_browse( configs, ): ''' - Run the "browse" action for the given repository. + Run the "browse" action for the given borgmatic configurations. This launches a console UI. + + Raise ValueError if the Textual library (a prerequisite for this action) can't be imported. ''' if not configs: return diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 09f014b2..4bd2117c 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1131,8 +1131,9 @@ properties: - info - break-lock - key - - borg - diff + - browse + - borg description: | List of one or more actions to skip running for this configuration file, even if specified on the command-line (explicitly or @@ -1348,8 +1349,9 @@ properties: - info - break-lock - key - - borg - diff + - browse + - borg description: | List of actions for which the commands will be run. Defaults to running for all actions. @@ -1414,8 +1416,9 @@ properties: - info - break-lock - key - - borg - diff + - browse + - borg description: | Only trigger the hook when borgmatic is run with particular actions listed here. Defaults to diff --git a/pyproject.toml b/pyproject.toml index b9a4de86..48ec4590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ namespaces = false [tool.pytest.ini_options] testpaths = "tests" -addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120" +addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120 --asyncio-mode=auto" [tool.ruff] line-length = 100 diff --git a/test_requirements.txt b/test_requirements.txt index ec4e11e3..a28e167d 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -18,6 +18,7 @@ packaging==26.2 # via pytest, -r test_requirements.in pluggy==1.6.0 # via pytest, pytest-cov, -r test_requirements.in pygments==2.20.0 # via pytest, -r test_requirements.in pytest==9.0.3 # via pytest-cov, pytest-timeout, -r test_requirements.in +pytest-asyncio===1.3.0 pytest-cov==7.1.0 # via -r test_requirements.in pytest-timeout==2.4.0 # via -r test_requirements.in pyyaml>5.0.0 diff --git a/tests/integration/actions/browse/__init__.py b/tests/integration/actions/browse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/actions/browse/test_app.py b/tests/integration/actions/browse/test_app.py new file mode 100644 index 00000000..e2542003 --- /dev/null +++ b/tests/integration/actions/browse/test_app.py @@ -0,0 +1,60 @@ +import borgmatic.actions.browse.app +import borgmatic.actions.browse.panels + + +async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + header = app.query_one(selector='Header') + header.name == 'borgmatic browse' + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].configs == app.configs + + app.query_one(selector='Logs') + app.query_one(selector='Footer') + + +async def test_browse_app_with_one_config_uses_repositories_list(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + + async with app.run_test() as pilot: + header = app.query_one(selector='Header') + header.name == 'borgmatic browse' + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + assert isinstance(carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list) + assert carousel.panels[0].config == app.configs['test1.yaml'] + + app.query_one(selector='Logs') + app.query_one(selector='Footer') + + +async def test_browse_app_key_toggles_logs_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + + async with app.run_test() as pilot: + logs_panel = app.query_one('#logs') + + await pilot.press('v') + await pilot.pause() + assert logs_panel.styles.display == 'block' diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py new file mode 100644 index 00000000..44a6a290 --- /dev/null +++ b/tests/integration/actions/browse/test_carousel.py @@ -0,0 +1,265 @@ +import borgmatic.actions.browse.app +import borgmatic.actions.browse.carousel +import borgmatic.actions.browse.panels +import borgmatic.actions.browse.workers + +from flexmock import flexmock + +import textual.widgets.option_list + + +async def test_carousel_previous_action_with_multiple_configs_does_not_raise(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('left') + + +async def test_carousel_previous_action_with_one_config_does_not_raise(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('left') + + +async def test_carousel_next_action_with_multiple_configs_advances_panels(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'none' + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[1].styles.display == 'block' + assert carousel.panels[1].highlighted == 0 + assert app.focused == carousel.panels[1] + + +async def test_carousel_next_action_with_one_config_advances_to_next_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[0].styles.display == 'none' + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Archives_list + ) + assert carousel.panels[1].styles.display == 'block' + assert carousel.panels[1].highlighted == 0 + assert app.focused == carousel.panels[1] + + +async def test_carousel_next_action_and_previous_action_returns_to_original_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('enter') + await pilot.press('left') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 0 + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[1].styles.display == 'none' + assert app.focused == carousel.panels[0] + + +async def test_carousel_next_action_and_previous_action_and_next_action_reuses_next_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + flexmock(borgmatic.actions.browse.carousel).should_call('make_next_panel').once() + + async with app.run_test() as pilot: + await pilot.press('enter') + await pilot.press('left') + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'none' + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[1].styles.display == 'block' + assert carousel.panels[1].highlighted == 0 + assert app.focused == carousel.panels[1] + + +async def test_carousel_next_action_with_no_next_panel_does_not_advance(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + flexmock(borgmatic.actions.browse.carousel).should_receive('make_next_panel').and_return(None) + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 0 + assert app.focused == carousel.panels[0] + + +async def test_carousel_next_action_and_previous_action_and_down_truncates_next_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('enter') + await pilot.press('left') + await pilot.press('down') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 1 + assert app.focused == carousel.panels[0] + + +async def test_carousel_down_does_not_raise(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('down') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 1 + assert app.focused == carousel.panels[0] + + +async def test_carousel_up_does_not_raise(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('down') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 1 + assert app.focused == carousel.panels[0] + + +async def test_carousel_next_action_and_select_dot_dot_returns_to_original_panel(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + carousel.panels[1].options[0] = textual.widgets.option_list.Option('..', id='..') + + await pilot.press('enter') + + assert len(carousel.panels) == 2 + + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + ) + assert carousel.panels[0].styles.display == 'block' + assert carousel.panels[0].highlighted == 0 + + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list + ) + assert carousel.panels[1].styles.display == 'none' + assert app.focused == carousel.panels[0] diff --git a/tests/unit/actions/browse/__init__.py b/tests/unit/actions/browse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/actions/browse/test_run.py b/tests/unit/actions/browse/test_run.py new file mode 100644 index 00000000..0febc13f --- /dev/null +++ b/tests/unit/actions/browse/test_run.py @@ -0,0 +1,29 @@ +from flexmock import flexmock + +from borgmatic.actions.browse import run as module +import borgmatic.actions.browse.app + + +def test_run_browse_without_configs_bails(): + app = flexmock() + app.should_receive('run').never() + + flexmock(borgmatic.actions.browse.app).should_receive('Browse_app').and_return(app) + + module.run_browse( + diff_arguments=flexmock(), + global_arguments=flexmock(), + configs=(), + ) + + +def test_run_browse_with_configs_does_not_raise(): + flexmock(borgmatic.actions.browse.app).should_receive('Browse_app').and_return( + flexmock(run=lambda: None) + ) + + module.run_browse( + diff_arguments=flexmock(), + global_arguments=flexmock(), + configs=(flexmock(), flexmock()), + ) From 9aea0b1d9072709fe0185f9a3dd0b8df0a441a17 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 22 May 2026 12:19:34 -0700 Subject: [PATCH 24/63] Fix broken tests. --- pyproject.toml | 2 +- test_requirements.in | 1 + test_requirements.txt | 2 +- tests/integration/actions/browse/test_app.py | 12 ++++++ .../actions/browse/test_carousel.py | 19 ++++++++++ tests/integration/borg/test_rename.py | 12 ++++++ tests/unit/borg/test_borg.py | 11 ++++++ tests/unit/borg/test_break_lock.py | 8 ++++ tests/unit/borg/test_change_passphrase.py | 10 +++++ tests/unit/borg/test_check.py | 26 ++++++++++++- tests/unit/borg/test_compact.py | 14 +++++++ tests/unit/borg/test_create.py | 12 ++++++ tests/unit/borg/test_delete.py | 19 +++++++++- tests/unit/borg/test_diff.py | 16 ++++++++ tests/unit/borg/test_export_key.py | 14 +++++++ tests/unit/borg/test_export_tar.py | 13 +++++++ tests/unit/borg/test_extract.py | 37 +++++++++++++++++-- tests/unit/borg/test_import_key.py | 12 ++++++ tests/unit/borg/test_info.py | 16 ++++++++ tests/unit/borg/test_list.py | 11 ++++++ tests/unit/borg/test_mount.py | 14 +++++++ tests/unit/borg/test_prune.py | 13 +++++++ tests/unit/borg/test_recreate.py | 22 ++++++++++- tests/unit/borg/test_repo_create.py | 15 ++++++++ tests/unit/borg/test_repo_delete.py | 12 ++++++ tests/unit/borg/test_repo_info.py | 16 +++++++- tests/unit/borg/test_repo_list.py | 30 +++++++++++++++ tests/unit/borg/test_transfer.py | 18 +++++++++ tests/unit/borg/test_umount.py | 5 +++ tests/unit/borg/test_version.py | 5 +++ 30 files changed, 404 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 48ec4590..b9a4de86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ namespaces = false [tool.pytest.ini_options] testpaths = "tests" -addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120 --asyncio-mode=auto" +addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120" [tool.ruff] line-length = 100 diff --git a/test_requirements.in b/test_requirements.in index 922cfd98..efed25f7 100644 --- a/test_requirements.in +++ b/test_requirements.in @@ -16,6 +16,7 @@ packaging pluggy pygments pytest +pytest-asyncio pytest-cov pytest-timeout pyyaml>5.0.0 diff --git a/test_requirements.txt b/test_requirements.txt index a28e167d..5e4338af 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -18,7 +18,7 @@ packaging==26.2 # via pytest, -r test_requirements.in pluggy==1.6.0 # via pytest, pytest-cov, -r test_requirements.in pygments==2.20.0 # via pytest, -r test_requirements.in pytest==9.0.3 # via pytest-cov, pytest-timeout, -r test_requirements.in -pytest-asyncio===1.3.0 +pytest-asyncio==1.3.0 pytest-cov==7.1.0 # via -r test_requirements.in pytest-timeout==2.4.0 # via -r test_requirements.in pyyaml>5.0.0 diff --git a/tests/integration/actions/browse/test_app.py b/tests/integration/actions/browse/test_app.py index e2542003..0071061d 100644 --- a/tests/integration/actions/browse/test_app.py +++ b/tests/integration/actions/browse/test_app.py @@ -1,6 +1,15 @@ +import asyncio + import borgmatic.actions.browse.app import borgmatic.actions.browse.panels +import pytest +from flexmock import flexmock + + +pytestmark = pytest.mark.asyncio(loop_scope="module") +loop: asyncio.AbstractEventLoop + async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): app = borgmatic.actions.browse.app.Browse_app( @@ -9,6 +18,7 @@ async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: header = app.query_one(selector='Header') @@ -31,6 +41,7 @@ async def test_browse_app_with_one_config_uses_repositories_list(): 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: header = app.query_one(selector='Header') @@ -51,6 +62,7 @@ async def test_browse_app_key_toggles_logs_panel(): 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: logs_panel = app.query_one('#logs') diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py index 44a6a290..95b02b55 100644 --- a/tests/integration/actions/browse/test_carousel.py +++ b/tests/integration/actions/browse/test_carousel.py @@ -1,13 +1,21 @@ +import asyncio + import borgmatic.actions.browse.app import borgmatic.actions.browse.carousel +import borgmatic.actions.browse.logs import borgmatic.actions.browse.panels import borgmatic.actions.browse.workers +import pytest from flexmock import flexmock import textual.widgets.option_list +pytestmark = pytest.mark.asyncio(loop_scope="module") +loop: asyncio.AbstractEventLoop + + async def test_carousel_previous_action_with_multiple_configs_does_not_raise(): app = borgmatic.actions.browse.app.Browse_app( configs={ @@ -15,6 +23,7 @@ async def test_carousel_previous_action_with_multiple_configs_does_not_raise(): 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: await pilot.press('left') @@ -26,6 +35,7 @@ async def test_carousel_previous_action_with_one_config_does_not_raise(): 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: await pilot.press('left') @@ -38,6 +48,7 @@ async def test_carousel_next_action_with_multiple_configs_advances_panels(): 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: await pilot.press('enter') @@ -64,6 +75,7 @@ async def test_carousel_next_action_with_one_config_advances_to_next_panel(): 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') async with app.run_test() as pilot: @@ -92,6 +104,7 @@ async def test_carousel_next_action_and_previous_action_returns_to_original_pane 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: await pilot.press('enter') @@ -120,6 +133,7 @@ async def test_carousel_next_action_and_previous_action_and_next_action_reuses_n 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') flexmock(borgmatic.actions.browse.carousel).should_call('make_next_panel').once() async with app.run_test() as pilot: @@ -150,6 +164,7 @@ async def test_carousel_next_action_with_no_next_panel_does_not_advance(): 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') flexmock(borgmatic.actions.browse.carousel).should_receive('make_next_panel').and_return(None) async with app.run_test() as pilot: @@ -173,6 +188,7 @@ async def test_carousel_next_action_and_previous_action_and_down_truncates_next_ 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: await pilot.press('enter') @@ -197,6 +213,7 @@ async def test_carousel_down_does_not_raise(): 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: await pilot.press('down') @@ -219,6 +236,7 @@ async def test_carousel_up_does_not_raise(): 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: await pilot.press('down') @@ -241,6 +259,7 @@ async def test_carousel_next_action_and_select_dot_dot_returns_to_original_panel 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, } ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') async with app.run_test() as pilot: await pilot.press('enter') diff --git a/tests/integration/borg/test_rename.py b/tests/integration/borg/test_rename.py index d9923018..68571231 100644 --- a/tests/integration/borg/test_rename.py +++ b/tests/integration/borg/test_rename.py @@ -57,6 +57,8 @@ def test_make_rename_command_includes_log_debug(): def test_make_rename_command_includes_dry_run(): + insert_logging_mock(logging.WARNING) + command = module.make_rename_command( dry_run=True, repository_name='repo', @@ -79,6 +81,8 @@ def test_make_rename_command_includes_dry_run(): def test_make_rename_command_includes_remote_path(): + insert_logging_mock(logging.WARNING) + command = module.make_rename_command( dry_run=False, repository_name='repo', @@ -102,6 +106,8 @@ def test_make_rename_command_includes_remote_path(): def test_make_rename_command_includes_umask(): + insert_logging_mock(logging.WARNING) + command = module.make_rename_command( dry_run=False, repository_name='repo', @@ -125,6 +131,8 @@ def test_make_rename_command_includes_umask(): def test_make_rename_command_includes_log_json(): + insert_logging_mock(logging.WARNING) + command = module.make_rename_command( dry_run=False, repository_name='repo', @@ -140,6 +148,8 @@ def test_make_rename_command_includes_log_json(): def test_make_rename_command_includes_lock_wait(): + insert_logging_mock(logging.WARNING) + command = module.make_rename_command( dry_run=False, repository_name='repo', @@ -163,6 +173,8 @@ def test_make_rename_command_includes_lock_wait(): def test_make_rename_command_includes_extra_borg_options(): + insert_logging_mock(logging.WARNING) + command = module.make_rename_command( dry_run=False, repository_name='repo', diff --git a/tests/unit/borg/test_borg.py b/tests/unit/borg/test_borg.py index df298724..269b96e5 100644 --- a/tests/unit/borg/test_borg.py +++ b/tests/unit/borg/test_borg.py @@ -22,6 +22,7 @@ def test_run_arbitrary_borg_calls_borg_with_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -99,6 +100,7 @@ def test_run_arbitrary_borg_with_lock_wait_calls_borg_with_lock_wait_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -123,6 +125,7 @@ def test_run_arbitrary_borg_with_archive_calls_borg_with_archive_flag(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -148,6 +151,7 @@ def test_run_arbitrary_borg_with_local_path_calls_borg_via_local_path(): borg_local_path='borg1', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -174,6 +178,7 @@ def test_run_arbitrary_borg_with_exit_codes_calls_borg_using_them(): borg_local_path='borg', borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -200,6 +205,7 @@ def test_run_arbitrary_borg_with_remote_path_calls_borg_with_remote_path_flags() borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -227,6 +233,7 @@ def test_run_arbitrary_borg_with_remote_path_injection_attack_gets_escaped(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -252,6 +259,7 @@ def test_run_arbitrary_borg_passes_borg_specific_flags_to_borg(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -276,6 +284,7 @@ def test_run_arbitrary_borg_omits_dash_dash_in_flags_passed_to_borg(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -300,6 +309,7 @@ def test_run_arbitrary_borg_without_borg_specific_flags_does_not_raise(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', @@ -376,6 +386,7 @@ def test_run_arbitrary_borg_calls_borg_with_working_directory(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.run_arbitrary_borg( repository_path='repo', diff --git a/tests/unit/borg/test_break_lock.py b/tests/unit/borg/test_break_lock.py index 41aa0751..1b43ca5f 100644 --- a/tests/unit/borg/test_break_lock.py +++ b/tests/unit/borg/test_break_lock.py @@ -24,6 +24,7 @@ def insert_execute_command_mock(command, working_directory=None, borg_exit_codes def test_break_lock_calls_borg_with_required_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.break_lock( repository_path='repo', @@ -36,6 +37,7 @@ def test_break_lock_calls_borg_with_required_flags(): def test_break_lock_calls_borg_with_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'break-lock', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.break_lock( repository_path='repo', @@ -49,6 +51,7 @@ def test_break_lock_calls_borg_with_local_path(): def test_break_lock_calls_borg_using_exit_codes(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'break-lock', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.break_lock( repository_path='repo', @@ -64,6 +67,7 @@ def test_break_lock_calls_borg_with_remote_path_flags(): insert_execute_command_mock( ('borg', 'break-lock', '--remote-path', 'borg1', '--log-json', 'repo') ) + insert_logging_mock(logging.WARNING) module.break_lock( repository_path='repo', @@ -77,6 +81,7 @@ def test_break_lock_calls_borg_with_remote_path_flags(): def test_break_lock_calls_borg_with_umask_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--umask', '0770', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.break_lock( repository_path='repo', @@ -89,6 +94,7 @@ def test_break_lock_calls_borg_with_umask_flags(): def test_break_lock_calls_borg_with_lock_wait_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--log-json', '--lock-wait', '5', 'repo')) + insert_logging_mock(logging.WARNING) module.break_lock( repository_path='repo', @@ -103,6 +109,7 @@ def test_break_lock_calls_borg_with_extra_borg_options(): insert_execute_command_mock( ('borg', 'break-lock', '--log-json', '--extra', 'value with space', 'repo') ) + insert_logging_mock(logging.WARNING) module.break_lock( repository_path='repo', @@ -145,6 +152,7 @@ def test_break_lock_calls_borg_with_working_directory(): insert_execute_command_mock( ('borg', 'break-lock', '--log-json', 'repo'), working_directory='/working/dir' ) + insert_logging_mock(logging.WARNING) module.break_lock( repository_path='repo', diff --git a/tests/unit/borg/test_change_passphrase.py b/tests/unit/borg/test_change_passphrase.py index b8fa01af..666c6be2 100644 --- a/tests/unit/borg/test_change_passphrase.py +++ b/tests/unit/borg/test_change_passphrase.py @@ -35,6 +35,7 @@ def insert_execute_command_mock( def test_change_passphrase_calls_borg_with_required_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'key', 'change-passphrase', 'repo')) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -48,6 +49,7 @@ def test_change_passphrase_calls_borg_with_required_flags(): def test_change_passphrase_calls_borg_with_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'key', 'change-passphrase', 'repo')) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -68,6 +70,7 @@ def test_change_passphrase_calls_borg_using_exit_codes(): config=config, borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -83,6 +86,7 @@ def test_change_passphrase_calls_borg_with_remote_path_flags(): insert_execute_command_mock( ('borg', 'key', 'change-passphrase', '--remote-path', 'borg1', 'repo'), ) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -101,6 +105,7 @@ def test_change_passphrase_calls_borg_with_umask_flags(): ('borg', 'key', 'change-passphrase', '--umask', '0770', 'repo'), config=config, ) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -118,6 +123,7 @@ def test_change_passphrase_calls_borg_with_lock_wait_flags(): ('borg', 'key', 'change-passphrase', '--lock-wait', '5', 'repo'), config=config, ) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -135,6 +141,7 @@ def test_change_passphrase_calls_borg_with_extra_borg_options(): ('borg', 'key', 'change-passphrase', '--extra', 'value with space', 'repo'), config=config, ) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -179,6 +186,7 @@ def test_change_passphrase_with_dry_run_skips_borg_call(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').never() + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -195,6 +203,7 @@ def test_change_passphrase_calls_borg_without_passphrase(): ('borg', 'key', 'change-passphrase', 'repo'), config={'option': 'foo'}, ) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', @@ -217,6 +226,7 @@ def test_change_passphrase_calls_borg_with_working_directory(): config=config, working_directory='/working/dir', ) + insert_logging_mock(logging.WARNING) module.change_passphrase( repository_path='repo', diff --git a/tests/unit/borg/test_check.py b/tests/unit/borg/test_check.py index d68c89e0..9e64cb1e 100644 --- a/tests/unit/borg/test_check.py +++ b/tests/unit/borg/test_check.py @@ -337,6 +337,7 @@ def test_check_archives_with_progress_passes_through_to_borg(): borg_local_path='borg', borg_exit_codes=[{'code': 1, 'treat_as': 'error'}], ).once() + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -373,6 +374,7 @@ def test_check_archives_with_log_json_and_progress_passes_through_both_to_borg() borg_local_path='borg', borg_exit_codes=[{'code': 1, 'treat_as': 'error'}], ).once() + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -409,6 +411,7 @@ def test_check_archives_with_repair_passes_through_to_borg(): borg_local_path='borg', borg_exit_codes=[{'code': 1, 'treat_as': 'error'}], ).once() + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -445,6 +448,7 @@ def test_check_archives_with_log_json_and_repair_passes_through_both_to_borg(): borg_local_path='borg', borg_exit_codes=[{'code': 1, 'treat_as': 'error'}], ).once() + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -481,6 +485,7 @@ def test_check_archives_with_max_duration_flag_passes_through_to_borg(): borg_local_path='borg', borg_exit_codes=[{'code': 1, 'treat_as': 'error'}], ).once() + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -517,6 +522,7 @@ def test_check_archives_with_max_duration_option_passes_through_to_borg(): borg_local_path='borg', borg_exit_codes=[{'code': 1, 'treat_as': 'error'}], ).once() + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -550,6 +556,7 @@ def test_check_archives_with_max_duration_option_and_archives_check_runs_reposit insert_execute_command_mock( ('borg', 'check', '--max-duration', '33', '--repository-only', '--log-json', 'repo'), ) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -583,6 +590,7 @@ def test_check_archives_with_max_duration_flag_and_archives_check_runs_repositor insert_execute_command_mock( ('borg', 'check', '--max-duration', '33', '--repository-only', '--log-json', 'repo'), ) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -619,6 +627,7 @@ def test_check_archives_with_max_duration_option_and_data_check_runs_repository_ insert_execute_command_mock( ('borg', 'check', '--max-duration', '33', '--repository-only', '--log-json', 'repo'), ) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -655,6 +664,7 @@ def test_check_archives_with_max_duration_flag_and_data_check_runs_repository_ch insert_execute_command_mock( ('borg', 'check', '--max-duration', '33', '--repository-only', '--log-json', 'repo'), ) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -691,6 +701,7 @@ def test_check_archives_with_max_duration_flag_overrides_max_duration_option(): borg_local_path='borg', borg_exit_codes=[{'code': 1, 'treat_as': 'error'}], ).once() + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -724,6 +735,7 @@ def test_check_archives_calls_borg_with_parameters(checks): flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -751,6 +763,7 @@ def test_check_archives_with_data_check_implies_archives_check_calls_borg_with_p ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -777,8 +790,8 @@ def test_check_archives_with_log_info_passes_through_to_borg(): (), ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) - insert_logging_mock(logging.INFO) insert_execute_command_mock(('borg', 'check', '--log-json', '--info', 'repo')) + insert_logging_mock(logging.INFO) module.check_archives( repository_path='repo', @@ -805,8 +818,8 @@ def test_check_archives_with_log_debug_passes_through_to_borg(): (), ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) - insert_logging_mock(logging.DEBUG) insert_execute_command_mock(('borg', 'check', '--log-json', '--debug', '--show-rc', 'repo')) + insert_logging_mock(logging.DEBUG) module.check_archives( repository_path='repo', @@ -832,6 +845,7 @@ def test_check_archives_with_local_path_calls_borg_via_local_path(): flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'check', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -861,6 +875,7 @@ def test_check_archives_with_exit_codes_calls_borg_using_them(): insert_execute_command_mock( ('borg', 'check', '--log-json', 'repo'), borg_exit_codes=borg_exit_codes ) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -886,6 +901,7 @@ def test_check_archives_with_remote_path_passes_through_to_borg(): flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--remote-path', 'borg1', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -912,6 +928,7 @@ def test_check_archives_with_umask_passes_through_to_borg(): flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--umask', '077', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -937,6 +954,7 @@ def test_check_archives_with_lock_wait_passes_through_to_borg(): flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--log-json', '--lock-wait', '5', 'repo')) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -963,6 +981,7 @@ def test_check_archives_with_retention_prefix(): flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -992,6 +1011,7 @@ def test_check_archives_with_extra_borg_options_passes_through_to_borg(): insert_execute_command_mock( ('borg', 'check', '--log-json', '--extra', '--options', 'value with space', 'repo'), ) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -1028,6 +1048,7 @@ def test_check_archives_with_match_archives_passes_through_to_borg(): borg_local_path='borg', borg_exit_codes=[{'code': 1, 'treat_as': 'error'}], ).once() + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', @@ -1059,6 +1080,7 @@ def test_check_archives_calls_borg_with_working_directory(): insert_execute_command_mock( ('borg', 'check', '--log-json', 'repo'), working_directory='/working/dir' ) + insert_logging_mock(logging.WARNING) module.check_archives( repository_path='repo', diff --git a/tests/unit/borg/test_compact.py b/tests/unit/borg/test_compact.py index a370d034..5c9b6bfe 100644 --- a/tests/unit/borg/test_compact.py +++ b/tests/unit/borg/test_compact.py @@ -33,6 +33,7 @@ COMPACT_COMMAND = ('borg', 'compact') def test_compact_segments_calls_borg_with_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock((*COMPACT_COMMAND, '--log-json', 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -83,6 +84,7 @@ def test_compact_segments_with_dry_run_skips_borg_call_when_feature_unavailable( flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').never() flexmock(module).should_receive('execute_command').never() flexmock(module.logger).should_receive('info').with_args('Skipping compact (dry run)').once() + insert_logging_mock(logging.WARNING) module.compact_segments( repository_path='repo', @@ -102,6 +104,7 @@ def test_compact_segments_with_dry_run_executes_borg_call_when_feature_available flexmock(module.environment).should_receive('make_environment').once() flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').once() flexmock(module).should_receive('execute_command').once() + insert_logging_mock(logging.WARNING) module.compact_segments( repository_path='repo', @@ -115,6 +118,7 @@ def test_compact_segments_with_dry_run_executes_borg_call_when_feature_available def test_compact_segments_with_local_path_calls_borg_via_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', *COMPACT_COMMAND[1:], '--log-json', 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -134,6 +138,7 @@ def test_compact_segments_with_exit_codes_calls_borg_using_them(): logging.INFO, borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -149,6 +154,7 @@ def test_compact_segments_with_remote_path_calls_borg_with_remote_path_flags(): insert_execute_command_mock( (*COMPACT_COMMAND, '--remote-path', 'borg1', '--log-json', 'repo'), logging.INFO ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -163,6 +169,7 @@ def test_compact_segments_with_remote_path_calls_borg_with_remote_path_flags(): def test_compact_segments_with_progress_calls_borg_with_progress_flag(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock((*COMPACT_COMMAND, '--progress', 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -178,6 +185,7 @@ def test_compact_segments_with_log_json_and_progress_calls_borg_with_both_flags( insert_execute_command_mock( (*COMPACT_COMMAND, '--log-json', '--progress', 'repo'), logging.INFO ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -193,6 +201,7 @@ def test_compact_segments_with_cleanup_commits_calls_borg_with_cleanup_commits_f insert_execute_command_mock( (*COMPACT_COMMAND, '--log-json', '--cleanup-commits', 'repo'), logging.INFO ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -209,6 +218,7 @@ def test_compact_segments_with_threshold_calls_borg_with_threshold_flag(): insert_execute_command_mock( (*COMPACT_COMMAND, '--log-json', '--threshold', '20', 'repo'), logging.INFO ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -225,6 +235,7 @@ def test_compact_segments_with_umask_calls_borg_with_umask_flags(): insert_execute_command_mock( (*COMPACT_COMMAND, '--umask', '077', '--log-json', 'repo'), logging.INFO ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -241,6 +252,7 @@ def test_compact_segments_with_lock_wait_calls_borg_with_lock_wait_flags(): insert_execute_command_mock( (*COMPACT_COMMAND, '--log-json', '--lock-wait', '5', 'repo'), logging.INFO ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -257,6 +269,7 @@ def test_compact_segments_with_extra_borg_options_calls_borg_with_extra_options( (*COMPACT_COMMAND, '--log-json', '--extra', '--options', 'value with space', 'repo'), logging.INFO, ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, @@ -274,6 +287,7 @@ def test_compact_segments_calls_borg_with_working_directory(): logging.INFO, working_directory='/working/dir', ) + insert_logging_mock(logging.WARNING) module.compact_segments( dry_run=False, diff --git a/tests/unit/borg/test_create.py b/tests/unit/borg/test_create.py index 2e21e648..f07ee419 100644 --- a/tests/unit/borg/test_create.py +++ b/tests/unit/borg/test_create.py @@ -1265,6 +1265,7 @@ def test_create_archive_calls_borg_with_flags(): working_directory=None, environment=None, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1299,6 +1300,7 @@ def test_create_archive_calls_borg_with_environment(): working_directory=None, environment=environment, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1504,6 +1506,7 @@ def test_create_archive_with_working_directory_calls_borg_with_working_directory working_directory='/working/dir', environment=None, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1539,6 +1542,7 @@ def test_create_archive_with_exit_codes_calls_borg_using_them(): working_directory=None, environment=None, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1573,6 +1577,7 @@ def test_create_archive_with_stats_calls_borg_with_stats_flag_and_answer_output_ working_directory=None, environment=None, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1611,6 +1616,7 @@ def test_create_archive_with_files_calls_borg_with_answer_output_log_level(): working_directory=None, environment=None, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1680,6 +1686,7 @@ def test_create_archive_with_progress_calls_borg_with_progress_flag(): working_directory=None, environment=None, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1731,6 +1738,7 @@ def test_create_archive_with_progress_and_stream_processes_calls_borg_with_progr working_directory=None, environment=None, ).and_yield() + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1764,6 +1772,7 @@ def test_create_archive_with_json_calls_borg_with_json_flag(): borg_local_path='borg', borg_exit_codes=None, ).and_yield('[]') + insert_logging_mock(logging.WARNING) json_output = module.create_archive( dry_run=False, @@ -1798,6 +1807,7 @@ def test_create_archive_with_stats_and_json_calls_borg_without_stats_flag(): borg_local_path='borg', borg_exit_codes=None, ).and_yield('[]') + insert_logging_mock(logging.WARNING) json_output = module.create_archive( dry_run=False, @@ -1834,6 +1844,7 @@ def test_create_archive_with_comment_calls_borg_with_comment_flag(): working_directory=None, environment=None, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, @@ -1871,6 +1882,7 @@ def test_create_archive_calls_borg_with_working_directory(): working_directory='/working/dir', environment=None, ) + insert_logging_mock(logging.WARNING) module.create_archive( dry_run=False, diff --git a/tests/unit/borg/test_delete.py b/tests/unit/borg/test_delete.py index f906331f..506317ef 100644 --- a/tests/unit/borg/test_delete.py +++ b/tests/unit/borg/test_delete.py @@ -9,13 +9,13 @@ from ..test_verbosity import insert_logging_mock def test_make_delete_command_includes_log_info(): - insert_logging_mock(logging.INFO) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.INFO) command = module.make_delete_command( repository={'path': 'repo'}, @@ -31,13 +31,13 @@ def test_make_delete_command_includes_log_info(): def test_make_delete_command_includes_log_debug(): - insert_logging_mock(logging.DEBUG) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.DEBUG) command = module.make_delete_command( repository={'path': 'repo'}, @@ -63,6 +63,7 @@ def test_make_delete_command_includes_dry_run(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -88,6 +89,7 @@ def test_make_delete_command_includes_remote_path(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -111,6 +113,7 @@ def test_make_delete_command_includes_umask(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -136,6 +139,7 @@ def test_make_delete_command_includes_lock_wait(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -157,6 +161,7 @@ def test_make_delete_command_includes_extra_borg_options(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -182,6 +187,7 @@ def test_make_delete_command_with_list_config_calls_borg_with_list_flag(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -203,6 +209,7 @@ def test_make_delete_command_includes_force(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -224,6 +231,7 @@ def test_make_delete_command_includes_force_twice(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -247,6 +255,7 @@ def test_make_delete_command_includes_archive(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -275,6 +284,7 @@ def test_make_delete_command_includes_match_archives(): flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',), ) + insert_logging_mock(logging.WARNING) command = module.make_delete_command( repository={'path': 'repo'}, @@ -307,6 +317,7 @@ def test_delete_archives_with_archive_calls_borg_delete(): ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').once() + insert_logging_mock(logging.WARNING) module.delete_archives( repository={'path': 'repo'}, @@ -327,6 +338,7 @@ def test_delete_archives_with_match_archives_calls_borg_delete(): ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').once() + insert_logging_mock(logging.WARNING) module.delete_archives( repository={'path': 'repo'}, @@ -348,6 +360,7 @@ def test_delete_archives_with_archive_related_argument_calls_borg_delete(argumen ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').once() + insert_logging_mock(logging.WARNING) module.delete_archives( repository={'path': 'repo'}, @@ -367,6 +380,7 @@ def test_delete_archives_without_archive_related_argument_calls_borg_repo_delete flexmock(module.borgmatic.borg.environment).should_receive('make_environment').never() flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').never() + insert_logging_mock(logging.WARNING) module.delete_archives( repository={'path': 'repo'}, @@ -403,6 +417,7 @@ def test_delete_archives_calls_borg_delete_with_working_directory(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.delete_archives( repository={'path': 'repo'}, diff --git a/tests/unit/borg/test_diff.py b/tests/unit/borg/test_diff.py index 040a94ee..c8b3663f 100644 --- a/tests/unit/borg/test_diff.py +++ b/tests/unit/borg/test_diff.py @@ -3,6 +3,7 @@ import logging from flexmock import flexmock from borgmatic.borg import diff as module +from ..test_verbosity import insert_logging_mock LOGGING_ANSWER = flexmock() @@ -40,6 +41,7 @@ def test_diff_calls_borg_with_archives(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -94,6 +96,7 @@ def test_diff_with_local_path_calls_borg_with_it(): borg_local_path='borg6', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -150,6 +153,7 @@ def test_diff_with_remote_path_calls_borg_with_it(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -206,6 +210,7 @@ def test_diff_with_lock_wait_calls_borg_with_it(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -262,6 +267,7 @@ def test_diff_with_log_level_info_calls_borg_with_info_flag(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -319,6 +325,7 @@ def test_diff_with_log_level_debug_calls_borg_with_debug_flags(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -376,6 +383,7 @@ def test_diff_with_only_patterns_calls_borg_with_configured_pattern_paths(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -434,6 +442,7 @@ def test_diff_with_exclude_config_calls_borg_with_exclude_flags(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -489,6 +498,7 @@ def test_diff_with_numeric_ids_calls_borg_with_numeric_ids_flag(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -549,6 +559,7 @@ def test_diff_with_numeric_ids_and_feature_not_available_calls_borg_with_numeric borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -604,6 +615,7 @@ def test_diff_with_same_chunker_params_calls_borg_with_it(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -660,6 +672,7 @@ def test_diff_with_sort_keys_calls_borg_with_formatted_sort_by_flags(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -715,6 +728,7 @@ def test_diff_with_content_only_calls_borg_with_it(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -771,6 +785,7 @@ def test_diff_with_extra_borg_options_calls_borg_with_them(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', @@ -828,6 +843,7 @@ def test_diff_without_separate_repository_archive_feature_available_calls_borg_j borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) module.borgmatic.borg.diff.diff( repository='repo', diff --git a/tests/unit/borg/test_export_key.py b/tests/unit/borg/test_export_key.py index 9995840f..51f487f1 100644 --- a/tests/unit/borg/test_export_key.py +++ b/tests/unit/borg/test_export_key.py @@ -36,6 +36,7 @@ def test_export_key_calls_borg_with_required_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -50,6 +51,7 @@ def test_export_key_calls_borg_with_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg1', 'key', 'export', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -66,6 +68,7 @@ def test_export_key_calls_borg_using_exit_codes(): flexmock(module.os.path).should_receive('exists').never() borg_exit_codes = flexmock() insert_execute_command_mock(('borg', 'key', 'export', 'repo'), borg_exit_codes=borg_exit_codes) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -80,6 +83,7 @@ def test_export_key_calls_borg_with_remote_path_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--remote-path', 'borg1', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -95,6 +99,7 @@ def test_export_key_calls_borg_with_umask_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--umask', '0770', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -109,6 +114,7 @@ def test_export_key_calls_borg_with_lock_wait_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--lock-wait', '5', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -123,6 +129,7 @@ def test_export_key_calls_borg_with_extra_borg_options(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--extra', 'value with space', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -167,6 +174,7 @@ def test_export_key_calls_borg_with_paper_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--paper', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -181,6 +189,7 @@ def test_export_key_calls_borg_with_paper_flag(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--paper', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -195,6 +204,7 @@ def test_export_key_calls_borg_with_qr_html_flag(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--qr-html', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -211,6 +221,7 @@ def test_export_key_calls_borg_with_path_argument(): insert_execute_command_mock( ('borg', 'key', 'export', '--log-json', 'repo', 'dest'), output_file=None ) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -240,6 +251,7 @@ def test_export_key_with_stdout_path_calls_borg_without_path_argument(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', 'repo')) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -268,6 +280,7 @@ def test_export_key_calls_borg_with_working_directory(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', 'repo'), working_directory='/working/dir') + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', @@ -288,6 +301,7 @@ def test_export_key_calls_borg_with_path_argument_and_working_directory(): output_file=None, working_directory='/working/dir', ) + insert_logging_mock(logging.WARNING) module.export_key( repository_path='repo', diff --git a/tests/unit/borg/test_export_tar.py b/tests/unit/borg/test_export_tar.py index 58573789..57e670fc 100644 --- a/tests/unit/borg/test_export_tar.py +++ b/tests/unit/borg/test_export_tar.py @@ -39,6 +39,7 @@ def test_export_tar_archive_calls_borg_with_path_flags(): insert_execute_command_mock( ('borg', 'export-tar', '--log-json', 'repo::archive', 'test.tar', 'path1', 'path2'), ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -62,6 +63,7 @@ def test_export_tar_archive_calls_borg_with_local_path_flags(): ('borg1', 'export-tar', '--log-json', 'repo::archive', 'test.tar'), borg_local_path='borg1', ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -87,6 +89,7 @@ def test_export_tar_archive_calls_borg_using_exit_codes(): ('borg', 'export-tar', '--log-json', 'repo::archive', 'test.tar'), borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -109,6 +112,7 @@ def test_export_tar_archive_calls_borg_with_remote_path_flags(): insert_execute_command_mock( ('borg', 'export-tar', '--remote-path', 'borg1', '--log-json', 'repo::archive', 'test.tar'), ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -132,6 +136,7 @@ def test_export_tar_archive_calls_borg_with_umask_flags(): insert_execute_command_mock( ('borg', 'export-tar', '--umask', '0770', '--log-json', 'repo::archive', 'test.tar'), ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -154,6 +159,7 @@ def test_export_tar_archive_calls_borg_with_lock_wait_flags(): insert_execute_command_mock( ('borg', 'export-tar', '--log-json', '--lock-wait', '5', 'repo::archive', 'test.tar'), ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -184,6 +190,7 @@ def test_export_tar_archive_calls_borg_with_extra_borg_options(): 'test.tar', ), ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -272,6 +279,7 @@ def test_export_tar_archive_calls_borg_with_tar_filter_flags(): insert_execute_command_mock( ('borg', 'export-tar', '--log-json', '--tar-filter', 'bzip2', 'repo::archive', 'test.tar'), ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -296,6 +304,7 @@ def test_export_tar_archive_calls_borg_with_list_flag(): ('borg', 'export-tar', '--log-json', '--list', 'repo::archive', 'test.tar'), output_log_level=logging.ANSWER, ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -326,6 +335,7 @@ def test_export_tar_archive_calls_borg_with_strip_components_flag(): 'test.tar', ), ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -349,6 +359,7 @@ def test_export_tar_archive_skips_abspath_for_remote_repository_flag(): insert_execute_command_mock( ('borg', 'export-tar', '--log-json', 'server:repo::archive', 'test.tar') ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -369,6 +380,7 @@ def test_export_tar_archive_calls_borg_with_stdout_destination_path(): ('repo::archive',), ) insert_execute_command_mock(('borg', 'export-tar', 'repo::archive', '-'), capture=False) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, @@ -392,6 +404,7 @@ def test_export_tar_archive_calls_borg_with_working_directory(): ('borg', 'export-tar', '--log-json', 'repo::archive', 'test.tar'), working_directory='/working/dir', ) + insert_logging_mock(logging.WARNING) module.export_tar_archive( dry_run=False, diff --git a/tests/unit/borg/test_extract.py b/tests/unit/borg/test_extract.py index 6983248d..29bdc9e2 100644 --- a/tests/unit/borg/test_extract.py +++ b/tests/unit/borg/test_extract.py @@ -25,6 +25,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_last_archive(): flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.WARNING) module.extract_last_archive_dry_run( config={}, @@ -38,6 +39,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_last_archive(): def test_extract_last_archive_dry_run_without_any_archives_should_not_raise(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_raise(ValueError) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) module.extract_last_archive_dry_run( config={}, @@ -53,10 +55,10 @@ def test_extract_last_archive_dry_run_with_log_info_calls_borg_with_info_paramet insert_execute_command_mock( ('borg', 'extract', '--dry-run', '--log-json', '--info', 'repo::archive') ) - insert_logging_mock(logging.INFO) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.INFO) module.extract_last_archive_dry_run( config={}, @@ -81,10 +83,10 @@ def test_extract_last_archive_dry_run_with_log_debug_calls_borg_with_debug_param 'repo::archive', ), ) - insert_logging_mock(logging.DEBUG) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.DEBUG) module.extract_last_archive_dry_run( config={}, @@ -109,6 +111,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_progress_flag(): flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.WARNING) module.extract_last_archive_dry_run( config={'progress': True}, @@ -125,6 +128,7 @@ def test_extract_last_archive_dry_run_calls_borg_via_local_path(): flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.WARNING) module.extract_last_archive_dry_run( config={}, @@ -146,6 +150,7 @@ def test_extract_last_archive_dry_run_calls_borg_using_exit_codes(): flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.WARNING) module.extract_last_archive_dry_run( config={'borg_exit_codes': borg_exit_codes}, @@ -164,6 +169,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_remote_path_flags(): flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.WARNING) module.extract_last_archive_dry_run( config={}, @@ -183,6 +189,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_lock_wait_flags(): flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.WARNING) module.extract_last_archive_dry_run( config={}, @@ -209,6 +216,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_extra_borg_options(): flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',), ) + insert_logging_mock(logging.WARNING) module.extract_last_archive_dry_run( config={'extra_borg_options': {'extract': '--extra "value with space"'}}, @@ -231,6 +239,7 @@ def test_extract_archive_calls_borg_with_path_flags(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -254,6 +263,7 @@ def test_extract_archive_calls_borg_with_local_path(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -282,6 +292,7 @@ def test_extract_archive_calls_borg_with_exit_codes(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -307,6 +318,7 @@ def test_extract_archive_calls_borg_with_remote_path_flags(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -338,6 +350,7 @@ def test_extract_archive_calls_borg_with_numeric_ids_parameter(feature_available flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -363,6 +376,7 @@ def test_extract_archive_calls_borg_with_umask_flags(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -388,6 +402,7 @@ def test_extract_archive_calls_borg_with_lock_wait_flags(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -413,6 +428,7 @@ def test_extract_archive_calls_borg_with_extra_borg_options(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -428,7 +444,6 @@ def test_extract_archive_calls_borg_with_extra_borg_options(): def test_extract_archive_with_log_info_calls_borg_with_info_parameter(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '--log-json', '--info', 'repo::archive')) - insert_logging_mock(logging.INFO) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( @@ -437,6 +452,7 @@ def test_extract_archive_with_log_info_calls_borg_with_info_parameter(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.INFO) module.extract_archive( dry_run=False, @@ -454,7 +470,6 @@ def test_extract_archive_with_log_debug_calls_borg_with_debug_flags(): insert_execute_command_mock( ('borg', 'extract', '--log-json', '--debug', '--list', '--show-rc', 'repo::archive'), ) - insert_logging_mock(logging.DEBUG) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( @@ -463,6 +478,7 @@ def test_extract_archive_with_log_debug_calls_borg_with_debug_flags(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.DEBUG) module.extract_archive( dry_run=False, @@ -486,6 +502,7 @@ def test_extract_archive_calls_borg_with_dry_run_parameter(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=True, @@ -511,6 +528,7 @@ def test_extract_archive_calls_borg_with_destination_path(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -537,6 +555,7 @@ def test_extract_archive_calls_borg_with_strip_components(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -572,6 +591,7 @@ def test_extract_archive_calls_borg_with_strip_components_calculated_from_all(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -607,6 +627,7 @@ def test_extract_archive_calls_borg_with_strip_components_calculated_from_all_wi flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -631,6 +652,7 @@ def test_extract_archive_with_strip_components_all_and_no_paths_raises(): 'normalize_repository_path', ).and_return('repo') flexmock(module).should_receive('execute_command').never() + insert_logging_mock(logging.WARNING) with pytest.raises(ValueError): module.extract_archive( @@ -665,6 +687,7 @@ def test_extract_archive_calls_borg_with_progress_flag(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -697,6 +720,7 @@ def test_extract_archive_with_log_json_and_progress_calls_borg_with_both_flags() flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -731,6 +755,7 @@ def test_extract_archive_calls_borg_with_extract_to_stdout_returns_process(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) assert ( module.extract_archive( @@ -769,6 +794,7 @@ def test_extract_archive_with_progress_and_extract_to_stdout_ignores_progress(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) assert ( module.extract_archive( @@ -804,6 +830,7 @@ def test_extract_archive_skips_abspath_for_remote_repository(): flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).and_return('repo') + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -829,6 +856,7 @@ def test_extract_archive_uses_configured_working_directory_in_repo_path_and_dest flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).with_args('repo', '/working/dir').and_return('/working/dir/repo').once() + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, @@ -852,6 +880,7 @@ def test_extract_archive_uses_configured_working_directory_in_repo_path_when_des flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path', ).with_args('repo', '/working/dir').and_return('/working/dir/repo').once() + insert_logging_mock(logging.WARNING) module.extract_archive( dry_run=False, diff --git a/tests/unit/borg/test_import_key.py b/tests/unit/borg/test_import_key.py index 3bd9fc58..6aed2d67 100644 --- a/tests/unit/borg/test_import_key.py +++ b/tests/unit/borg/test_import_key.py @@ -32,6 +32,7 @@ def test_import_key_calls_borg_with_required_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'import', '--log-json', 'repo', '-')) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -47,6 +48,7 @@ def test_import_key_calls_borg_with_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg1', 'key', 'import', '--log-json', 'repo', '-')) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -66,6 +68,7 @@ def test_import_key_calls_borg_using_exit_codes(): insert_execute_command_mock( ('borg', 'key', 'import', '--log-json', 'repo', '-'), borg_exit_codes=borg_exit_codes ) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -83,6 +86,7 @@ def test_import_key_calls_borg_with_remote_path_flags(): insert_execute_command_mock( ('borg', 'key', 'import', '--remote-path', 'borg1', '--log-json', 'repo', '-') ) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -101,6 +105,7 @@ def test_import_key_calls_borg_with_umask_flags(): insert_execute_command_mock( ('borg', 'key', 'import', '--umask', '0770', '--log-json', 'repo', '-') ) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -118,6 +123,7 @@ def test_import_key_calls_borg_with_lock_wait_flags(): insert_execute_command_mock( ('borg', 'key', 'import', '--log-json', '--lock-wait', '5', 'repo', '-') ) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -135,6 +141,7 @@ def test_import_key_calls_borg_with_extra_borg_options(): insert_execute_command_mock( ('borg', 'key', 'import', '--log-json', '--extra', 'value with space', 'repo', '-') ) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -184,6 +191,7 @@ def test_import_key_calls_borg_with_paper_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'import', '--log-json', '--paper', 'repo', '-')) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -201,6 +209,7 @@ def test_import_key_calls_borg_with_path_argument(): insert_execute_command_mock( ('borg', 'key', 'import', '--log-json', 'repo', 'source'), ) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -232,6 +241,7 @@ def test_import_key_with_stdin_path_calls_borg_without_path_argument(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'import', '--log-json', 'repo', '-')) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -264,6 +274,7 @@ def test_import_key_calls_borg_with_working_directory(): insert_execute_command_mock( ('borg', 'key', 'import', '--log-json', 'repo', '-'), working_directory='/working/dir' ) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', @@ -284,6 +295,7 @@ def test_import_key_calls_borg_with_path_argument_and_working_directory(): ('borg', 'key', 'import', '--log-json', 'repo', 'source'), working_directory='/working/dir', ) + insert_logging_mock(logging.WARNING) module.import_key( repository_path='repo', diff --git a/tests/unit/borg/test_info.py b/tests/unit/borg/test_info.py index eb34b6a3..2ed8a07c 100644 --- a/tests/unit/borg/test_info.py +++ b/tests/unit/borg/test_info.py @@ -17,6 +17,7 @@ def test_make_info_command_constructs_borg_info_command(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -112,6 +113,7 @@ def test_make_info_command_with_log_debug_and_json_omits_borg_logging_flags(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -135,6 +137,7 @@ def test_make_info_command_with_json_passes_through_to_command(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -158,6 +161,7 @@ def test_make_info_command_with_archive_uses_match_archives_flags(): ).and_return(('--match-archives', 'archive')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -189,6 +193,7 @@ def test_make_info_command_with_local_path_passes_through_to_command(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -216,6 +221,7 @@ def test_make_info_command_with_remote_path_passes_through_to_command(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -241,6 +247,7 @@ def test_make_info_command_with_umask_passes_through_to_command(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -268,6 +275,7 @@ def test_make_info_command_with_lock_wait_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) config = {'lock_wait': 5} + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -292,6 +300,7 @@ def test_make_info_command_with_extra_borg_options_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) config = {'extra_borg_options': {'info': '--extra "value with space"'}} + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -327,6 +336,7 @@ def test_make_info_command_transforms_prefix_into_match_archives_flags(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -362,6 +372,7 @@ def test_make_info_command_prefers_prefix_over_archive_name_format(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -393,6 +404,7 @@ def test_make_info_command_transforms_archive_name_format_into_match_archives_fl ).and_return(('--match-archives', 'sh:bar-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -425,6 +437,7 @@ def test_make_info_command_with_match_archives_option_passes_through_to_command( flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -460,6 +473,7 @@ def test_make_info_command_with_match_archives_flag_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -496,6 +510,7 @@ def test_make_info_command_passes_arguments_through_to_command(argument_name): ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', @@ -537,6 +552,7 @@ def test_make_info_command_with_date_based_matching_passes_through_to_command(): older='1m', oldest='1w', ) + insert_logging_mock(logging.WARNING) command = module.make_info_command( repository_path='repo', diff --git a/tests/unit/borg/test_list.py b/tests/unit/borg/test_list.py index fbb3a3a7..67ed518e 100644 --- a/tests/unit/borg/test_list.py +++ b/tests/unit/borg/test_list.py @@ -78,6 +78,7 @@ def test_make_list_command_includes_json_but_not_debug(): def test_make_list_command_includes_json(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) @@ -94,6 +95,7 @@ def test_make_list_command_includes_json(): def test_make_list_command_includes_lock_wait(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()).and_return(()).and_return( ('--lock-wait', '5'), ).and_return(()) @@ -112,6 +114,7 @@ def test_make_list_command_includes_lock_wait(): def test_make_list_command_includes_format(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()).and_return(()).and_return( () ).and_return(('--format', 'stuff')) @@ -130,6 +133,7 @@ def test_make_list_command_includes_format(): def test_make_list_command_includes_extra_borg_options(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) @@ -146,6 +150,7 @@ def test_make_list_command_includes_extra_borg_options(): def test_make_list_command_includes_archive(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( @@ -164,6 +169,7 @@ def test_make_list_command_includes_archive(): def test_make_list_command_includes_archive_and_path(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( @@ -182,6 +188,7 @@ def test_make_list_command_includes_archive_and_path(): def test_make_list_command_includes_local_path(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) @@ -199,6 +206,7 @@ def test_make_list_command_includes_local_path(): def test_make_list_command_includes_remote_path(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args( 'remote-path', @@ -223,6 +231,7 @@ def test_make_list_command_includes_remote_path(): def test_make_list_command_includes_umask(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else (), ) @@ -241,6 +250,7 @@ def test_make_list_command_includes_umask(): def test_make_list_command_includes_short(): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--short',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) @@ -271,6 +281,7 @@ def test_make_list_command_includes_short(): ), ) def test_make_list_command_includes_additional_flags(argument_name): + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( (f"--{argument_name.replace('_', '-')}", 'value'), diff --git a/tests/unit/borg/test_mount.py b/tests/unit/borg/test_mount.py index 4d3fe57a..6e67ce32 100644 --- a/tests/unit/borg/test_mount.py +++ b/tests/unit/borg/test_mount.py @@ -25,6 +25,7 @@ def test_mount_archive_calls_borg_with_required_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'mount', '--log-json', 'repo', '/mnt')) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -48,6 +49,7 @@ def test_mount_archive_with_borg_features_calls_borg_with_repository_and_match_a insert_execute_command_mock( ('borg', 'mount', '--log-json', '--repo', 'repo', '--match-archives', 'archive', '/mnt'), ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -66,6 +68,7 @@ def test_mount_archive_without_archive_calls_borg_with_repository_flags_only(): ('repo::archive',), ) insert_execute_command_mock(('borg', 'mount', '--log-json', 'repo::archive', '/mnt')) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -86,6 +89,7 @@ def test_mount_archive_calls_borg_with_path_flags(): insert_execute_command_mock( ('borg', 'mount', '--log-json', 'repo::archive', '/mnt', 'path1', 'path2') ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock( mount_point='/mnt', @@ -109,6 +113,7 @@ def test_mount_archive_calls_borg_with_local_path(): ('repo::archive',), ) insert_execute_command_mock(('borg1', 'mount', '--log-json', 'repo::archive', '/mnt')) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -132,6 +137,7 @@ def test_mount_archive_calls_borg_using_exit_codes(): ('borg', 'mount', '--log-json', 'repo::archive', '/mnt'), borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -152,6 +158,7 @@ def test_mount_archive_calls_borg_with_remote_path_flags(): insert_execute_command_mock( ('borg', 'mount', '--remote-path', 'borg1', '--log-json', 'repo::archive', '/mnt'), ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -173,6 +180,7 @@ def test_mount_archive_calls_borg_with_umask_flags(): insert_execute_command_mock( ('borg', 'mount', '--umask', '0770', '--log-json', 'repo::archive', '/mnt') ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -193,6 +201,7 @@ def test_mount_archive_calls_borg_with_lock_wait_flags(): insert_execute_command_mock( ('borg', 'mount', '--log-json', '--lock-wait', '5', 'repo::archive', '/mnt') ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -213,6 +222,7 @@ def test_mount_archive_calls_borg_with_extra_borg_options(): insert_execute_command_mock( ('borg', 'mount', '--log-json', '--extra', 'value with space', 'repo::archive', '/mnt') ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( @@ -280,6 +290,7 @@ def test_mount_archive_calls_borg_with_foreground_parameter(): borg_local_path='borg', borg_exit_codes=None, ).once() + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=True) module.mount_archive( @@ -300,6 +311,7 @@ def test_mount_archive_calls_borg_with_options_flags(): insert_execute_command_mock( ('borg', 'mount', '--log-json', '-o', 'super_mount', 'repo::archive', '/mnt') ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock( mount_point='/mnt', @@ -361,6 +373,7 @@ def test_mount_archive_with_date_based_matching_calls_borg_with_date_based_flags borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock( mount_point='/mnt', @@ -388,6 +401,7 @@ def test_mount_archive_calls_borg_with_working_directory(): insert_execute_command_mock( ('borg', 'mount', '--log-json', 'repo', '/mnt'), working_directory='/working/dir' ) + insert_logging_mock(logging.WARNING) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( diff --git a/tests/unit/borg/test_prune.py b/tests/unit/borg/test_prune.py index fc7d6d10..00e9e98f 100644 --- a/tests/unit/borg/test_prune.py +++ b/tests/unit/borg/test_prune.py @@ -244,6 +244,7 @@ def test_prune_archives_calls_borg_with_flags(): '1.2.3', ).and_return(False) insert_execute_command_mock((*PRUNE_COMMAND, 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -312,6 +313,7 @@ def test_prune_archives_with_dry_run_calls_borg_with_dry_run_flag(): '1.2.3', ).and_return(False) insert_execute_command_mock((*PRUNE_COMMAND, '--dry-run', 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -334,6 +336,7 @@ def test_prune_archives_with_local_path_calls_borg_via_local_path(): '1.2.3', ).and_return(False) insert_execute_command_mock(('borg1', *PRUNE_COMMAND[1:], 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -362,6 +365,7 @@ def test_prune_archives_with_exit_codes_calls_borg_using_them(): logging.INFO, borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -384,6 +388,7 @@ def test_prune_archives_with_remote_path_calls_borg_with_remote_path_flags(): '1.2.3', ).and_return(False) insert_execute_command_mock((*PRUNE_COMMAND, '--remote-path', 'borg1', 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -407,6 +412,7 @@ def test_prune_archives_with_stats_config_calls_borg_with_stats_flag(): '1.2.3', ).and_return(False) insert_execute_command_mock((*PRUNE_COMMAND, '--stats', 'repo'), module.borgmatic.logger.ANSWER) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=None, list_details=False) module.prune_archives( @@ -429,6 +435,7 @@ def test_prune_archives_with_list_config_calls_borg_with_list_flag(): '1.2.3', ).and_return(False) insert_execute_command_mock((*PRUNE_COMMAND, '--list', 'repo'), module.borgmatic.logger.ANSWER) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=None) module.prune_archives( @@ -452,6 +459,7 @@ def test_prune_archives_with_umask_calls_borg_with_umask_flags(): '1.2.3', ).and_return(False) insert_execute_command_mock((*PRUNE_COMMAND, '--umask', '077', 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -475,6 +483,7 @@ def test_prune_archives_with_lock_wait_calls_borg_with_lock_wait_flags(): '1.2.3', ).and_return(False) insert_execute_command_mock((*PRUNE_COMMAND, '--lock-wait', '5', 'repo'), logging.INFO) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -500,6 +509,7 @@ def test_prune_archives_with_extra_borg_options_calls_borg_with_extra_options(): (*PRUNE_COMMAND, '--extra', '--options', 'value with space', 'repo'), logging.INFO, ) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -569,6 +579,7 @@ def test_prune_archives_with_date_based_matching_calls_borg_with_date_based_flag borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock( statistics=False, @@ -602,6 +613,7 @@ def test_prune_archives_calls_borg_with_working_directory(): logging.INFO, working_directory='/working/dir', ) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=False, list_details=False) module.prune_archives( @@ -624,6 +636,7 @@ def test_prune_archives_calls_borg_without_stats_when_feature_is_not_available() '2.0.0b10', ).and_return(True) insert_execute_command_mock((*PRUNE_COMMAND, 'repo'), logging.ANSWER) + insert_logging_mock(logging.WARNING) prune_arguments = flexmock(statistics=True, list_details=False) module.prune_archives( diff --git a/tests/unit/borg/test_recreate.py b/tests/unit/borg/test_recreate.py index ac402728..36db2466 100644 --- a/tests/unit/borg/test_recreate.py +++ b/tests/unit/borg/test_recreate.py @@ -35,6 +35,7 @@ def test_recreate_calls_borg_with_required_flags(): ), ) insert_execute_command_mock(('borg', 'recreate', '--log-json', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -70,6 +71,7 @@ def test_recreate_with_dry_run_calls_borg_with_dry_run_flag(): ), ) insert_execute_command_mock(('borg', 'recreate', '--log-json', '--dry-run', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -107,6 +109,7 @@ def test_recreate_with_remote_path(): insert_execute_command_mock( ('borg', 'recreate', '--remote-path', 'borg1', '--log-json', '--repo', 'repo') ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -144,6 +147,7 @@ def test_recreate_with_lock_wait(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--lock-wait', '5', '--repo', 'repo') ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -180,6 +184,7 @@ def test_recreate_with_extra_borg_options(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--extra', 'value with space', '--repo', 'repo') ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -214,7 +219,6 @@ def test_recreate_with_log_info(): ), ) insert_execute_command_mock(('borg', 'recreate', '--log-json', '--info', '--repo', 'repo')) - insert_logging_mock(logging.INFO) module.recreate_archive( @@ -291,6 +295,7 @@ def test_recreate_with_list_config_calls_borg_with_list_flag(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--list', '--filter', 'AME+-', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -328,6 +333,7 @@ def test_recreate_with_patterns_from_flag(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--patterns-from', 'patterns_file', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -366,6 +372,7 @@ def test_recreate_with_exclude_flags(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--exclude', 'pattern', '--repo', 'repo') ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -402,6 +409,7 @@ def test_recreate_with_target_flag(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--target', 'new-archive', '--repo', 'repo') ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -446,6 +454,7 @@ def test_recreate_with_comment_flag(): 'repo', ), ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -482,6 +491,7 @@ def test_recreate_with_timestamp_flag(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--timestamp', '2023-10-01T12:00:00', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -518,6 +528,7 @@ def test_recreate_with_compression_flag(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--compression', 'lz4', '--repo', 'repo') ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -554,6 +565,7 @@ def test_recreate_with_chunker_params_flag(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--chunker-params', '19,23,21,4095', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -590,6 +602,7 @@ def test_recreate_with_recompress_flag(): insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--recompress', 'always', '--repo', 'repo') ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -624,6 +637,7 @@ def test_recreate_with_match_archives_star(): ), ) insert_execute_command_mock(('borg', 'recreate', '--log-json', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -658,6 +672,7 @@ def test_recreate_with_match_archives_regex(): ), ) insert_execute_command_mock(('borg', 'recreate', '--log-json', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -692,6 +707,7 @@ def test_recreate_with_match_archives_shell(): ), ) insert_execute_command_mock(('borg', 'recreate', '--log-json', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -728,6 +744,7 @@ def test_recreate_with_match_archives_and_feature_available_calls_borg_with_matc insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--repo', 'repo', '--match-archives', 'foo-*') ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -764,6 +781,7 @@ def test_recreate_with_archives_flag_and_feature_available_calls_borg_with_match insert_execute_command_mock( ('borg', 'recreate', '--log-json', '--repo', 'repo', '--match-archives', 'archive'), ) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -794,6 +812,7 @@ def test_recreate_with_match_archives_and_feature_not_available_calls_borg_witho ) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never() insert_execute_command_mock(('borg', 'recreate', '--log-json', 'repo')) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', @@ -824,6 +843,7 @@ def test_recreate_with_archives_flags_and_feature_not_available_calls_borg_with_ ).and_return(('repo::archive',)) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').never() insert_execute_command_mock(('borg', 'recreate', '--log-json', 'repo::archive')) + insert_logging_mock(logging.WARNING) module.recreate_archive( repository='repo', diff --git a/tests/unit/borg/test_repo_create.py b/tests/unit/borg/test_repo_create.py index e7222b52..92515671 100644 --- a/tests/unit/borg/test_repo_create.py +++ b/tests/unit/borg/test_repo_create.py @@ -50,6 +50,7 @@ def insert_repo_create_command_mock( def test_create_repository_calls_borg_with_flags(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock((*REPO_CREATE_COMMAND, '--repo', 'repo')) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -177,6 +178,7 @@ def test_create_repository_with_source_repository_calls_borg_with_other_repo_fla insert_repo_create_command_mock( (*REPO_CREATE_COMMAND, '--other-repo', 'other.borg', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -199,6 +201,7 @@ def test_create_repository_with_source_repository_calls_borg_with_other_repo_fla def test_create_repository_with_copy_crypt_key_calls_borg_with_copy_crypt_key_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock((*REPO_CREATE_COMMAND, '--copy-crypt-key', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -221,6 +224,7 @@ def test_create_repository_with_copy_crypt_key_calls_borg_with_copy_crypt_key_fl def test_create_repository_with_append_only_calls_borg_with_append_only_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock((*REPO_CREATE_COMMAND, '--append-only', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -243,6 +247,7 @@ def test_create_repository_with_append_only_calls_borg_with_append_only_flag(): def test_create_repository_with_append_only_config_calls_borg_with_append_only_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock((*REPO_CREATE_COMMAND, '--append-only', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -267,6 +272,7 @@ def test_create_repository_with_storage_quota_calls_borg_with_storage_quota_flag insert_repo_create_command_mock( (*REPO_CREATE_COMMAND, '--storage-quota', '5G', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -289,6 +295,7 @@ def test_create_repository_with_storage_quota_calls_borg_with_storage_quota_flag def test_create_repository_with_make_parent_dirs_calls_borg_with_make_parent_dirs_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock((*REPO_CREATE_COMMAND, '--make-parent-dirs', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -355,6 +362,7 @@ def test_create_repository_with_log_debug_calls_borg_with_debug_flag(): def test_create_repository_with_lock_wait_calls_borg_with_lock_wait_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock((*REPO_CREATE_COMMAND, '--lock-wait', '5', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -376,6 +384,7 @@ def test_create_repository_with_lock_wait_calls_borg_with_lock_wait_flag(): def test_create_repository_with_local_path_calls_borg_via_local_path(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(('borg1', *REPO_CREATE_COMMAND[1:], '--repo', 'repo')) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -402,6 +411,7 @@ def test_create_repository_with_exit_codes_calls_borg_using_them(): ('borg', *REPO_CREATE_COMMAND[1:], '--repo', 'repo'), borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -425,6 +435,7 @@ def test_create_repository_with_remote_path_calls_borg_with_remote_path_flag(): insert_repo_create_command_mock( (*REPO_CREATE_COMMAND, '--remote-path', 'borg1', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -447,6 +458,7 @@ def test_create_repository_with_remote_path_calls_borg_with_remote_path_flag(): def test_create_repository_with_umask_calls_borg_with_umask_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock((*REPO_CREATE_COMMAND, '--umask', '077', '--repo', 'repo')) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -470,6 +482,7 @@ def test_create_repository_calls_borg_with_extra_borg_options(): insert_repo_create_command_mock( (*REPO_CREATE_COMMAND, '--extra', '--options', 'value with space', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -494,6 +507,7 @@ def test_create_repository_calls_borg_with_extra_borg_options_from_deprecated_in insert_repo_create_command_mock( (*REPO_CREATE_COMMAND, '--extra', '--options', 'value with space', '--repo', 'repo'), ) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( @@ -518,6 +532,7 @@ def test_create_repository_calls_borg_with_working_directory(): (*REPO_CREATE_COMMAND, '--repo', 'repo'), working_directory='/working/dir', ) + insert_logging_mock(logging.WARNING) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( diff --git a/tests/unit/borg/test_repo_delete.py b/tests/unit/borg/test_repo_delete.py index 6f096721..c7968ad0 100644 --- a/tests/unit/borg/test_repo_delete.py +++ b/tests/unit/borg/test_repo_delete.py @@ -8,6 +8,7 @@ from ..test_verbosity import insert_logging_mock def test_make_repo_delete_command_with_feature_available_runs_borg_repo_delete(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) @@ -30,6 +31,7 @@ def test_make_repo_delete_command_with_feature_available_runs_borg_repo_delete() def test_make_repo_delete_command_without_feature_available_runs_borg_delete(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(False) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) @@ -98,6 +100,7 @@ def test_make_repo_delete_command_includes_log_debug(): def test_make_repo_delete_command_includes_dry_run(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( @@ -124,6 +127,7 @@ def test_make_repo_delete_command_includes_dry_run(): def test_make_repo_delete_command_includes_remote_path(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( @@ -150,6 +154,7 @@ def test_make_repo_delete_command_includes_remote_path(): def test_make_repo_delete_command_includes_umask(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else (), @@ -174,6 +179,7 @@ def test_make_repo_delete_command_includes_umask(): def test_make_repo_delete_command_includes_lock_wait(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( @@ -200,6 +206,7 @@ def test_make_repo_delete_command_includes_lock_wait(): def test_make_repo_delete_command_without_feature_available_includes_delete_extra_borg_options(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(False) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) @@ -222,6 +229,7 @@ def test_make_repo_delete_command_without_feature_available_includes_delete_extr def test_make_repo_delete_command_with_feature_available_includes_delete_extra_borg_options(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) @@ -244,6 +252,7 @@ def test_make_repo_delete_command_with_feature_available_includes_delete_extra_b def test_make_repo_delete_command_includes_list(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( @@ -270,6 +279,7 @@ def test_make_repo_delete_command_includes_list(): def test_make_repo_delete_command_includes_force(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) @@ -292,6 +302,7 @@ def test_make_repo_delete_command_includes_force(): def test_make_repo_delete_command_includes_force_twice(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) @@ -314,6 +325,7 @@ def test_make_repo_delete_command_includes_force_twice(): def test_make_repo_delete_command_with_output_file_omits_log_json(): + insert_logging_mock(logging.WARNING) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) diff --git a/tests/unit/borg/test_repo_info.py b/tests/unit/borg/test_repo_info.py index a85837ff..ee8b6316 100644 --- a/tests/unit/borg/test_repo_info.py +++ b/tests/unit/borg/test_repo_info.py @@ -38,6 +38,7 @@ def test_display_repository_info_calls_borg_with_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -74,6 +75,7 @@ def test_display_repository_info_without_borg_features_calls_borg_with_info_sub_ borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -116,6 +118,7 @@ def test_display_repository_info_with_log_info_calls_borg_with_info_flag(): borg_exit_codes=None, ) insert_logging_mock(logging.INFO) + module.display_repository_info( repository_path='repo', config={}, @@ -148,8 +151,8 @@ def test_display_repository_info_with_log_info_and_json_suppresses_most_borg_out borg_exit_codes=None, ).and_yield('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() - insert_logging_mock(logging.INFO) + json_output = module.display_repository_info( repository_path='repo', config={}, @@ -226,8 +229,8 @@ def test_display_repository_info_with_log_debug_and_json_suppresses_most_borg_ou borg_exit_codes=None, ).and_yield('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() - insert_logging_mock(logging.DEBUG) + json_output = module.display_repository_info( repository_path='repo', config={}, @@ -262,6 +265,7 @@ def test_display_repository_info_with_json_calls_borg_with_json_flag(): borg_exit_codes=None, ).and_yield('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() + insert_logging_mock(logging.WARNING) json_output = module.display_repository_info( repository_path='repo', @@ -305,6 +309,7 @@ def test_display_repository_info_with_local_path_calls_borg_via_local_path(): borg_local_path='borg1', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -348,6 +353,7 @@ def test_display_repository_info_with_exit_codes_calls_borg_using_them(): borg_local_path='borg', borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -389,6 +395,7 @@ def test_display_repository_info_with_remote_path_calls_borg_with_remote_path_fl borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -431,6 +438,7 @@ def test_display_repository_info_with_umask_calls_borg_with_umask_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -467,6 +475,7 @@ def test_display_repository_info_with_lock_wait_calls_borg_with_lock_wait_flags( borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -509,6 +518,7 @@ def test_display_repository_info_without_feature_available_calls_borg_with_info_ borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -561,6 +571,7 @@ def test_display_repository_info_with_feature_available_calls_borg_with_repo_inf borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', @@ -604,6 +615,7 @@ def test_display_repository_info_calls_borg_with_working_directory(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.display_repository_info( repository_path='repo', diff --git a/tests/unit/borg/test_repo_list.py b/tests/unit/borg/test_repo_list.py index a99ad41b..47b21c73 100644 --- a/tests/unit/borg/test_repo_list.py +++ b/tests/unit/borg/test_repo_list.py @@ -126,6 +126,7 @@ def test_get_latest_archive_calls_borg_with_flags(): environment=None, working_directory=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -218,6 +219,7 @@ def test_get_latest_archive_with_local_path_calls_borg_via_local_path(): borg_local_path='borg1', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -250,6 +252,7 @@ def test_get_latest_archive_with_exit_codes_calls_borg_using_them(): borg_local_path='borg', borg_exit_codes=borg_exit_codes, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -283,6 +286,7 @@ def test_get_latest_archive_with_remote_path_calls_borg_with_remote_path_flags() borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -317,6 +321,7 @@ def test_get_latest_archive_with_umask_calls_borg_with_umask_flags(): borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -346,6 +351,7 @@ def test_get_latest_archive_without_archives_raises(): borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': []})) + insert_logging_mock(logging.WARNING) with pytest.raises(ValueError): module.get_latest_archive( @@ -377,6 +383,7 @@ def test_get_latest_archive_with_lock_wait_calls_borg_with_lock_wait_flags(): borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -409,6 +416,7 @@ def test_get_latest_archive_with_match_archives_calls_borg_with_match_archives_f borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -447,6 +455,7 @@ def test_get_latest_archive_calls_borg_with_list_extra_borg_options(): borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -485,6 +494,7 @@ def test_get_latest_archive_with_feature_available_calls_borg_with_repo_list_ext borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -518,6 +528,7 @@ def test_get_latest_archive_with_consider_checkpoints_calls_borg_with_consider_c borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -552,6 +563,7 @@ def test_get_latest_archive_with_consider_checkpoints_and_feature_available_call borg_local_path='borg', borg_exit_codes=None, ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -585,6 +597,7 @@ def test_get_latest_archive_calls_borg_with_working_directory(): environment=None, working_directory='/working/dir', ).and_yield(json.dumps({'archives': [expected_archive]})) + insert_logging_mock(logging.WARNING) assert ( module.get_latest_archive( @@ -758,6 +771,7 @@ def test_make_repo_list_command_includes_lock_wait(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -787,6 +801,7 @@ def test_make_repo_list_command_includes_list_extra_borg_options(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -816,6 +831,7 @@ def test_make_repo_list_command_with_feature_available_includes_repo_list_extra_ ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -845,6 +861,7 @@ def test_make_repo_list_command_includes_local_path(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -877,6 +894,7 @@ def test_make_repo_list_command_includes_remote_path(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -909,6 +927,7 @@ def test_make_repo_list_command_includes_umask(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -940,6 +959,7 @@ def test_make_repo_list_command_transforms_prefix_into_match_archives(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -962,6 +982,7 @@ def test_make_repo_list_command_prefers_prefix_over_archive_name_format(): flexmock(module.flags).should_receive('make_match_archives_flags').never() flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -986,6 +1007,7 @@ def test_make_repo_list_command_transforms_archive_name_format_into_match_archiv ).and_return(('--match-archives', 'sh:bar-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -1017,6 +1039,7 @@ def test_make_repo_list_command_includes_format_from_command_line(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -1047,6 +1070,7 @@ def test_make_repo_list_command_includes_short(): ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--short',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -1091,6 +1115,7 @@ def test_make_repo_list_command_includes_additional_flags(argument_name): (f"--{argument_name.replace('_', '-')}", 'value'), ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -1134,6 +1159,7 @@ def test_make_repo_list_command_with_match_archives_calls_borg_with_match_archiv ).and_return(('--match-archives', 'foo-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -1161,6 +1187,7 @@ def test_list_repository_calls_borg_command(): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').and_yield('').once() + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') module.list_repository( @@ -1178,6 +1205,7 @@ def test_list_repository_with_json_calls_borg_json_command_only(): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').and_yield('{}') + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() assert ( @@ -1204,6 +1232,7 @@ def test_make_repo_list_command_with_date_based_matching_calls_borg_with_date_ba ('--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w'), ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_logging_mock(logging.WARNING) command = module.make_repo_list_command( repository_path='repo', @@ -1256,6 +1285,7 @@ def test_list_repository_calls_borg_with_working_directory(): borg_local_path=object, borg_exit_codes=object, ).and_yield('').once() + insert_logging_mock(logging.WARNING) flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') module.list_repository( diff --git a/tests/unit/borg/test_transfer.py b/tests/unit/borg/test_transfer.py index 65c53c84..6d973525 100644 --- a/tests/unit/borg/test_transfer.py +++ b/tests/unit/borg/test_transfer.py @@ -26,6 +26,7 @@ def test_transfer_archives_calls_borg_with_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -63,6 +64,7 @@ def test_transfer_archives_with_dry_run_calls_borg_with_dry_run_flag(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=True, @@ -98,6 +100,7 @@ def test_transfer_archives_with_log_info_calls_borg_with_info_flag(): borg_exit_codes=None, ) insert_logging_mock(logging.INFO) + module.transfer_archives( dry_run=False, repository_path='repo', @@ -170,6 +173,7 @@ def test_transfer_archives_with_archive_calls_borg_with_match_archives_flag(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -208,6 +212,7 @@ def test_transfer_archives_with_match_archives_calls_borg_with_match_archives_fl borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -246,6 +251,7 @@ def test_transfer_archives_with_archive_name_format_calls_borg_with_match_archiv borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -280,6 +286,7 @@ def test_transfer_archives_with_local_path_calls_borg_via_local_path(): borg_local_path='borg2', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -316,6 +323,7 @@ def test_transfer_archives_with_exit_codes_calls_borg_using_them(): borg_local_path='borg', borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -354,6 +362,7 @@ def test_transfer_archives_with_remote_path_calls_borg_with_remote_path_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -391,6 +400,7 @@ def test_transfer_archives_with_umask_calls_borg_with_umask_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -429,6 +439,7 @@ def test_transfer_archives_with_lock_wait_calls_borg_with_lock_wait_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -464,6 +475,7 @@ def test_transfer_archives_calls_borg_with_extra_borg_options(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -501,6 +513,7 @@ def test_transfer_archives_with_progress_calls_borg_with_progress_flags(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -538,6 +551,7 @@ def test_transfer_archives_with_log_json_and_progress_calls_borg_with_both_flags borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -576,6 +590,7 @@ def test_transfer_archives_passes_through_arguments_to_borg(argument_name): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -613,6 +628,7 @@ def test_transfer_archives_with_source_repository_calls_borg_with_other_repo_fla borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -662,6 +678,7 @@ def test_transfer_archives_with_date_based_matching_calls_borg_with_date_based_f borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, @@ -701,6 +718,7 @@ def test_transfer_archives_calls_borg_with_working_directory(): borg_local_path='borg', borg_exit_codes=None, ) + insert_logging_mock(logging.WARNING) module.transfer_archives( dry_run=False, diff --git a/tests/unit/borg/test_umount.py b/tests/unit/borg/test_umount.py index f9a3cc0c..2da35e11 100644 --- a/tests/unit/borg/test_umount.py +++ b/tests/unit/borg/test_umount.py @@ -26,6 +26,7 @@ def insert_execute_command_mock( def test_unmount_archive_calls_borg_with_required_parameters(): insert_execute_command_mock(('borg', 'umount', '--log-json', '/mnt')) + insert_logging_mock(logging.WARNING) module.unmount_archive(config={}, mount_point='/mnt') @@ -49,6 +50,7 @@ def test_unmount_archive_calls_borg_with_extra_borg_options(): ('borg', 'umount', '--log-json', '--extra', 'value with space', '/mnt'), borg_local_path='borg', ) + insert_logging_mock(logging.WARNING) module.unmount_archive( config={'extra_borg_options': {'umount': '--extra "value with space"'}}, mount_point='/mnt' @@ -57,6 +59,7 @@ def test_unmount_archive_calls_borg_with_extra_borg_options(): def test_unmount_archive_calls_borg_with_local_path(): insert_execute_command_mock(('borg1', 'umount', '--log-json', '/mnt'), borg_local_path='borg1') + insert_logging_mock(logging.WARNING) module.unmount_archive(config={}, mount_point='/mnt', local_path='borg1') @@ -66,6 +69,7 @@ def test_unmount_archive_calls_borg_with_exit_codes(): insert_execute_command_mock( ('borg', 'umount', '--log-json', '/mnt'), borg_exit_codes=borg_exit_codes ) + insert_logging_mock(logging.WARNING) module.unmount_archive(config={'borg_exit_codes': borg_exit_codes}, mount_point='/mnt') @@ -74,5 +78,6 @@ def test_unmount_archive_calls_borg_with_working_directory(): insert_execute_command_mock( ('borg', 'umount', '--log-json', '/mnt'), working_directory='/working/dir' ) + insert_logging_mock(logging.WARNING) module.unmount_archive(config={'working_directory': '/working/dir'}, mount_point='/mnt') diff --git a/tests/unit/borg/test_version.py b/tests/unit/borg/test_version.py index f38d0592..09fca1ab 100644 --- a/tests/unit/borg/test_version.py +++ b/tests/unit/borg/test_version.py @@ -32,6 +32,7 @@ def insert_execute_command_and_capture_output_mock( def test_local_borg_version_calls_borg_with_required_parameters(): insert_execute_command_and_capture_output_mock(('borg', '--version', '--log-json')) + insert_logging_mock(logging.WARNING) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({}) == VERSION @@ -59,6 +60,7 @@ def test_local_borg_version_with_local_borg_path_calls_borg_with_it(): insert_execute_command_and_capture_output_mock( ('borg1', '--version', '--log-json'), borg_local_path='borg1' ) + insert_logging_mock(logging.WARNING) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({}, 'borg1') == VERSION @@ -70,6 +72,7 @@ def test_local_borg_version_with_borg_exit_codes_calls_using_with_them(): ('borg', '--version', '--log-json'), borg_exit_codes=borg_exit_codes, ) + insert_logging_mock(logging.WARNING) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({'borg_exit_codes': borg_exit_codes}) == VERSION @@ -79,6 +82,7 @@ def test_local_borg_version_with_invalid_version_raises(): insert_execute_command_and_capture_output_mock( ('borg', '--version', '--log-json'), version_output='wtf' ) + insert_logging_mock(logging.WARNING) flexmock(module.environment).should_receive('make_environment') with pytest.raises(ValueError): @@ -90,6 +94,7 @@ def test_local_borg_version_calls_borg_with_working_directory(): ('borg', '--version', '--log-json'), working_directory='/working/dir', ) + insert_logging_mock(logging.WARNING) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({'working_directory': '/working/dir'}) == VERSION From 220c76b3c04310cad1e355746bf46f1df37bdc0a Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 22 May 2026 13:37:24 -0700 Subject: [PATCH 25/63] Formatting. --- tests/integration/actions/browse/test_app.py | 2 +- .../actions/browse/test_carousel.py | 26 +++++-------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/tests/integration/actions/browse/test_app.py b/tests/integration/actions/browse/test_app.py index 0071061d..4d309e2b 100644 --- a/tests/integration/actions/browse/test_app.py +++ b/tests/integration/actions/browse/test_app.py @@ -7,7 +7,7 @@ import pytest from flexmock import flexmock -pytestmark = pytest.mark.asyncio(loop_scope="module") +pytestmark = pytest.mark.asyncio(loop_scope='module') loop: asyncio.AbstractEventLoop diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py index 95b02b55..0ce64dbe 100644 --- a/tests/integration/actions/browse/test_carousel.py +++ b/tests/integration/actions/browse/test_carousel.py @@ -12,7 +12,7 @@ from flexmock import flexmock import textual.widgets.option_list -pytestmark = pytest.mark.asyncio(loop_scope="module") +pytestmark = pytest.mark.asyncio(loop_scope='module') loop: asyncio.AbstractEventLoop @@ -61,9 +61,7 @@ async def test_carousel_next_action_with_multiple_configs_advances_panels(): ) assert carousel.panels[0].styles.display == 'none' - assert isinstance( - carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list - ) + assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list) assert carousel.panels[1].styles.display == 'block' assert carousel.panels[1].highlighted == 0 assert app.focused == carousel.panels[1] @@ -84,14 +82,10 @@ async def test_carousel_next_action_with_one_config_advances_to_next_panel(): carousel = app.query_one(selector='Carousel') assert len(carousel.panels) == 2 - assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list - ) + assert isinstance(carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list) assert carousel.panels[0].styles.display == 'none' - assert isinstance( - carousel.panels[1], borgmatic.actions.browse.panels.Archives_list - ) + assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Archives_list) assert carousel.panels[1].styles.display == 'block' assert carousel.panels[1].highlighted == 0 assert app.focused == carousel.panels[1] @@ -119,9 +113,7 @@ async def test_carousel_next_action_and_previous_action_returns_to_original_pane assert carousel.panels[0].styles.display == 'block' assert carousel.panels[0].highlighted == 0 - assert isinstance( - carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list - ) + assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list) assert carousel.panels[1].styles.display == 'none' assert app.focused == carousel.panels[0] @@ -149,9 +141,7 @@ async def test_carousel_next_action_and_previous_action_and_next_action_reuses_n ) assert carousel.panels[0].styles.display == 'none' - assert isinstance( - carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list - ) + assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list) assert carousel.panels[1].styles.display == 'block' assert carousel.panels[1].highlighted == 0 assert app.focused == carousel.panels[1] @@ -277,8 +267,6 @@ async def test_carousel_next_action_and_select_dot_dot_returns_to_original_panel assert carousel.panels[0].styles.display == 'block' assert carousel.panels[0].highlighted == 0 - assert isinstance( - carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list - ) + assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list) assert carousel.panels[1].styles.display == 'none' assert app.focused == carousel.panels[0] From 006825838fbb7778b98cb28f9306494482bf35d1 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 22 May 2026 13:39:45 -0700 Subject: [PATCH 26/63] NEWS tweak. --- NEWS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 078d25de..2ee2cfc1 100644 --- a/NEWS +++ b/NEWS @@ -10,7 +10,7 @@ * #1303: For the MariaDB hook, include only a subset of system data when dumping the "mysql" system database (or "all" databases), so the dump is actually restorable. See the documentation for more information: https://torsion.org/borgmatic/reference/configuration/data-sources/mariadb/ - * Add an experimental "borgmatic browse" action, a console UI for browsing your backups. See the + * Add an experimental "browse" action, a console UI for browsing your backups. See the documentation for more information: https://torsion.org/borgmatic/how-to/inspect-your-backups/#browsing-backups * Update the KeePassXC credential hook to support KeePassXC's secret service integration. See the From ecedccadee4a93690f0c6df3a6dc7116a3367679 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 22 May 2026 13:43:44 -0700 Subject: [PATCH 27/63] Yet another NEWS wording tweak. --- NEWS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 2ee2cfc1..f166b7ec 100644 --- a/NEWS +++ b/NEWS @@ -10,7 +10,7 @@ * #1303: For the MariaDB hook, include only a subset of system data when dumping the "mysql" system database (or "all" databases), so the dump is actually restorable. See the documentation for more information: https://torsion.org/borgmatic/reference/configuration/data-sources/mariadb/ - * Add an experimental "browse" action, a console UI for browsing your backups. See the + * Add an experimental "browse" action providing a console UI for browsing your backups. See the documentation for more information: https://torsion.org/borgmatic/how-to/inspect-your-backups/#browsing-backups * Update the KeePassXC credential hook to support KeePassXC's secret service integration. See the From 61d86c55b49b9e24da9602aedbd4c12963a54e07 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 22 May 2026 13:44:38 -0700 Subject: [PATCH 28/63] Add missing Rich library to test requirements. --- test_requirements.in | 1 + 1 file changed, 1 insertion(+) diff --git a/test_requirements.in b/test_requirements.in index efed25f7..caeda01c 100644 --- a/test_requirements.in +++ b/test_requirements.in @@ -23,6 +23,7 @@ pyyaml>5.0.0 referencing requests requests-oauthlib +rich rpds-py ruamel-yaml>0.15.0 typing-extensions From 13031a4ce4a51194e3f9f5ed5e9368d093873dba Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 22 May 2026 20:03:32 -0700 Subject: [PATCH 29/63] Upgrade/add test requirements. --- binary_requirements.txt | 12 ++++++++++-- test_requirements.txt | 11 ++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/binary_requirements.txt b/binary_requirements.txt index 8b9a4c8e..d1ef3175 100644 --- a/binary_requirements.txt +++ b/binary_requirements.txt @@ -9,15 +9,23 @@ click==8.4.1 # via apprise, -r binary_requirements.in idna==3.16 # via requests, -r binary_requirements.in jsonschema==4.26.0 # via borgmatic, -r binary_requirements.in jsonschema-specifications==2025.9.1 # via jsonschema, -r binary_requirements.in +linkify-it-py==2.1.0 # via markdown-it-py markdown==3.10.2 # via apprise, -r binary_requirements.in +markdown-it-py==4.2.0 # via mdit-py-plugins, rich, textual +mdit-py-plugins==0.6.1 # via textual +mdurl==0.1.2 # via markdown-it-py oauthlib==3.3.1 # via requests-oauthlib, -r binary_requirements.in packaging==26.2 # via borgmatic, -r binary_requirements.in +platformdirs==4.9.6 # via textual +pygments==2.20.0 # via rich, textual pyyaml==6.0.3 # via apprise, -r binary_requirements.in referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r binary_requirements.in requests==2.34.2 # via apprise, borgmatic, requests-oauthlib, -r binary_requirements.in requests-oauthlib==2.0.0 # via apprise, -r binary_requirements.in -rich==15.0.0 +rich==15.0.0 # via textual, -r binary_requirements.in rpds-py==0.30.0 # via jsonschema, referencing, -r binary_requirements.in ruamel-yaml==0.19.1 # via borgmatic, -r binary_requirements.in -textual==8.2.5 +textual==8.2.7 # via -r binary_requirements.in +typing-extensions==4.15.0 # via textual +uc-micro-py==2.0.0 # via linkify-it-py urllib3==2.7.0 # via requests, -r binary_requirements.in diff --git a/test_requirements.txt b/test_requirements.txt index 878b2072..8fc78811 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -13,21 +13,22 @@ iniconfig==2.3.0 # via pytest, -r test_requirements.in jsonschema==4.26.0 # via -r test_requirements.in jsonschema-specifications==2025.9.1 # via jsonschema, -r test_requirements.in markdown==3.10.2 # via apprise, -r test_requirements.in +markdown-it-py==4.2.0 # via rich +mdurl==0.1.2 # via markdown-it-py oauthlib==3.3.1 # via requests-oauthlib, -r test_requirements.in packaging==26.2 # via pytest, -r test_requirements.in pluggy==1.6.0 # via pytest, pytest-cov, -r test_requirements.in -pygments==2.20.0 # via pytest, -r test_requirements.in -pytest==9.0.3 # via pytest-cov, pytest-timeout, -r test_requirements.in -pytest-asyncio==1.3.0 +pygments==2.20.0 # via pytest, rich, -r test_requirements.in +pytest==9.0.3 # via pytest-asyncio, pytest-cov, pytest-timeout, -r test_requirements.in +pytest-asyncio==1.3.0 # via -r test_requirements.in pytest-cov==7.1.0 # via -r test_requirements.in pytest-timeout==2.4.0 # via -r test_requirements.in pyyaml>5.0.0 referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r test_requirements.in requests==2.34.2 # via apprise, requests-oauthlib, -r test_requirements.in requests-oauthlib==2.0.0 # via apprise, -r test_requirements.in -rich==15.0.0 +rich==15.0.0 # via -r test_requirements.in rpds-py==0.30.0 # via jsonschema, referencing, -r test_requirements.in ruamel-yaml>0.15.0 -textual==8.2.5 typing-extensions==4.15.0 # via -r test_requirements.in urllib3==2.7.0 # via requests, -r test_requirements.in From c9ba0762f0206ba875bb6269e6bdeb99a0dcf4bb Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 23 May 2026 18:31:24 -0700 Subject: [PATCH 30/63] Add docstring. --- borgmatic/actions/browse/carousel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index acbc29c8..4d81c4bd 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -116,6 +116,10 @@ class Carousel(textual.containers.Horizontal): del self.panels[next_panel_index:] def on_option_list_option_selected(self, event): + ''' + An option has been selected, so advance to the next panel—unless the option selected is + "..", in which case go to the previous panel. + ''' if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': return From 94a2d681988f67fff6d208861cf10522ab7239d7 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 23 May 2026 19:20:24 -0700 Subject: [PATCH 31/63] Switch from a Static to a RichLog for file preview so we can get scrolling. --- borgmatic/actions/browse/archive.py | 8 ++++++-- borgmatic/actions/browse/loading.py | 11 +++++++---- borgmatic/actions/browse/panels.py | 24 ++++++++++++++++++++---- borgmatic/actions/browse/workers.py | 3 ++- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/borgmatic/actions/browse/archive.py b/borgmatic/actions/browse/archive.py index d188915a..c31a8018 100644 --- a/borgmatic/actions/browse/archive.py +++ b/borgmatic/actions/browse/archive.py @@ -78,7 +78,7 @@ def get_archive_files(config, repository, archive_name, list_path=None): ) -READLINES_HINT_BYTES = 2000 +READLINES_HINT_BYTES = 100000 def get_archive_file_content(config, repository, archive_name, file_path): @@ -104,4 +104,8 @@ def get_archive_file_content(config, repository, archive_name, file_path): content = ''.join(line.decode() for line in lines) - return content if len(content) < READLINES_HINT_BYTES else f'{content}[...]' + return ( + content + if len(content) < READLINES_HINT_BYTES + else f'{content}[... truncated for display ...]' + ) diff --git a/borgmatic/actions/browse/loading.py b/borgmatic/actions/browse/loading.py index 60438553..44bb7b69 100644 --- a/borgmatic/actions/browse/loading.py +++ b/borgmatic/actions/browse/loading.py @@ -14,8 +14,11 @@ def update_inline_loading_indicator(widget): 'loading-indicator', (str(widget.get_option('loading-indicator').prompt) + '.').replace('....', ''), ) - elif isinstance(widget, textual.widgets.Static): - widget.update((str(widget.content) + '.').replace('....', '')) + elif isinstance(widget, textual.widgets.RichLog): + with contextlib.suppress(IndexError): + loading_message = str(widget.lines[0].text) + widget.clear() + widget.write((loading_message + '.').replace('....', '')) else: raise ValueError(f'Unsupported widget type: {type(widget)}') @@ -28,8 +31,8 @@ def add_inline_loading_indicator(widget): loading_option = textual.widgets.option_list.Option(loading_message, id='loading-indicator') widget.add_option(loading_option) widget.highlighted = None - elif isinstance(widget, textual.widgets.Static): - widget.update(loading_message) + elif isinstance(widget, textual.widgets.RichLog): + widget.write(loading_message) else: raise ValueError(f'Unsupported widget type: {type(widget)}') diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index d09ccf2b..aad66e5e 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -10,10 +10,10 @@ import borgmatic.actions.browse.workers OPTION_LIST_BINDINGS = ( *textual.widgets.OptionList.BINDINGS, textual.binding.Binding( - key='up,k', action='cursor_up', description='up', show=True, priority=True + key='up,k', action='cursor_up', description='scroll up', show=True, priority=True ), textual.binding.Binding( - key='down,j', action='cursor_down', description='down', show=True, priority=True + key='down,j', action='cursor_down', description='scroll down', show=True, priority=True ), textual.binding.Binding( key='enter', action='select', description='select', show=True, priority=True @@ -124,13 +124,28 @@ class Directory_list(textual.widgets.OptionList): self.highlighted_option_changed = True -class File_preview(textual.widgets.Static): +class File_preview(textual.widgets.RichLog): + BINDINGS = [ + *textual.widgets.RichLog.BINDINGS, + textual.binding.Binding( + key='up', action='scroll_up', description='scroll up', show=True, priority=True + ), + textual.binding.Binding( + key='down', action='scroll_down', description='scroll down', show=True, priority=True + ), + textual.binding.Binding( + key='pageup', action='page_up', description='page up', show=True, priority=True + ), + textual.binding.Binding( + key='pagedown', action='page_down', description='page down', show=True, priority=True + ), + ] + def __init__(self, config, repository, archive_name, file_path): self.config = config self.repository = repository self.archive_name = archive_name self.file_path = file_path - self.can_focus = True super().__init__(classes='panel') self.border_title = ' '.join( @@ -142,6 +157,7 @@ class File_preview(textual.widgets.Static): 'preview', ) ) + self.auto_scroll = False timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index 26267123..210009ff 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -89,4 +89,5 @@ def load_file_preview(browse_app, file_preview, config, repository, archive_name browse_app.call_from_thread(timer.stop) syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) - browse_app.call_from_thread(file_preview.update, rich.syntax.Syntax(file_content, syntax_lexer)) + browse_app.call_from_thread(file_preview.clear) + browse_app.call_from_thread(file_preview.write, rich.syntax.Syntax(file_content, syntax_lexer)) From 43e995a4f3134a3274e328f88f54a4ef23751205 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 23 May 2026 22:50:51 -0700 Subject: [PATCH 32/63] Binary versus text file detection for borgmatic browse. --- binary_requirements.in | 1 + binary_requirements.txt | 1 + borgmatic/actions/browse/archive.py | 25 +++++++++++++++++++------ borgmatic/actions/browse/panels.py | 16 +++++++++++----- borgmatic/actions/browse/paths.py | 2 ++ borgmatic/actions/browse/workers.py | 10 +++++++--- pyproject.toml | 2 +- test_requirements.in | 1 + test_requirements.txt | 1 + 9 files changed, 44 insertions(+), 15 deletions(-) diff --git a/binary_requirements.in b/binary_requirements.in index 5df75d93..af6f4163 100644 --- a/binary_requirements.in +++ b/binary_requirements.in @@ -1,6 +1,7 @@ . apprise attrs +binaryornot certifi charset-normalizer click diff --git a/binary_requirements.txt b/binary_requirements.txt index d1ef3175..e6567ccc 100644 --- a/binary_requirements.txt +++ b/binary_requirements.txt @@ -2,6 +2,7 @@ # uv pip compile --annotation-style line binary_requirements.in -o binary_requirements.txt apprise==1.10.0 # via -r binary_requirements.in attrs==26.1.0 # via jsonschema, referencing, -r binary_requirements.in +binaryornot==0.6.0 # via -r binary_requirements.in . # via -r binary_requirements.in certifi==2026.5.20 # via apprise, requests, -r binary_requirements.in charset-normalizer==3.4.7 # via requests, -r binary_requirements.in diff --git a/borgmatic/actions/browse/archive.py b/borgmatic/actions/browse/archive.py index c31a8018..e68f0933 100644 --- a/borgmatic/actions/browse/archive.py +++ b/borgmatic/actions/browse/archive.py @@ -7,6 +7,9 @@ import borgmatic.borg.extract import borgmatic.borg.repo_list import borgmatic.borg.version +import binaryornot.helpers + + logger = logging.getLogger(__name__) @@ -82,6 +85,10 @@ READLINES_HINT_BYTES = 100000 def get_archive_file_content(config, repository, archive_name, file_path): + ''' + Given a configuration dict, a repository dict, an archive name in that repository, and a file + path in that archive, return the file's contents or None if the file can't be loaded. + ''' with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): logger.info(f'Getting archive content of file {file_path}') local_path = config.get('local_path', 'borg') @@ -102,10 +109,16 @@ def get_archive_file_content(config, repository, archive_name, file_path): extract_to_stdout=True, ).stdout.readlines(READLINES_HINT_BYTES) - content = ''.join(line.decode() for line in lines) + content = b''.join(lines) - return ( - content - if len(content) < READLINES_HINT_BYTES - else f'{content}[... truncated for display ...]' - ) + if binaryornot.helpers.is_binary_string(content): + return None + + try: + return ( + content.decode() + if len(content) < READLINES_HINT_BYTES + else f'{content.decode()}\n[... truncated for display ...]' + ) + except UnicodeDecodeError: + return None diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index aad66e5e..00cbada0 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -15,6 +15,12 @@ OPTION_LIST_BINDINGS = ( textual.binding.Binding( key='down,j', action='cursor_down', description='scroll down', show=True, priority=True ), + textual.binding.Binding( + key='pageup', action='page_up', description='page up', show=True, priority=True + ), + textual.binding.Binding( + key='pagedown', action='page_down', description='page down', show=True, priority=True + ), textual.binding.Binding( key='enter', action='select', description='select', show=True, priority=True ), @@ -55,7 +61,7 @@ class Repositories_list(textual.widgets.OptionList): ), classes='panel', ) - self.border_title = 'repositories' + self.border_title = '📦 repositories' class Archives_list(textual.widgets.OptionList): @@ -66,7 +72,7 @@ class Archives_list(textual.widgets.OptionList): self.repository = repository super().__init__(classes='panel') - self.border_title = 'archives' + self.border_title = '📚 archives' self.highlighted_option_changed = False timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) @@ -128,10 +134,10 @@ class File_preview(textual.widgets.RichLog): BINDINGS = [ *textual.widgets.RichLog.BINDINGS, textual.binding.Binding( - key='up', action='scroll_up', description='scroll up', show=True, priority=True + key='up,k', action='scroll_up', description='scroll up', show=True, priority=True ), textual.binding.Binding( - key='down', action='scroll_down', description='scroll down', show=True, priority=True + key='down,j', action='scroll_down', description='scroll down', show=True, priority=True ), textual.binding.Binding( key='pageup', action='page_up', description='page up', show=True, priority=True @@ -175,4 +181,4 @@ class File_preview(textual.widgets.RichLog): class Logs(textual.widgets.RichLog): def __init__(self): super().__init__(markup=True, id='logs', classes='panel') - self.border_title = 'logs' + self.border_title = '🪵 logs' diff --git a/borgmatic/actions/browse/paths.py b/borgmatic/actions/browse/paths.py index 08ebf996..b458ebde 100644 --- a/borgmatic/actions/browse/paths.py +++ b/borgmatic/actions/browse/paths.py @@ -4,11 +4,13 @@ import enum class Path_type(enum.Enum): DIRECTORY = 'd' LINK = 'l' + PIPE = 'p' FILE = '-' PATH_TYPE_ICONS = { Path_type.DIRECTORY.value: '📁', Path_type.LINK.value: '🔗', + Path_type.PIPE.value: '🚰', Path_type.FILE.value: '📄', } diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index 210009ff..4f8e9808 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -59,7 +59,7 @@ def add_archive_files( ) for path_type, file_path, link_target in file_type_paths: - pieces = (borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get(path_type, '?'), file_path) + ( + pieces = (borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get(path_type, '❓'), file_path) + ( ('→', link_target) if link_target else () ) highlighted_option = directory_list.highlighted_option @@ -88,6 +88,10 @@ def load_file_preview(browse_app, file_preview, config, repository, archive_name ) browse_app.call_from_thread(timer.stop) - syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) browse_app.call_from_thread(file_preview.clear) - browse_app.call_from_thread(file_preview.write, rich.syntax.Syntax(file_content, syntax_lexer)) + + if file_content is None: + browse_app.call_from_thread(file_preview.write, 'Cannot load a preview for this file') + else: + syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) + browse_app.call_from_thread(file_preview.write, rich.syntax.Syntax(file_content, syntax_lexer)) diff --git a/pyproject.toml b/pyproject.toml index 8f028eec..a2d76607 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ validate-borgmatic-config = "borgmatic.commands.validate_config:main" [project.optional-dependencies] Apprise = ["apprise"] -browse = ["textual"] +browse = ["textual", "binaryornot"] browse-dev = ["textual-dev"] [project.urls] diff --git a/test_requirements.in b/test_requirements.in index caeda01c..8db5916e 100644 --- a/test_requirements.in +++ b/test_requirements.in @@ -1,5 +1,6 @@ apprise attrs +binaryornot certifi charset-normalizer click>=8.1.8 diff --git a/test_requirements.txt b/test_requirements.txt index 8fc78811..4d73938c 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -2,6 +2,7 @@ # uv pip compile --annotation-style line test_requirements.in -o test_requirements.txt apprise==1.10.0 # via -r test_requirements.in attrs==26.1.0 # via jsonschema, referencing, -r test_requirements.in +binaryornot==0.6.0 # via -r test_requirements.in certifi==2026.5.20 # via apprise, requests, -r test_requirements.in charset-normalizer==3.4.7 # via requests, -r test_requirements.in click>=8.1.8 From bfd79a5500b79d2cec1ad8abb69a961da2368fdd Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 23 May 2026 23:05:28 -0700 Subject: [PATCH 33/63] Refuse to preview named pipes. --- borgmatic/actions/browse/carousel.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index 4d81c4bd..4fee13fc 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -37,13 +37,17 @@ def make_next_panel(focused_panel, option_id): focused_panel.archive_name, path_components=(*focused_panel.path_components, option_id), ) - - return borgmatic.actions.browse.panels.File_preview( - focused_panel.config, - focused_panel.repository, - focused_panel.archive_name, - file_path=os.path.sep.join((*focused_panel.path_components, option_id)), - ) + elif option.prompt.startswith( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ + borgmatic.actions.browse.paths.Path_type.FILE.value + ] + ): + return borgmatic.actions.browse.panels.File_preview( + focused_panel.config, + focused_panel.repository, + focused_panel.archive_name, + file_path=os.path.sep.join((*focused_panel.path_components, option_id)), + ) return None @@ -97,6 +101,7 @@ class Carousel(textual.containers.Horizontal): next_panel = make_next_panel(self.focused_panel, option_id) if next_panel is None: + self.notify('Cannot load this content', severity='warning') return self.panels.append(next_panel) From 60201541e05c92701b5e9e76f1615f3381512e0b Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sun, 24 May 2026 10:16:36 -0700 Subject: [PATCH 34/63] Add Textual to stand-alone binary to support the browse action. --- scripts/release | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release b/scripts/release index 6ffd5560..670ccdf0 100755 --- a/scripts/release +++ b/scripts/release @@ -47,7 +47,7 @@ docs_path=dist/borgmatic-docs.tar.gz uv venv --python 3.13 --clear binary source binary/bin/activate uv pip install -r binary_requirements.txt nuitka[onefile] -nuitka --mode=onefile --enable-plugin=upx --include-package-data=borgmatic --include-data-dir=borgmatic.egg-info=borgmatic.egg-info --include-package=borgmatic.hooks --include-package=apprise --no-deployment-flag=self-execution borgmatic/commands/borgmatic.py +nuitka --mode=onefile --enable-plugin=upx --include-package-data=borgmatic --include-data-dir=borgmatic.egg-info=borgmatic.egg-info --include-package=borgmatic.hooks --include-package=apprise --include-package=textual --no-deployment-flag=self-execution borgmatic/commands/borgmatic.py deactivate rm -fr binary borgmatic.build borgmatic.dist standalone_binary_path="dist/borgmatic-${version}-binary-linux-glibc-x86_64" From 4b8a6d1a34b4127dcc670ecc7827108cf3969c39 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sun, 24 May 2026 19:22:50 -0700 Subject: [PATCH 35/63] More browse tests. --- borgmatic/actions/browse/carousel.py | 12 +- borgmatic/actions/browse/logs.py | 2 - borgmatic/actions/browse/workers.py | 13 +- pyproject.toml | 2 +- test_requirements.in | 1 + test_requirements.txt | 13 +- tests/integration/actions/browse/test_app.py | 6 - .../actions/browse/test_carousel.py | 196 +++++++++++++++++- 8 files changed, 219 insertions(+), 26 deletions(-) diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index 4fee13fc..e1beefe6 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -8,6 +8,14 @@ import borgmatic.actions.browse.paths def make_next_panel(focused_panel, option_id): + ''' + Given a focused panel widget and the selected option ID, return the next panel corresponding to + that selection. This is the mechanism by which the user can successively drill down from + configuration file to repository to archive to root directory to non-root directory or file. + + If the particular option ID on the focused panel doesn't have a supported next panel, then + return None. + ''' if isinstance(focused_panel, borgmatic.actions.browse.panels.Configuration_files_list): return borgmatic.actions.browse.panels.Repositories_list( config=focused_panel.configs[option_id] @@ -101,7 +109,7 @@ class Carousel(textual.containers.Horizontal): next_panel = make_next_panel(self.focused_panel, option_id) if next_panel is None: - self.notify('Cannot load this content', severity='warning') + self.notify('Cannot display this content', severity='warning') return self.panels.append(next_panel) @@ -125,7 +133,7 @@ class Carousel(textual.containers.Horizontal): An option has been selected, so advance to the next panel—unless the option selected is "..", in which case go to the previous panel. ''' - if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': + if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': # pragma: no cover return if event.option_id == '..': diff --git a/borgmatic/actions/browse/logs.py b/borgmatic/actions/browse/logs.py index aa4bd962..3e59de39 100644 --- a/borgmatic/actions/browse/logs.py +++ b/borgmatic/actions/browse/logs.py @@ -61,8 +61,6 @@ def log_to_widget(logs_widget): logger.setLevel(min(handler.level for handler in logger.handlers)) logger.addHandler(handler) - # Remove the console log handler so it doesn't try to log all over our UI; we have our own - # log handler for surfacing logs within the UI. with contextlib.suppress(StopIteration): console_handler = next( handler diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index 4f8e9808..816a10ca 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -59,9 +59,10 @@ def add_archive_files( ) for path_type, file_path, link_target in file_type_paths: - pieces = (borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get(path_type, '❓'), file_path) + ( - ('→', link_target) if link_target else () - ) + pieces = ( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get(path_type, '❓'), + file_path, + ) + (('→', link_target) if link_target else ()) highlighted_option = directory_list.highlighted_option sorted_options = sorted( [ @@ -91,7 +92,9 @@ def load_file_preview(browse_app, file_preview, config, repository, archive_name browse_app.call_from_thread(file_preview.clear) if file_content is None: - browse_app.call_from_thread(file_preview.write, 'Cannot load a preview for this file') + browse_app.call_from_thread(file_preview.write, 'Cannot display a preview for this file') else: syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) - browse_app.call_from_thread(file_preview.write, rich.syntax.Syntax(file_content, syntax_lexer)) + browse_app.call_from_thread( + file_preview.write, rich.syntax.Syntax(file_content, syntax_lexer) + ) diff --git a/pyproject.toml b/pyproject.toml index a2d76607..62e6a48d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ namespaces = false [tool.pytest.ini_options] testpaths = "tests" -addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120" +addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120 --asyncio-mode=auto" [tool.ruff] line-length = 100 diff --git a/test_requirements.in b/test_requirements.in index 8db5916e..dc8ed6c7 100644 --- a/test_requirements.in +++ b/test_requirements.in @@ -27,5 +27,6 @@ requests-oauthlib rich rpds-py ruamel-yaml>0.15.0 +textual typing-extensions urllib3 diff --git a/test_requirements.txt b/test_requirements.txt index 4d73938c..3aa2cd76 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -13,13 +13,16 @@ idna==3.16 # via requests, -r test_requirements.in iniconfig==2.3.0 # via pytest, -r test_requirements.in jsonschema==4.26.0 # via -r test_requirements.in jsonschema-specifications==2025.9.1 # via jsonschema, -r test_requirements.in +linkify-it-py==2.1.0 # via markdown-it-py markdown==3.10.2 # via apprise, -r test_requirements.in -markdown-it-py==4.2.0 # via rich +markdown-it-py==4.2.0 # via mdit-py-plugins, rich, textual +mdit-py-plugins==0.6.1 # via textual mdurl==0.1.2 # via markdown-it-py oauthlib==3.3.1 # via requests-oauthlib, -r test_requirements.in packaging==26.2 # via pytest, -r test_requirements.in +platformdirs==4.9.6 # via textual pluggy==1.6.0 # via pytest, pytest-cov, -r test_requirements.in -pygments==2.20.0 # via pytest, rich, -r test_requirements.in +pygments==2.20.0 # via pytest, rich, textual, -r test_requirements.in pytest==9.0.3 # via pytest-asyncio, pytest-cov, pytest-timeout, -r test_requirements.in pytest-asyncio==1.3.0 # via -r test_requirements.in pytest-cov==7.1.0 # via -r test_requirements.in @@ -28,8 +31,10 @@ pyyaml>5.0.0 referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r test_requirements.in requests==2.34.2 # via apprise, requests-oauthlib, -r test_requirements.in requests-oauthlib==2.0.0 # via apprise, -r test_requirements.in -rich==15.0.0 # via -r test_requirements.in +rich==15.0.0 # via textual, -r test_requirements.in rpds-py==0.30.0 # via jsonschema, referencing, -r test_requirements.in ruamel-yaml>0.15.0 -typing-extensions==4.15.0 # via -r test_requirements.in +textual==8.2.7 # via -r test_requirements.in +typing-extensions==4.15.0 # via textual, -r test_requirements.in +uc-micro-py==2.0.0 # via linkify-it-py urllib3==2.7.0 # via requests, -r test_requirements.in diff --git a/tests/integration/actions/browse/test_app.py b/tests/integration/actions/browse/test_app.py index 4d309e2b..02b79421 100644 --- a/tests/integration/actions/browse/test_app.py +++ b/tests/integration/actions/browse/test_app.py @@ -1,5 +1,3 @@ -import asyncio - import borgmatic.actions.browse.app import borgmatic.actions.browse.panels @@ -7,10 +5,6 @@ import pytest from flexmock import flexmock -pytestmark = pytest.mark.asyncio(loop_scope='module') -loop: asyncio.AbstractEventLoop - - async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): app = borgmatic.actions.browse.app.Browse_app( configs={ diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py index 0ce64dbe..90ff3e6a 100644 --- a/tests/integration/actions/browse/test_carousel.py +++ b/tests/integration/actions/browse/test_carousel.py @@ -1,19 +1,182 @@ -import asyncio - import borgmatic.actions.browse.app import borgmatic.actions.browse.carousel +import borgmatic.actions.browse.loading import borgmatic.actions.browse.logs import borgmatic.actions.browse.panels import borgmatic.actions.browse.workers import pytest +import pytest_asyncio from flexmock import flexmock import textual.widgets.option_list +from borgmatic.actions.browse import carousel as module -pytestmark = pytest.mark.asyncio(loop_scope='module') -loop: asyncio.AbstractEventLoop +def test_make_next_panel_with_configuration_files_list_returns_repositories_list(): + configs = {'test.yaml': {'repositories': [{'path': 'test.borg'}]}} + + repositories_list = module.make_next_panel( + focused_panel=borgmatic.actions.browse.panels.Configuration_files_list(configs), + option_id='test.yaml', + ) + + assert isinstance(repositories_list, borgmatic.actions.browse.panels.Repositories_list) + assert repositories_list.config == configs['test.yaml'] + + +def test_make_next_panel_with_repositories_list_returns_archives_list(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(borgmatic.actions.browse.panels.Archives_list).should_receive('app').and_return( + flexmock() + ) + + archives_list = module.make_next_panel( + focused_panel=borgmatic.actions.browse.panels.Repositories_list(config), + option_id=0, + ) + + assert isinstance(archives_list, borgmatic.actions.browse.panels.Archives_list) + assert archives_list.config == config + assert archives_list.repository == config['repositories'][0] + + +def test_make_next_panel_with_archives_list_returns_directory_list(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.panels.Archives_list).should_receive('app').and_return( + flexmock() + ) + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + + directory_list = module.make_next_panel( + focused_panel=borgmatic.actions.browse.panels.Archives_list( + config, config['repositories'][0] + ), + option_id='archive', + ) + + assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + + +def test_make_next_panel_with_root_directory_list_and_selected_directory_option_returns_new_directory_list(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive' + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📁 etc')) + + directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='etc') + + assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + assert directory_list.archive_name == 'archive' + assert directory_list.path_components == ('etc',) + + +def test_make_next_panel_with_non_root_directory_list_and_selected_directory_option_returns_new_directory_list(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive', path_components=('etc',), + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📁 borgmatic')) + + directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='borgmatic') + + assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + assert directory_list.archive_name == 'archive' + assert directory_list.path_components == ('etc', 'borgmatic') + + +def test_make_next_panel_with_root_directory_list_and_selected_file_option_returns_new_file_preview(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.workers).should_receive('load_file_preview') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + flexmock(borgmatic.actions.browse.panels.File_preview).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive' + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📄 config.yaml')) + + directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') + + assert isinstance(directory_list, borgmatic.actions.browse.panels.File_preview) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + assert directory_list.archive_name == 'archive' + assert directory_list.file_path == 'config.yaml' + + +def test_make_next_panel_with_non_root_directory_list_and_selected_file_option_returns_new_file_preview(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.workers).should_receive('load_file_preview') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + flexmock(borgmatic.actions.browse.panels.File_preview).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive', path_components=('etc', 'borgmatic'), + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📄 config.yaml')) + + directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') + + assert isinstance(directory_list, borgmatic.actions.browse.panels.File_preview) + assert directory_list.config == config + assert directory_list.repository == config['repositories'][0] + assert directory_list.archive_name == 'archive' + assert directory_list.file_path == 'etc/borgmatic/config.yaml' + + +def test_make_next_panel_with_unsupported_focused_panel_returns_none(): + assert module.make_next_panel(focused_panel=flexmock(), option_id='hmmm') is None + + +@pytest.mark.parametrize('icon', ('🔗', '🚰', '🐙')) +def test_make_next_panel_with_directory_list_and_unsupported_selected_option_returns_none(icon): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock() + ) + focused_panel = borgmatic.actions.browse.panels.Directory_list( + config, config['repositories'][0], 'archive' + ) + flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt=f'{icon} config.yaml')) + + assert module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') is None async def test_carousel_previous_action_with_multiple_configs_does_not_raise(): @@ -41,7 +204,7 @@ async def test_carousel_previous_action_with_one_config_does_not_raise(): await pilot.press('left') -async def test_carousel_next_action_with_multiple_configs_advances_panels(): +async def test_carousel_next_action_with_multiple_configs_advances_panel(): app = borgmatic.actions.browse.app.Browse_app( configs={ 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, @@ -67,7 +230,7 @@ async def test_carousel_next_action_with_multiple_configs_advances_panels(): assert app.focused == carousel.panels[1] -async def test_carousel_next_action_with_one_config_advances_to_next_panel(): +async def test_carousel_next_action_with_one_config_advances_panel(): app = borgmatic.actions.browse.app.Browse_app( configs={ 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, @@ -91,6 +254,27 @@ async def test_carousel_next_action_with_one_config_advances_to_next_panel(): assert app.focused == carousel.panels[1] +async def test_carousel_next_action_with_no_next_panel_does_not_advance(): + app = borgmatic.actions.browse.app.Browse_app( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + } + ) + flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') + flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module).should_receive('make_next_panel') + + async with app.run_test() as pilot: + await pilot.press('enter') + + carousel = app.query_one(selector='Carousel') + assert len(carousel.panels) == 1 + + assert isinstance(carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list) + assert carousel.panels[0].styles.display == 'block' + assert app.focused == carousel.panels[0] + + async def test_carousel_next_action_and_previous_action_returns_to_original_panel(): app = borgmatic.actions.browse.app.Browse_app( configs={ From ea0ed8345abc05cf656972e314b0dd76769ff0e4 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 25 May 2026 16:27:36 -0700 Subject: [PATCH 36/63] Performance: Switch browse action to list archive files only once per archive. --- borgmatic/actions/browse/archive.py | 16 +-- borgmatic/actions/browse/carousel.py | 7 +- borgmatic/actions/browse/loading.py | 4 + borgmatic/actions/browse/panels.py | 105 ++++++++++++++++-- borgmatic/actions/browse/workers.py | 105 +++++++++++------- .../actions/browse/test_carousel.py | 22 +++- 6 files changed, 191 insertions(+), 68 deletions(-) diff --git a/borgmatic/actions/browse/archive.py b/borgmatic/actions/browse/archive.py index e68f0933..6ac7733e 100644 --- a/borgmatic/actions/browse/archive.py +++ b/borgmatic/actions/browse/archive.py @@ -45,12 +45,9 @@ def get_repository_archives(config, repository): ) -def get_archive_files(config, repository, archive_name, list_path=None): +def get_archive_files(config, repository, archive_name): with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): - if list_path: - logger.info(f'Listing archive {archive_name} at path {list_path}') - else: - logger.info(f'Listing archive {archive_name}') + logger.info(f'Listing archive {archive_name}') global_arguments = argparse.Namespace() local_path = config.get('local_path', 'borg') @@ -60,10 +57,8 @@ def get_archive_files(config, repository, archive_name, list_path=None): return ( ( - path_data['type'] - if os.path.join(list_path, base_path) == path_data['path'] - else 'd', - base_path, + path_data['type'], + path_data['path'], path_data.get('linktarget'), ) for path_data in borgmatic.borg.list.capture_archive_listing( @@ -72,12 +67,9 @@ def get_archive_files(config, repository, archive_name, list_path=None): config, local_borg_version, global_arguments, - list_paths=(rf're:^{list_path}/[^/]+',) if list_path else (r're:^[^/]+',), local_path=local_path, remote_path=remote_path, ) - for base_path in (os.path.relpath(path_data['path'], list_path).split(os.path.sep)[0],) - if base_path not in seen and not seen.add(base_path) ) diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index e1beefe6..aa9a8b11 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -43,6 +43,7 @@ def make_next_panel(focused_panel, option_id): focused_panel.config, focused_panel.repository, focused_panel.archive_name, + path_loaded=focused_panel.path_loaded, path_components=(*focused_panel.path_components, option_id), ) elif option.prompt.startswith( @@ -118,7 +119,7 @@ class Carousel(textual.containers.Horizontal): self.focused_panel.styles.display = 'none' self.focused_panel = next_panel self.focused_panel.focus() - self.refresh(recompose=True) + self.mount(self.focused_panel) def on_option_list_option_highlighted(self, event): ''' @@ -133,7 +134,9 @@ class Carousel(textual.containers.Horizontal): An option has been selected, so advance to the next panel—unless the option selected is "..", in which case go to the previous panel. ''' - if event.option_list != self.focused_panel or event.option_id == 'loading-indicator': # pragma: no cover + if ( + event.option_list != self.focused_panel or event.option_id == 'loading-indicator' + ): # pragma: no cover return if event.option_id == '..': diff --git a/borgmatic/actions/browse/loading.py b/borgmatic/actions/browse/loading.py index 44bb7b69..1dc8c41f 100644 --- a/borgmatic/actions/browse/loading.py +++ b/borgmatic/actions/browse/loading.py @@ -7,6 +7,10 @@ import textual.widgets.option_list LOADING_DOT_INTERVAL_SECONDS = 0.3 +import logging +logger = logging.getLogger('__name__') + + def update_inline_loading_indicator(widget): if isinstance(widget, textual.widgets.OptionList): with contextlib.suppress(textual.widgets.option_list.OptionDoesNotExist): diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index 00cbada0..3073586f 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -1,3 +1,5 @@ +import contextlib +import logging import os import textual.binding @@ -7,6 +9,10 @@ import borgmatic.actions.browse.loading import borgmatic.actions.browse.paths import borgmatic.actions.browse.workers + +logger = logging.getLogger('__name__') + + OPTION_LIST_BINDINGS = ( *textual.widgets.OptionList.BINDINGS, textual.binding.Binding( @@ -90,10 +96,59 @@ class Archives_list(textual.widgets.OptionList): self.highlighted_option_changed = True +def add_archive_path( + directory_list, + config, + repository, + archive_name, + archive_path, +): + archive_path_components = archive_path.file_path.split(os.path.sep) + + if directory_list.path_components: + # If the loaded path doesn't match this directory list's own path, then we don't care about + # it for purposes of displaying this particular directory. + if tuple(archive_path_components[: len(directory_list.path_components)]) != directory_list.path_components: + return + + # Strip off the portion of the archive path that matches the directory list's own path. + archive_path_components = archive_path_components[len(directory_list.path_components):] + + base_path = archive_path_components[0] + + # If the option is already in the list, bail. + with contextlib.suppress(textual.widgets.option_list.OptionDoesNotExist): + directory_list.get_option(option_id=base_path) + + return + + pieces = ( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get( + archive_path.path_type if len(archive_path_components) == 1 else 'd', '❓' + ), + base_path, + ) + (('→', archive_path.link_target) if archive_path.link_target else ()) + highlighted_option = directory_list.highlighted_option + sorted_options = sorted( + [ + *directory_list.options, + textual.widgets.option_list.Option(' '.join(pieces), id=base_path), + ], + key=lambda option: ((option.id == 'loading-indicator'), option.prompt), + ) + + directory_list.set_options(sorted_options) + directory_list.highlighted = ( + directory_list.get_option_index(highlighted_option.id) + if highlighted_option and directory_list.highlighted_option_changed + else 0 + ) + + class Directory_list(textual.widgets.OptionList): BINDINGS = OPTION_LIST_BINDINGS - def __init__(self, config, repository, archive_name, path_components=None): + def __init__(self, config, repository, archive_name, path_loaded=None, path_components=None): self.config = config self.repository = repository self.archive_name = archive_name @@ -101,6 +156,7 @@ class Directory_list(textual.widgets.OptionList): self.highlighted_option_changed = False super().__init__(classes='panel') + self.border_title = ' '.join( ( borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ @@ -112,17 +168,52 @@ class Directory_list(textual.widgets.OptionList): ) ) - timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + # FIXME: This isn't working when loading is still underway??? I don't see a ".." + if self.path_components: + self.add_option( + textual.widgets.option_list.Option( + f'{borgmatic.actions.browse.paths.PATH_TYPE_ICONS[borgmatic.actions.browse.paths.Path_type.DIRECTORY.value]} ..', + id='..', + ), + ) - borgmatic.actions.browse.workers.add_archive_files( - self.app, + self.path_loaded = path_loaded or borgmatic.actions.browse.workers.Archive_path_loaded( + self, 'archive path loaded' + ) + + if not self.path_loaded.complete: + # FIXME: After going back to a previous panel, the loading indicator is no longer animating. + self.timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + + if self.path_components: + for archive_path in borgmatic.actions.browse.workers.get_paths( + self.path_loaded.path_hierarchy, self.path_components + ): + self.on_archive_path_loaded(archive_path) + else: + borgmatic.actions.browse.workers.load_archive_files( + self.app, + directory_list=self, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + ) + + def on_mount(self): + self.path_loaded.subscribe(self, self.on_archive_path_loaded) + + def on_archive_path_loaded(self, data): + if data is borgmatic.actions.browse.workers.LOADING_DONE: + self.timer.stop() + self.remove_option('loading-indicator') + return + + add_archive_path( directory_list=self, config=self.config, repository=self.repository, archive_name=self.archive_name, - list_path=os.path.sep.join(self.path_components), - root_directory=not bool(self.path_components), - timer=timer, + archive_path=data, ) def on_option_list_option_highlighted(self, event): diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index 816a10ca..04c80d31 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -1,10 +1,19 @@ +import collections +import contextlib +import logging +import os + import rich.syntax import textual +import textual.signal import textual.widgets.option_list import borgmatic.actions.browse.archive +logger = logging.getLogger('__name__') + + @textual.work(thread=True) def add_repository_archives(browse_app, archives_list, config, repository, timer): archives_data = borgmatic.actions.browse.archive.get_repository_archives(config, repository) @@ -36,50 +45,62 @@ def add_repository_archives(browse_app, archives_list, config, repository, timer browse_app.call_from_thread(timer.stop) +Archive_path = collections.namedtuple( + 'Archive_path', + ('path_type', 'file_path', 'link_target'), +) + + +def record_path(archive_path, hierarchy, path_components): + if len(path_components) == 1: + hierarchy[path_components[0]] = {} if archive_path.path_type == 'd' else archive_path + return + + record_path(archive_path, hierarchy.setdefault(path_components[0], {}), path_components[1:]) + + +def get_paths(hierarchy, path_components, full_path_components=None): + if full_path_components is None: + full_path_components = path_components + + if len(path_components) == 1: + return ( + archive_path + if isinstance(archive_path, Archive_path) + else Archive_path('d', os.path.join(*full_path_components, component), '') + for component, archive_path in hierarchy[path_components[0]].items() + ) + + return get_paths(hierarchy[path_components[0]], path_components[1:], full_path_components) + + +LOADING_DONE = object() + + +class Archive_path_loaded(textual.signal.Signal): + def __init__(self, owner, name): + self.path_hierarchy = {} + self.complete = False + + super().__init__(owner, name) + + def publish(self, data): + super().publish(data) + + if data is LOADING_DONE: + self.complete = True + else: + record_path(data, self.path_hierarchy, data.file_path.split(os.path.sep)) + + @textual.work(thread=True) -def add_archive_files( - browse_app, directory_list, config, repository, archive_name, list_path, root_directory, timer -): - file_type_paths = borgmatic.actions.browse.archive.get_archive_files( - config, repository, archive_name, list_path - ) - loading_option = directory_list.get_option('loading-indicator') +def load_archive_files(browse_app, directory_list, config, repository, archive_name): + for path_type, file_path, link_target in borgmatic.actions.browse.archive.get_archive_files( + config, repository, archive_name + ): + directory_list.path_loaded.publish(Archive_path(path_type, file_path, link_target)) - if not root_directory: - browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') - browse_app.call_from_thread( - directory_list.add_options, - ( - textual.widgets.option_list.Option( - f'{borgmatic.actions.browse.paths.PATH_TYPE_ICONS[borgmatic.actions.browse.paths.Path_type.DIRECTORY.value]} ..', - id='..', - ), - loading_option, - ), - ) - - for path_type, file_path, link_target in file_type_paths: - pieces = ( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get(path_type, '❓'), - file_path, - ) + (('→', link_target) if link_target else ()) - highlighted_option = directory_list.highlighted_option - sorted_options = sorted( - [ - *directory_list.options, - textual.widgets.option_list.Option(' '.join(pieces), id=file_path), - ], - key=lambda option: ((option.id == 'loading-indicator'), option.prompt), - ) - browse_app.call_from_thread(directory_list.set_options, sorted_options) - directory_list.highlighted = ( - directory_list.get_option_index(highlighted_option.id) - if highlighted_option and directory_list.highlighted_option_changed - else 0 - ) - - browse_app.call_from_thread(timer.stop) - browse_app.call_from_thread(directory_list.remove_option, 'loading-indicator') + directory_list.path_loaded.publish(LOADING_DONE) @textual.work(thread=True) diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py index 90ff3e6a..01e106a8 100644 --- a/tests/integration/actions/browse/test_carousel.py +++ b/tests/integration/actions/browse/test_carousel.py @@ -96,7 +96,10 @@ def test_make_next_panel_with_non_root_directory_list_and_selected_directory_opt flexmock() ) focused_panel = borgmatic.actions.browse.panels.Directory_list( - config, config['repositories'][0], 'archive', path_components=('etc',), + config, + config['repositories'][0], + 'archive', + path_components=('etc',), ) flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📁 borgmatic')) @@ -123,7 +126,9 @@ def test_make_next_panel_with_root_directory_list_and_selected_file_option_retur focused_panel = borgmatic.actions.browse.panels.Directory_list( config, config['repositories'][0], 'archive' ) - flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📄 config.yaml')) + flexmock(focused_panel).should_receive('get_option').and_return( + flexmock(prompt='📄 config.yaml') + ) directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') @@ -146,9 +151,14 @@ def test_make_next_panel_with_non_root_directory_list_and_selected_file_option_r flexmock() ) focused_panel = borgmatic.actions.browse.panels.Directory_list( - config, config['repositories'][0], 'archive', path_components=('etc', 'borgmatic'), + config, + config['repositories'][0], + 'archive', + path_components=('etc', 'borgmatic'), + ) + flexmock(focused_panel).should_receive('get_option').and_return( + flexmock(prompt='📄 config.yaml') ) - flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📄 config.yaml')) directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') @@ -174,7 +184,9 @@ def test_make_next_panel_with_directory_list_and_unsupported_selected_option_ret focused_panel = borgmatic.actions.browse.panels.Directory_list( config, config['repositories'][0], 'archive' ) - flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt=f'{icon} config.yaml')) + flexmock(focused_panel).should_receive('get_option').and_return( + flexmock(prompt=f'{icon} config.yaml') + ) assert module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') is None From b979e0356d3101179de49032ad0d53c401e4b3a8 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 25 May 2026 17:30:06 -0700 Subject: [PATCH 37/63] Fix display of ".." while loading is still underway in the browse action. --- borgmatic/actions/browse/loading.py | 1 - borgmatic/actions/browse/panels.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/borgmatic/actions/browse/loading.py b/borgmatic/actions/browse/loading.py index 1dc8c41f..c086375a 100644 --- a/borgmatic/actions/browse/loading.py +++ b/borgmatic/actions/browse/loading.py @@ -31,7 +31,6 @@ def add_inline_loading_indicator(widget): loading_message = '⏳ loading...' if isinstance(widget, textual.widgets.OptionList): - widget.clear_options() loading_option = textual.widgets.option_list.Option(loading_message, id='loading-indicator') widget.add_option(loading_option) widget.highlighted = None diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index 3073586f..da3a2535 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -168,7 +168,6 @@ class Directory_list(textual.widgets.OptionList): ) ) - # FIXME: This isn't working when loading is still underway??? I don't see a ".." if self.path_components: self.add_option( textual.widgets.option_list.Option( @@ -182,10 +181,10 @@ class Directory_list(textual.widgets.OptionList): ) if not self.path_loaded.complete: - # FIXME: After going back to a previous panel, the loading indicator is no longer animating. self.timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) if self.path_components: + # FIXME: For performance reasons, maybe add these in bulk instead of in a loop one at a time. for archive_path in borgmatic.actions.browse.workers.get_paths( self.path_loaded.path_hierarchy, self.path_components ): From 3abe025a80e9b53d23d1116b79bcd4087a595815 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 26 May 2026 11:38:42 -0700 Subject: [PATCH 38/63] Better performance when bulk populating directory lists. --- borgmatic/actions/browse/panels.py | 107 ++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 26 deletions(-) diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index da3a2535..3e97939e 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -96,6 +96,38 @@ class Archives_list(textual.widgets.OptionList): self.highlighted_option_changed = True +def get_relative_archive_path_components(archive_path, current_directory_path_components): + ''' + Given an Archive_path instance and a tuple of path components for the currently browsed + directory, get the path components for the archive path relative to that directory. + + If the archive path is not actually relative to that directory, return None. + ''' + archive_path_components = archive_path.file_path.split(os.path.sep) + + if not current_directory_path_components: + return archive_path_components + + # If the loaded path doesn't match this directory list's own path, then we don't care about + # it for purposes of displaying this particular directory. + if tuple(archive_path_components[: len(current_directory_path_components)]) != current_directory_path_components: + return None + + # Strip off the portion of the archive path that matches the directory list's own path. + return archive_path_components[len(current_directory_path_components):] + + +def make_directory_list_option(archive_path, archive_path_components): + pieces = ( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get( + archive_path.path_type if len(archive_path_components) == 1 else 'd', '❓' + ), + archive_path_components[0], + ) + (('→', archive_path.link_target) if archive_path.link_target else ()) + + return textual.widgets.option_list.Option(' '.join(pieces), id=archive_path_components[0]) + + def add_archive_path( directory_list, config, @@ -103,37 +135,56 @@ def add_archive_path( archive_name, archive_path, ): - archive_path_components = archive_path.file_path.split(os.path.sep) + archive_path_components = get_relative_archive_path_components(archive_path, directory_list.path_components) - if directory_list.path_components: - # If the loaded path doesn't match this directory list's own path, then we don't care about - # it for purposes of displaying this particular directory. - if tuple(archive_path_components[: len(directory_list.path_components)]) != directory_list.path_components: - return - - # Strip off the portion of the archive path that matches the directory list's own path. - archive_path_components = archive_path_components[len(directory_list.path_components):] - - base_path = archive_path_components[0] + if archive_path_components is None: + return # If the option is already in the list, bail. with contextlib.suppress(textual.widgets.option_list.OptionDoesNotExist): - directory_list.get_option(option_id=base_path) + directory_list.get_option(option_id=archive_path_components[0]) return - pieces = ( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get( - archive_path.path_type if len(archive_path_components) == 1 else 'd', '❓' - ), - base_path, - ) + (('→', archive_path.link_target) if archive_path.link_target else ()) highlighted_option = directory_list.highlighted_option sorted_options = sorted( - [ + ( *directory_list.options, - textual.widgets.option_list.Option(' '.join(pieces), id=base_path), - ], + make_directory_list_option(archive_path, archive_path_components), + ), + key=lambda option: ((option.id == 'loading-indicator'), option.prompt), + ) + + directory_list.set_options(sorted_options) + directory_list.highlighted = ( + directory_list.get_option_index(highlighted_option.id) + if highlighted_option and directory_list.highlighted_option_changed + else 0 + ) + + +def bulk_add_archive_paths( + directory_list, + config, + repository, + archive_name, + archive_paths, +): + highlighted_option = directory_list.highlighted_option + + sorted_options = sorted( + ( + *directory_list.options, + *( + make_directory_list_option(archive_path, archive_path_components) + for archive_path in archive_paths + for archive_path_components in (get_relative_archive_path_components( + archive_path, + directory_list.path_components, + ),) + if archive_path_components is not None + ), + ), key=lambda option: ((option.id == 'loading-indicator'), option.prompt), ) @@ -184,11 +235,15 @@ class Directory_list(textual.widgets.OptionList): self.timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) if self.path_components: - # FIXME: For performance reasons, maybe add these in bulk instead of in a loop one at a time. - for archive_path in borgmatic.actions.browse.workers.get_paths( - self.path_loaded.path_hierarchy, self.path_components - ): - self.on_archive_path_loaded(archive_path) + bulk_add_archive_paths( + directory_list=self, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + archive_paths=borgmatic.actions.browse.workers.get_paths( + self.path_loaded.path_hierarchy, self.path_components + ), + ) else: borgmatic.actions.browse.workers.load_archive_files( self.app, From 8820a1eeab2bd99249c6c310eebe3eb782bfc67c Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 26 May 2026 12:01:55 -0700 Subject: [PATCH 39/63] Faster exit for the browse action. --- borgmatic/actions/browse/app.py | 8 ++++++++ borgmatic/actions/browse/panels.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py index 30035f9c..2d0e6f4a 100644 --- a/borgmatic/actions/browse/app.py +++ b/borgmatic/actions/browse/app.py @@ -1,3 +1,5 @@ +import signal + import textual.app import textual.binding import textual.widgets @@ -75,3 +77,9 @@ class Browse_app(textual.app.App): ''' logs_panel = self.query_one('#logs') logs_panel.styles.display = 'none' if logs_panel.styles.display == 'block' else 'block' + + def exit(self): + # Encourages a fast exit by triggering the signal handler in borgmatic/signals.py. + signal.raise_signal(signal.SIGTERM) + + super().exit() diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index 3e97939e..c45e252e 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -137,7 +137,7 @@ def add_archive_path( ): archive_path_components = get_relative_archive_path_components(archive_path, directory_list.path_components) - if archive_path_components is None: + if not archive_path_components: return # If the option is already in the list, bail. From ac65e653023364cbf03c32ccaab5502deab5807b Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 26 May 2026 22:53:04 -0700 Subject: [PATCH 40/63] Merge get_archive_path() into get_archive_paths(). --- borgmatic/actions/browse/app.py | 7 ++-- borgmatic/actions/browse/panels.py | 51 +++++++----------------------- 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py index 2d0e6f4a..70a2e4ee 100644 --- a/borgmatic/actions/browse/app.py +++ b/borgmatic/actions/browse/app.py @@ -78,8 +78,11 @@ class Browse_app(textual.app.App): logs_panel = self.query_one('#logs') logs_panel.styles.display = 'none' if logs_panel.styles.display == 'block' else 'block' - def exit(self): - # Encourages a fast exit by triggering the signal handler in borgmatic/signals.py. + def exit(self): # pragma: no cover + ''' + Exit the application. But first raise a SIGTERM (handled in borgmatic/signals.py) to + encourage a fast exit by killing any ongoing Borg subprocesses. + ''' signal.raise_signal(signal.SIGTERM) super().exit() diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/panels.py index c45e252e..a81a8b5e 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/panels.py @@ -128,42 +128,7 @@ def make_directory_list_option(archive_path, archive_path_components): return textual.widgets.option_list.Option(' '.join(pieces), id=archive_path_components[0]) -def add_archive_path( - directory_list, - config, - repository, - archive_name, - archive_path, -): - archive_path_components = get_relative_archive_path_components(archive_path, directory_list.path_components) - - if not archive_path_components: - return - - # If the option is already in the list, bail. - with contextlib.suppress(textual.widgets.option_list.OptionDoesNotExist): - directory_list.get_option(option_id=archive_path_components[0]) - - return - - highlighted_option = directory_list.highlighted_option - sorted_options = sorted( - ( - *directory_list.options, - make_directory_list_option(archive_path, archive_path_components), - ), - key=lambda option: ((option.id == 'loading-indicator'), option.prompt), - ) - - directory_list.set_options(sorted_options) - directory_list.highlighted = ( - directory_list.get_option_index(highlighted_option.id) - if highlighted_option and directory_list.highlighted_option_changed - else 0 - ) - - -def bulk_add_archive_paths( +def add_archive_paths( directory_list, config, repository, @@ -171,6 +136,7 @@ def bulk_add_archive_paths( archive_paths, ): highlighted_option = directory_list.highlighted_option + original_options_count = len(directory_list.options) sorted_options = sorted( ( @@ -182,12 +148,17 @@ def bulk_add_archive_paths( archive_path, directory_list.path_components, ),) - if archive_path_components is not None + if archive_path_components + if not archive_path_components[0] in directory_list._id_to_option ), ), key=lambda option: ((option.id == 'loading-indicator'), option.prompt), ) + # If there aren't actually any options to add (due to deduplication), bail. + if len(sorted_options) == original_options_count: + return + directory_list.set_options(sorted_options) directory_list.highlighted = ( directory_list.get_option_index(highlighted_option.id) @@ -235,7 +206,7 @@ class Directory_list(textual.widgets.OptionList): self.timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) if self.path_components: - bulk_add_archive_paths( + add_archive_paths( directory_list=self, config=self.config, repository=self.repository, @@ -262,12 +233,12 @@ class Directory_list(textual.widgets.OptionList): self.remove_option('loading-indicator') return - add_archive_path( + add_archive_paths( directory_list=self, config=self.config, repository=self.repository, archive_name=self.archive_name, - archive_path=data, + archive_paths=(data,), ) def on_option_list_option_highlighted(self, event): From 9da6d0f90a332c007313451ec6adf0ee8fcda107 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 26 May 2026 23:06:36 -0700 Subject: [PATCH 41/63] Split out panels.py into multiple separate source files. --- borgmatic/actions/browse/app.py | 14 +- borgmatic/actions/browse/archives_list.py | 36 ++++ borgmatic/actions/browse/bindings.py | 23 +++ borgmatic/actions/browse/carousel.py | 26 +-- .../browse/configuration_files_list.py | 28 +++ .../browse/{panels.py => directory_list.py} | 161 ++---------------- borgmatic/actions/browse/file_preview.py | 57 +++++++ borgmatic/actions/browse/loading.py | 1 + borgmatic/actions/browse/logs.py | 7 + borgmatic/actions/browse/repositories_list.py | 28 +++ 10 files changed, 220 insertions(+), 161 deletions(-) create mode 100644 borgmatic/actions/browse/archives_list.py create mode 100644 borgmatic/actions/browse/bindings.py create mode 100644 borgmatic/actions/browse/configuration_files_list.py rename borgmatic/actions/browse/{panels.py => directory_list.py} (51%) create mode 100644 borgmatic/actions/browse/file_preview.py create mode 100644 borgmatic/actions/browse/repositories_list.py diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py index 70a2e4ee..fd270ba4 100644 --- a/borgmatic/actions/browse/app.py +++ b/borgmatic/actions/browse/app.py @@ -5,7 +5,9 @@ import textual.binding import textual.widgets import borgmatic.actions.browse.carousel +import borgmatic.actions.browse.configuration_files_list import borgmatic.actions.browse.logs +import borgmatic.actions.browse.repositories_list class Browse_app(textual.app.App): @@ -52,14 +54,20 @@ class Browse_app(textual.app.App): ''' yield textual.widgets.Header() yield borgmatic.actions.browse.carousel.Carousel( - [borgmatic.actions.browse.panels.Configuration_files_list(self.configs)] + [ + borgmatic.actions.browse.configuration_files_list.Configuration_files_list( + self.configs + ) + ] if len(self.configs) > 1 else [ - borgmatic.actions.browse.panels.Repositories_list(next(iter(self.configs.values()))) + borgmatic.actions.browse.repositories_list.Repositories_list( + next(iter(self.configs.values())) + ) ] ) - logs_panel = borgmatic.actions.browse.panels.Logs() + logs_panel = borgmatic.actions.browse.logs.Logs() yield logs_panel yield textual.widgets.Footer() diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py new file mode 100644 index 00000000..01c0febf --- /dev/null +++ b/borgmatic/actions/browse/archives_list.py @@ -0,0 +1,36 @@ +import contextlib +import os + +import textual.binding +import textual.widgets + +import borgmatic.actions.browse.bindings +import borgmatic.actions.browse.loading +import borgmatic.actions.browse.paths +import borgmatic.actions.browse.workers + + +class Archives_list(textual.widgets.OptionList): + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS + + def __init__(self, config, repository): + self.config = config + self.repository = repository + + super().__init__(classes='panel') + self.border_title = '📚 archives' + self.highlighted_option_changed = False + + timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + + borgmatic.actions.browse.workers.add_repository_archives( + self.app, + archives_list=self, + config=self.config, + repository=self.repository, + timer=timer, + ) + + def on_option_list_option_highlighted(self, event): + if self.highlighted not in {None, 0}: + self.highlighted_option_changed = True diff --git a/borgmatic/actions/browse/bindings.py b/borgmatic/actions/browse/bindings.py new file mode 100644 index 00000000..f42422c5 --- /dev/null +++ b/borgmatic/actions/browse/bindings.py @@ -0,0 +1,23 @@ +import textual.binding +import textual.widgets + + +OPTION_LIST_BINDINGS = ( + *textual.widgets.OptionList.BINDINGS, + textual.binding.Binding( + key='up,k', action='cursor_up', description='scroll up', show=True, priority=True + ), + textual.binding.Binding( + key='down,j', action='cursor_down', description='scroll down', show=True, priority=True + ), + textual.binding.Binding( + key='pageup', action='page_up', description='page up', show=True, priority=True + ), + textual.binding.Binding( + key='pagedown', action='page_down', description='page down', show=True, priority=True + ), + textual.binding.Binding( + key='enter', action='select', description='select', show=True, priority=True + ), + textual.binding.Binding(key='right,l', action='select', description='select', show=False), +) diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index aa9a8b11..2b3f2580 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -3,8 +3,12 @@ import os import textual.binding import textual.containers -import borgmatic.actions.browse.panels +import borgmatic.actions.browse.archives_list +import borgmatic.actions.browse.directory_list +import borgmatic.actions.browse.configuration_files_list +import borgmatic.actions.browse.file_preview import borgmatic.actions.browse.paths +import borgmatic.actions.browse.repositories_list def make_next_panel(focused_panel, option_id): @@ -16,22 +20,24 @@ def make_next_panel(focused_panel, option_id): If the particular option ID on the focused panel doesn't have a supported next panel, then return None. ''' - if isinstance(focused_panel, borgmatic.actions.browse.panels.Configuration_files_list): - return borgmatic.actions.browse.panels.Repositories_list( + if isinstance( + focused_panel, borgmatic.actions.browse.configuration_files_list.Configuration_files_list + ): + return borgmatic.actions.browse.repositories_list.Repositories_list( config=focused_panel.configs[option_id] ) - if isinstance(focused_panel, borgmatic.actions.browse.panels.Repositories_list): - return borgmatic.actions.browse.panels.Archives_list( + if isinstance(focused_panel, borgmatic.actions.browse.repositories_list.Repositories_list): + return borgmatic.actions.browse.archives_list.Archives_list( config=focused_panel.config, repository=focused_panel.repositories[option_id] ) - if isinstance(focused_panel, borgmatic.actions.browse.panels.Archives_list): - return borgmatic.actions.browse.panels.Directory_list( + if isinstance(focused_panel, borgmatic.actions.browse.archives_list.Archives_list): + return borgmatic.actions.browse.directory_list.Directory_list( config=focused_panel.config, repository=focused_panel.repository, archive_name=option_id ) - if isinstance(focused_panel, borgmatic.actions.browse.panels.Directory_list): + if isinstance(focused_panel, borgmatic.actions.browse.directory_list.Directory_list): option = focused_panel.get_option(option_id) if option.prompt.startswith( @@ -39,7 +45,7 @@ def make_next_panel(focused_panel, option_id): borgmatic.actions.browse.paths.Path_type.DIRECTORY.value ] ): - return borgmatic.actions.browse.panels.Directory_list( + return borgmatic.actions.browse.directory_list.Directory_list( focused_panel.config, focused_panel.repository, focused_panel.archive_name, @@ -51,7 +57,7 @@ def make_next_panel(focused_panel, option_id): borgmatic.actions.browse.paths.Path_type.FILE.value ] ): - return borgmatic.actions.browse.panels.File_preview( + return borgmatic.actions.browse.file_preview.File_preview( focused_panel.config, focused_panel.repository, focused_panel.archive_name, diff --git a/borgmatic/actions/browse/configuration_files_list.py b/borgmatic/actions/browse/configuration_files_list.py new file mode 100644 index 00000000..4d7d1a2f --- /dev/null +++ b/borgmatic/actions/browse/configuration_files_list.py @@ -0,0 +1,28 @@ +import contextlib +import os + +import textual.binding +import textual.widgets + +import borgmatic.actions.browse.bindings +import borgmatic.actions.browse.loading +import borgmatic.actions.browse.paths +import borgmatic.actions.browse.workers + + +class Configuration_files_list(textual.widgets.OptionList): + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS + + def __init__(self, configs): + self.configs = configs + home_directory = os.path.expanduser('~') + + super().__init__( + *( + textual.widgets.option_list.Option(unexpanded_path, id=config_path) + for config_path in configs + for unexpanded_path in (config_path.replace(home_directory, '~'),) + ), + classes='panel', + ) + self.border_title = 'configuration files' diff --git a/borgmatic/actions/browse/panels.py b/borgmatic/actions/browse/directory_list.py similarity index 51% rename from borgmatic/actions/browse/panels.py rename to borgmatic/actions/browse/directory_list.py index a81a8b5e..4b2a0cf3 100644 --- a/borgmatic/actions/browse/panels.py +++ b/borgmatic/actions/browse/directory_list.py @@ -1,101 +1,15 @@ import contextlib -import logging import os import textual.binding import textual.widgets +import borgmatic.actions.browse.bindings import borgmatic.actions.browse.loading import borgmatic.actions.browse.paths import borgmatic.actions.browse.workers -logger = logging.getLogger('__name__') - - -OPTION_LIST_BINDINGS = ( - *textual.widgets.OptionList.BINDINGS, - textual.binding.Binding( - key='up,k', action='cursor_up', description='scroll up', show=True, priority=True - ), - textual.binding.Binding( - key='down,j', action='cursor_down', description='scroll down', show=True, priority=True - ), - textual.binding.Binding( - key='pageup', action='page_up', description='page up', show=True, priority=True - ), - textual.binding.Binding( - key='pagedown', action='page_down', description='page down', show=True, priority=True - ), - textual.binding.Binding( - key='enter', action='select', description='select', show=True, priority=True - ), - textual.binding.Binding(key='right,l', action='select', description='select', show=False), -) - - -class Configuration_files_list(textual.widgets.OptionList): - BINDINGS = OPTION_LIST_BINDINGS - - def __init__(self, configs): - self.configs = configs - home_directory = os.path.expanduser('~') - - super().__init__( - *( - textual.widgets.option_list.Option(unexpanded_path, id=config_path) - for config_path in configs - for unexpanded_path in (config_path.replace(home_directory, '~'),) - ), - classes='panel', - ) - self.border_title = 'configuration files' - - -class Repositories_list(textual.widgets.OptionList): - BINDINGS = OPTION_LIST_BINDINGS - - def __init__(self, config): - self.config = config - self.repositories = config['repositories'] - - super().__init__( - *( - textual.widgets.option_list.Option(label, id=index) - for index, repository in enumerate(self.repositories) - for label in (repository.get('label', repository.get('path')),) - ), - classes='panel', - ) - self.border_title = '📦 repositories' - - -class Archives_list(textual.widgets.OptionList): - BINDINGS = OPTION_LIST_BINDINGS - - def __init__(self, config, repository): - self.config = config - self.repository = repository - - super().__init__(classes='panel') - self.border_title = '📚 archives' - self.highlighted_option_changed = False - - timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) - - borgmatic.actions.browse.workers.add_repository_archives( - self.app, - archives_list=self, - config=self.config, - repository=self.repository, - timer=timer, - ) - - def on_option_list_option_highlighted(self, event): - if self.highlighted not in {None, 0}: - self.highlighted_option_changed = True - - def get_relative_archive_path_components(archive_path, current_directory_path_components): ''' Given an Archive_path instance and a tuple of path components for the currently browsed @@ -110,11 +24,14 @@ def get_relative_archive_path_components(archive_path, current_directory_path_co # If the loaded path doesn't match this directory list's own path, then we don't care about # it for purposes of displaying this particular directory. - if tuple(archive_path_components[: len(current_directory_path_components)]) != current_directory_path_components: + if ( + tuple(archive_path_components[: len(current_directory_path_components)]) + != current_directory_path_components + ): return None # Strip off the portion of the archive path that matches the directory list's own path. - return archive_path_components[len(current_directory_path_components):] + return archive_path_components[len(current_directory_path_components) :] def make_directory_list_option(archive_path, archive_path_components): @@ -144,10 +61,12 @@ def add_archive_paths( *( make_directory_list_option(archive_path, archive_path_components) for archive_path in archive_paths - for archive_path_components in (get_relative_archive_path_components( - archive_path, - directory_list.path_components, - ),) + for archive_path_components in ( + get_relative_archive_path_components( + archive_path, + directory_list.path_components, + ), + ) if archive_path_components if not archive_path_components[0] in directory_list._id_to_option ), @@ -168,7 +87,7 @@ def add_archive_paths( class Directory_list(textual.widgets.OptionList): - BINDINGS = OPTION_LIST_BINDINGS + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS def __init__(self, config, repository, archive_name, path_loaded=None, path_components=None): self.config = config @@ -244,57 +163,3 @@ class Directory_list(textual.widgets.OptionList): def on_option_list_option_highlighted(self, event): if self.highlighted not in {None, 0}: self.highlighted_option_changed = True - - -class File_preview(textual.widgets.RichLog): - BINDINGS = [ - *textual.widgets.RichLog.BINDINGS, - textual.binding.Binding( - key='up,k', action='scroll_up', description='scroll up', show=True, priority=True - ), - textual.binding.Binding( - key='down,j', action='scroll_down', description='scroll down', show=True, priority=True - ), - textual.binding.Binding( - key='pageup', action='page_up', description='page up', show=True, priority=True - ), - textual.binding.Binding( - key='pagedown', action='page_down', description='page down', show=True, priority=True - ), - ] - - def __init__(self, config, repository, archive_name, file_path): - self.config = config - self.repository = repository - self.archive_name = archive_name - self.file_path = file_path - - super().__init__(classes='panel') - self.border_title = ' '.join( - ( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ - borgmatic.actions.browse.paths.Path_type.FILE.value - ], - self.file_path, - 'preview', - ) - ) - self.auto_scroll = False - - timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) - - borgmatic.actions.browse.workers.load_file_preview( - self.app, - file_preview=self, - config=self.config, - repository=self.repository, - archive_name=self.archive_name, - file_path=self.file_path, - timer=timer, - ) - - -class Logs(textual.widgets.RichLog): - def __init__(self): - super().__init__(markup=True, id='logs', classes='panel') - self.border_title = '🪵 logs' diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py new file mode 100644 index 00000000..3d0ac8e3 --- /dev/null +++ b/borgmatic/actions/browse/file_preview.py @@ -0,0 +1,57 @@ +import contextlib +import os + +import textual.binding +import textual.widgets + +import borgmatic.actions.browse.loading +import borgmatic.actions.browse.paths +import borgmatic.actions.browse.workers + + +class File_preview(textual.widgets.RichLog): + BINDINGS = [ + *textual.widgets.RichLog.BINDINGS, + textual.binding.Binding( + key='up,k', action='scroll_up', description='scroll up', show=True, priority=True + ), + textual.binding.Binding( + key='down,j', action='scroll_down', description='scroll down', show=True, priority=True + ), + textual.binding.Binding( + key='pageup', action='page_up', description='page up', show=True, priority=True + ), + textual.binding.Binding( + key='pagedown', action='page_down', description='page down', show=True, priority=True + ), + ] + + def __init__(self, config, repository, archive_name, file_path): + self.config = config + self.repository = repository + self.archive_name = archive_name + self.file_path = file_path + + super().__init__(classes='panel') + self.border_title = ' '.join( + ( + borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ + borgmatic.actions.browse.paths.Path_type.FILE.value + ], + self.file_path, + 'preview', + ) + ) + self.auto_scroll = False + + timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + + borgmatic.actions.browse.workers.load_file_preview( + self.app, + file_preview=self, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + file_path=self.file_path, + timer=timer, + ) diff --git a/borgmatic/actions/browse/loading.py b/borgmatic/actions/browse/loading.py index c086375a..18d140a4 100644 --- a/borgmatic/actions/browse/loading.py +++ b/borgmatic/actions/browse/loading.py @@ -8,6 +8,7 @@ LOADING_DOT_INTERVAL_SECONDS = 0.3 import logging + logger = logging.getLogger('__name__') diff --git a/borgmatic/actions/browse/logs.py b/borgmatic/actions/browse/logs.py index 3e59de39..a7f8280e 100644 --- a/borgmatic/actions/browse/logs.py +++ b/borgmatic/actions/browse/logs.py @@ -2,6 +2,7 @@ import contextlib import logging import textual._context +import textual.widgets import textual.worker import borgmatic.logger @@ -68,3 +69,9 @@ def log_to_widget(logs_widget): if isinstance(handler, borgmatic.logger.Multi_stream_handler) ) logger.removeHandler(console_handler) + + +class Logs(textual.widgets.RichLog): + def __init__(self): + super().__init__(markup=True, id='logs', classes='panel') + self.border_title = '🪵 logs' diff --git a/borgmatic/actions/browse/repositories_list.py b/borgmatic/actions/browse/repositories_list.py new file mode 100644 index 00000000..086d4408 --- /dev/null +++ b/borgmatic/actions/browse/repositories_list.py @@ -0,0 +1,28 @@ +import contextlib +import os + +import textual.binding +import textual.widgets + +import borgmatic.actions.browse.bindings +import borgmatic.actions.browse.loading +import borgmatic.actions.browse.paths +import borgmatic.actions.browse.workers + + +class Repositories_list(textual.widgets.OptionList): + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS + + def __init__(self, config): + self.config = config + self.repositories = config['repositories'] + + super().__init__( + *( + textual.widgets.option_list.Option(label, id=index) + for index, repository in enumerate(self.repositories) + for label in (repository.get('label', repository.get('path')),) + ), + classes='panel', + ) + self.border_title = '📦 repositories' From 6869a5d5f25473bda90aff2034651cab7822ee3f Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 27 May 2026 22:40:13 -0700 Subject: [PATCH 42/63] Additional tests and docstrings. --- borgmatic/actions/browse/archive.py | 30 ++++++-- borgmatic/actions/browse/workers.py | 17 ++--- tests/unit/actions/browse/test_archive.py | 90 +++++++++++++++++++++++ 3 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 tests/unit/actions/browse/test_archive.py diff --git a/borgmatic/actions/browse/archive.py b/borgmatic/actions/browse/archive.py index 6ac7733e..f64b85a1 100644 --- a/borgmatic/actions/browse/archive.py +++ b/borgmatic/actions/browse/archive.py @@ -1,9 +1,11 @@ import argparse +import collections import json import logging import os import borgmatic.borg.extract +import borgmatic.borg.list import borgmatic.borg.repo_list import borgmatic.borg.version @@ -13,7 +15,17 @@ import binaryornot.helpers logger = logging.getLogger(__name__) +Archive_path = collections.namedtuple( + 'Archive_path', + ('path_type', 'file_path', 'link_target'), +) + + def get_repository_archives(config, repository): + ''' + Given a configuration dict and a repository dict, return a list of the repository's archives, + one dict per archive. + ''' with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): logger.info('Listing repository') repo_list_arguments = argparse.Namespace( @@ -45,7 +57,12 @@ def get_repository_archives(config, repository): ) -def get_archive_files(config, repository, archive_name): +def get_archive_paths(config, repository, archive_name): + ''' + Given a configuration dict, a repository dict, and an archive name in that repository, get a + list of files, directories, symlinks, etc. found in the archive, each as an Archive_path + instance. + ''' with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): logger.info(f'Listing archive {archive_name}') @@ -53,10 +70,9 @@ def get_archive_files(config, repository, archive_name): local_path = config.get('local_path', 'borg') remote_path = config.get('remote_path') local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path) - seen = set() return ( - ( + Archive_path( path_data['type'], path_data['path'], path_data.get('linktarget'), @@ -74,12 +90,16 @@ def get_archive_files(config, repository, archive_name): READLINES_HINT_BYTES = 100000 +TRUNCATION_MESSAGE = '[... truncated for display ...]' def get_archive_file_content(config, repository, archive_name, file_path): ''' Given a configuration dict, a repository dict, an archive name in that repository, and a file - path in that archive, return the file's contents or None if the file can't be loaded. + path in that archive, return the file's contents or None if the file can't be loaded, e.g. + because it's binary or can't be decoded. + + If the file is too large, then truncate the returned content. ''' with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): logger.info(f'Getting archive content of file {file_path}') @@ -110,7 +130,7 @@ def get_archive_file_content(config, repository, archive_name, file_path): return ( content.decode() if len(content) < READLINES_HINT_BYTES - else f'{content.decode()}\n[... truncated for display ...]' + else f'{content.decode()}\n{TRUNCATION_MESSAGE}' ) except UnicodeDecodeError: return None diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index 04c80d31..a464e9e9 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -1,4 +1,3 @@ -import collections import contextlib import logging import os @@ -45,12 +44,6 @@ def add_repository_archives(browse_app, archives_list, config, repository, timer browse_app.call_from_thread(timer.stop) -Archive_path = collections.namedtuple( - 'Archive_path', - ('path_type', 'file_path', 'link_target'), -) - - def record_path(archive_path, hierarchy, path_components): if len(path_components) == 1: hierarchy[path_components[0]] = {} if archive_path.path_type == 'd' else archive_path @@ -66,8 +59,10 @@ def get_paths(hierarchy, path_components, full_path_components=None): if len(path_components) == 1: return ( archive_path - if isinstance(archive_path, Archive_path) - else Archive_path('d', os.path.join(*full_path_components, component), '') + if isinstance(archive_path, borgmatic.actions.browse.archive.Archive_path) + else borgmatic.actions.browse.archive.Archive_path( + 'd', os.path.join(*full_path_components, component), '' + ) for component, archive_path in hierarchy[path_components[0]].items() ) @@ -95,10 +90,10 @@ class Archive_path_loaded(textual.signal.Signal): @textual.work(thread=True) def load_archive_files(browse_app, directory_list, config, repository, archive_name): - for path_type, file_path, link_target in borgmatic.actions.browse.archive.get_archive_files( + for archive_path in borgmatic.actions.browse.archive.get_archive_paths( config, repository, archive_name ): - directory_list.path_loaded.publish(Archive_path(path_type, file_path, link_target)) + directory_list.path_loaded.publish(archive_path) directory_list.path_loaded.publish(LOADING_DONE) diff --git a/tests/unit/actions/browse/test_archive.py b/tests/unit/actions/browse/test_archive.py new file mode 100644 index 00000000..44f0b46c --- /dev/null +++ b/tests/unit/actions/browse/test_archive.py @@ -0,0 +1,90 @@ +from flexmock import flexmock + +from borgmatic.actions.browse import archive as module + + +def test_get_repository_archives_does_not_raise(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) + flexmock(module.borgmatic.borg.version).should_receive('local_borg_version').and_return('3.0') + flexmock(module.borgmatic.borg.repo_list).should_receive('list_repository').and_return('{}') + + assert module.get_repository_archives(config, config['repositories'][0]) == {} + + +def test_get_archive_paths_returns_each_as_archive_path_with_metadata(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) + flexmock(module.borgmatic.borg.version).should_receive('local_borg_version').and_return('3.0') + flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield( + {'path': 'foo.txt', 'type': '-', 'linktarget': ''}, + {'path': 'bar.txt', 'type': 'l', 'linktarget': 'foo.txt'}, + {'path': 'etc', 'type': 'd', 'linktarget': ''}, + ) + + assert tuple(module.get_archive_paths(config, config['repositories'][0], 'archive')) == ( + module.Archive_path('-', 'foo.txt', ''), + module.Archive_path('l', 'bar.txt', 'foo.txt'), + module.Archive_path('d', 'etc', ''), + ) + + +def test_get_archive_file_content_with_binary_file_bails(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) + flexmock(module.borgmatic.borg.version).should_receive('local_borg_version').and_return('3.0') + flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( + flexmock(stdout=flexmock(readlines=lambda hint: [b'foo\n', b'bar\n'])), + ) + flexmock(module.binaryornot.helpers).should_receive('is_binary_string').and_return(True) + + assert ( + module.get_archive_file_content(config, config['repositories'][0], 'archive', 'etc/foo.txt') + is None + ) + + +def test_get_archive_file_content_decodes_content(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) + flexmock(module.borgmatic.borg.version).should_receive('local_borg_version').and_return('3.0') + flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( + flexmock(stdout=flexmock(readlines=lambda hint: [b'foo\n', b'bar\n'])), + ) + flexmock(module.binaryornot.helpers).should_receive('is_binary_string').and_return(False) + + assert ( + module.get_archive_file_content(config, config['repositories'][0], 'archive', 'etc/foo.txt') + == 'foo\nbar\n' + ) + + +def test_get_archive_file_content_with_large_file_truncates_content(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) + flexmock(module.borgmatic.borg.version).should_receive('local_borg_version').and_return('3.0') + flexmock(module).READLINES_HINT_BYTES = 3 + flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( + flexmock(stdout=flexmock(readlines=lambda hint: [b'foo\n', b'bar\n'])), + ) + flexmock(module.binaryornot.helpers).should_receive('is_binary_string').and_return(False) + + assert ( + module.get_archive_file_content(config, config['repositories'][0], 'archive', 'etc/foo.txt') + == f'foo\nbar\n\n{module.TRUNCATION_MESSAGE}' + ) + + +def test_get_archive_file_content_with_unicode_decode_error_does_not_raise(): + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) + flexmock(module.borgmatic.borg.version).should_receive('local_borg_version').and_return('3.0') + flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( + flexmock(stdout=flexmock(readlines=lambda hint: [b'foo\n', b'\xc3\n'])), + ) + flexmock(module.binaryornot.helpers).should_receive('is_binary_string').and_return(False) + + assert ( + module.get_archive_file_content(config, config['repositories'][0], 'archive', 'etc/foo.txt') + is None + ) From 16061f4f6d32c083b0dbca572a0675938b6d7003 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 28 May 2026 17:15:13 -0700 Subject: [PATCH 43/63] Fix tests after recent refactoring. --- borgmatic/actions/browse/directory_list.py | 2 +- borgmatic/actions/browse/workers.py | 2 +- tests/integration/actions/browse/test_app.py | 11 +- .../actions/browse/test_carousel.py | 170 ++++++++++++------ 4 files changed, 123 insertions(+), 62 deletions(-) diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 4b2a0cf3..70168c74 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -135,7 +135,7 @@ class Directory_list(textual.widgets.OptionList): ), ) else: - borgmatic.actions.browse.workers.load_archive_files( + borgmatic.actions.browse.workers.load_archive_paths( self.app, directory_list=self, config=self.config, diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index a464e9e9..bdbd100e 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -89,7 +89,7 @@ class Archive_path_loaded(textual.signal.Signal): @textual.work(thread=True) -def load_archive_files(browse_app, directory_list, config, repository, archive_name): +def load_archive_paths(browse_app, directory_list, config, repository, archive_name): for archive_path in borgmatic.actions.browse.archive.get_archive_paths( config, repository, archive_name ): diff --git a/tests/integration/actions/browse/test_app.py b/tests/integration/actions/browse/test_app.py index 02b79421..8e620eac 100644 --- a/tests/integration/actions/browse/test_app.py +++ b/tests/integration/actions/browse/test_app.py @@ -1,5 +1,7 @@ import borgmatic.actions.browse.app -import borgmatic.actions.browse.panels +import borgmatic.actions.browse.configuration_files_list +import borgmatic.actions.browse.logs +import borgmatic.actions.browse.repositories_list import pytest from flexmock import flexmock @@ -21,7 +23,8 @@ async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): carousel = app.query_one(selector='Carousel') assert len(carousel.panels) == 1 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].configs == app.configs @@ -43,7 +46,9 @@ async def test_browse_app_with_one_config_uses_repositories_list(): carousel = app.query_one(selector='Carousel') assert len(carousel.panels) == 1 - assert isinstance(carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list) + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.repositories_list.Repositories_list + ) assert carousel.panels[0].config == app.configs['test1.yaml'] app.query_one(selector='Logs') diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py index 01e106a8..438eeccc 100644 --- a/tests/integration/actions/browse/test_carousel.py +++ b/tests/integration/actions/browse/test_carousel.py @@ -1,8 +1,8 @@ import borgmatic.actions.browse.app +import borgmatic.actions.browse.archive import borgmatic.actions.browse.carousel import borgmatic.actions.browse.loading import borgmatic.actions.browse.logs -import borgmatic.actions.browse.panels import borgmatic.actions.browse.workers import pytest @@ -17,11 +17,15 @@ def test_make_next_panel_with_configuration_files_list_returns_repositories_list configs = {'test.yaml': {'repositories': [{'path': 'test.borg'}]}} repositories_list = module.make_next_panel( - focused_panel=borgmatic.actions.browse.panels.Configuration_files_list(configs), + focused_panel=borgmatic.actions.browse.configuration_files_list.Configuration_files_list( + configs + ), option_id='test.yaml', ) - assert isinstance(repositories_list, borgmatic.actions.browse.panels.Repositories_list) + assert isinstance( + repositories_list, borgmatic.actions.browse.repositories_list.Repositories_list + ) assert repositories_list.config == configs['test.yaml'] @@ -29,16 +33,16 @@ def test_make_next_panel_with_repositories_list_returns_archives_list(): config = {'repositories': [{'path': 'test.borg'}]} flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') - flexmock(borgmatic.actions.browse.panels.Archives_list).should_receive('app').and_return( + flexmock(borgmatic.actions.browse.archives_list.Archives_list).should_receive('app').and_return( flexmock() ) archives_list = module.make_next_panel( - focused_panel=borgmatic.actions.browse.panels.Repositories_list(config), + focused_panel=borgmatic.actions.browse.repositories_list.Repositories_list(config), option_id=0, ) - assert isinstance(archives_list, borgmatic.actions.browse.panels.Archives_list) + assert isinstance(archives_list, borgmatic.actions.browse.archives_list.Archives_list) assert archives_list.config == config assert archives_list.repository == config['repositories'][0] @@ -47,22 +51,22 @@ def test_make_next_panel_with_archives_list_returns_directory_list(): config = {'repositories': [{'path': 'test.borg'}]} flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') flexmock(borgmatic.actions.browse.workers).should_receive('add_repository_archives') - flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') - flexmock(borgmatic.actions.browse.panels.Archives_list).should_receive('app').and_return( - flexmock() - ) - flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock(borgmatic.actions.browse.workers).should_receive('load_archive_paths') + flexmock(borgmatic.actions.browse.archives_list.Archives_list).should_receive('app').and_return( flexmock() ) + flexmock(borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) directory_list = module.make_next_panel( - focused_panel=borgmatic.actions.browse.panels.Archives_list( + focused_panel=borgmatic.actions.browse.archives_list.Archives_list( config, config['repositories'][0] ), option_id='archive', ) - assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert isinstance(directory_list, borgmatic.actions.browse.directory_list.Directory_list) assert directory_list.config == config assert directory_list.repository == config['repositories'][0] @@ -70,18 +74,26 @@ def test_make_next_panel_with_archives_list_returns_directory_list(): def test_make_next_panel_with_root_directory_list_and_selected_directory_option_returns_new_directory_list(): config = {'repositories': [{'path': 'test.borg'}]} flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') - flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( - flexmock() + flexmock(borgmatic.actions.browse.workers).should_receive('load_archive_paths') + flexmock(borgmatic.actions.browse.workers).should_receive('Archive_path_loaded').replace_with( + flexmock( + path_hierarchy={ + 'etc': {}, + }, + complete=False, + ), ) - focused_panel = borgmatic.actions.browse.panels.Directory_list( + flexmock(borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + focused_panel = borgmatic.actions.browse.directory_list.Directory_list( config, config['repositories'][0], 'archive' ) flexmock(focused_panel).should_receive('get_option').and_return(flexmock(prompt='📁 etc')) directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='etc') - assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert isinstance(directory_list, borgmatic.actions.browse.directory_list.Directory_list) assert directory_list.config == config assert directory_list.repository == config['repositories'][0] assert directory_list.archive_name == 'archive' @@ -91,11 +103,21 @@ def test_make_next_panel_with_root_directory_list_and_selected_directory_option_ def test_make_next_panel_with_non_root_directory_list_and_selected_directory_option_returns_new_directory_list(): config = {'repositories': [{'path': 'test.borg'}]} flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') - flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( - flexmock() + flexmock(borgmatic.actions.browse.workers).should_receive('load_archive_paths') + flexmock(borgmatic.actions.browse.workers).should_receive('Archive_path_loaded').replace_with( + flexmock( + path_hierarchy={ + 'etc': { + 'borgmatic': {}, + }, + }, + complete=False, + ), ) - focused_panel = borgmatic.actions.browse.panels.Directory_list( + flexmock(borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + focused_panel = borgmatic.actions.browse.directory_list.Directory_list( config, config['repositories'][0], 'archive', @@ -105,7 +127,7 @@ def test_make_next_panel_with_non_root_directory_list_and_selected_directory_opt directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='borgmatic') - assert isinstance(directory_list, borgmatic.actions.browse.panels.Directory_list) + assert isinstance(directory_list, borgmatic.actions.browse.directory_list.Directory_list) assert directory_list.config == config assert directory_list.repository == config['repositories'][0] assert directory_list.archive_name == 'archive' @@ -115,15 +137,15 @@ def test_make_next_panel_with_non_root_directory_list_and_selected_directory_opt def test_make_next_panel_with_root_directory_list_and_selected_file_option_returns_new_file_preview(): config = {'repositories': [{'path': 'test.borg'}]} flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.workers).should_receive('load_archive_paths') flexmock(borgmatic.actions.browse.workers).should_receive('load_file_preview') - flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock(borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + flexmock(borgmatic.actions.browse.file_preview.File_preview).should_receive('app').and_return( flexmock() ) - flexmock(borgmatic.actions.browse.panels.File_preview).should_receive('app').and_return( - flexmock() - ) - focused_panel = borgmatic.actions.browse.panels.Directory_list( + focused_panel = borgmatic.actions.browse.directory_list.Directory_list( config, config['repositories'][0], 'archive' ) flexmock(focused_panel).should_receive('get_option').and_return( @@ -132,7 +154,7 @@ def test_make_next_panel_with_root_directory_list_and_selected_file_option_retur directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') - assert isinstance(directory_list, borgmatic.actions.browse.panels.File_preview) + assert isinstance(directory_list, borgmatic.actions.browse.file_preview.File_preview) assert directory_list.config == config assert directory_list.repository == config['repositories'][0] assert directory_list.archive_name == 'archive' @@ -142,15 +164,29 @@ def test_make_next_panel_with_root_directory_list_and_selected_file_option_retur def test_make_next_panel_with_non_root_directory_list_and_selected_file_option_returns_new_file_preview(): config = {'repositories': [{'path': 'test.borg'}]} flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') + flexmock(borgmatic.actions.browse.workers).should_receive('load_archive_paths') flexmock(borgmatic.actions.browse.workers).should_receive('load_file_preview') - flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( + flexmock(borgmatic.actions.browse.workers).should_receive('Archive_path_loaded').replace_with( + flexmock( + path_hierarchy={ + 'etc': { + 'borgmatic': { + 'config.yaml': borgmatic.actions.browse.archive.Archive_path( + '-', 'config.yaml', '' + ), + }, + }, + }, + complete=False, + ), + ) + flexmock(borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + flexmock(borgmatic.actions.browse.file_preview.File_preview).should_receive('app').and_return( flexmock() ) - flexmock(borgmatic.actions.browse.panels.File_preview).should_receive('app').and_return( - flexmock() - ) - focused_panel = borgmatic.actions.browse.panels.Directory_list( + focused_panel = borgmatic.actions.browse.directory_list.Directory_list( config, config['repositories'][0], 'archive', @@ -162,7 +198,7 @@ def test_make_next_panel_with_non_root_directory_list_and_selected_file_option_r directory_list = module.make_next_panel(focused_panel=focused_panel, option_id='config.yaml') - assert isinstance(directory_list, borgmatic.actions.browse.panels.File_preview) + assert isinstance(directory_list, borgmatic.actions.browse.file_preview.File_preview) assert directory_list.config == config assert directory_list.repository == config['repositories'][0] assert directory_list.archive_name == 'archive' @@ -177,11 +213,11 @@ def test_make_next_panel_with_unsupported_focused_panel_returns_none(): def test_make_next_panel_with_directory_list_and_unsupported_selected_option_returns_none(icon): config = {'repositories': [{'path': 'test.borg'}]} flexmock(borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(borgmatic.actions.browse.workers).should_receive('add_archive_files') - flexmock(borgmatic.actions.browse.panels.Directory_list).should_receive('app').and_return( - flexmock() - ) - focused_panel = borgmatic.actions.browse.panels.Directory_list( + flexmock(borgmatic.actions.browse.workers).should_receive('load_archive_paths') + flexmock(borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + focused_panel = borgmatic.actions.browse.directory_list.Directory_list( config, config['repositories'][0], 'archive' ) flexmock(focused_panel).should_receive('get_option').and_return( @@ -232,11 +268,14 @@ async def test_carousel_next_action_with_multiple_configs_advances_panel(): assert len(carousel.panels) == 2 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].styles.display == 'none' - assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list) + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.repositories_list.Repositories_list + ) assert carousel.panels[1].styles.display == 'block' assert carousel.panels[1].highlighted == 0 assert app.focused == carousel.panels[1] @@ -257,10 +296,12 @@ async def test_carousel_next_action_with_one_config_advances_panel(): carousel = app.query_one(selector='Carousel') assert len(carousel.panels) == 2 - assert isinstance(carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list) + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.repositories_list.Repositories_list + ) assert carousel.panels[0].styles.display == 'none' - assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Archives_list) + assert isinstance(carousel.panels[1], borgmatic.actions.browse.archives_list.Archives_list) assert carousel.panels[1].styles.display == 'block' assert carousel.panels[1].highlighted == 0 assert app.focused == carousel.panels[1] @@ -282,7 +323,9 @@ async def test_carousel_next_action_with_no_next_panel_does_not_advance(): carousel = app.query_one(selector='Carousel') assert len(carousel.panels) == 1 - assert isinstance(carousel.panels[0], borgmatic.actions.browse.panels.Repositories_list) + assert isinstance( + carousel.panels[0], borgmatic.actions.browse.repositories_list.Repositories_list + ) assert carousel.panels[0].styles.display == 'block' assert app.focused == carousel.panels[0] @@ -304,12 +347,15 @@ async def test_carousel_next_action_and_previous_action_returns_to_original_pane assert len(carousel.panels) == 2 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].styles.display == 'block' assert carousel.panels[0].highlighted == 0 - assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list) + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.repositories_list.Repositories_list + ) assert carousel.panels[1].styles.display == 'none' assert app.focused == carousel.panels[0] @@ -333,11 +379,14 @@ async def test_carousel_next_action_and_previous_action_and_next_action_reuses_n assert len(carousel.panels) == 2 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].styles.display == 'none' - assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list) + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.repositories_list.Repositories_list + ) assert carousel.panels[1].styles.display == 'block' assert carousel.panels[1].highlighted == 0 assert app.focused == carousel.panels[1] @@ -360,7 +409,8 @@ async def test_carousel_next_action_with_no_next_panel_does_not_advance(): assert len(carousel.panels) == 1 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].styles.display == 'block' assert carousel.panels[0].highlighted == 0 @@ -385,7 +435,8 @@ async def test_carousel_next_action_and_previous_action_and_down_truncates_next_ assert len(carousel.panels) == 1 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].styles.display == 'block' assert carousel.panels[0].highlighted == 1 @@ -408,7 +459,8 @@ async def test_carousel_down_does_not_raise(): assert len(carousel.panels) == 1 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].styles.display == 'block' assert carousel.panels[0].highlighted == 1 @@ -431,7 +483,8 @@ async def test_carousel_up_does_not_raise(): assert len(carousel.panels) == 1 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].styles.display == 'block' assert carousel.panels[0].highlighted == 1 @@ -458,11 +511,14 @@ async def test_carousel_next_action_and_select_dot_dot_returns_to_original_panel assert len(carousel.panels) == 2 assert isinstance( - carousel.panels[0], borgmatic.actions.browse.panels.Configuration_files_list + carousel.panels[0], + borgmatic.actions.browse.configuration_files_list.Configuration_files_list, ) assert carousel.panels[0].styles.display == 'block' assert carousel.panels[0].highlighted == 0 - assert isinstance(carousel.panels[1], borgmatic.actions.browse.panels.Repositories_list) + assert isinstance( + carousel.panels[1], borgmatic.actions.browse.repositories_list.Repositories_list + ) assert carousel.panels[1].styles.display == 'none' assert app.focused == carousel.panels[0] From e82c1bb19580ee2078a33c9a2b7eb2302062904b Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 28 May 2026 20:00:47 -0700 Subject: [PATCH 44/63] More tests and refactoring. --- borgmatic/actions/browse/archive.py | 9 +++++ borgmatic/actions/browse/archives_list.py | 1 - borgmatic/actions/browse/carousel.py | 11 +++--- .../browse/configuration_files_list.py | 1 - borgmatic/actions/browse/directory_list.py | 11 +++--- borgmatic/actions/browse/file_preview.py | 7 ++-- borgmatic/actions/browse/icons.py | 9 +++++ borgmatic/actions/browse/paths.py | 16 -------- borgmatic/actions/browse/repositories_list.py | 1 - tests/unit/commands/test_borgmatic.py | 38 +++++++++++++++++++ 10 files changed, 72 insertions(+), 32 deletions(-) create mode 100644 borgmatic/actions/browse/icons.py delete mode 100644 borgmatic/actions/browse/paths.py diff --git a/borgmatic/actions/browse/archive.py b/borgmatic/actions/browse/archive.py index f64b85a1..5e3cc8f8 100644 --- a/borgmatic/actions/browse/archive.py +++ b/borgmatic/actions/browse/archive.py @@ -1,5 +1,6 @@ import argparse import collections +import enum import json import logging import os @@ -15,6 +16,14 @@ import binaryornot.helpers logger = logging.getLogger(__name__) +class Path_type(enum.Enum): + DIRECTORY = 'd' + LINK = 'l' + PIPE = 'p' + FILE = '-' + + +# A data structure capturing a path stored in a Borg archive. Archive_path = collections.namedtuple( 'Archive_path', ('path_type', 'file_path', 'link_target'), diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py index 01c0febf..0e8f8159 100644 --- a/borgmatic/actions/browse/archives_list.py +++ b/borgmatic/actions/browse/archives_list.py @@ -6,7 +6,6 @@ import textual.widgets import borgmatic.actions.browse.bindings import borgmatic.actions.browse.loading -import borgmatic.actions.browse.paths import borgmatic.actions.browse.workers diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index 2b3f2580..6c5bdec9 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -3,11 +3,12 @@ import os import textual.binding import textual.containers +import borgmatic.actions.browse.archive import borgmatic.actions.browse.archives_list import borgmatic.actions.browse.directory_list import borgmatic.actions.browse.configuration_files_list import borgmatic.actions.browse.file_preview -import borgmatic.actions.browse.paths +import borgmatic.actions.browse.icons import borgmatic.actions.browse.repositories_list @@ -41,8 +42,8 @@ def make_next_panel(focused_panel, option_id): option = focused_panel.get_option(option_id) if option.prompt.startswith( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ - borgmatic.actions.browse.paths.Path_type.DIRECTORY.value + borgmatic.actions.browse.icons.PATH_TYPE_ICONS[ + borgmatic.actions.browse.archive.Path_type.DIRECTORY.value ] ): return borgmatic.actions.browse.directory_list.Directory_list( @@ -53,8 +54,8 @@ def make_next_panel(focused_panel, option_id): path_components=(*focused_panel.path_components, option_id), ) elif option.prompt.startswith( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ - borgmatic.actions.browse.paths.Path_type.FILE.value + borgmatic.actions.browse.icons.PATH_TYPE_ICONS[ + borgmatic.actions.browse.archive.Path_type.FILE.value ] ): return borgmatic.actions.browse.file_preview.File_preview( diff --git a/borgmatic/actions/browse/configuration_files_list.py b/borgmatic/actions/browse/configuration_files_list.py index 4d7d1a2f..c6da1390 100644 --- a/borgmatic/actions/browse/configuration_files_list.py +++ b/borgmatic/actions/browse/configuration_files_list.py @@ -6,7 +6,6 @@ import textual.widgets import borgmatic.actions.browse.bindings import borgmatic.actions.browse.loading -import borgmatic.actions.browse.paths import borgmatic.actions.browse.workers diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 70168c74..3e94c2cc 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -4,9 +4,10 @@ import os import textual.binding import textual.widgets +import borgmatic.actions.browse.archive import borgmatic.actions.browse.bindings import borgmatic.actions.browse.loading -import borgmatic.actions.browse.paths +import borgmatic.actions.browse.icons import borgmatic.actions.browse.workers @@ -36,7 +37,7 @@ def get_relative_archive_path_components(archive_path, current_directory_path_co def make_directory_list_option(archive_path, archive_path_components): pieces = ( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS.get( + borgmatic.actions.browse.icons.PATH_TYPE_ICONS.get( archive_path.path_type if len(archive_path_components) == 1 else 'd', '❓' ), archive_path_components[0], @@ -100,8 +101,8 @@ class Directory_list(textual.widgets.OptionList): self.border_title = ' '.join( ( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ - borgmatic.actions.browse.paths.Path_type.DIRECTORY.value + borgmatic.actions.browse.icons.PATH_TYPE_ICONS[ + borgmatic.actions.browse.archive.Path_type.DIRECTORY.value ], os.path.sep.join(self.path_components) if self.path_components @@ -112,7 +113,7 @@ class Directory_list(textual.widgets.OptionList): if self.path_components: self.add_option( textual.widgets.option_list.Option( - f'{borgmatic.actions.browse.paths.PATH_TYPE_ICONS[borgmatic.actions.browse.paths.Path_type.DIRECTORY.value]} ..', + f'{borgmatic.actions.browse.icons.PATH_TYPE_ICONS[borgmatic.actions.browse.archive.Path_type.DIRECTORY.value]} ..', id='..', ), ) diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index 3d0ac8e3..b653a4cc 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -4,8 +4,9 @@ import os import textual.binding import textual.widgets +import borgmatic.actions.browse.archive import borgmatic.actions.browse.loading -import borgmatic.actions.browse.paths +import borgmatic.actions.browse.icons import borgmatic.actions.browse.workers @@ -35,8 +36,8 @@ class File_preview(textual.widgets.RichLog): super().__init__(classes='panel') self.border_title = ' '.join( ( - borgmatic.actions.browse.paths.PATH_TYPE_ICONS[ - borgmatic.actions.browse.paths.Path_type.FILE.value + borgmatic.actions.browse.icons.PATH_TYPE_ICONS[ + borgmatic.actions.browse.archive.Path_type.FILE.value ], self.file_path, 'preview', diff --git a/borgmatic/actions/browse/icons.py b/borgmatic/actions/browse/icons.py new file mode 100644 index 00000000..2d621d38 --- /dev/null +++ b/borgmatic/actions/browse/icons.py @@ -0,0 +1,9 @@ +import borgmatic.actions.browse.archive + + +PATH_TYPE_ICONS = { + borgmatic.actions.browse.archive.Path_type.DIRECTORY.value: '📁', + borgmatic.actions.browse.archive.Path_type.LINK.value: '🔗', + borgmatic.actions.browse.archive.Path_type.PIPE.value: '🚰', + borgmatic.actions.browse.archive.Path_type.FILE.value: '📄', +} diff --git a/borgmatic/actions/browse/paths.py b/borgmatic/actions/browse/paths.py deleted file mode 100644 index b458ebde..00000000 --- a/borgmatic/actions/browse/paths.py +++ /dev/null @@ -1,16 +0,0 @@ -import enum - - -class Path_type(enum.Enum): - DIRECTORY = 'd' - LINK = 'l' - PIPE = 'p' - FILE = '-' - - -PATH_TYPE_ICONS = { - Path_type.DIRECTORY.value: '📁', - Path_type.LINK.value: '🔗', - Path_type.PIPE.value: '🚰', - Path_type.FILE.value: '📄', -} diff --git a/borgmatic/actions/browse/repositories_list.py b/borgmatic/actions/browse/repositories_list.py index 086d4408..8d01e466 100644 --- a/borgmatic/actions/browse/repositories_list.py +++ b/borgmatic/actions/browse/repositories_list.py @@ -6,7 +6,6 @@ import textual.widgets import borgmatic.actions.browse.bindings import borgmatic.actions.browse.loading -import borgmatic.actions.browse.paths import borgmatic.actions.browse.workers diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 73ae1a09..69b84168 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -2095,6 +2095,44 @@ def test_collect_highlander_action_summary_logs_error_on_run_show_failure(): assert {log.levelno for log in logs} == {logging.CRITICAL} +def test_collect_highlander_action_summary_logs_nothing_additional_for_success_with_browse(): + flexmock(module.borgmatic.actions.browse.run).should_receive('run_browse') + arguments = { + 'browse': flexmock(), + 'global': flexmock(), + } + + logs = tuple( + module.collect_highlander_action_summary_logs( + {'test.yaml': {}}, + arguments=arguments, + configuration_parse_errors=False, + ), + ) + assert not logs + + +def test_collect_highlander_action_summary_logs_error_on_run_browse_failure(): + flexmock(module.borgmatic.actions.browse.run).should_receive('run_browse').and_raise( + ValueError, + ) + arguments = { + 'browse': flexmock(), + 'global': flexmock(), + } + + logs = tuple( + module.collect_highlander_action_summary_logs( + {'test.yaml': {}}, + arguments=arguments, + configuration_parse_errors=False, + ), + ) + + assert {log.levelno for log in logs} == {logging.CRITICAL} + + + def test_collect_configuration_run_summary_logs_info_for_success(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module.command).should_receive('filter_hooks').with_args( From a876ce20583692c1a535558d7aaf5ed7a312e321 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 29 May 2026 11:52:02 -0700 Subject: [PATCH 45/63] Yet more tests. --- borgmatic/actions/browse/archives_list.py | 14 +++ borgmatic/actions/browse/directory_list.py | 30 ++++--- borgmatic/actions/browse/file_preview.py | 20 ++--- borgmatic/actions/browse/repositories_list.py | 9 ++ borgmatic/actions/browse/workers.py | 2 + .../actions/browse/test_archives_list.py | 63 +++++++++++++ .../actions/browse/test_file_preview.py | 14 +++ .../actions/browse/test_repositories_list.py | 14 +++ .../actions/browse/test_directories_list.py | 90 +++++++++++++++++++ tests/unit/commands/test_borgmatic.py | 1 - 10 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 tests/integration/actions/browse/test_archives_list.py create mode 100644 tests/integration/actions/browse/test_file_preview.py create mode 100644 tests/integration/actions/browse/test_repositories_list.py create mode 100644 tests/unit/actions/browse/test_directories_list.py diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py index 0e8f8159..8d414068 100644 --- a/borgmatic/actions/browse/archives_list.py +++ b/borgmatic/actions/browse/archives_list.py @@ -10,9 +10,18 @@ import borgmatic.actions.browse.workers class Archives_list(textual.widgets.OptionList): + ''' + A widget for selecting a single Borg archive from among the archives in a repository. The item + selection event is handled in a Carousel instance, the parent widget of an Archives_list. + ''' + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS def __init__(self, config, repository): + ''' + Given a configuration dict and a repository dict, start loading the archives from the + repository for eventual display in this widget. + ''' self.config = config self.repository = repository @@ -31,5 +40,10 @@ class Archives_list(textual.widgets.OptionList): ) def on_option_list_option_highlighted(self, event): + ''' + When the highlighted option changes, record that fact. This flag is consumed in + borgmatic.actions.browse.workers.add_repository_archives() in order to retain the + highlighted option even as other options load around it. + ''' if self.highlighted not in {None, 0}: self.highlighted_option_changed = True diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 3e94c2cc..cfa54b89 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -14,11 +14,14 @@ import borgmatic.actions.browse.workers def get_relative_archive_path_components(archive_path, current_directory_path_components): ''' Given an Archive_path instance and a tuple of path components for the currently browsed - directory, get the path components for the archive path relative to that directory. + directory, get the path components as a tuple for the archive path relative to that directory. - If the archive path is not actually relative to that directory, return None. + For instance, given an archive path with a path of 'foo/bar/baz/quux.txt' and current + directory path components of ('foo', 'bar'), return ('baz', 'quux.txt'). + + If the archive path is not actually relative to the current directory, return None. ''' - archive_path_components = archive_path.file_path.split(os.path.sep) + archive_path_components = tuple(archive_path.file_path.split(os.path.sep)) if not current_directory_path_components: return archive_path_components @@ -35,15 +38,20 @@ def get_relative_archive_path_components(archive_path, current_directory_path_co return archive_path_components[len(current_directory_path_components) :] -def make_directory_list_option(archive_path, archive_path_components): +def make_directory_list_option(archive_path, relative_path_components): + ''' + Given an Archive_path instance and a tuple of relative path components for it, make a + textual.widgets.option_list.Option for the path. Use an the icon based on whether this looks + like a terminal filename or a directory. + ''' pieces = ( borgmatic.actions.browse.icons.PATH_TYPE_ICONS.get( - archive_path.path_type if len(archive_path_components) == 1 else 'd', '❓' + archive_path.path_type if len(relative_path_components) == 1 else 'd', '❓' ), - archive_path_components[0], + relative_path_components[0], ) + (('→', archive_path.link_target) if archive_path.link_target else ()) - return textual.widgets.option_list.Option(' '.join(pieces), id=archive_path_components[0]) + return textual.widgets.option_list.Option(prompt=' '.join(pieces), id=relative_path_components[0]) def add_archive_paths( @@ -60,16 +68,16 @@ def add_archive_paths( ( *directory_list.options, *( - make_directory_list_option(archive_path, archive_path_components) + make_directory_list_option(archive_path, relative_path_components) for archive_path in archive_paths - for archive_path_components in ( + for relative_path_components in ( get_relative_archive_path_components( archive_path, directory_list.path_components, ), ) - if archive_path_components - if not archive_path_components[0] in directory_list._id_to_option + if relative_path_components + if not relative_path_components[0] in directory_list._id_to_option ), ), key=lambda option: ((option.id == 'loading-indicator'), option.prompt), diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index b653a4cc..1f34cc48 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -4,13 +4,15 @@ import os import textual.binding import textual.widgets -import borgmatic.actions.browse.archive import borgmatic.actions.browse.loading -import borgmatic.actions.browse.icons import borgmatic.actions.browse.workers class File_preview(textual.widgets.RichLog): + ''' + A widget for extracting and previewing the contents of a file stored in a Borg archive. + ''' + BINDINGS = [ *textual.widgets.RichLog.BINDINGS, textual.binding.Binding( @@ -28,21 +30,17 @@ class File_preview(textual.widgets.RichLog): ] def __init__(self, config, repository, archive_name, file_path): + ''' + Given a configuration dict, a repository dict, an archive name, and the path of a file in + the archive, start loading the file's contents for eventual display in this widget. + ''' self.config = config self.repository = repository self.archive_name = archive_name self.file_path = file_path super().__init__(classes='panel') - self.border_title = ' '.join( - ( - borgmatic.actions.browse.icons.PATH_TYPE_ICONS[ - borgmatic.actions.browse.archive.Path_type.FILE.value - ], - self.file_path, - 'preview', - ) - ) + self.border_title = ' '.join(('📄', self.file_path, 'preview')) self.auto_scroll = False timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) diff --git a/borgmatic/actions/browse/repositories_list.py b/borgmatic/actions/browse/repositories_list.py index 8d01e466..8985c78f 100644 --- a/borgmatic/actions/browse/repositories_list.py +++ b/borgmatic/actions/browse/repositories_list.py @@ -10,9 +10,18 @@ import borgmatic.actions.browse.workers class Repositories_list(textual.widgets.OptionList): + ''' + A widget for selecting a single Borg repository from among the repositories in a borgmatic + configuratin file. The item selection event is handled in a Carousel instance, the parent widget + of a Repositories_list. + ''' + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS def __init__(self, config): + ''' + Given a configuration dict, populate the repositories in this widget. + ''' self.config = config self.repositories = config['repositories'] diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index bdbd100e..e7697722 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -34,6 +34,8 @@ def add_repository_archives(browse_app, archives_list, config, repository, timer loading_option, ), ) + + # Retain the highlighted option position even as other options load around it. archives_list.highlighted = ( archives_list.get_option_index(highlighted_option.id) if highlighted_option and archives_list.highlighted_option_changed diff --git a/tests/integration/actions/browse/test_archives_list.py b/tests/integration/actions/browse/test_archives_list.py new file mode 100644 index 00000000..5bf205e6 --- /dev/null +++ b/tests/integration/actions/browse/test_archives_list.py @@ -0,0 +1,63 @@ +from borgmatic.actions.browse import archives_list as module + +from flexmock import flexmock +import textual.widgets.option_list + + +def test_archives_list_on_option_list_option_highlighted_with_highlighted_none_marks_it_unchanged(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( + 'app' + ).and_return(flexmock()) + + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + archives_list.highlighted = None + archives_list.on_option_list_option_highlighted(event=flexmock()) + + assert archives_list.highlighted_option_changed is False + + +def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_marks_it_unchanged(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( + 'app' + ).and_return(flexmock()) + + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + archives_list.highlighted = 0 + archives_list.on_option_list_option_highlighted(event=flexmock()) + + assert archives_list.highlighted_option_changed is False + + +def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_marks_it_unchanged(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( + 'app' + ).and_return(flexmock()) + + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + archives_list.add_option(textual.widgets.option_list.Option('zero', id='zero')) + archives_list.highlighted = 0 + archives_list.on_option_list_option_highlighted(event=flexmock()) + + assert archives_list.highlighted_option_changed is False + + +def test_archives_list_on_option_list_option_highlighted_with_highlighted_non_zero_marks_it_changed(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( + 'app' + ).and_return(flexmock()) + + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + archives_list.add_option(textual.widgets.option_list.Option('zero', id='zero')) + archives_list.add_option(textual.widgets.option_list.Option('one', id='one')) + archives_list.highlighted = 1 + archives_list.on_option_list_option_highlighted(event=flexmock()) + + assert archives_list.highlighted_option_changed is True diff --git a/tests/integration/actions/browse/test_file_preview.py b/tests/integration/actions/browse/test_file_preview.py new file mode 100644 index 00000000..6ef8cf7c --- /dev/null +++ b/tests/integration/actions/browse/test_file_preview.py @@ -0,0 +1,14 @@ +from borgmatic.actions.browse import file_preview as module + +from flexmock import flexmock +import textual.widgets.option_list + + +def test_file_preview_does_not_raise(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_file_preview') + flexmock(module.borgmatic.actions.browse.repositories_list.Repositories_list).should_receive( + 'app' + ).and_return(flexmock()) + + module.Repositories_list(config=flexmock()) diff --git a/tests/integration/actions/browse/test_repositories_list.py b/tests/integration/actions/browse/test_repositories_list.py new file mode 100644 index 00000000..b0916554 --- /dev/null +++ b/tests/integration/actions/browse/test_repositories_list.py @@ -0,0 +1,14 @@ +from borgmatic.actions.browse import repositories_list as module + +from flexmock import flexmock + + +def test_repositories_list_populates_options(): + config = {'repositories': [{'path': 'test1.borg'}, {'path': 'test2.borg', 'label': 'two'}]} + + repositories_list = module.Repositories_list(config=config) + assert len(repositories_list.options) == 2 + assert repositories_list.options[0].prompt == 'test1.borg' + assert repositories_list.options[0].id == 0 + assert repositories_list.options[1].prompt == 'two' + assert repositories_list.options[1].id == 1 diff --git a/tests/unit/actions/browse/test_directories_list.py b/tests/unit/actions/browse/test_directories_list.py new file mode 100644 index 00000000..3ee21c38 --- /dev/null +++ b/tests/unit/actions/browse/test_directories_list.py @@ -0,0 +1,90 @@ +from borgmatic.actions.browse import directory_list as module + +from flexmock import flexmock + + +def test_get_relative_archive_path_components_strips_off_current_directory(): + assert module.get_relative_archive_path_components( + flexmock(file_path='foo/bar/baz/quux.txt'), ('foo', 'bar') + ) == ('baz', 'quux.txt') + + +def test_get_relative_archive_path_components_with_root_current_directory_strips_off_nothing(): + assert module.get_relative_archive_path_components( + flexmock(file_path='foo/bar/baz/quux.txt'), () + ) == ('foo', 'bar', 'baz', 'quux.txt') + + +def test_get_relative_archive_path_components_with_non_matching_paths_returns_none(): + assert ( + module.get_relative_archive_path_components( + flexmock(file_path='foo/bar/baz/quux.txt'), ('etc',) + ) + is None + ) + + +def test_make_directory_list_option_with_file_path_makes_file_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='-', file_path='foo/bar/baz.txt', link_target=''), ('baz.txt',) + ) + + assert option.prompt == '📄 baz.txt' + assert option.id == 'baz.txt' + + +def test_make_directory_list_option_with_directory_path_makes_directory_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='d', file_path='foo/bar/baz', link_target=''), ('baz',) + ) + + assert option.prompt == '📁 baz' + assert option.id == 'baz' + + +def test_make_directory_list_option_with_contained_file_path_makes_directory_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='d', file_path='foo/bar/baz.txt', link_target=''), ('bar', 'baz.txt',) + ) + + assert option.prompt == '📁 bar' + assert option.id == 'bar' + + +def test_make_directory_list_option_with_link_path_makes_link_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='l', file_path='foo/bar/baz.txt', link_target='quux.txt'), ('baz.txt',) + ) + + assert option.prompt == '🔗 baz.txt → quux.txt' + assert option.id == 'baz.txt' + + +def test_make_directory_list_option_with_pipe_path_makes_pipe_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='p', file_path='foo/bar/baz.txt', link_target=''), ('baz.txt',) + ) + + assert option.prompt == '🚰 baz.txt' + assert option.id == 'baz.txt' + + +def test_make_directory_list_option_with_unknown_path_makes_unknown_option(): + flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) + + option = module.make_directory_list_option( + flexmock(path_type='wtf', file_path='foo/bar/baz.txt', link_target=''), ('baz.txt',) + ) + + assert option.prompt == '❓ baz.txt' + assert option.id == 'baz.txt' diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 69b84168..5d3f129a 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -2132,7 +2132,6 @@ def test_collect_highlander_action_summary_logs_error_on_run_browse_failure(): assert {log.levelno for log in logs} == {logging.CRITICAL} - def test_collect_configuration_run_summary_logs_info_for_success(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module.command).should_receive('filter_hooks').with_args( From 5a74b3d081c3f8c6ec711f91be82250269aa5ee1 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 29 May 2026 11:57:13 -0700 Subject: [PATCH 46/63] Fix broken test. --- borgmatic/actions/browse/directory_list.py | 8 +++++++- tests/integration/actions/browse/test_file_preview.py | 6 ++++-- tests/unit/actions/browse/test_directories_list.py | 6 +++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index cfa54b89..6e39c912 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -51,7 +51,9 @@ def make_directory_list_option(archive_path, relative_path_components): relative_path_components[0], ) + (('→', archive_path.link_target) if archive_path.link_target else ()) - return textual.widgets.option_list.Option(prompt=' '.join(pieces), id=relative_path_components[0]) + return textual.widgets.option_list.Option( + prompt=' '.join(pieces), id=relative_path_components[0] + ) def add_archive_paths( @@ -61,6 +63,10 @@ def add_archive_paths( archive_name, archive_paths, ): + ''' + Given a DirectoryList instance, a configuration dict, a repository dict, an archive name, and a + sequence of ArchivePath instances, add the archive paths to the directory list as options. + ''' highlighted_option = directory_list.highlighted_option original_options_count = len(directory_list.options) diff --git a/tests/integration/actions/browse/test_file_preview.py b/tests/integration/actions/browse/test_file_preview.py index 6ef8cf7c..72735ae8 100644 --- a/tests/integration/actions/browse/test_file_preview.py +++ b/tests/integration/actions/browse/test_file_preview.py @@ -7,8 +7,10 @@ import textual.widgets.option_list def test_file_preview_does_not_raise(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') flexmock(module.borgmatic.actions.browse.workers).should_receive('load_file_preview') - flexmock(module.borgmatic.actions.browse.repositories_list.Repositories_list).should_receive( + flexmock(module.borgmatic.actions.browse.file_preview.File_preview).should_receive( 'app' ).and_return(flexmock()) - module.Repositories_list(config=flexmock()) + module.File_preview( + config=flexmock(), repository=flexmock(), archive_name='archive', file_path='foo/bar.txt' + ) diff --git a/tests/unit/actions/browse/test_directories_list.py b/tests/unit/actions/browse/test_directories_list.py index 3ee21c38..76f8a1f6 100644 --- a/tests/unit/actions/browse/test_directories_list.py +++ b/tests/unit/actions/browse/test_directories_list.py @@ -50,7 +50,11 @@ def test_make_directory_list_option_with_contained_file_path_makes_directory_opt flexmock(module.textual.widgets.option_list).should_receive('Option').replace_with(flexmock) option = module.make_directory_list_option( - flexmock(path_type='d', file_path='foo/bar/baz.txt', link_target=''), ('bar', 'baz.txt',) + flexmock(path_type='d', file_path='foo/bar/baz.txt', link_target=''), + ( + 'bar', + 'baz.txt', + ), ) assert option.prompt == '📁 bar' From 174aa3d522b6bbf73e5625c82eb79e1ee1886f3e Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 29 May 2026 16:59:00 -0700 Subject: [PATCH 47/63] Additional tests. Also fix a potential race that loses loaded archive paths for display. --- borgmatic/actions/browse/directory_list.py | 68 +++- .../actions/browse/test_directory_list.py | 296 ++++++++++++++++++ ...ctories_list.py => test_directory_list.py} | 0 3 files changed, 347 insertions(+), 17 deletions(-) create mode 100644 tests/integration/actions/browse/test_directory_list.py rename tests/unit/actions/browse/{test_directories_list.py => test_directory_list.py} (100%) diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 6e39c912..569076e8 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -6,8 +6,8 @@ import textual.widgets import borgmatic.actions.browse.archive import borgmatic.actions.browse.bindings -import borgmatic.actions.browse.loading import borgmatic.actions.browse.icons +import borgmatic.actions.browse.loading import borgmatic.actions.browse.workers @@ -65,7 +65,10 @@ def add_archive_paths( ): ''' Given a DirectoryList instance, a configuration dict, a repository dict, an archive name, and a - sequence of ArchivePath instances, add the archive paths to the directory list as options. + sequence of ArchivePath instances, add the paths to the directory list as options, sorting and + deduplicating the resulting directory list's options. + + After all of this reshuffling, make sure the orignal highlighted option remains highlighted. ''' highlighted_option = directory_list.highlighted_option original_options_count = len(directory_list.options) @@ -86,6 +89,7 @@ def add_archive_paths( if not relative_path_components[0] in directory_list._id_to_option ), ), + # The loading indicator "option" always goes to the bottom. key=lambda option: ((option.id == 'loading-indicator'), option.prompt), ) @@ -93,6 +97,7 @@ def add_archive_paths( if len(sorted_options) == original_options_count: return + # Retain the highlighted option position even as other options load around it. directory_list.set_options(sorted_options) directory_list.highlighted = ( directory_list.get_option_index(highlighted_option.id) @@ -102,9 +107,23 @@ def add_archive_paths( class Directory_list(textual.widgets.OptionList): + ''' + A widget for selecting a path from among the contents of a particular directory in a Borg + archive. The item selection event is handled in a Carousel instance, the parent widget of a + Directory_list. + ''' + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS def __init__(self, config, repository, archive_name, path_loaded=None, path_components=None): + ''' + Given a configuration dict, a repository dict, an archive name, an optional + Archive_path_loaded instance for signalling new paths as they load, and an optional tuple of + path components indicating this directory's position in the backed up filesystem, start + loading paths from the archive for eventual display in this widget. Or, if paths have + already started loading (by the root directory list), just listen for new paths as they come + in. + ''' self.config = config self.repository = repository self.archive_name = archive_name @@ -115,9 +134,7 @@ class Directory_list(textual.widgets.OptionList): self.border_title = ' '.join( ( - borgmatic.actions.browse.icons.PATH_TYPE_ICONS[ - borgmatic.actions.browse.archive.Path_type.DIRECTORY.value - ], + '📁', os.path.sep.join(self.path_components) if self.path_components else f'{archive_name}', @@ -127,7 +144,7 @@ class Directory_list(textual.widgets.OptionList): if self.path_components: self.add_option( textual.widgets.option_list.Option( - f'{borgmatic.actions.browse.icons.PATH_TYPE_ICONS[borgmatic.actions.browse.archive.Path_type.DIRECTORY.value]} ..', + '📁 ..', id='..', ), ) @@ -139,6 +156,25 @@ class Directory_list(textual.widgets.OptionList): if not self.path_loaded.complete: self.timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + if not self.path_components: + borgmatic.actions.browse.workers.load_archive_paths( + self.app, + directory_list=self, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + ) + + def on_mount(self): + ''' + When this widgets gets mounted in the DOM, subcribe to path loaded events so that we can + find out about relevant archive paths as they load. And if this is a non-root directory + list, add any already loaded archive paths to this widget as options. This is done *after* + subscribing to path loaded signals so that there's not a gap where we might miss out on any + paths. + ''' + self.path_loaded.subscribe(self, self.on_archive_path_loaded) + if self.path_components: add_archive_paths( directory_list=self, @@ -149,19 +185,12 @@ class Directory_list(textual.widgets.OptionList): self.path_loaded.path_hierarchy, self.path_components ), ) - else: - borgmatic.actions.browse.workers.load_archive_paths( - self.app, - directory_list=self, - config=self.config, - repository=self.repository, - archive_name=self.archive_name, - ) - - def on_mount(self): - self.path_loaded.subscribe(self, self.on_archive_path_loaded) def on_archive_path_loaded(self, data): + ''' + When an archive path loads, add it as an option to this directory list. But if we get a + signal that all path loading is complete, stop and remove our loading indicator. + ''' if data is borgmatic.actions.browse.workers.LOADING_DONE: self.timer.stop() self.remove_option('loading-indicator') @@ -176,5 +205,10 @@ class Directory_list(textual.widgets.OptionList): ) def on_option_list_option_highlighted(self, event): + ''' + When the highlighted option changes, record that fact. This flag is consumed in + add_archive_paths() in order to retain the highlighted option even as other options load + around it. + ''' if self.highlighted not in {None, 0}: self.highlighted_option_changed = True diff --git a/tests/integration/actions/browse/test_directory_list.py b/tests/integration/actions/browse/test_directory_list.py new file mode 100644 index 00000000..19f506a0 --- /dev/null +++ b/tests/integration/actions/browse/test_directory_list.py @@ -0,0 +1,296 @@ +from borgmatic.actions.browse import directory_list as module + +from flexmock import flexmock +import textual.widgets +import textual.widgets.option_list + + +def test_add_archive_paths_with_only_duplicate_paths_bails(): + directory_list = textual.widgets.OptionList() + directory_list.path_components = ('etc',) + directory_list.add_option(textual.widgets.option_list.Option('foo', id='foo')) + directory_list.add_option(textual.widgets.option_list.Option('bar', id='bar')) + config = {'repositories': [{'path': 'test.borg'}]} + flexmock(directory_list).should_receive('set_options').never() + + module.add_archive_paths( + directory_list=directory_list, + config=config, + repository=config['repositories'][0], + archive_name='archive', + archive_paths=( + flexmock(path_type='-', file_path='etc/foo/one.txt', link_target=''), + flexmock(path_type='-', file_path='etc/foo/two.txt', link_target=''), + ), + ) + + +def test_add_archive_paths_adds_ands_sorts_and_filters_and_deduplicates(): + directory_list = textual.widgets.OptionList() + directory_list.path_components = ('etc',) + directory_list.add_option(textual.widgets.option_list.Option('📄 foo', id='foo')) + directory_list.add_option(textual.widgets.option_list.Option('📄 bar', id='bar')) + directory_list.highlighted = 1 + directory_list.highlighted_option_changed = True + config = {'repositories': [{'path': 'test.borg'}]} + + module.add_archive_paths( + directory_list=directory_list, + config=config, + repository=config['repositories'][0], + archive_name='archive', + archive_paths=( + flexmock(path_type='d', file_path='etc/quux', link_target=''), + flexmock(path_type='-', file_path='etc/foo', link_target=''), + flexmock(path_type='-', file_path='etc/baz', link_target=''), + flexmock(path_type='d', file_path='root/nope', link_target=''), + flexmock(path_type='d', file_path='etc/other', link_target=''), + ), + ) + + assert len(directory_list.options) == 5 + assert directory_list.options[0].prompt == '📁 other' + assert directory_list.options[0].id == 'other' + assert directory_list.options[1].prompt == '📁 quux' + assert directory_list.options[1].id == 'quux' + assert directory_list.options[2].prompt == '📄 bar' + assert directory_list.options[2].id == 'bar' + assert directory_list.options[3].prompt == '📄 baz' + assert directory_list.options[3].id == 'baz' + assert directory_list.options[4].prompt == '📄 foo' + assert directory_list.options[4].id == 'foo' + assert directory_list.highlighted == 2 + + +def test_add_archive_paths_highlights_first_option_if_highlight_has_not_changed(): + directory_list = textual.widgets.OptionList() + directory_list.path_components = ('etc',) + directory_list.add_option(textual.widgets.option_list.Option('📄 foo', id='foo')) + directory_list.add_option(textual.widgets.option_list.Option('📄 bar', id='bar')) + directory_list.highlighted = None + directory_list.highlighted_option_changed = False + config = {'repositories': [{'path': 'test.borg'}]} + + module.add_archive_paths( + directory_list=directory_list, + config=config, + repository=config['repositories'][0], + archive_name='archive', + archive_paths=(flexmock(path_type='-', file_path='etc/baz', link_target=''),), + ) + + assert len(directory_list.options) == 3 + assert directory_list.options[0].prompt == '📄 bar' + assert directory_list.options[0].id == 'bar' + assert directory_list.options[1].prompt == '📄 baz' + assert directory_list.options[1].id == 'baz' + assert directory_list.options[2].prompt == '📄 foo' + assert directory_list.options[2].id == 'foo' + assert directory_list.highlighted == 0 + + +def test_add_archive_paths_retains_loading_indicator_at_bottom(): + directory_list = textual.widgets.OptionList() + directory_list.path_components = ('etc',) + directory_list.add_option(textual.widgets.option_list.Option('📄 foo', id='foo')) + directory_list.add_option(textual.widgets.option_list.Option('📄 bar', id='bar')) + directory_list.add_option( + textual.widgets.option_list.Option('loading!!!', id='loading-indicator') + ) + directory_list.highlighted = 0 + directory_list.highlighted_option_changed = True + config = {'repositories': [{'path': 'test.borg'}]} + + module.add_archive_paths( + directory_list=directory_list, + config=config, + repository=config['repositories'][0], + archive_name='archive', + archive_paths=(flexmock(path_type='-', file_path='etc/baz', link_target=''),), + ) + + assert len(directory_list.options) == 4 + assert directory_list.options[0].prompt == '📄 bar' + assert directory_list.options[0].id == 'bar' + assert directory_list.options[1].prompt == '📄 baz' + assert directory_list.options[1].id == 'baz' + assert directory_list.options[2].prompt == '📄 foo' + assert directory_list.options[2].id == 'foo' + assert directory_list.options[3].prompt == 'loading!!!' + assert directory_list.options[3].id == 'loading-indicator' + assert directory_list.highlighted == 2 + + +def test_directory_list_with_root_directory_starts_loading_archive_paths(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths').once() + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + + directory_list = module.Directory_list( + config=flexmock(), repository=flexmock(), archive_name='archive' + ) + assert directory_list.border_title == '📁 archive' + assert len(directory_list.options) == 0 + assert not directory_list.path_loaded.complete + + +def test_directory_list_with_non_root_directory_relies_on_existing_path_loading_worker(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths').never() + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + + directory_list = module.Directory_list( + config=flexmock(), + repository=flexmock(), + archive_name='archive', + path_loaded=flexmock(complete=False), + path_components=('etc',), + ) + assert directory_list.border_title == '📁 etc' + assert len(directory_list.options) == 1 + assert directory_list.options[0].prompt == '📁 ..' + assert directory_list.options[0].id == '..' + + +def test_directory_list_with_already_complete_loading_skips_loading_indicator(): + flexmock(module.borgmatic.actions.browse.loading).should_receive( + 'add_inline_loading_indicator' + ).never() + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths').never() + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + + directory_list = module.Directory_list( + config=flexmock(), + repository=flexmock(), + archive_name='archive', + path_loaded=flexmock(complete=True), + path_components=('etc',), + ) + assert directory_list.border_title == '📁 etc' + assert len(directory_list.options) == 1 + assert directory_list.options[0].prompt == '📁 ..' + assert directory_list.options[0].id == '..' + + +def test_directory_list_on_mount_with_root_directory_skips_adding_archives_paths(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths') + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + flexmock(module).should_receive('add_archive_paths').never() + directory_list = module.Directory_list( + config=flexmock(), repository=flexmock(), archive_name='archive' + ) + flexmock(directory_list.path_loaded).should_receive('subscribe') + + directory_list.on_mount() + + +def test_directory_list_on_mount_with_non_root_directory_adds_archive_paths(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + flexmock(module).should_receive('add_archive_paths').once() + directory_list = module.Directory_list( + config=flexmock(), repository=flexmock(), archive_name='archive', + path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), + path_components=('etc',), + ) + flexmock(directory_list.path_loaded).should_receive('subscribe') + + directory_list.on_mount() + + +def test_on_archive_path_loaded_with_loading_done_signal_removes_loading_indicator(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + flexmock(module).should_receive('add_archive_paths').never() + directory_list = module.Directory_list( + config=flexmock(), repository=flexmock(), archive_name='archive', + path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), + path_components=('etc',), + ) + directory_list.timer = flexmock(stop=lambda: None) + flexmock(directory_list).should_receive('remove_option').once() + + directory_list.on_archive_path_loaded(data=module.borgmatic.actions.browse.workers.LOADING_DONE) + + +def test_on_archive_path_loaded_with_path_loaded_signal_adds_archive_path(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + flexmock(module).should_receive('add_archive_paths').once() + directory_list = module.Directory_list( + config=flexmock(), repository=flexmock(), archive_name='archive', + path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), + path_components=('etc',), + ) + flexmock(directory_list).should_receive('remove_option').never() + + directory_list.on_archive_path_loaded(data=flexmock()) + + +def test_directory_list_on_option_list_option_highlighted_with_highlighted_none_marks_it_unchanged(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + directory_list = module.Directory_list( + config=flexmock(), repository=flexmock(), archive_name='archive', + path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), + path_components=('etc',), + ) + directory_list.highlighted = None + + directory_list.on_option_list_option_highlighted(event=flexmock()) + + assert directory_list.highlighted_option_changed is False + + +def test_directory_list_on_option_list_option_highlighted_with_highlighted_zero_marks_it_unchanged(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + directory_list = module.Directory_list( + config=flexmock(), repository=flexmock(), archive_name='archive', + path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), + path_components=('etc',), + ) + directory_list.add_option(textual.widgets.option_list.Option('zero', id='zero')) + directory_list.highlighted = 0 + + directory_list.on_option_list_option_highlighted(event=flexmock()) + + assert directory_list.highlighted_option_changed is False + + +def test_directory_list_on_option_list_option_highlighted_with_highlighted_non_zero_marks_it_changed(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( + 'app' + ).and_return(flexmock()) + directory_list = module.Directory_list( + config=flexmock(), repository=flexmock(), archive_name='archive', + path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), + path_components=('etc',), + ) + directory_list.add_option(textual.widgets.option_list.Option('zero', id='zero')) + directory_list.add_option(textual.widgets.option_list.Option('one', id='one')) + directory_list.highlighted = 1 + + directory_list.on_option_list_option_highlighted(event=flexmock()) + + assert directory_list.highlighted_option_changed is True diff --git a/tests/unit/actions/browse/test_directories_list.py b/tests/unit/actions/browse/test_directory_list.py similarity index 100% rename from tests/unit/actions/browse/test_directories_list.py rename to tests/unit/actions/browse/test_directory_list.py From ed955c2a7309d583cfc8781dfa8ae6dcf3cb09b3 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 29 May 2026 19:56:17 -0700 Subject: [PATCH 48/63] Add additional tests. --- borgmatic/actions/browse/loading.py | 17 ++- .../actions/browse/test_loading.py | 101 ++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 tests/integration/actions/browse/test_loading.py diff --git a/borgmatic/actions/browse/loading.py b/borgmatic/actions/browse/loading.py index 18d140a4..351014e7 100644 --- a/borgmatic/actions/browse/loading.py +++ b/borgmatic/actions/browse/loading.py @@ -13,6 +13,10 @@ logger = logging.getLogger('__name__') def update_inline_loading_indicator(widget): + ''' + Given a textual.widgets.OptionList or a textual.widgets.RichLog instance, animate the existing + loading indicator inside it. + ''' if isinstance(widget, textual.widgets.OptionList): with contextlib.suppress(textual.widgets.option_list.OptionDoesNotExist): widget.replace_option_prompt( @@ -28,15 +32,20 @@ def update_inline_loading_indicator(widget): raise ValueError(f'Unsupported widget type: {type(widget)}') -def add_inline_loading_indicator(widget): - loading_message = '⏳ loading...' +LOADING_MESSAGE = '⏳ loading...' + +def add_inline_loading_indicator(widget): + ''' + Given a textual.widgets.OptionList or a textual.widgets.RichLog instance, add a loading + indicator to it. + ''' if isinstance(widget, textual.widgets.OptionList): - loading_option = textual.widgets.option_list.Option(loading_message, id='loading-indicator') + loading_option = textual.widgets.option_list.Option(LOADING_MESSAGE, id='loading-indicator') widget.add_option(loading_option) widget.highlighted = None elif isinstance(widget, textual.widgets.RichLog): - widget.write(loading_message) + widget.write(LOADING_MESSAGE) else: raise ValueError(f'Unsupported widget type: {type(widget)}') diff --git a/tests/integration/actions/browse/test_loading.py b/tests/integration/actions/browse/test_loading.py new file mode 100644 index 00000000..e1117b73 --- /dev/null +++ b/tests/integration/actions/browse/test_loading.py @@ -0,0 +1,101 @@ +from borgmatic.actions.browse import loading as module + +from flexmock import flexmock +import pytest +import textual.app +import textual.widgets + + +def test_update_inline_loading_indicator_with_option_list_adds_a_dot(): + widget = textual.widgets.OptionList() + widget.add_option( + textual.widgets.option_list.Option('HOLD.', id='loading-indicator') + ) + + module.update_inline_loading_indicator(widget) + + assert len(widget.options) == 1 + assert widget.options[0].prompt == 'HOLD..' + + +def test_update_inline_loading_indicator_with_option_list_wraps_dots_beyond_three(): + widget = textual.widgets.OptionList() + widget.add_option( + textual.widgets.option_list.Option('HOLD...', id='loading-indicator') + ) + + module.update_inline_loading_indicator(widget) + + assert len(widget.options) == 1 + assert widget.options[0].prompt == 'HOLD' + + +def test_update_inline_loading_indicator_with_option_list_and_missing_indicator_does_not_raise(): + widget = textual.widgets.OptionList() + + module.update_inline_loading_indicator(widget) + + assert len(widget.options) == 0 + + +async def test_update_inline_loading_indicator_with_rich_log_adds_a_dot(): + async with textual.app.App().run_test() as pilot: + widget = textual.widgets.RichLog() + widget._size_known = True + widget.write('HOLD.') + + module.update_inline_loading_indicator(widget) + + assert str(widget.lines[0].text) == 'HOLD..' + + +async def test_update_inline_loading_indicator_with_rich_log_wraps_dots_beyond_three(): + async with textual.app.App().run_test() as pilot: + widget = textual.widgets.RichLog() + widget._size_known = True + widget.write('HOLD...') + + module.update_inline_loading_indicator(widget) + + assert str(widget.lines[0].text) == 'HOLD' + + +def test_update_inline_loading_indicator_with_rich_log_and_missing_indicator_does_not_raise(): + widget = textual.widgets.RichLog() + + module.update_inline_loading_indicator(widget) + + assert len(widget.lines) == 0 + + +def test_update_inline_loading_indicator_with_unsupported_widget_type_raises(): + with pytest.raises(ValueError): + module.update_inline_loading_indicator(flexmock()) + + +def test_add_inline_loading_indicator_with_option_list_adds_loading_indicator_option(): + widget = textual.widgets.OptionList() + flexmock(widget).should_receive('set_interval') + + module.add_inline_loading_indicator(widget) + + assert len(widget.options) == 1 + assert widget.options[0].prompt == module.LOADING_MESSAGE + assert widget.options[0].id == 'loading-indicator' + assert widget.highlighted is None + + +async def test_add_inline_loading_indicator_with_rich_log_writes_loading_indicator_text(): + async with textual.app.App().run_test() as pilot: + widget = textual.widgets.RichLog() + widget._size_known = True + flexmock(widget).should_receive('set_interval') + + module.add_inline_loading_indicator(widget) + + assert str(widget.lines[0].text) == module.LOADING_MESSAGE + + +def test_add_inline_loading_indicator_with_unsupported_widget_type_raises(): + with pytest.raises(ValueError): + module.add_inline_loading_indicator(flexmock()) From 3a4bdfb3c539c6d75b7ef0da51dd3e68ce303f9e Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 30 May 2026 11:27:59 -0700 Subject: [PATCH 49/63] Additional tests and docstrings. --- borgmatic/actions/browse/logs.py | 25 ++++++ .../actions/browse/test_directory_list.py | 24 +++-- .../actions/browse/test_loading.py | 8 +- tests/integration/actions/browse/test_logs.py | 36 ++++++++ tests/unit/actions/browse/test_logs.py | 87 +++++++++++++++++++ 5 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 tests/integration/actions/browse/test_logs.py create mode 100644 tests/unit/actions/browse/test_logs.py diff --git a/borgmatic/actions/browse/logs.py b/borgmatic/actions/browse/logs.py index a7f8280e..40f4ae6d 100644 --- a/borgmatic/actions/browse/logs.py +++ b/borgmatic/actions/browse/logs.py @@ -9,6 +9,11 @@ import borgmatic.logger class Rich_color_formatter(logging.Formatter): + ''' + A Python logging formatter that formats log records with Rich-compatible color markup according + to their levels. + ''' + def __init__(self, *args, **kwargs): self.prefix = None super().__init__( @@ -19,6 +24,10 @@ class Rich_color_formatter(logging.Formatter): ) def format(self, record): + ''' + Given a log record, format it with Rich-compatibe color markup correponding to its log + level. + ''' borgmatic.logger.add_custom_log_levels() color = { @@ -35,12 +44,23 @@ class Rich_color_formatter(logging.Formatter): class Browse_log_handler(logging.Handler): + ''' + A Python log handler that writes any log records to a logging widget. + ''' + def __init__(self, logs_widget): + ''' + Given a logs widget, save it for use below. + ''' self.logs_widget = logs_widget super().__init__() def emit(self, record): + ''' + Given a log record, format it and log it to the logs widgets. This works whether or not the + logging is happening in the main thread. + ''' message = self.format(record) try: @@ -72,6 +92,11 @@ def log_to_widget(logs_widget): class Logs(textual.widgets.RichLog): + ''' + A widget for viewing borgmatic logs in realtime. The log level is determined by borgmatic's + current verbosity level. + ''' + def __init__(self): super().__init__(markup=True, id='logs', classes='panel') self.border_title = '🪵 logs' diff --git a/tests/integration/actions/browse/test_directory_list.py b/tests/integration/actions/browse/test_directory_list.py index 19f506a0..a6d29cca 100644 --- a/tests/integration/actions/browse/test_directory_list.py +++ b/tests/integration/actions/browse/test_directory_list.py @@ -200,7 +200,9 @@ def test_directory_list_on_mount_with_non_root_directory_adds_archive_paths(): ).and_return(flexmock()) flexmock(module).should_receive('add_archive_paths').once() directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -216,7 +218,9 @@ def test_on_archive_path_loaded_with_loading_done_signal_removes_loading_indicat ).and_return(flexmock()) flexmock(module).should_receive('add_archive_paths').never() directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -233,7 +237,9 @@ def test_on_archive_path_loaded_with_path_loaded_signal_adds_archive_path(): ).and_return(flexmock()) flexmock(module).should_receive('add_archive_paths').once() directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -248,7 +254,9 @@ def test_directory_list_on_option_list_option_highlighted_with_highlighted_none_ 'app' ).and_return(flexmock()) directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -265,7 +273,9 @@ def test_directory_list_on_option_list_option_highlighted_with_highlighted_zero_ 'app' ).and_return(flexmock()) directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) @@ -283,7 +293,9 @@ def test_directory_list_on_option_list_option_highlighted_with_highlighted_non_z 'app' ).and_return(flexmock()) directory_list = module.Directory_list( - config=flexmock(), repository=flexmock(), archive_name='archive', + config=flexmock(), + repository=flexmock(), + archive_name='archive', path_loaded=flexmock(complete=False, path_hierarchy={'etc': {}}), path_components=('etc',), ) diff --git a/tests/integration/actions/browse/test_loading.py b/tests/integration/actions/browse/test_loading.py index e1117b73..0d4d12d9 100644 --- a/tests/integration/actions/browse/test_loading.py +++ b/tests/integration/actions/browse/test_loading.py @@ -8,9 +8,7 @@ import textual.widgets def test_update_inline_loading_indicator_with_option_list_adds_a_dot(): widget = textual.widgets.OptionList() - widget.add_option( - textual.widgets.option_list.Option('HOLD.', id='loading-indicator') - ) + widget.add_option(textual.widgets.option_list.Option('HOLD.', id='loading-indicator')) module.update_inline_loading_indicator(widget) @@ -20,9 +18,7 @@ def test_update_inline_loading_indicator_with_option_list_adds_a_dot(): def test_update_inline_loading_indicator_with_option_list_wraps_dots_beyond_three(): widget = textual.widgets.OptionList() - widget.add_option( - textual.widgets.option_list.Option('HOLD...', id='loading-indicator') - ) + widget.add_option(textual.widgets.option_list.Option('HOLD...', id='loading-indicator')) module.update_inline_loading_indicator(widget) diff --git a/tests/integration/actions/browse/test_logs.py b/tests/integration/actions/browse/test_logs.py new file mode 100644 index 00000000..beffade5 --- /dev/null +++ b/tests/integration/actions/browse/test_logs.py @@ -0,0 +1,36 @@ +import contextlib + +from borgmatic.actions.browse import logs as module + +from flexmock import flexmock + + +def test_log_to_widget_adds_our_handler_and_removes_default_handler(): + default_handler = module.borgmatic.logger.Multi_stream_handler({}) + root_logger = module.logging.getLogger() + root_logger.addHandler(default_handler) + browse_log_handler = None + + try: + module.log_to_widget(flexmock()) + + with contextlib.suppress(StopIteration): + browse_log_handler = next( + handler for handler in root_logger.handlers + if isinstance(handler, module.Browse_log_handler) + ) + + assert browse_log_handler + finally: + if browse_log_handler: + root_logger.removeHandler(browse_log_handler) + + assert not any( + handler for handler in root_logger.handlers + if isinstance(handler, module.borgmatic.logger.Multi_stream_handler) + ) + + + +def test_logs_does_not_raise(): + module.Logs() diff --git a/tests/unit/actions/browse/test_logs.py b/tests/unit/actions/browse/test_logs.py new file mode 100644 index 00000000..b59526f7 --- /dev/null +++ b/tests/unit/actions/browse/test_logs.py @@ -0,0 +1,87 @@ +from borgmatic.actions.browse import logs as module + +from flexmock import flexmock + + +def test_rich_color_formatter_format_colors_log_record_based_on_level(): + formatted = module.Rich_color_formatter().format( + module.logging.makeLogRecord( + dict( + levelno=module.logging.ERROR, + levelname='ERROR', + msg='oh no', + ) + ), + ) + + assert formatted == '[bright_red]oh no[/bright_red]' + + +def test_rich_color_formatter_format_includes_prefix(): + formatter = module.Rich_color_formatter() + formatter.prefix = 'sup' + formatted = formatter.format( + module.logging.makeLogRecord( + dict( + levelno=module.logging.ERROR, + levelname='ERROR', + msg='oh no', + ), + ), + ) + + assert formatted == '[bright_red]sup: oh no[/bright_red]' + + +def test_browse_log_handler_emit_from_worker_thread_calls_write_in_main_thread(): + flexmock(module.textual.worker).should_receive('get_current_worker') + logs_widget = flexmock(write=lambda message: None, app=flexmock()) + flexmock(logs_widget.app).should_receive('call_from_thread').with_args( + logs_widget.write, 'hi' + ).once() + + module.Browse_log_handler(logs_widget).emit( + module.logging.makeLogRecord( + dict( + levelno=module.logging.DEBUG, + levelname='DEBUG', + msg='hi', + ), + ), + ) + + +def test_browse_log_handler_emit_from_main_thread_calls_write_directly(): + flexmock(module.textual.worker).should_receive('get_current_worker').and_raise(RuntimeError) + logs_widget = flexmock(write=lambda message: None, app=flexmock()) + flexmock(logs_widget.app).should_receive('call_from_thread').never() + flexmock(logs_widget).should_receive('write').with_args('hi').once() + + module.Browse_log_handler(logs_widget).emit( + module.logging.makeLogRecord( + dict( + levelno=module.logging.DEBUG, + levelname='DEBUG', + msg='hi', + ), + ), + ) + + +def test_browse_log_handler_emit_from_main_thread_with_no_active_app_does_not_raise(): + flexmock(module.textual.worker).should_receive('get_current_worker').and_raise(RuntimeError) + logs_widget = flexmock(write=lambda message: None, app=flexmock()) + flexmock(logs_widget.app).should_receive('call_from_thread').never() + flexmock(logs_widget).should_receive('write').with_args('hi').and_raise( + module.textual._context.NoActiveAppError + ) + + module.Browse_log_handler(logs_widget).emit( + module.logging.makeLogRecord( + dict( + levelno=module.logging.DEBUG, + levelname='DEBUG', + msg='hi', + ), + ), + ) From f832a361edcdc21b5edd1c108546dd4f66f41b2d Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 30 May 2026 13:58:04 -0700 Subject: [PATCH 50/63] More tests. --- .../browse/configuration_files_list.py | 9 +++++ .../browse/test_configuration_files_list.py | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/integration/actions/browse/test_configuration_files_list.py diff --git a/borgmatic/actions/browse/configuration_files_list.py b/borgmatic/actions/browse/configuration_files_list.py index c6da1390..95920ea9 100644 --- a/borgmatic/actions/browse/configuration_files_list.py +++ b/borgmatic/actions/browse/configuration_files_list.py @@ -10,9 +10,18 @@ import borgmatic.actions.browse.workers class Configuration_files_list(textual.widgets.OptionList): + ''' + A widget for selecting a single borgmatic configuration file from among available configuration + files. The item selection event is handled in a Carousel instance, the parent widget of an + Configuration_files_list. + ''' BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS def __init__(self, configs): + ''' + Given a dict mapping from configuration path to corresponding configuration dict, add each + configuration path as an option to this widget. + ''' self.configs = configs home_directory = os.path.expanduser('~') diff --git a/tests/integration/actions/browse/test_configuration_files_list.py b/tests/integration/actions/browse/test_configuration_files_list.py new file mode 100644 index 00000000..1b657bc4 --- /dev/null +++ b/tests/integration/actions/browse/test_configuration_files_list.py @@ -0,0 +1,34 @@ +from borgmatic.actions.browse import configuration_files_list as module + +from flexmock import flexmock + + +def test_configuration_files_list_adds_config_paths_as_options(): + flexmock(module.os.path).should_receive('expanduser').and_return('/home/user') + + configuration_files_list = module.Configuration_files_list( + configs={ + 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, + 'test2.yaml': {'repositories': [{'path': 'test2.borg'}]}, + } + ) + + assert len(configuration_files_list.options) == 2 + assert configuration_files_list.options[0].prompt == 'test1.yaml' + assert configuration_files_list.options[0].id == 'test1.yaml' + assert configuration_files_list.options[1].prompt == 'test2.yaml' + assert configuration_files_list.options[1].id == 'test2.yaml' + + +def test_configuration_files_list_collapses_home_directory_in_config_path_option(): + flexmock(module.os.path).should_receive('expanduser').and_return('/home/user') + + configuration_files_list = module.Configuration_files_list( + configs={ + '/home/user/test.yaml': {'repositories': [{'path': '/home/user/test.borg'}]}, + } + ) + + assert len(configuration_files_list.options) == 1 + assert configuration_files_list.options[0].prompt == '~/test.yaml' + assert configuration_files_list.options[0].id == '/home/user/test.yaml' From 7439d7cb8f4b4feb86ad013f1dfdfdf873ea8f0c Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 30 May 2026 16:58:29 -0700 Subject: [PATCH 51/63] Docstrings. --- borgmatic/actions/browse/archives_list.py | 2 +- borgmatic/actions/browse/file_preview.py | 2 +- borgmatic/actions/browse/workers.py | 66 +++++++++++++++++++++-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py index 8d414068..c6c24ebe 100644 --- a/borgmatic/actions/browse/archives_list.py +++ b/borgmatic/actions/browse/archives_list.py @@ -36,7 +36,7 @@ class Archives_list(textual.widgets.OptionList): archives_list=self, config=self.config, repository=self.repository, - timer=timer, + loading_timer=timer, ) def on_option_list_option_highlighted(self, event): diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index 1f34cc48..171daf44 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -52,5 +52,5 @@ class File_preview(textual.widgets.RichLog): repository=self.repository, archive_name=self.archive_name, file_path=self.file_path, - timer=timer, + loading_timer=timer, ) diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index e7697722..ed8b8579 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -14,7 +14,16 @@ logger = logging.getLogger('__name__') @textual.work(thread=True) -def add_repository_archives(browse_app, archives_list, config, repository, timer): +def add_repository_archives(browse_app, archives_list, config, repository, loading_timer): + ''' + Given a running Browse_app instance, an Archives_list instance, a configuration dict, a + repository dict, and a loading indicator timer, load a list of the archives from the repository + and add them as options in the archives list. Reverse the order so the most recent archive is + first. + + This function runs in a separate thread from the main UI. When loading is complete, remove the + loading indicator and stop its timer. + ''' archives_data = borgmatic.actions.browse.archive.get_repository_archives(config, repository) loading_option = archives_list.get_option('loading-indicator') @@ -43,10 +52,21 @@ def add_repository_archives(browse_app, archives_list, config, repository, timer ) browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') - browse_app.call_from_thread(timer.stop) + browse_app.call_from_thread(loading_timer.stop) def record_path(archive_path, hierarchy, path_components): + ''' + Given an Archive_path instance, a dict capturing a filesystem hierarchy of paths, and a tuple of + path components for the archive path, set the archive path into the hierarchy data structure. + + For instance, if given an archive path and path components representing "foo/bar/baz.txt", + produce a hierarchy that looks like: + + {'foo': {'bar': {'baz.txt': Archive_path('-', 'foo/bar/baz.txt', '')}}} + + Note that the hierarchy is modified in place, so any existing paths there are retained. + ''' if len(path_components) == 1: hierarchy[path_components[0]] = {} if archive_path.path_type == 'd' else archive_path return @@ -55,6 +75,23 @@ def record_path(archive_path, hierarchy, path_components): def get_paths(hierarchy, path_components, full_path_components=None): + ''' + Given a dict capturing a filesystem hierarchy of paths (or a subset thereof), a tuple of path + components for an archive path relative to the hierarchy root, and an optional tuple of + *absolute* path components for the same archive path (if different), return the corresponding + Archive_path from the hierarchy. + + For instance, given the following hierarchy: + + {'foo': {'bar': {'baz.txt': Archive_path('-', 'foo/bar/baz.txt', '')}}} + + ... and path components of ('foo', 'bar', 'baz.txt'), return the Archive_path instance above. + + Or in the case of path components of only ('foo', 'bar'), return a directory with the absolute + path: + + Archive_path('d', 'foo/bar', '') + ''' if full_path_components is None: full_path_components = path_components @@ -75,6 +112,13 @@ LOADING_DONE = object() class Archive_path_loaded(textual.signal.Signal): + ''' + A signal that publishes when each subsequent path is loaded from an archive, intended for + consumption in widgets that display paths as they are loaded. This signal also tracks the + complete filesystem hierarchy seen thus far, so new widgets that get created after loading has + started can "catch up" with existing known paths. Lastly, this signal publishes and tracks when + loading is complete. + ''' def __init__(self, owner, name): self.path_hierarchy = {} self.complete = False @@ -92,6 +136,14 @@ class Archive_path_loaded(textual.signal.Signal): @textual.work(thread=True) def load_archive_paths(browse_app, directory_list, config, repository, archive_name): + ''' + Given a running Browse_app instance, a Directory_list instance, a configuration dict, a + repository dict, and an archive name, load the paths in this archive and publish each one via + the Archive_path_loaded signal, so interested widgets can subscribe. Also send a "loading done" + signal when loading completes. + + This function runs in a separate thread from the main UI. + ''' for archive_path in borgmatic.actions.browse.archive.get_archive_paths( config, repository, archive_name ): @@ -101,12 +153,18 @@ def load_archive_paths(browse_app, directory_list, config, repository, archive_n @textual.work(thread=True) -def load_file_preview(browse_app, file_preview, config, repository, archive_name, file_path, timer): +def load_file_preview(browse_app, file_preview, config, repository, archive_name, file_path, + loading_timer): + ''' + Given a running Browse_app instance, a File_preview instance, a configuration dict, a repository + dict, an archive name, the path of a file in that archive, and a loading indicator timer, load + the contents of the file and write it into the given file preview widget. + ''' file_content = borgmatic.actions.browse.archive.get_archive_file_content( config, repository, archive_name, file_path ) - browse_app.call_from_thread(timer.stop) + browse_app.call_from_thread(loading_timer.stop) browse_app.call_from_thread(file_preview.clear) if file_content is None: From 64ceebb2c861ef68aabef42d05ac82bb1091d2e9 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 30 May 2026 21:12:02 -0700 Subject: [PATCH 52/63] Replace cross-thread calls with signals. --- borgmatic/actions/browse/archives_list.py | 45 +++++++- .../browse/configuration_files_list.py | 1 + borgmatic/actions/browse/directory_list.py | 4 +- borgmatic/actions/browse/file_preview.py | 28 ++++- borgmatic/actions/browse/workers.py | 102 +++++++++--------- tests/integration/actions/browse/test_logs.py | 7 +- 6 files changed, 126 insertions(+), 61 deletions(-) diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py index c6c24ebe..e7d5636f 100644 --- a/borgmatic/actions/browse/archives_list.py +++ b/borgmatic/actions/browse/archives_list.py @@ -28,15 +28,54 @@ class Archives_list(textual.widgets.OptionList): super().__init__(classes='panel') self.border_title = '📚 archives' self.highlighted_option_changed = False + self.archive_loaded = borgmatic.actions.browse.workers.Archive_loaded( + self, 'archive loaded' + ) - timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + self.loading_timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) borgmatic.actions.browse.workers.add_repository_archives( self.app, - archives_list=self, + archive_loaded=self.archive_loaded, config=self.config, repository=self.repository, - loading_timer=timer, + loading_timer=self.loading_timer, + ) + + def on_mount(self): + ''' + When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can + find out about archives as they load. + ''' + self.archive_loaded.subscribe(self, self.on_archive_loaded) + + def on_archive_loaded(self, data): + ''' + When an archive loads, add it as an option to this archives list. But if we get a + signal that all path loading is complete, stop and remove our loading indicator. + ''' + if data is borgmatic.actions.browse.workers.LOADING_DONE: + self.loading_timer.stop() + self.remove_option('loading-indicator') + return + + label_pieces = (data, '[dim](latest)[/dim]') if len(self.options) == 1 else (data,) + highlighted_option = self.highlighted_option + + loading_indicator = self.get_option('loading-indicator') + self.remove_option('loading-indicator') + self.add_options( + ( + textual.widgets.option_list.Option(' '.join(label_pieces), id=data), + loading_indicator, + ), + ) + + # Retain the highlighted option position even as other options load around it. + self.highlighted = ( + self.get_option_index(highlighted_option.id) + if highlighted_option and self.highlighted_option_changed + else 0 ) def on_option_list_option_highlighted(self, event): diff --git a/borgmatic/actions/browse/configuration_files_list.py b/borgmatic/actions/browse/configuration_files_list.py index 95920ea9..fd723200 100644 --- a/borgmatic/actions/browse/configuration_files_list.py +++ b/borgmatic/actions/browse/configuration_files_list.py @@ -15,6 +15,7 @@ class Configuration_files_list(textual.widgets.OptionList): files. The item selection event is handled in a Carousel instance, the parent widget of an Configuration_files_list. ''' + BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS def __init__(self, configs): diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 569076e8..7479d621 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -159,7 +159,7 @@ class Directory_list(textual.widgets.OptionList): if not self.path_components: borgmatic.actions.browse.workers.load_archive_paths( self.app, - directory_list=self, + path_loaded=self.path_loaded, config=self.config, repository=self.repository, archive_name=self.archive_name, @@ -167,7 +167,7 @@ class Directory_list(textual.widgets.OptionList): def on_mount(self): ''' - When this widgets gets mounted in the DOM, subcribe to path loaded events so that we can + When this widget gets mounted in the DOM, subcribe to path loaded events so that we can find out about relevant archive paths as they load. And if this is a non-root directory list, add any already loaded archive paths to this widget as options. This is done *after* subscribing to path loaded signals so that there's not a gap where we might miss out on any diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index 171daf44..1e9131b9 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -1,6 +1,7 @@ import contextlib import os +import rich.syntax import textual.binding import textual.widgets @@ -42,15 +43,36 @@ class File_preview(textual.widgets.RichLog): super().__init__(classes='panel') self.border_title = ' '.join(('📄', self.file_path, 'preview')) self.auto_scroll = False + self.file_preview_loaded = borgmatic.actions.browse.workers.File_preview_loaded( + self, 'file preview loaded' + ) - timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + self.loading_timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) borgmatic.actions.browse.workers.load_file_preview( self.app, - file_preview=self, + file_preview_loaded=self.file_preview_loaded, config=self.config, repository=self.repository, archive_name=self.archive_name, file_path=self.file_path, - loading_timer=timer, + loading_timer=self.loading_timer, ) + + def on_mount(self): + ''' + When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can + find out about archives as they load. + ''' + self.file_preview_loaded.subscribe(self, self.on_file_preview_loaded) + + def on_file_preview_loaded(self, data): + self.loading_timer.stop() + self.clear() + + if data is None: + self.write('Cannot display a preview for this file') + else: + self.write( + rich.syntax.Syntax(data, rich.syntax.Syntax.guess_lexer(self.file_path, data)) + ) diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index ed8b8579..bf80cf4c 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -2,7 +2,6 @@ import contextlib import logging import os -import rich.syntax import textual import textual.signal import textual.widgets.option_list @@ -13,10 +12,27 @@ import borgmatic.actions.browse.archive logger = logging.getLogger('__name__') -@textual.work(thread=True) -def add_repository_archives(browse_app, archives_list, config, repository, loading_timer): +LOADING_DONE = object() + + +class Archive_loaded(textual.signal.Signal): ''' - Given a running Browse_app instance, an Archives_list instance, a configuration dict, a + A signal that publishes when each subsequent archive is loaded from a repository, intended for + consumption in widgets that display archives as they are loaded. This signal also publishes + when loading is complete. + + Each subscribed callback call includes the archive as an archive name string. Given the lack of + other identifying information (configuration file, repository), there should be a separate + Archive_loaded instance per repository. + ''' + + pass + + +@textual.work(thread=True) +def add_repository_archives(browse_app, archive_loaded, config, repository, loading_timer): + ''' + Given a running Browse_app instance, an Archive_loaded instance, a configuration dict, a repository dict, and a loading indicator timer, load a list of the archives from the repository and add them as options in the archives list. Reverse the order so the most recent archive is first. @@ -25,34 +41,13 @@ def add_repository_archives(browse_app, archives_list, config, repository, loadi loading indicator and stop its timer. ''' archives_data = borgmatic.actions.browse.archive.get_repository_archives(config, repository) - loading_option = archives_list.get_option('loading-indicator') # Reverse the archives, so the common case of accessing the latest archive is easy because it's # at the top. for index, archive in enumerate(reversed(archives_data['archives'])): - label_pieces = ( - (archive['archive'], '[dim](latest)[/dim]') if index == 0 else (archive['archive'],) - ) - highlighted_option = archives_list.highlighted_option + archive_loaded.publish(archive['archive']) - browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') - browse_app.call_from_thread( - archives_list.add_options, - ( - textual.widgets.option_list.Option(' '.join(label_pieces), id=archive['archive']), - loading_option, - ), - ) - - # Retain the highlighted option position even as other options load around it. - archives_list.highlighted = ( - archives_list.get_option_index(highlighted_option.id) - if highlighted_option and archives_list.highlighted_option_changed - else 0 - ) - - browse_app.call_from_thread(archives_list.remove_option, 'loading-indicator') - browse_app.call_from_thread(loading_timer.stop) + archive_loaded.publish(LOADING_DONE) def record_path(archive_path, hierarchy, path_components): @@ -108,9 +103,6 @@ def get_paths(hierarchy, path_components, full_path_components=None): return get_paths(hierarchy[path_components[0]], path_components[1:], full_path_components) -LOADING_DONE = object() - - class Archive_path_loaded(textual.signal.Signal): ''' A signal that publishes when each subsequent path is loaded from an archive, intended for @@ -118,7 +110,12 @@ class Archive_path_loaded(textual.signal.Signal): complete filesystem hierarchy seen thus far, so new widgets that get created after loading has started can "catch up" with existing known paths. Lastly, this signal publishes and tracks when loading is complete. + + Each subscrided callback call includes the loaded path as an Archive_path instance. There is + intended to be a separate Archive_path_loaded instance per archive, but that instance should be + shared among several different widgets for the same archive for performance reasons. ''' + def __init__(self, owner, name): self.path_hierarchy = {} self.complete = False @@ -135,9 +132,9 @@ class Archive_path_loaded(textual.signal.Signal): @textual.work(thread=True) -def load_archive_paths(browse_app, directory_list, config, repository, archive_name): +def load_archive_paths(browse_app, path_loaded, config, repository, archive_name): ''' - Given a running Browse_app instance, a Directory_list instance, a configuration dict, a + Given a running Browse_app instance, an Archive_path_loaded instance, a configuration dict, a repository dict, and an archive name, load the paths in this archive and publish each one via the Archive_path_loaded signal, so interested widgets can subscribe. Also send a "loading done" signal when loading completes. @@ -147,30 +144,35 @@ def load_archive_paths(browse_app, directory_list, config, repository, archive_n for archive_path in borgmatic.actions.browse.archive.get_archive_paths( config, repository, archive_name ): - directory_list.path_loaded.publish(archive_path) + path_loaded.publish(archive_path) - directory_list.path_loaded.publish(LOADING_DONE) + path_loaded.publish(LOADING_DONE) + + +class File_preview_loaded(textual.signal.Signal): + ''' + A signal that publishes when file contents are loaded from an archive, intended for consumption + in widgets that display loaded files. This signal also publishes when loading is complete. + + Each published callback includes a the file's contents as a string. Given the lack of other + identifying information (configuration file, repository, archive), there should be a separate + Archive_loaded instance per previewed file. + ''' + + pass @textual.work(thread=True) -def load_file_preview(browse_app, file_preview, config, repository, archive_name, file_path, - loading_timer): +def load_file_preview( + browse_app, file_preview_loaded, config, repository, archive_name, file_path, loading_timer +): ''' - Given a running Browse_app instance, a File_preview instance, a configuration dict, a repository - dict, an archive name, the path of a file in that archive, and a loading indicator timer, load - the contents of the file and write it into the given file preview widget. + Given a running Browse_app instance, a File_preview_loaded instance, a configuration dict, a + repository dict, an archive name, the path of a file in that archive, and a loading indicator + timer, load the contents of the file and write it into the given file preview widget. ''' - file_content = borgmatic.actions.browse.archive.get_archive_file_content( + file_contents = borgmatic.actions.browse.archive.get_archive_file_content( config, repository, archive_name, file_path ) - browse_app.call_from_thread(loading_timer.stop) - browse_app.call_from_thread(file_preview.clear) - - if file_content is None: - browse_app.call_from_thread(file_preview.write, 'Cannot display a preview for this file') - else: - syntax_lexer = rich.syntax.Syntax.guess_lexer(file_path, file_content) - browse_app.call_from_thread( - file_preview.write, rich.syntax.Syntax(file_content, syntax_lexer) - ) + file_preview_loaded.publish(file_contents) diff --git a/tests/integration/actions/browse/test_logs.py b/tests/integration/actions/browse/test_logs.py index beffade5..7ccd3e59 100644 --- a/tests/integration/actions/browse/test_logs.py +++ b/tests/integration/actions/browse/test_logs.py @@ -16,7 +16,8 @@ def test_log_to_widget_adds_our_handler_and_removes_default_handler(): with contextlib.suppress(StopIteration): browse_log_handler = next( - handler for handler in root_logger.handlers + handler + for handler in root_logger.handlers if isinstance(handler, module.Browse_log_handler) ) @@ -26,11 +27,11 @@ def test_log_to_widget_adds_our_handler_and_removes_default_handler(): root_logger.removeHandler(browse_log_handler) assert not any( - handler for handler in root_logger.handlers + handler + for handler in root_logger.handlers if isinstance(handler, module.borgmatic.logger.Multi_stream_handler) ) - def test_logs_does_not_raise(): module.Logs() From dc91f81a3924be6d6b5623e383de2a13fa21088b Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 30 May 2026 22:22:06 -0700 Subject: [PATCH 53/63] Fix loading race conditions. --- borgmatic/actions/browse/archives_list.py | 32 ++++++++++------- borgmatic/actions/browse/directory_list.py | 36 +++++++++---------- borgmatic/actions/browse/file_preview.py | 35 ++++++++++++------ borgmatic/actions/browse/workers.py | 3 ++ .../actions/browse/test_archives_list.py | 16 --------- .../actions/browse/test_directory_list.py | 33 +++-------------- .../actions/browse/test_file_preview.py | 4 --- 7 files changed, 70 insertions(+), 89 deletions(-) diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py index e7d5636f..6335a8cd 100644 --- a/borgmatic/actions/browse/archives_list.py +++ b/borgmatic/actions/browse/archives_list.py @@ -19,8 +19,9 @@ class Archives_list(textual.widgets.OptionList): def __init__(self, config, repository): ''' - Given a configuration dict and a repository dict, start loading the archives from the - repository for eventual display in this widget. + Given a configuration dict and a repository dict, prepare to load the archives from the + repository for eventual display in this widget. Actual loading kicks off in on_mount() + below. ''' self.config = config self.repository = repository @@ -34,6 +35,16 @@ class Archives_list(textual.widgets.OptionList): self.loading_timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + def on_mount(self): + ''' + When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can + find out about archives as they load. Also start loading archives from the repository. + + Loading is started *after* subscribing to archive loaded signals so that there's not a gap + where we might miss out on signal publishes. + ''' + self.archive_loaded.subscribe(self, self.on_archive_loaded) + borgmatic.actions.browse.workers.add_repository_archives( self.app, archive_loaded=self.archive_loaded, @@ -42,31 +53,26 @@ class Archives_list(textual.widgets.OptionList): loading_timer=self.loading_timer, ) - def on_mount(self): - ''' - When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can - find out about archives as they load. - ''' - self.archive_loaded.subscribe(self, self.on_archive_loaded) - - def on_archive_loaded(self, data): + def on_archive_loaded(self, archive_name): ''' When an archive loads, add it as an option to this archives list. But if we get a signal that all path loading is complete, stop and remove our loading indicator. ''' - if data is borgmatic.actions.browse.workers.LOADING_DONE: + if archive_name is borgmatic.actions.browse.workers.LOADING_DONE: self.loading_timer.stop() self.remove_option('loading-indicator') return - label_pieces = (data, '[dim](latest)[/dim]') if len(self.options) == 1 else (data,) + label_pieces = ( + (archive_name, '[dim](latest)[/dim]') if len(self.options) == 1 else (archive_name,) + ) highlighted_option = self.highlighted_option loading_indicator = self.get_option('loading-indicator') self.remove_option('loading-indicator') self.add_options( ( - textual.widgets.option_list.Option(' '.join(label_pieces), id=data), + textual.widgets.option_list.Option(' '.join(label_pieces), id=archive_name), loading_indicator, ), ) diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 7479d621..95a8b747 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -119,10 +119,9 @@ class Directory_list(textual.widgets.OptionList): ''' Given a configuration dict, a repository dict, an archive name, an optional Archive_path_loaded instance for signalling new paths as they load, and an optional tuple of - path components indicating this directory's position in the backed up filesystem, start - loading paths from the archive for eventual display in this widget. Or, if paths have - already started loading (by the root directory list), just listen for new paths as they come - in. + path components indicating this directory's position in the backed up filesystem, prepare to + load paths from the archive for eventual display in this widget. Actual loading kicks off in + on_mount() below. ''' self.config = config self.repository = repository @@ -156,22 +155,15 @@ class Directory_list(textual.widgets.OptionList): if not self.path_loaded.complete: self.timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) - if not self.path_components: - borgmatic.actions.browse.workers.load_archive_paths( - self.app, - path_loaded=self.path_loaded, - config=self.config, - repository=self.repository, - archive_name=self.archive_name, - ) - def on_mount(self): ''' - When this widget gets mounted in the DOM, subcribe to path loaded events so that we can - find out about relevant archive paths as they load. And if this is a non-root directory - list, add any already loaded archive paths to this widget as options. This is done *after* - subscribing to path loaded signals so that there's not a gap where we might miss out on any - paths. + When this widget gets mounted in the DOM, subcribe to path loaded events so that we can find + out about relevant archive paths as they load. And if this is a root directory list, start + loading paths from the archive. If this is a non-root directory list, add any already loaded + archive paths to this widget as options. + + Loading is started *after* subscribing to path loaded signals so that there's not a gap + where we might miss out on any paths. ''' self.path_loaded.subscribe(self, self.on_archive_path_loaded) @@ -185,6 +177,14 @@ class Directory_list(textual.widgets.OptionList): self.path_loaded.path_hierarchy, self.path_components ), ) + else: + borgmatic.actions.browse.workers.load_archive_paths( + self.app, + path_loaded=self.path_loaded, + config=self.config, + repository=self.repository, + archive_name=self.archive_name, + ) def on_archive_path_loaded(self, data): ''' diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index 1e9131b9..88b403d6 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -1,3 +1,4 @@ +import logging import contextlib import os @@ -9,6 +10,9 @@ import borgmatic.actions.browse.loading import borgmatic.actions.browse.workers +logger = logging.getLogger('__name__') + + class File_preview(textual.widgets.RichLog): ''' A widget for extracting and previewing the contents of a file stored in a Borg archive. @@ -33,7 +37,8 @@ class File_preview(textual.widgets.RichLog): def __init__(self, config, repository, archive_name, file_path): ''' Given a configuration dict, a repository dict, an archive name, and the path of a file in - the archive, start loading the file's contents for eventual display in this widget. + the archive, prepare to load the file's contents for eventual display in this widget. Actual + loading kicks off in on_mount() below. ''' self.config = config self.repository = repository @@ -49,6 +54,16 @@ class File_preview(textual.widgets.RichLog): self.loading_timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self) + def on_mount(self): + ''' + When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can + find out about archives as they load. Also start loading file contents from the archive. + + Loading is started *after* subscribing to file preview loaded signals so that there's not a + gap where we might miss out on signal publishes. + ''' + self.file_preview_loaded.subscribe(self, self.on_file_preview_loaded) + borgmatic.actions.browse.workers.load_file_preview( self.app, file_preview_loaded=self.file_preview_loaded, @@ -59,20 +74,20 @@ class File_preview(textual.widgets.RichLog): loading_timer=self.loading_timer, ) - def on_mount(self): - ''' - When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can - find out about archives as they load. - ''' - self.file_preview_loaded.subscribe(self, self.on_file_preview_loaded) + def on_file_preview_loaded(self, file_contents): + import time - def on_file_preview_loaded(self, data): + logger.debug(('on_file_preview_loaded', time.time())) self.loading_timer.stop() self.clear() - if data is None: + if file_contents is None: self.write('Cannot display a preview for this file') else: + logger.debug(('before write', time.time())) self.write( - rich.syntax.Syntax(data, rich.syntax.Syntax.guess_lexer(self.file_path, data)) + rich.syntax.Syntax( + file_contents, rich.syntax.Syntax.guess_lexer(self.file_path, file_contents) + ) ) + logger.debug(('after write', time.time())) diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index bf80cf4c..d84bf187 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -171,6 +171,9 @@ def load_file_preview( repository dict, an archive name, the path of a file in that archive, and a loading indicator timer, load the contents of the file and write it into the given file preview widget. ''' + import time + + logger.debug(('before get_archive_file_content', time.time())) file_contents = borgmatic.actions.browse.archive.get_archive_file_content( config, repository, archive_name, file_path ) diff --git a/tests/integration/actions/browse/test_archives_list.py b/tests/integration/actions/browse/test_archives_list.py index 5bf205e6..4f00c0e6 100644 --- a/tests/integration/actions/browse/test_archives_list.py +++ b/tests/integration/actions/browse/test_archives_list.py @@ -6,10 +6,6 @@ import textual.widgets.option_list def test_archives_list_on_option_list_option_highlighted_with_highlighted_none_marks_it_unchanged(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') - flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( - 'app' - ).and_return(flexmock()) archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) archives_list.highlighted = None @@ -20,10 +16,6 @@ def test_archives_list_on_option_list_option_highlighted_with_highlighted_none_m def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_marks_it_unchanged(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') - flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( - 'app' - ).and_return(flexmock()) archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) archives_list.highlighted = 0 @@ -34,10 +26,6 @@ def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_m def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_marks_it_unchanged(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') - flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( - 'app' - ).and_return(flexmock()) archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) archives_list.add_option(textual.widgets.option_list.Option('zero', id='zero')) @@ -49,10 +37,6 @@ def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_m def test_archives_list_on_option_list_option_highlighted_with_highlighted_non_zero_marks_it_changed(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') - flexmock(module.borgmatic.actions.browse.archives_list.Archives_list).should_receive( - 'app' - ).and_return(flexmock()) archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) archives_list.add_option(textual.widgets.option_list.Option('zero', id='zero')) diff --git a/tests/integration/actions/browse/test_directory_list.py b/tests/integration/actions/browse/test_directory_list.py index a6d29cca..99dd358b 100644 --- a/tests/integration/actions/browse/test_directory_list.py +++ b/tests/integration/actions/browse/test_directory_list.py @@ -121,12 +121,8 @@ def test_add_archive_paths_retains_loading_indicator_at_bottom(): assert directory_list.highlighted == 2 -def test_directory_list_with_root_directory_starts_loading_archive_paths(): - flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths').once() - flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( - 'app' - ).and_return(flexmock()) +def test_directory_list_with_root_directory_adds_loading_indicator(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator').once() directory_list = module.Directory_list( config=flexmock(), repository=flexmock(), archive_name='archive' @@ -136,26 +132,6 @@ def test_directory_list_with_root_directory_starts_loading_archive_paths(): assert not directory_list.path_loaded.complete -def test_directory_list_with_non_root_directory_relies_on_existing_path_loading_worker(): - flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths').never() - flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( - 'app' - ).and_return(flexmock()) - - directory_list = module.Directory_list( - config=flexmock(), - repository=flexmock(), - archive_name='archive', - path_loaded=flexmock(complete=False), - path_components=('etc',), - ) - assert directory_list.border_title == '📁 etc' - assert len(directory_list.options) == 1 - assert directory_list.options[0].prompt == '📁 ..' - assert directory_list.options[0].id == '..' - - def test_directory_list_with_already_complete_loading_skips_loading_indicator(): flexmock(module.borgmatic.actions.browse.loading).should_receive( 'add_inline_loading_indicator' @@ -178,12 +154,12 @@ def test_directory_list_with_already_complete_loading_skips_loading_indicator(): assert directory_list.options[0].id == '..' -def test_directory_list_on_mount_with_root_directory_skips_adding_archives_paths(): +def test_directory_list_on_mount_with_root_directory_loads_archive_paths(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths') flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( 'app' ).and_return(flexmock()) + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths').once() flexmock(module).should_receive('add_archive_paths').never() directory_list = module.Directory_list( config=flexmock(), repository=flexmock(), archive_name='archive' @@ -198,6 +174,7 @@ def test_directory_list_on_mount_with_non_root_directory_adds_archive_paths(): flexmock(module.borgmatic.actions.browse.directory_list.Directory_list).should_receive( 'app' ).and_return(flexmock()) + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_archive_paths').never() flexmock(module).should_receive('add_archive_paths').once() directory_list = module.Directory_list( config=flexmock(), diff --git a/tests/integration/actions/browse/test_file_preview.py b/tests/integration/actions/browse/test_file_preview.py index 72735ae8..4c9ef44e 100644 --- a/tests/integration/actions/browse/test_file_preview.py +++ b/tests/integration/actions/browse/test_file_preview.py @@ -6,10 +6,6 @@ import textual.widgets.option_list def test_file_preview_does_not_raise(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') - flexmock(module.borgmatic.actions.browse.workers).should_receive('load_file_preview') - flexmock(module.borgmatic.actions.browse.file_preview.File_preview).should_receive( - 'app' - ).and_return(flexmock()) module.File_preview( config=flexmock(), repository=flexmock(), archive_name='archive', file_path='foo/bar.txt' From 9748a0298aa699758315fa9d7a2f1d276ad49fd9 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 30 May 2026 23:11:41 -0700 Subject: [PATCH 54/63] Add icon to configuration files list border title. --- borgmatic/actions/browse/configuration_files_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/borgmatic/actions/browse/configuration_files_list.py b/borgmatic/actions/browse/configuration_files_list.py index fd723200..0c21748f 100644 --- a/borgmatic/actions/browse/configuration_files_list.py +++ b/borgmatic/actions/browse/configuration_files_list.py @@ -34,4 +34,4 @@ class Configuration_files_list(textual.widgets.OptionList): ), classes='panel', ) - self.border_title = 'configuration files' + self.border_title = '📄 configuration files' From 30ca942f76a6756fa14a5226cf536b32c4108ad4 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sun, 31 May 2026 18:55:56 -0700 Subject: [PATCH 55/63] Improve performance wen loading file previews. --- borgmatic/actions/browse/file_preview.py | 11 +++-------- borgmatic/actions/browse/workers.py | 3 --- .../integration/actions/browse/test_directory_list.py | 4 +++- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index 88b403d6..7e117eae 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -75,19 +75,14 @@ class File_preview(textual.widgets.RichLog): ) def on_file_preview_loaded(self, file_contents): - import time - - logger.debug(('on_file_preview_loaded', time.time())) self.loading_timer.stop() self.clear() if file_contents is None: self.write('Cannot display a preview for this file') else: - logger.debug(('before write', time.time())) + # Only pass the file path and not its contents to guess_lexer(). Passing the contents is + # more accurate, but also much slower. self.write( - rich.syntax.Syntax( - file_contents, rich.syntax.Syntax.guess_lexer(self.file_path, file_contents) - ) + rich.syntax.Syntax(file_contents, rich.syntax.Syntax.guess_lexer(self.file_path)) ) - logger.debug(('after write', time.time())) diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index d84bf187..bf80cf4c 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -171,9 +171,6 @@ def load_file_preview( repository dict, an archive name, the path of a file in that archive, and a loading indicator timer, load the contents of the file and write it into the given file preview widget. ''' - import time - - logger.debug(('before get_archive_file_content', time.time())) file_contents = borgmatic.actions.browse.archive.get_archive_file_content( config, repository, archive_name, file_path ) diff --git a/tests/integration/actions/browse/test_directory_list.py b/tests/integration/actions/browse/test_directory_list.py index 99dd358b..5b72b5af 100644 --- a/tests/integration/actions/browse/test_directory_list.py +++ b/tests/integration/actions/browse/test_directory_list.py @@ -122,7 +122,9 @@ def test_add_archive_paths_retains_loading_indicator_at_bottom(): def test_directory_list_with_root_directory_adds_loading_indicator(): - flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator').once() + flexmock(module.borgmatic.actions.browse.loading).should_receive( + 'add_inline_loading_indicator' + ).once() directory_list = module.Directory_list( config=flexmock(), repository=flexmock(), archive_name='archive' From ec8e52944c1747c9dac12860484c24261ac380d6 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sun, 31 May 2026 20:30:59 -0700 Subject: [PATCH 56/63] More tests! --- borgmatic/actions/browse/archives_list.py | 4 +- borgmatic/actions/browse/file_preview.py | 7 +++- docs/how-to/develop-on-borgmatic.md | 7 ++++ .../actions/browse/test_archives_list.py | 39 +++++++++++++++++++ .../actions/browse/test_file_preview.py | 33 ++++++++++++++++ 5 files changed, 86 insertions(+), 4 deletions(-) diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py index 6335a8cd..84098c84 100644 --- a/borgmatic/actions/browse/archives_list.py +++ b/borgmatic/actions/browse/archives_list.py @@ -40,8 +40,8 @@ class Archives_list(textual.widgets.OptionList): When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can find out about archives as they load. Also start loading archives from the repository. - Loading is started *after* subscribing to archive loaded signals so that there's not a gap - where we might miss out on signal publishes. + Loading is started *after* subscribing to the archive loaded signal so that there's not a + gap where we might miss out on signal publishes. ''' self.archive_loaded.subscribe(self, self.on_archive_loaded) diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index 7e117eae..edae0b46 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -59,8 +59,8 @@ class File_preview(textual.widgets.RichLog): When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can find out about archives as they load. Also start loading file contents from the archive. - Loading is started *after* subscribing to file preview loaded signals so that there's not a - gap where we might miss out on signal publishes. + Loading is started *after* subscribing to the file preview loaded signal so that there's not + a gap where we might miss out on signal publishes. ''' self.file_preview_loaded.subscribe(self, self.on_file_preview_loaded) @@ -75,6 +75,9 @@ class File_preview(textual.widgets.RichLog): ) def on_file_preview_loaded(self, file_contents): + ''' + When a file loads, write its contents (syntax highlighted) to this file preview widget. + ''' self.loading_timer.stop() self.clear() diff --git a/docs/how-to/develop-on-borgmatic.md b/docs/how-to/develop-on-borgmatic.md index 5e6264a2..9edba4af 100644 --- a/docs/how-to/develop-on-borgmatic.md +++ b/docs/how-to/develop-on-borgmatic.md @@ -42,6 +42,13 @@ change that last line to: uv tool install --editable .[dev,Apprise] ``` +Or to work on the [browse +action](https://torsion.org/borgmatic/reference/command-line/actions/browse/): + +```bash +uv tool install --editable .[dev,browse] +``` + To get oriented with the borgmatic source code, have a look at the [source code reference](https://torsion.org/borgmatic/reference/source-code/). diff --git a/tests/integration/actions/browse/test_archives_list.py b/tests/integration/actions/browse/test_archives_list.py index 4f00c0e6..ad28146f 100644 --- a/tests/integration/actions/browse/test_archives_list.py +++ b/tests/integration/actions/browse/test_archives_list.py @@ -1,9 +1,48 @@ from borgmatic.actions.browse import archives_list as module from flexmock import flexmock +import textual.app import textual.widgets.option_list +async def test_archives_list_on_mount_does_not_raise(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('add_repository_archives') + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + flexmock(archives_list.archive_loaded).should_receive('subscribe') + + async with textual.app.App().run_test() as pilot: + archives_list.on_mount() + + +def test_archives_list_on_archive_loaded_with_loading_done_removes_loading_indicator(): + loading_timer = flexmock() + flexmock(module.borgmatic.actions.browse.loading).should_receive( + 'add_inline_loading_indicator' + ).and_return(loading_timer) + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + flexmock(loading_timer).should_receive('stop') + flexmock(archives_list).should_receive('remove_option').with_args('loading-indicator').once() + flexmock(archives_list).should_receive('add_options').never() + + archives_list.on_archive_loaded(module.borgmatic.actions.browse.workers.LOADING_DONE) + + +def test_archives_list_on_archive_loaded_adds_archive_name(): + loading_timer = flexmock() + flexmock(module.borgmatic.actions.browse.loading).should_receive( + 'add_inline_loading_indicator' + ).and_return(loading_timer) + archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) + flexmock(loading_timer).should_receive('stop').never() + loading_indicator = flexmock() + flexmock(archives_list).should_receive('get_option').and_return(loading_indicator) + flexmock(archives_list).should_receive('remove_option').with_args('loading-indicator').once() + flexmock(archives_list).should_receive('add_options').once() + + archives_list.on_archive_loaded('archive') + + def test_archives_list_on_option_list_option_highlighted_with_highlighted_none_marks_it_unchanged(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') diff --git a/tests/integration/actions/browse/test_file_preview.py b/tests/integration/actions/browse/test_file_preview.py index 4c9ef44e..f35ff1b2 100644 --- a/tests/integration/actions/browse/test_file_preview.py +++ b/tests/integration/actions/browse/test_file_preview.py @@ -10,3 +10,36 @@ def test_file_preview_does_not_raise(): module.File_preview( config=flexmock(), repository=flexmock(), archive_name='archive', file_path='foo/bar.txt' ) + + +async def test_file_preview_on_mount_does_not_raise(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') + flexmock(module.borgmatic.actions.browse.workers).should_receive('load_file_preview') + file_preview = module.File_preview( + config=flexmock(), repository=flexmock(), archive_name='archive', file_path='foo.txt' + ) + flexmock(file_preview.file_preview_loaded).should_receive('subscribe') + + async with textual.app.App().run_test() as pilot: + file_preview.on_mount() + + +def test_file_preview_on_file_preview_loaded_with_none_file_contents_displays_error(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator').and_return(flexmock(stop=lambda: None)) + file_preview = module.File_preview( + config=flexmock(), repository=flexmock(), archive_name='archive', file_path='foo.txt' + ) + flexmock(file_preview).should_receive('write').with_args('Cannot display a preview for this file').once() + + file_preview.on_file_preview_loaded(None) + + +def test_file_preview_on_file_preview_loaded_with_file_contents_displays_contents(): + flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator').and_return(flexmock(stop=lambda: None)) + file_preview = module.File_preview( + config=flexmock(), repository=flexmock(), archive_name='archive', file_path='foo.txt' + ) + flexmock(file_preview).should_receive('write').with_args('Cannot display a preview for this file').never() + flexmock(file_preview).should_receive('write').once() + + file_preview.on_file_preview_loaded('hi') From 5accda1a65c221496b23a93aec7edac8da97d308 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 1 Jun 2026 11:54:28 -0700 Subject: [PATCH 57/63] Complete tests. --- borgmatic/actions/browse/archive.py | 8 +- borgmatic/actions/browse/archives_list.py | 5 - borgmatic/actions/browse/bindings.py | 1 - borgmatic/actions/browse/carousel.py | 5 +- .../browse/configuration_files_list.py | 4 - borgmatic/actions/browse/directory_list.py | 5 +- borgmatic/actions/browse/file_preview.py | 10 +- borgmatic/actions/browse/icons.py | 1 - borgmatic/actions/browse/loading.py | 3 +- borgmatic/actions/browse/repositories_list.py | 6 -- borgmatic/actions/browse/workers.py | 92 +++++++++++-------- borgmatic/borg/borg.py | 2 +- borgmatic/commands/borgmatic.py | 4 +- borgmatic/logger.py | 2 +- .../hooks/credential/test_container.py | 2 +- .../end-to-end/hooks/credential/test_file.py | 2 +- .../hooks/credential/test_keepassxc.py | 2 +- .../hooks/credential/test_systemd.py | 2 +- .../hooks/data_source/test_btrfs.py | 2 +- .../hooks/data_source/test_database.py | 10 +- .../end-to-end/hooks/data_source/test_lvm.py | 2 +- .../end-to-end/hooks/data_source/test_zfs.py | 2 +- .../hooks/monitoring/test_monitoring.py | 2 +- tests/end-to-end/test_borgmatic.py | 2 +- tests/end-to-end/test_environment_variable.py | 2 +- tests/end-to-end/test_passcommand.py | 2 +- tests/integration/actions/browse/test_app.py | 15 ++- .../actions/browse/test_archives_list.py | 10 +- .../actions/browse/test_carousel.py | 14 ++- .../browse/test_configuration_files_list.py | 4 +- .../actions/browse/test_directory_list.py | 6 +- .../actions/browse/test_file_preview.py | 24 +++-- .../actions/browse/test_loading.py | 12 +-- tests/integration/actions/browse/test_logs.py | 4 +- .../actions/browse/test_repositories_list.py | 2 - .../actions/browse/test_directory_list.py | 4 +- tests/unit/actions/browse/test_logs.py | 4 +- tests/unit/actions/browse/test_run.py | 2 +- tests/unit/borg/test_diff.py | 1 + 39 files changed, 136 insertions(+), 146 deletions(-) diff --git a/borgmatic/actions/browse/archive.py b/borgmatic/actions/browse/archive.py index 5e3cc8f8..d474969e 100644 --- a/borgmatic/actions/browse/archive.py +++ b/borgmatic/actions/browse/archive.py @@ -3,16 +3,14 @@ import collections import enum import json import logging -import os + +import binaryornot.helpers import borgmatic.borg.extract import borgmatic.borg.list import borgmatic.borg.repo_list import borgmatic.borg.version -import binaryornot.helpers - - logger = logging.getLogger(__name__) @@ -69,7 +67,7 @@ def get_repository_archives(config, repository): def get_archive_paths(config, repository, archive_name): ''' Given a configuration dict, a repository dict, and an archive name in that repository, get a - list of files, directories, symlinks, etc. found in the archive, each as an Archive_path + generator of files, directories, symlinks, etc. found in the archive, each as an Archive_path instance. ''' with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])): diff --git a/borgmatic/actions/browse/archives_list.py b/borgmatic/actions/browse/archives_list.py index 84098c84..9182849a 100644 --- a/borgmatic/actions/browse/archives_list.py +++ b/borgmatic/actions/browse/archives_list.py @@ -1,7 +1,3 @@ -import contextlib -import os - -import textual.binding import textual.widgets import borgmatic.actions.browse.bindings @@ -50,7 +46,6 @@ class Archives_list(textual.widgets.OptionList): archive_loaded=self.archive_loaded, config=self.config, repository=self.repository, - loading_timer=self.loading_timer, ) def on_archive_loaded(self, archive_name): diff --git a/borgmatic/actions/browse/bindings.py b/borgmatic/actions/browse/bindings.py index f42422c5..b2bfc4e9 100644 --- a/borgmatic/actions/browse/bindings.py +++ b/borgmatic/actions/browse/bindings.py @@ -1,7 +1,6 @@ import textual.binding import textual.widgets - OPTION_LIST_BINDINGS = ( *textual.widgets.OptionList.BINDINGS, textual.binding.Binding( diff --git a/borgmatic/actions/browse/carousel.py b/borgmatic/actions/browse/carousel.py index 6c5bdec9..9890dfdd 100644 --- a/borgmatic/actions/browse/carousel.py +++ b/borgmatic/actions/browse/carousel.py @@ -5,8 +5,8 @@ import textual.containers import borgmatic.actions.browse.archive import borgmatic.actions.browse.archives_list -import borgmatic.actions.browse.directory_list import borgmatic.actions.browse.configuration_files_list +import borgmatic.actions.browse.directory_list import borgmatic.actions.browse.file_preview import borgmatic.actions.browse.icons import borgmatic.actions.browse.repositories_list @@ -53,7 +53,8 @@ def make_next_panel(focused_panel, option_id): path_loaded=focused_panel.path_loaded, path_components=(*focused_panel.path_components, option_id), ) - elif option.prompt.startswith( + + if option.prompt.startswith( borgmatic.actions.browse.icons.PATH_TYPE_ICONS[ borgmatic.actions.browse.archive.Path_type.FILE.value ] diff --git a/borgmatic/actions/browse/configuration_files_list.py b/borgmatic/actions/browse/configuration_files_list.py index 0c21748f..90e451c2 100644 --- a/borgmatic/actions/browse/configuration_files_list.py +++ b/borgmatic/actions/browse/configuration_files_list.py @@ -1,12 +1,8 @@ -import contextlib import os -import textual.binding import textual.widgets import borgmatic.actions.browse.bindings -import borgmatic.actions.browse.loading -import borgmatic.actions.browse.workers class Configuration_files_list(textual.widgets.OptionList): diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 95a8b747..1cfe36ec 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -1,10 +1,7 @@ -import contextlib import os -import textual.binding import textual.widgets -import borgmatic.actions.browse.archive import borgmatic.actions.browse.bindings import borgmatic.actions.browse.icons import borgmatic.actions.browse.loading @@ -86,7 +83,7 @@ def add_archive_paths( ), ) if relative_path_components - if not relative_path_components[0] in directory_list._id_to_option + if relative_path_components[0] not in directory_list._id_to_option ), ), # The loading indicator "option" always goes to the bottom. diff --git a/borgmatic/actions/browse/file_preview.py b/borgmatic/actions/browse/file_preview.py index edae0b46..6ea37903 100644 --- a/borgmatic/actions/browse/file_preview.py +++ b/borgmatic/actions/browse/file_preview.py @@ -1,6 +1,4 @@ import logging -import contextlib -import os import rich.syntax import textual.binding @@ -9,7 +7,6 @@ import textual.widgets import borgmatic.actions.browse.loading import borgmatic.actions.browse.workers - logger = logging.getLogger('__name__') @@ -18,7 +15,7 @@ class File_preview(textual.widgets.RichLog): A widget for extracting and previewing the contents of a file stored in a Borg archive. ''' - BINDINGS = [ + BINDINGS = ( *textual.widgets.RichLog.BINDINGS, textual.binding.Binding( key='up,k', action='scroll_up', description='scroll up', show=True, priority=True @@ -32,7 +29,7 @@ class File_preview(textual.widgets.RichLog): textual.binding.Binding( key='pagedown', action='page_down', description='page down', show=True, priority=True ), - ] + ) def __init__(self, config, repository, archive_name, file_path): ''' @@ -46,7 +43,7 @@ class File_preview(textual.widgets.RichLog): self.file_path = file_path super().__init__(classes='panel') - self.border_title = ' '.join(('📄', self.file_path, 'preview')) + self.border_title = f'📄 {self.file_path} preview' self.auto_scroll = False self.file_preview_loaded = borgmatic.actions.browse.workers.File_preview_loaded( self, 'file preview loaded' @@ -71,7 +68,6 @@ class File_preview(textual.widgets.RichLog): repository=self.repository, archive_name=self.archive_name, file_path=self.file_path, - loading_timer=self.loading_timer, ) def on_file_preview_loaded(self, file_contents): diff --git a/borgmatic/actions/browse/icons.py b/borgmatic/actions/browse/icons.py index 2d621d38..57d400af 100644 --- a/borgmatic/actions/browse/icons.py +++ b/borgmatic/actions/browse/icons.py @@ -1,6 +1,5 @@ import borgmatic.actions.browse.archive - PATH_TYPE_ICONS = { borgmatic.actions.browse.archive.Path_type.DIRECTORY.value: '📁', borgmatic.actions.browse.archive.Path_type.LINK.value: '🔗', diff --git a/borgmatic/actions/browse/loading.py b/borgmatic/actions/browse/loading.py index 351014e7..24414c89 100644 --- a/borgmatic/actions/browse/loading.py +++ b/borgmatic/actions/browse/loading.py @@ -1,5 +1,6 @@ import contextlib import functools +import logging import textual.widgets import textual.widgets.option_list @@ -7,8 +8,6 @@ import textual.widgets.option_list LOADING_DOT_INTERVAL_SECONDS = 0.3 -import logging - logger = logging.getLogger('__name__') diff --git a/borgmatic/actions/browse/repositories_list.py b/borgmatic/actions/browse/repositories_list.py index 8985c78f..57ddc803 100644 --- a/borgmatic/actions/browse/repositories_list.py +++ b/borgmatic/actions/browse/repositories_list.py @@ -1,12 +1,6 @@ -import contextlib -import os - -import textual.binding import textual.widgets import borgmatic.actions.browse.bindings -import borgmatic.actions.browse.loading -import borgmatic.actions.browse.workers class Repositories_list(textual.widgets.OptionList): diff --git a/borgmatic/actions/browse/workers.py b/borgmatic/actions/browse/workers.py index bf80cf4c..338ac8c8 100644 --- a/borgmatic/actions/browse/workers.py +++ b/borgmatic/actions/browse/workers.py @@ -1,14 +1,11 @@ -import contextlib import logging import os import textual import textual.signal -import textual.widgets.option_list import borgmatic.actions.browse.archive - logger = logging.getLogger('__name__') @@ -26,25 +23,22 @@ class Archive_loaded(textual.signal.Signal): Archive_loaded instance per repository. ''' - pass - @textual.work(thread=True) -def add_repository_archives(browse_app, archive_loaded, config, repository, loading_timer): +def add_repository_archives(browse_app, archive_loaded, config, repository): ''' - Given a running Browse_app instance, an Archive_loaded instance, a configuration dict, a - repository dict, and a loading indicator timer, load a list of the archives from the repository - and add them as options in the archives list. Reverse the order so the most recent archive is - first. + Given a running Browse_app instance, an Archive_loaded instance, a configuration dict, and a + repository dict, load a list of the archives from the repository and add them as options in the + archives list. Reverse the order so the most recent archive is first. - This function runs in a separate thread from the main UI. When loading is complete, remove the - loading indicator and stop its timer. + This function runs in a separate thread from the main UI. When loading is complete, publish a + loading done signal. ''' archives_data = borgmatic.actions.browse.archive.get_repository_archives(config, repository) # Reverse the archives, so the common case of accessing the latest archive is easy because it's # at the top. - for index, archive in enumerate(reversed(archives_data['archives'])): + for archive in reversed(archives_data['archives']): archive_loaded.publish(archive['archive']) archive_loaded.publish(LOADING_DONE) @@ -72,35 +66,42 @@ def record_path(archive_path, hierarchy, path_components): def get_paths(hierarchy, path_components, full_path_components=None): ''' Given a dict capturing a filesystem hierarchy of paths (or a subset thereof), a tuple of path - components for an archive path relative to the hierarchy root, and an optional tuple of - *absolute* path components for the same archive path (if different), return the corresponding - Archive_path from the hierarchy. + components for a directory path relative to the hierarchy root, and an optional tuple of + *absolute* path components for the same path (if different), return a generator of the + contained file and directory Archive_path instances from the hierarchy. For instance, given the following hierarchy: - {'foo': {'bar': {'baz.txt': Archive_path('-', 'foo/bar/baz.txt', '')}}} + {'foo': {'bar': {'baz.txt': Archive_path('-', 'foo/bar/baz.txt', ''), 'quux': {}}}} - ... and path components of ('foo', 'bar', 'baz.txt'), return the Archive_path instance above. + ... and path components of ('foo', 'bar'), return a generator with the following: - Or in the case of path components of only ('foo', 'bar'), return a directory with the absolute - path: + * Archive_path('-', 'foo/bar/baz.txt', '') + * Archive_path('d', 'foo/bar/quux', '') - Archive_path('d', 'foo/bar', '') + The given absolute path components are use to construct directory paths like that last archive + path. ''' if full_path_components is None: full_path_components = path_components if len(path_components) == 1: - return ( - archive_path - if isinstance(archive_path, borgmatic.actions.browse.archive.Archive_path) - else borgmatic.actions.browse.archive.Archive_path( - 'd', os.path.join(*full_path_components, component), '' + try: + return ( + archive_path + if isinstance(archive_path, borgmatic.actions.browse.archive.Archive_path) + else borgmatic.actions.browse.archive.Archive_path( + 'd', os.path.join(*full_path_components, component), '' + ) + for component, archive_path in hierarchy[path_components[0]].items() ) - for component, archive_path in hierarchy[path_components[0]].items() - ) + except KeyError: + raise ValueError(f'Unknown file or directory: {path_components[0]}') - return get_paths(hierarchy[path_components[0]], path_components[1:], full_path_components) + try: + return get_paths(hierarchy[path_components[0]], path_components[1:], full_path_components) + except KeyError: + raise ValueError(f'Unknown directory: {path_components[0]}') class Archive_path_loaded(textual.signal.Signal): @@ -122,13 +123,19 @@ class Archive_path_loaded(textual.signal.Signal): super().__init__(owner, name) - def publish(self, data): - super().publish(data) + def publish(self, archive_path): + ''' + Publish the given archive path to subscribers and record its path locally. But if the + archive path is actually LOADING_DONE, then record loading as complete. + ''' + super().publish(archive_path) - if data is LOADING_DONE: + if archive_path is LOADING_DONE: self.complete = True else: - record_path(data, self.path_hierarchy, data.file_path.split(os.path.sep)) + record_path( + archive_path, self.path_hierarchy, archive_path.file_path.split(os.path.sep) + ) @textual.work(thread=True) @@ -139,7 +146,8 @@ def load_archive_paths(browse_app, path_loaded, config, repository, archive_name the Archive_path_loaded signal, so interested widgets can subscribe. Also send a "loading done" signal when loading completes. - This function runs in a separate thread from the main UI. + This function runs in a separate thread from the main UI. When loading is complete, publish a + loading done signal. ''' for archive_path in borgmatic.actions.browse.archive.get_archive_paths( config, repository, archive_name @@ -159,17 +167,23 @@ class File_preview_loaded(textual.signal.Signal): Archive_loaded instance per previewed file. ''' - pass - @textual.work(thread=True) def load_file_preview( - browse_app, file_preview_loaded, config, repository, archive_name, file_path, loading_timer + browse_app, + file_preview_loaded, + config, + repository, + archive_name, + file_path, ): ''' Given a running Browse_app instance, a File_preview_loaded instance, a configuration dict, a - repository dict, an archive name, the path of a file in that archive, and a loading indicator - timer, load the contents of the file and write it into the given file preview widget. + repository dict, an archive name, and the path of a file in that archive, load the contents of + the file and write it into the given file preview widget. + + This function runs in a separate thread from the main UI. When loading is complete, publish a + loading done signal. ''' file_contents = borgmatic.actions.browse.archive.get_archive_file_content( config, repository, archive_name, file_path diff --git a/borgmatic/borg/borg.py b/borgmatic/borg/borg.py index 20af8e11..dd46f687 100644 --- a/borgmatic/borg/borg.py +++ b/borgmatic/borg/borg.py @@ -31,7 +31,7 @@ def run_arbitrary_borg( borgmatic.logger.add_custom_log_levels() lock_wait = config.get('lock_wait', None) - try: + try: # noqa: PLW0717 options = options[1:] if options[0] == '--' else options # Borg commands like "key" have a sub-command ("export", etc.) that must follow it. diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index f2a3b613..1d4837c9 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -212,7 +212,7 @@ def run_configuration(config_filename, config, config_paths, arguments): # noqa f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions", ) - try: # noqa: PLR1702 + try: # noqa: PLR1702, PLW0717 with ( Monitoring_hooks(config_filename, config, arguments, global_arguments), borgmatic.hooks.command.Before_after_hooks( @@ -823,7 +823,7 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par ''' add_custom_log_levels() - try: + try: # noqa: PLW0717 if 'bootstrap' in arguments: try: local_borg_version = borg_version.local_borg_version( diff --git a/borgmatic/logger.py b/borgmatic/logger.py index 09414615..28e5f423 100644 --- a/borgmatic/logger.py +++ b/borgmatic/logger.py @@ -109,7 +109,7 @@ class JournaldHandler(logging.Handler): def emit(self, record): sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) - try: + try: # noqa: PLW0717 message_parts = [] entry = dict( MESSAGE=record.getMessage(), diff --git a/tests/end-to-end/hooks/credential/test_container.py b/tests/end-to-end/hooks/credential/test_container.py index 6279f522..ea12fb21 100644 --- a/tests/end-to-end/hooks/credential/test_container.py +++ b/tests/end-to-end/hooks/credential/test_container.py @@ -40,7 +40,7 @@ def test_container_secret(): original_working_directory = os.getcwd() os.chdir(temporary_directory) - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path, secrets_directory=temporary_directory) diff --git a/tests/end-to-end/hooks/credential/test_file.py b/tests/end-to-end/hooks/credential/test_file.py index 0ae9b37a..6aa80f98 100644 --- a/tests/end-to-end/hooks/credential/test_file.py +++ b/tests/end-to-end/hooks/credential/test_file.py @@ -40,7 +40,7 @@ def test_file_credential(): original_working_directory = os.getcwd() os.chdir(temporary_directory) - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') credential_path = os.path.join(temporary_directory, 'mycredential') generate_configuration(config_path, repository_path, credential_path) diff --git a/tests/end-to-end/hooks/credential/test_keepassxc.py b/tests/end-to-end/hooks/credential/test_keepassxc.py index e990458f..af02da7a 100644 --- a/tests/end-to-end/hooks/credential/test_keepassxc.py +++ b/tests/end-to-end/hooks/credential/test_keepassxc.py @@ -39,7 +39,7 @@ def test_keepassxc_password(): original_working_directory = os.getcwd() os.chdir(temporary_directory) - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/credential/test_systemd.py b/tests/end-to-end/hooks/credential/test_systemd.py index 375eb476..9710cdd0 100644 --- a/tests/end-to-end/hooks/credential/test_systemd.py +++ b/tests/end-to-end/hooks/credential/test_systemd.py @@ -38,7 +38,7 @@ def test_systemd_credential(): original_working_directory = os.getcwd() os.chdir(temporary_directory) - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/data_source/test_btrfs.py b/tests/end-to-end/hooks/data_source/test_btrfs.py index 15d8ec97..38773cde 100644 --- a/tests/end-to-end/hooks/data_source/test_btrfs.py +++ b/tests/end-to-end/hooks/data_source/test_btrfs.py @@ -35,7 +35,7 @@ def test_btrfs_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/data_source/test_database.py b/tests/end-to-end/hooks/data_source/test_database.py index aed8e0a7..32214198 100644 --- a/tests/end-to-end/hooks/data_source/test_database.py +++ b/tests/end-to-end/hooks/data_source/test_database.py @@ -443,7 +443,7 @@ def test_database_dump_and_restore(): original_working_directory = os.getcwd() - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') config = write_configuration( temporary_directory, @@ -501,7 +501,7 @@ def test_database_dump_and_restore_with_restore_cli_flags(): original_working_directory = os.getcwd() - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') config = write_simple_custom_restore_configuration( temporary_directory, @@ -582,7 +582,7 @@ def test_database_dump_and_restore_with_restore_configuration_options(): original_working_directory = os.getcwd() - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') config = write_custom_restore_configuration( temporary_directory, @@ -641,7 +641,7 @@ def test_database_dump_and_restore_with_directory_format(): original_working_directory = os.getcwd() - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') config = write_configuration( temporary_directory, @@ -737,7 +737,7 @@ def test_database_dump_and_restore_containers(): os.environ['PATH'] = f'/app/tests/end-to-end/commands:{original_path}' - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') config = write_container_configuration( temporary_directory, diff --git a/tests/end-to-end/hooks/data_source/test_lvm.py b/tests/end-to-end/hooks/data_source/test_lvm.py index 5d30f869..dd9895c9 100644 --- a/tests/end-to-end/hooks/data_source/test_lvm.py +++ b/tests/end-to-end/hooks/data_source/test_lvm.py @@ -41,7 +41,7 @@ def test_lvm_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/data_source/test_zfs.py b/tests/end-to-end/hooks/data_source/test_zfs.py index 8f750e62..186f5575 100644 --- a/tests/end-to-end/hooks/data_source/test_zfs.py +++ b/tests/end-to-end/hooks/data_source/test_zfs.py @@ -37,7 +37,7 @@ def test_zfs_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/monitoring/test_monitoring.py b/tests/end-to-end/hooks/monitoring/test_monitoring.py index ec5b7b5c..7b40d836 100644 --- a/tests/end-to-end/hooks/monitoring/test_monitoring.py +++ b/tests/end-to-end/hooks/monitoring/test_monitoring.py @@ -129,7 +129,7 @@ def test_borgmatic_command(monitoring_hook_configuration, expected_request_count os.mkdir(extract_path) os.chdir(extract_path) - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path, monitoring_hook_configuration) diff --git a/tests/end-to-end/test_borgmatic.py b/tests/end-to-end/test_borgmatic.py index 766c39a9..51d87ae6 100644 --- a/tests/end-to-end/test_borgmatic.py +++ b/tests/end-to-end/test_borgmatic.py @@ -76,7 +76,7 @@ def test_borgmatic_command(generate_configuration): os.mkdir(extract_path) os.chdir(extract_path) - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/test_environment_variable.py b/tests/end-to-end/test_environment_variable.py index 83d87e8e..f91356d2 100644 --- a/tests/end-to-end/test_environment_variable.py +++ b/tests/end-to-end/test_environment_variable.py @@ -44,7 +44,7 @@ def test_borgmatic_command(): os.chdir(extract_path) environment = dict(os.environ, PASSPHRASE='test') - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/test_passcommand.py b/tests/end-to-end/test_passcommand.py index d650d82d..aeb61176 100644 --- a/tests/end-to-end/test_passcommand.py +++ b/tests/end-to-end/test_passcommand.py @@ -40,7 +40,7 @@ def test_borgmatic_command(): os.mkdir(extract_path) os.chdir(extract_path) - try: + try: # noqa: PLW0717 config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/integration/actions/browse/test_app.py b/tests/integration/actions/browse/test_app.py index 8e620eac..a1ba36e5 100644 --- a/tests/integration/actions/browse/test_app.py +++ b/tests/integration/actions/browse/test_app.py @@ -1,11 +1,10 @@ +from flexmock import flexmock + import borgmatic.actions.browse.app import borgmatic.actions.browse.configuration_files_list import borgmatic.actions.browse.logs import borgmatic.actions.browse.repositories_list -import pytest -from flexmock import flexmock - async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): app = borgmatic.actions.browse.app.Browse_app( @@ -16,9 +15,8 @@ async def test_browse_app_with_multiple_configs_uses_configuration_files_list(): ) flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') - async with app.run_test() as pilot: - header = app.query_one(selector='Header') - header.name == 'borgmatic browse' + async with app.run_test(): + app.query_one(selector='Header') carousel = app.query_one(selector='Carousel') assert len(carousel.panels) == 1 @@ -40,9 +38,8 @@ async def test_browse_app_with_one_config_uses_repositories_list(): ) flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') - async with app.run_test() as pilot: - header = app.query_one(selector='Header') - header.name == 'borgmatic browse' + async with app.run_test(): + app.query_one(selector='Header') carousel = app.query_one(selector='Carousel') assert len(carousel.panels) == 1 diff --git a/tests/integration/actions/browse/test_archives_list.py b/tests/integration/actions/browse/test_archives_list.py index ad28146f..d5839274 100644 --- a/tests/integration/actions/browse/test_archives_list.py +++ b/tests/integration/actions/browse/test_archives_list.py @@ -1,8 +1,8 @@ -from borgmatic.actions.browse import archives_list as module - -from flexmock import flexmock import textual.app import textual.widgets.option_list +from flexmock import flexmock + +from borgmatic.actions.browse import archives_list as module async def test_archives_list_on_mount_does_not_raise(): @@ -11,7 +11,7 @@ async def test_archives_list_on_mount_does_not_raise(): archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) flexmock(archives_list.archive_loaded).should_receive('subscribe') - async with textual.app.App().run_test() as pilot: + async with textual.app.App().run_test(): archives_list.on_mount() @@ -63,7 +63,7 @@ def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_m assert archives_list.highlighted_option_changed is False -def test_archives_list_on_option_list_option_highlighted_with_highlighted_zero_marks_it_unchanged(): +def test_archives_list_on_option_list_option_highlighted_with_existing_option_and_highlighted_zero_marks_it_unchanged(): flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator') archives_list = module.Archives_list(config=flexmock(), repository=flexmock()) diff --git a/tests/integration/actions/browse/test_carousel.py b/tests/integration/actions/browse/test_carousel.py index 438eeccc..2dfd2190 100644 --- a/tests/integration/actions/browse/test_carousel.py +++ b/tests/integration/actions/browse/test_carousel.py @@ -1,15 +1,13 @@ +import pytest +import textual.widgets.option_list +from flexmock import flexmock + import borgmatic.actions.browse.app import borgmatic.actions.browse.archive import borgmatic.actions.browse.carousel import borgmatic.actions.browse.loading import borgmatic.actions.browse.logs import borgmatic.actions.browse.workers - -import pytest -import pytest_asyncio -from flexmock import flexmock - -import textual.widgets.option_list from borgmatic.actions.browse import carousel as module @@ -392,7 +390,7 @@ async def test_carousel_next_action_and_previous_action_and_next_action_reuses_n assert app.focused == carousel.panels[1] -async def test_carousel_next_action_with_no_next_panel_does_not_advance(): +async def test_carousel_next_action_with_multiple_configs_and_no_next_panel_does_not_advance(): app = borgmatic.actions.browse.app.Browse_app( configs={ 'test1.yaml': {'repositories': [{'path': 'test1.borg'}]}, @@ -400,7 +398,7 @@ async def test_carousel_next_action_with_no_next_panel_does_not_advance(): } ) flexmock(borgmatic.actions.browse.logs).should_receive('log_to_widget') - flexmock(borgmatic.actions.browse.carousel).should_receive('make_next_panel').and_return(None) + flexmock(module).should_receive('make_next_panel').and_return(None) async with app.run_test() as pilot: await pilot.press('enter') diff --git a/tests/integration/actions/browse/test_configuration_files_list.py b/tests/integration/actions/browse/test_configuration_files_list.py index 1b657bc4..204cef5f 100644 --- a/tests/integration/actions/browse/test_configuration_files_list.py +++ b/tests/integration/actions/browse/test_configuration_files_list.py @@ -1,7 +1,7 @@ -from borgmatic.actions.browse import configuration_files_list as module - from flexmock import flexmock +from borgmatic.actions.browse import configuration_files_list as module + def test_configuration_files_list_adds_config_paths_as_options(): flexmock(module.os.path).should_receive('expanduser').and_return('/home/user') diff --git a/tests/integration/actions/browse/test_directory_list.py b/tests/integration/actions/browse/test_directory_list.py index 5b72b5af..103d0f4f 100644 --- a/tests/integration/actions/browse/test_directory_list.py +++ b/tests/integration/actions/browse/test_directory_list.py @@ -1,8 +1,8 @@ -from borgmatic.actions.browse import directory_list as module - -from flexmock import flexmock import textual.widgets import textual.widgets.option_list +from flexmock import flexmock + +from borgmatic.actions.browse import directory_list as module def test_add_archive_paths_with_only_duplicate_paths_bails(): diff --git a/tests/integration/actions/browse/test_file_preview.py b/tests/integration/actions/browse/test_file_preview.py index f35ff1b2..cebf1787 100644 --- a/tests/integration/actions/browse/test_file_preview.py +++ b/tests/integration/actions/browse/test_file_preview.py @@ -1,7 +1,7 @@ -from borgmatic.actions.browse import file_preview as module - -from flexmock import flexmock import textual.widgets.option_list +from flexmock import flexmock + +from borgmatic.actions.browse import file_preview as module def test_file_preview_does_not_raise(): @@ -20,26 +20,34 @@ async def test_file_preview_on_mount_does_not_raise(): ) flexmock(file_preview.file_preview_loaded).should_receive('subscribe') - async with textual.app.App().run_test() as pilot: + async with textual.app.App().run_test(): file_preview.on_mount() def test_file_preview_on_file_preview_loaded_with_none_file_contents_displays_error(): - flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator').and_return(flexmock(stop=lambda: None)) + flexmock(module.borgmatic.actions.browse.loading).should_receive( + 'add_inline_loading_indicator' + ).and_return(flexmock(stop=lambda: None)) file_preview = module.File_preview( config=flexmock(), repository=flexmock(), archive_name='archive', file_path='foo.txt' ) - flexmock(file_preview).should_receive('write').with_args('Cannot display a preview for this file').once() + flexmock(file_preview).should_receive('write').with_args( + 'Cannot display a preview for this file' + ).once() file_preview.on_file_preview_loaded(None) def test_file_preview_on_file_preview_loaded_with_file_contents_displays_contents(): - flexmock(module.borgmatic.actions.browse.loading).should_receive('add_inline_loading_indicator').and_return(flexmock(stop=lambda: None)) + flexmock(module.borgmatic.actions.browse.loading).should_receive( + 'add_inline_loading_indicator' + ).and_return(flexmock(stop=lambda: None)) file_preview = module.File_preview( config=flexmock(), repository=flexmock(), archive_name='archive', file_path='foo.txt' ) - flexmock(file_preview).should_receive('write').with_args('Cannot display a preview for this file').never() + flexmock(file_preview).should_receive('write').with_args( + 'Cannot display a preview for this file' + ).never() flexmock(file_preview).should_receive('write').once() file_preview.on_file_preview_loaded('hi') diff --git a/tests/integration/actions/browse/test_loading.py b/tests/integration/actions/browse/test_loading.py index 0d4d12d9..b7f6e12c 100644 --- a/tests/integration/actions/browse/test_loading.py +++ b/tests/integration/actions/browse/test_loading.py @@ -1,9 +1,9 @@ -from borgmatic.actions.browse import loading as module - -from flexmock import flexmock import pytest import textual.app import textual.widgets +from flexmock import flexmock + +from borgmatic.actions.browse import loading as module def test_update_inline_loading_indicator_with_option_list_adds_a_dot(): @@ -35,7 +35,7 @@ def test_update_inline_loading_indicator_with_option_list_and_missing_indicator_ async def test_update_inline_loading_indicator_with_rich_log_adds_a_dot(): - async with textual.app.App().run_test() as pilot: + async with textual.app.App().run_test(): widget = textual.widgets.RichLog() widget._size_known = True widget.write('HOLD.') @@ -46,7 +46,7 @@ async def test_update_inline_loading_indicator_with_rich_log_adds_a_dot(): async def test_update_inline_loading_indicator_with_rich_log_wraps_dots_beyond_three(): - async with textual.app.App().run_test() as pilot: + async with textual.app.App().run_test(): widget = textual.widgets.RichLog() widget._size_known = True widget.write('HOLD...') @@ -82,7 +82,7 @@ def test_add_inline_loading_indicator_with_option_list_adds_loading_indicator_op async def test_add_inline_loading_indicator_with_rich_log_writes_loading_indicator_text(): - async with textual.app.App().run_test() as pilot: + async with textual.app.App().run_test(): widget = textual.widgets.RichLog() widget._size_known = True flexmock(widget).should_receive('set_interval') diff --git a/tests/integration/actions/browse/test_logs.py b/tests/integration/actions/browse/test_logs.py index 7ccd3e59..6149cca5 100644 --- a/tests/integration/actions/browse/test_logs.py +++ b/tests/integration/actions/browse/test_logs.py @@ -1,9 +1,9 @@ import contextlib -from borgmatic.actions.browse import logs as module - from flexmock import flexmock +from borgmatic.actions.browse import logs as module + def test_log_to_widget_adds_our_handler_and_removes_default_handler(): default_handler = module.borgmatic.logger.Multi_stream_handler({}) diff --git a/tests/integration/actions/browse/test_repositories_list.py b/tests/integration/actions/browse/test_repositories_list.py index b0916554..7786c53d 100644 --- a/tests/integration/actions/browse/test_repositories_list.py +++ b/tests/integration/actions/browse/test_repositories_list.py @@ -1,7 +1,5 @@ from borgmatic.actions.browse import repositories_list as module -from flexmock import flexmock - def test_repositories_list_populates_options(): config = {'repositories': [{'path': 'test1.borg'}, {'path': 'test2.borg', 'label': 'two'}]} diff --git a/tests/unit/actions/browse/test_directory_list.py b/tests/unit/actions/browse/test_directory_list.py index 76f8a1f6..5767a2f4 100644 --- a/tests/unit/actions/browse/test_directory_list.py +++ b/tests/unit/actions/browse/test_directory_list.py @@ -1,7 +1,7 @@ -from borgmatic.actions.browse import directory_list as module - from flexmock import flexmock +from borgmatic.actions.browse import directory_list as module + def test_get_relative_archive_path_components_strips_off_current_directory(): assert module.get_relative_archive_path_components( diff --git a/tests/unit/actions/browse/test_logs.py b/tests/unit/actions/browse/test_logs.py index b59526f7..583b81a9 100644 --- a/tests/unit/actions/browse/test_logs.py +++ b/tests/unit/actions/browse/test_logs.py @@ -1,7 +1,7 @@ -from borgmatic.actions.browse import logs as module - from flexmock import flexmock +from borgmatic.actions.browse import logs as module + def test_rich_color_formatter_format_colors_log_record_based_on_level(): formatted = module.Rich_color_formatter().format( diff --git a/tests/unit/actions/browse/test_run.py b/tests/unit/actions/browse/test_run.py index 0febc13f..6b451273 100644 --- a/tests/unit/actions/browse/test_run.py +++ b/tests/unit/actions/browse/test_run.py @@ -1,7 +1,7 @@ from flexmock import flexmock -from borgmatic.actions.browse import run as module import borgmatic.actions.browse.app +from borgmatic.actions.browse import run as module def test_run_browse_without_configs_bails(): diff --git a/tests/unit/borg/test_diff.py b/tests/unit/borg/test_diff.py index c8b3663f..70d3a7db 100644 --- a/tests/unit/borg/test_diff.py +++ b/tests/unit/borg/test_diff.py @@ -3,6 +3,7 @@ import logging from flexmock import flexmock from borgmatic.borg import diff as module + from ..test_verbosity import insert_logging_mock LOGGING_ANSWER = flexmock() From 1670ba2aeba7b853c0f15fb4b139dcaaf2297362 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 1 Jun 2026 11:57:49 -0700 Subject: [PATCH 58/63] Add missing tests. --- .../actions/browse/test_workers.py | 19 +++ tests/unit/actions/browse/test_workers.py | 126 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/integration/actions/browse/test_workers.py create mode 100644 tests/unit/actions/browse/test_workers.py diff --git a/tests/integration/actions/browse/test_workers.py b/tests/integration/actions/browse/test_workers.py new file mode 100644 index 00000000..22f328b1 --- /dev/null +++ b/tests/integration/actions/browse/test_workers.py @@ -0,0 +1,19 @@ +from flexmock import flexmock + +from borgmatic.actions.browse import workers as module + + +def test_archive_path_loaded_publish_records_complete(): + signal = module.Archive_path_loaded(owner=flexmock(), name='Bob') + signal.publish(module.LOADING_DONE) + + assert signal.complete + + +def test_archive_path_loaded_publish_records_published_path(): + archive_path = module.borgmatic.actions.browse.archive.Archive_path('-', 'foo/bar.txt', '') + signal = module.Archive_path_loaded(owner=flexmock(), name='Bob') + signal.publish(archive_path) + + assert signal.path_hierarchy == {'foo': {'bar.txt': archive_path}} + assert not signal.complete diff --git a/tests/unit/actions/browse/test_workers.py b/tests/unit/actions/browse/test_workers.py new file mode 100644 index 00000000..cf02a14f --- /dev/null +++ b/tests/unit/actions/browse/test_workers.py @@ -0,0 +1,126 @@ +import pytest +from flexmock import flexmock + +from borgmatic.actions.browse import workers as module + + +def test_add_repository_archives_publishes_for_each_archive(): + flexmock(module.borgmatic.actions.browse.archive).should_receive( + 'get_repository_archives' + ).and_return({'archives': [{'archive': 'foo'}, {'archive': 'bar'}]}) + archive_loaded = flexmock() + archive_loaded.should_receive('publish').with_args('bar').once() # Reversed order. + archive_loaded.should_receive('publish').with_args('foo').once() + archive_loaded.should_receive('publish').with_args(module.LOADING_DONE).once() + + module.add_repository_archives.__wrapped__( + browse_app=flexmock(), + archive_loaded=archive_loaded, + config=flexmock(), + repository=flexmock(), + ) + + +def test_record_path_sets_file_path_in_filesystem_hierarchy(): + archive_path = flexmock(path_type='-', file_path='foo/bar/baz.txt') + quux_path = flexmock() + hierarchy = {'foo': {'bar': {'quux.txt': quux_path}, 'empty': {}}} + + module.record_path( + archive_path=archive_path, hierarchy=hierarchy, path_components=('foo', 'bar', 'baz.txt') + ) + + assert hierarchy == { + 'foo': {'bar': {'quux.txt': quux_path, 'baz.txt': archive_path}, 'empty': {}} + } + + +def test_record_path_sets_directory_path_in_filesystem_hierarchy(): + archive_path = flexmock(path_type='d', file_path='foo/bar/baz') + quux_path = flexmock() + hierarchy = {'foo': {'bar': {'quux.txt': quux_path}, 'empty': {}}} + + module.record_path( + archive_path=archive_path, hierarchy=hierarchy, path_components=('foo', 'bar', 'baz') + ) + + assert hierarchy == {'foo': {'bar': {'quux.txt': quux_path, 'baz': {}}, 'empty': {}}} + + +def test_get_paths_lists_directory_contents(): + baz_path = module.borgmatic.actions.browse.archive.Archive_path('-', 'foo/bar/baz.txt', '') + hierarchy = {'foo': {'bar': {'baz.txt': baz_path, 'other': {}}}, 'nope': {}} + + assert tuple(module.get_paths(hierarchy, ('foo', 'bar'))) == ( + baz_path, + module.borgmatic.actions.browse.archive.Archive_path('d', 'foo/bar/other', ''), + ) + + +def test_get_paths_with_full_path_components_lists_directory_contents(): + baz_path = module.borgmatic.actions.browse.archive.Archive_path('-', 'etc/foo/bar/baz.txt', '') + hierarchy = {'foo': {'bar': {'baz.txt': baz_path, 'other': {}}}, 'nope': {}} + + assert tuple(module.get_paths(hierarchy, ('foo', 'bar'), ('etc', 'foo', 'bar'))) == ( + baz_path, + module.borgmatic.actions.browse.archive.Archive_path('d', 'etc/foo/bar/other', ''), + ) + + +def test_get_paths_lists_empty_directory_contents(): + baz_path = module.borgmatic.actions.browse.archive.Archive_path('-', 'etc/foo/bar/baz.txt', '') + hierarchy = {'foo': {'bar': {'baz.txt': baz_path, 'other': {}}}, 'nope': {}} + + assert ( + tuple(module.get_paths(hierarchy, ('foo', 'bar', 'other'), ('etc', 'foo', 'bar', 'other'))) + == () + ) + + +def test_get_paths_with_unknown_file_raises(): + hierarchy = {'foo': {'other': {}}} + + with pytest.raises(ValueError): + tuple(module.get_paths(hierarchy, ('foo', 'bar.txt'))) + + +def test_get_paths_with_unknown_directory_raises(): + hierarchy = {'foo': {'other': {}}} + + with pytest.raises(ValueError): + tuple(module.get_paths(hierarchy, ('foo', 'bar', 'baz'))) + + +def test_load_archive_paths_publishes_for_each_archive_path(): + flexmock(module.borgmatic.actions.browse.archive).should_receive('get_archive_paths').and_yield( + 'foo.txt', 'bar.txt' + ) + path_loaded = flexmock() + path_loaded.should_receive('publish').with_args('foo.txt').once() + path_loaded.should_receive('publish').with_args('bar.txt').once() + path_loaded.should_receive('publish').with_args(module.LOADING_DONE).once() + + module.load_archive_paths.__wrapped__( + browse_app=flexmock(), + path_loaded=path_loaded, + config=flexmock(), + repository=flexmock(), + archive_name='archive', + ) + + +def test_load_file_preview_publishes_file_contents(): + flexmock(module.borgmatic.actions.browse.archive).should_receive( + 'get_archive_file_content' + ).and_return('hi') + file_preview_loaded = flexmock() + file_preview_loaded.should_receive('publish').with_args('hi').once() + + module.load_file_preview.__wrapped__( + browse_app=flexmock(), + file_preview_loaded=file_preview_loaded, + config=flexmock(), + repository=flexmock(), + archive_name='archive', + file_path='foo/bar/baz.txt', + ) From 16c8f81a5ad5aa4f7f89669e2062e390be0df667 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 1 Jun 2026 12:24:57 -0700 Subject: [PATCH 59/63] Attempted CI test fixes. Also spelling. --- borgmatic/actions/browse/directory_list.py | 10 +++++----- borgmatic/actions/browse/logs.py | 2 +- borgmatic/actions/browse/repositories_list.py | 4 ++-- borgmatic/borg/borg.py | 2 +- borgmatic/commands/borgmatic.py | 4 ++-- borgmatic/logger.py | 2 +- pyproject.toml | 3 +++ tests/end-to-end/hooks/credential/test_container.py | 2 +- tests/end-to-end/hooks/credential/test_file.py | 2 +- tests/end-to-end/hooks/credential/test_keepassxc.py | 2 +- tests/end-to-end/hooks/credential/test_systemd.py | 2 +- tests/end-to-end/hooks/data_source/test_btrfs.py | 2 +- tests/end-to-end/hooks/data_source/test_database.py | 10 +++++----- tests/end-to-end/hooks/data_source/test_lvm.py | 2 +- tests/end-to-end/hooks/data_source/test_zfs.py | 2 +- tests/end-to-end/hooks/monitoring/test_monitoring.py | 2 +- tests/end-to-end/test_borgmatic.py | 2 +- tests/end-to-end/test_environment_variable.py | 2 +- tests/end-to-end/test_passcommand.py | 2 +- 19 files changed, 31 insertions(+), 28 deletions(-) diff --git a/borgmatic/actions/browse/directory_list.py b/borgmatic/actions/browse/directory_list.py index 1cfe36ec..ff331e27 100644 --- a/borgmatic/actions/browse/directory_list.py +++ b/borgmatic/actions/browse/directory_list.py @@ -65,7 +65,7 @@ def add_archive_paths( sequence of ArchivePath instances, add the paths to the directory list as options, sorting and deduplicating the resulting directory list's options. - After all of this reshuffling, make sure the orignal highlighted option remains highlighted. + After all of this reshuffling, make sure the original highlighted option remains highlighted. ''' highlighted_option = directory_list.highlighted_option original_options_count = len(directory_list.options) @@ -154,10 +154,10 @@ class Directory_list(textual.widgets.OptionList): def on_mount(self): ''' - When this widget gets mounted in the DOM, subcribe to path loaded events so that we can find - out about relevant archive paths as they load. And if this is a root directory list, start - loading paths from the archive. If this is a non-root directory list, add any already loaded - archive paths to this widget as options. + When this widget gets mounted in the DOM, subscribe to path loaded events so that we can + find out about relevant archive paths as they load. And if this is a root directory list, + start loading paths from the archive. If this is a non-root directory list, add any already + loaded archive paths to this widget as options. Loading is started *after* subscribing to path loaded signals so that there's not a gap where we might miss out on any paths. diff --git a/borgmatic/actions/browse/logs.py b/borgmatic/actions/browse/logs.py index 40f4ae6d..c01d55c0 100644 --- a/borgmatic/actions/browse/logs.py +++ b/borgmatic/actions/browse/logs.py @@ -25,7 +25,7 @@ class Rich_color_formatter(logging.Formatter): def format(self, record): ''' - Given a log record, format it with Rich-compatibe color markup correponding to its log + Given a log record, format it with Rich-compatibe color markup corresponding to its log level. ''' borgmatic.logger.add_custom_log_levels() diff --git a/borgmatic/actions/browse/repositories_list.py b/borgmatic/actions/browse/repositories_list.py index 57ddc803..1caebc13 100644 --- a/borgmatic/actions/browse/repositories_list.py +++ b/borgmatic/actions/browse/repositories_list.py @@ -6,8 +6,8 @@ import borgmatic.actions.browse.bindings class Repositories_list(textual.widgets.OptionList): ''' A widget for selecting a single Borg repository from among the repositories in a borgmatic - configuratin file. The item selection event is handled in a Carousel instance, the parent widget - of a Repositories_list. + configuration file. The item selection event is handled in a Carousel instance, the parent + widget of a Repositories_list. ''' BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS diff --git a/borgmatic/borg/borg.py b/borgmatic/borg/borg.py index dd46f687..20af8e11 100644 --- a/borgmatic/borg/borg.py +++ b/borgmatic/borg/borg.py @@ -31,7 +31,7 @@ def run_arbitrary_borg( borgmatic.logger.add_custom_log_levels() lock_wait = config.get('lock_wait', None) - try: # noqa: PLW0717 + try: options = options[1:] if options[0] == '--' else options # Borg commands like "key" have a sub-command ("export", etc.) that must follow it. diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 1d4837c9..f2a3b613 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -212,7 +212,7 @@ def run_configuration(config_filename, config, config_paths, arguments): # noqa f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions", ) - try: # noqa: PLR1702, PLW0717 + try: # noqa: PLR1702 with ( Monitoring_hooks(config_filename, config, arguments, global_arguments), borgmatic.hooks.command.Before_after_hooks( @@ -823,7 +823,7 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par ''' add_custom_log_levels() - try: # noqa: PLW0717 + try: if 'bootstrap' in arguments: try: local_borg_version = borg_version.local_borg_version( diff --git a/borgmatic/logger.py b/borgmatic/logger.py index 28e5f423..09414615 100644 --- a/borgmatic/logger.py +++ b/borgmatic/logger.py @@ -109,7 +109,7 @@ class JournaldHandler(logging.Handler): def emit(self, record): sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) - try: # noqa: PLW0717 + try: message_parts = [] entry = dict( MESSAGE=record.getMessage(), diff --git a/pyproject.toml b/pyproject.toml index 62e6a48d..f0748c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,5 +134,8 @@ known-first-party = ["borgmatic"] "T201", # print statement ] +[tool.ruff.lint.pylint] +max-statements-in-try = 35 + [tool.codespell] skip = ".git,.tox,build" diff --git a/tests/end-to-end/hooks/credential/test_container.py b/tests/end-to-end/hooks/credential/test_container.py index ea12fb21..6279f522 100644 --- a/tests/end-to-end/hooks/credential/test_container.py +++ b/tests/end-to-end/hooks/credential/test_container.py @@ -40,7 +40,7 @@ def test_container_secret(): original_working_directory = os.getcwd() os.chdir(temporary_directory) - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path, secrets_directory=temporary_directory) diff --git a/tests/end-to-end/hooks/credential/test_file.py b/tests/end-to-end/hooks/credential/test_file.py index 6aa80f98..0ae9b37a 100644 --- a/tests/end-to-end/hooks/credential/test_file.py +++ b/tests/end-to-end/hooks/credential/test_file.py @@ -40,7 +40,7 @@ def test_file_credential(): original_working_directory = os.getcwd() os.chdir(temporary_directory) - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') credential_path = os.path.join(temporary_directory, 'mycredential') generate_configuration(config_path, repository_path, credential_path) diff --git a/tests/end-to-end/hooks/credential/test_keepassxc.py b/tests/end-to-end/hooks/credential/test_keepassxc.py index af02da7a..e990458f 100644 --- a/tests/end-to-end/hooks/credential/test_keepassxc.py +++ b/tests/end-to-end/hooks/credential/test_keepassxc.py @@ -39,7 +39,7 @@ def test_keepassxc_password(): original_working_directory = os.getcwd() os.chdir(temporary_directory) - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/credential/test_systemd.py b/tests/end-to-end/hooks/credential/test_systemd.py index 9710cdd0..375eb476 100644 --- a/tests/end-to-end/hooks/credential/test_systemd.py +++ b/tests/end-to-end/hooks/credential/test_systemd.py @@ -38,7 +38,7 @@ def test_systemd_credential(): original_working_directory = os.getcwd() os.chdir(temporary_directory) - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/data_source/test_btrfs.py b/tests/end-to-end/hooks/data_source/test_btrfs.py index 38773cde..15d8ec97 100644 --- a/tests/end-to-end/hooks/data_source/test_btrfs.py +++ b/tests/end-to-end/hooks/data_source/test_btrfs.py @@ -35,7 +35,7 @@ def test_btrfs_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/data_source/test_database.py b/tests/end-to-end/hooks/data_source/test_database.py index 32214198..aed8e0a7 100644 --- a/tests/end-to-end/hooks/data_source/test_database.py +++ b/tests/end-to-end/hooks/data_source/test_database.py @@ -443,7 +443,7 @@ def test_database_dump_and_restore(): original_working_directory = os.getcwd() - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_configuration( temporary_directory, @@ -501,7 +501,7 @@ def test_database_dump_and_restore_with_restore_cli_flags(): original_working_directory = os.getcwd() - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_simple_custom_restore_configuration( temporary_directory, @@ -582,7 +582,7 @@ def test_database_dump_and_restore_with_restore_configuration_options(): original_working_directory = os.getcwd() - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_custom_restore_configuration( temporary_directory, @@ -641,7 +641,7 @@ def test_database_dump_and_restore_with_directory_format(): original_working_directory = os.getcwd() - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_configuration( temporary_directory, @@ -737,7 +737,7 @@ def test_database_dump_and_restore_containers(): os.environ['PATH'] = f'/app/tests/end-to-end/commands:{original_path}' - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_container_configuration( temporary_directory, diff --git a/tests/end-to-end/hooks/data_source/test_lvm.py b/tests/end-to-end/hooks/data_source/test_lvm.py index dd9895c9..5d30f869 100644 --- a/tests/end-to-end/hooks/data_source/test_lvm.py +++ b/tests/end-to-end/hooks/data_source/test_lvm.py @@ -41,7 +41,7 @@ def test_lvm_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/data_source/test_zfs.py b/tests/end-to-end/hooks/data_source/test_zfs.py index 186f5575..8f750e62 100644 --- a/tests/end-to-end/hooks/data_source/test_zfs.py +++ b/tests/end-to-end/hooks/data_source/test_zfs.py @@ -37,7 +37,7 @@ def test_zfs_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/hooks/monitoring/test_monitoring.py b/tests/end-to-end/hooks/monitoring/test_monitoring.py index 7b40d836..ec5b7b5c 100644 --- a/tests/end-to-end/hooks/monitoring/test_monitoring.py +++ b/tests/end-to-end/hooks/monitoring/test_monitoring.py @@ -129,7 +129,7 @@ def test_borgmatic_command(monitoring_hook_configuration, expected_request_count os.mkdir(extract_path) os.chdir(extract_path) - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path, monitoring_hook_configuration) diff --git a/tests/end-to-end/test_borgmatic.py b/tests/end-to-end/test_borgmatic.py index 51d87ae6..766c39a9 100644 --- a/tests/end-to-end/test_borgmatic.py +++ b/tests/end-to-end/test_borgmatic.py @@ -76,7 +76,7 @@ def test_borgmatic_command(generate_configuration): os.mkdir(extract_path) os.chdir(extract_path) - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/test_environment_variable.py b/tests/end-to-end/test_environment_variable.py index f91356d2..83d87e8e 100644 --- a/tests/end-to-end/test_environment_variable.py +++ b/tests/end-to-end/test_environment_variable.py @@ -44,7 +44,7 @@ def test_borgmatic_command(): os.chdir(extract_path) environment = dict(os.environ, PASSPHRASE='test') - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) diff --git a/tests/end-to-end/test_passcommand.py b/tests/end-to-end/test_passcommand.py index aeb61176..d650d82d 100644 --- a/tests/end-to-end/test_passcommand.py +++ b/tests/end-to-end/test_passcommand.py @@ -40,7 +40,7 @@ def test_borgmatic_command(): os.mkdir(extract_path) os.chdir(extract_path) - try: # noqa: PLW0717 + try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) From 2a026a484222d230117d5451089eb34870d0e3b9 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 1 Jun 2026 12:42:02 -0700 Subject: [PATCH 60/63] Stop using Ruff "preview" rules, as CI has an older version of Ruff. --- borgmatic/commands/arguments.py | 6 +++--- borgmatic/commands/borgmatic.py | 2 +- borgmatic/logger.py | 2 +- pyproject.toml | 5 +---- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 8c9419a1..25c49f7a 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -325,7 +325,7 @@ def make_argument_description(schema, flag_name): ' To specify a different list element, replace the "[0]" with another array index ("[1]", "[2]", etc.).', ) - if example and schema_type in ('array', 'object'): # noqa: PLR6201 + if example and schema_type in ('array', 'object'): example_buffer = io.StringIO() yaml = ruamel.yaml.YAML(typ='safe') yaml.default_flow_style = True @@ -2128,7 +2128,7 @@ def parse_arguments(schema, *unparsed_arguments): ) if ( - ('list' in arguments and 'repo-info' in arguments and arguments['list'].json) # noqa: PLR0916 + ('list' in arguments and 'repo-info' in arguments and arguments['list'].json) or ('list' in arguments and 'info' in arguments and arguments['list'].json) or ('repo-info' in arguments and 'info' in arguments and arguments['repo-info'].json) ): @@ -2146,7 +2146,7 @@ def parse_arguments(schema, *unparsed_arguments): 'With the repo-list action, only one of --prefix or --match-archives flags can be used.', ) - if 'info' in arguments and ( # noqa: PLR0916 + if 'info' in arguments and ( (arguments['info'].archive and arguments['info'].prefix) or (arguments['info'].archive and arguments['info'].match_archives) or (arguments['info'].prefix and arguments['info'].match_archives) diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index f2a3b613..2298b239 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -212,7 +212,7 @@ def run_configuration(config_filename, config, config_paths, arguments): # noqa f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions", ) - try: # noqa: PLR1702 + try: with ( Monitoring_hooks(config_filename, config, arguments, global_arguments), borgmatic.hooks.command.Before_after_hooks( diff --git a/borgmatic/logger.py b/borgmatic/logger.py index 09414615..fad810df 100644 --- a/borgmatic/logger.py +++ b/borgmatic/logger.py @@ -169,7 +169,7 @@ class Json_formatter(logging.Formatter): def __init__(self, fmt='{message}', *args, style='{', **kwargs): super().__init__(*args, fmt=fmt, style=style, **kwargs) - def format(self, record): # noqa: PLR6301 + def format(self, record): return log_record_to_json(record) diff --git a/pyproject.toml b/pyproject.toml index f0748c4d..77ba0475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ exclude = ["*.*/*"] quote-style = "preserve" [tool.ruff.lint] -preview = true +preview = false extend-select = [ "A", # flake8-builtins: builtin shadowing "B", # flake8-bugbear: bugs and design problems @@ -134,8 +134,5 @@ known-first-party = ["borgmatic"] "T201", # print statement ] -[tool.ruff.lint.pylint] -max-statements-in-try = 35 - [tool.codespell] skip = ".git,.tox,build" From 23a8d665d65277aea527dd9ad923a23bd53348ef Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 1 Jun 2026 12:44:32 -0700 Subject: [PATCH 61/63] Add a screenshot to the browse docs. --- docs/how-to/inspect-your-backups.md | 3 +++ docs/static/browse.png | Bin 0 -> 32878 bytes 2 files changed, 3 insertions(+) create mode 100644 docs/static/browse.png diff --git a/docs/how-to/inspect-your-backups.md b/docs/how-to/inspect-your-backups.md index 9d1a20fa..662f0e20 100644 --- a/docs/how-to/inspect-your-backups.md +++ b/docs/how-to/inspect-your-backups.md @@ -142,6 +142,9 @@ documentation for information on output format, what is compared, and more. New in version 2.1.6 Experimental feature borgmatic has an experimental console UI for browsing your repositories, archives, and files. +Here's what it looks like: + +borgmatic browse screenshot This feature is not intended to be a general-purpose Borg UI with every borgmatic feature, but rather it's for use cases like quickly looking at the diff --git a/docs/static/browse.png b/docs/static/browse.png new file mode 100644 index 0000000000000000000000000000000000000000..966814416c685ce0d6a017cea907de3c2afbd2ba GIT binary patch literal 32878 zcmYIw1yCJLu=V2Z?vfB3f)fZ1ArRbMg1fsD+}&M+yE_DTcPF0l;dPa$mHy=S5Zh$rGGR;#+g=j6Vi9b7IF+`|wo~%V6V4}F z%568c%TfS!U7BJ;-}Kr*cENn<^&0y9QENGBAK^)DxQfT?j4NjL%7)HOGGCvny9s(ABT(esY3=opq{4u2+Pe4 zJ@74|v{F96V6dWS`6+8 z76OZy3t4;j@vV!%QB2KI#Ky|X$l4JQu{Y9pG%_T2F>^E}7nhKhQ}si|0|0VBLR3h} zb>(E;#S>eM2=0%GvrSdg^#+mzJhbq+vY==|_)-mhn7{T(hXGz&-lf0N;fzHL;aBp6 z>y2~!7OnMlw^wdVNa*Hpgq+VHG@th_4!mt_E?Z3wb#;bgbyomI0J<x(@vfr^!_-+_BZ z!q4r-(nRxh2K74m1=vSpNqC(a?JrXZmJ)yOq&*9vF(TpYbC)McVNo-6;a@8;>HPtI z+h~3)Dry{SxG{lHQ&~B!9(HeNR)b79}>T znU2}~(y{}L{PXR@q4Jmbg=A59*my?6(UvKc&2emoeLkpR{N%lm~M z*7jDBcmWPYwnIi*D|gHHe?)MfTbmh0f~cIw@Mw-W*bC`X*7vc(S0g4iWAGhkY~%``itM!6hKGHPyF*Tb9%V}8E za?isoJ1*3N%kkO@IN9G%rt{HK1mN*27trYD;eso%1 zfa-L=31+i#yA2mpcPq>m@Ct5{;qW;<%hv6brAW3GaNOSD>QWslAQND*k~N-?Epujf z5{j(plqS@F%Ec0>+W6#k(!DM&b2>Q!<*WJS`fT;n?cpSD-5gb8m z9v5m3ph}S}HS{Hib0Or>=o1i7tN&TdntWY4DAg{wUKh7HTl`c>yQ_=BG$RLWo)>nQ zoaEHMTW0obS(=}Fs=LJP;M?&-5J|S-HErN!QWq?6JfB`0BG(0mhG_XsQ0t{eoFY(D z+w@*Y@}G*0G9*WbiV|>D7iy7*nalDL9zD#iB+EuDj^+?k$!EeeW-I%OSK2XX3bPqk zra1VY$AdpeyMd7en8(LT-r-r_dz_xV+fQKEv|L3c#S`eppmDsBn$ z)zQVMnU_dp0=167S;WIkrJPx$GPq(jd5uinn)`?cGCC=56Jsy%TN!eKLWjzHBg%fh zB3IYmjG7ILTP@JvPgJTYTp&D9A zZ)Bxs9W!m4cd(WkX%t4Np4W}mB{j*!j{CucU$_2Gg}6@peo-}W{ybB9B}=WfPsFy7 z?E@aQIY_=7`n`oA)QOwEGgQ(${#55o%p?&H!fQ31Pzzy)@Sa#m7=TWJdXme^&$V{;23PYcf+{RQf5MWS ziD46bA)>%5dcZ-KWP~rgv?tpXyWFHB;BDJfq${A^yj4b({&}TWjF4_hRUBd>)kI6h z$`Ia8+M?6YFMQTQ`9rvLMa;ty`zNYIpqo`Hq-*iA0uG)+A@rBIi&kTkoGcS;pft9;tFBK2ELPOICc~?uLeP)$f$2L9{G3F9tyEG)lEgGm6aSUo6}Lq#}v6B zrT*M>w(3)VV#U%>AmPYOiiK21=&`Em!#CV!(JHnG@-WGk1W2pu6$rtS7%`ZK);JoV zxoA*}^J2>n$ceG9(TAuMJTF4?)}Fn-TB(0m<=ymoO1yZHCVa$#$q_O5FE(&|fOM2F zVuY#Yy}ynRR6V^wK1=$kN}v678I7}w+DeLm^oO6?v@x{2U18B4_cXVB&ouC%YHi#D4c@_ zowU-2iUSg`g&z`>h-$;>*Jw8>L$HD03d3&u!mz0ciX#OuyBtu8Si&zI)ToC#7UnT_ zajRA5ZH8CgvQ@(tHB@4;X=Av$nXP8jP+pGHJ>c=t`-3?fzR!o<7tiM^mL5C7I5)Vf zt87IX0#Lk5lgGy<=Z}}Eyy;zcf6W?D36J(A=M6<=gU_oz&DuO}AM)ar%VLSzeZ4D? z5NUgDWU8oG5d3pB)7&`@w>ZQSV~r3?0oDQJbhP&;lAnU3_7!6{_*-=)5``*aKrF4g zZqk|YcyBmh@q}MX-ol)}ug9>lmcd~(WP=)U1jxxLf233At5{KcJ-w`8D~@tK-Wq-j zRWYLlO%Vqq1Se0|CP><4nOe2J-(>7y3a@L@#qZtBXbbsyoQx_CCrN^(P!>+|!o0P& zK`(CeL}=24v&_4NZr=v4&ub4(34E*tmclH~H7q5Bu_c8)@NOeyEuU&hh0am6uxJF7TL> zis@U^*?B54o5KRL7~ucZF;)XEN^i`s1kb&<6_v<^>t@ju79Xa};{hk+i98kBmo z9PWr+>X+gYX72W?VkKHk&WNury>?`=6TJQ$uh{)id>e=ZiUf$W6|`v`TDQ+++DdqN zp5G!+xneiDOb06=3$g|@5Lzsw8~X12Br-Xm6hLu^?rIn%1@P&kAm<1 z?VM9^Z(MG)Ts^bp(e6r0N>o){L?-zBl_HsoIY^QF-|6U(Z*lwcNE5Ljo5c{A^r6?y<#lb&Y`$#znCf4 z#ANo$4N>#fTko{5Yv45KJHvwHBoT&xxqFEZhyQf`vw+D?XhU*9M>jD&)vdf&vPKL; z_!UO9OtFo(yBn{{^XVdS$@t9(5RcmhLSCdPd@n)VC_D8FnxZWlv<4?)W=NnH%XK@X zBGUkHK8+R|nN8lJSvB6<$Q#ssKO9YUh5h*eVUVBjBL?&HUA#07uaxp->8hVZ^LBDO+SSp_Vvzg2ktH`JnWCj zQ5a&B$%#(u_a}+j24KTPf;oVhHXJ?@fk;l3;*Tz(_NI}(_x@Ps(D%##@l?{V5 zI}22?G~voSes(oxD$(AW$oRO-VE*Rg4yVRn1iD?^1J{G)Zb=Aek>>=PpKC5h7n3-V z-S7Q)-J!W1Ts&`vpEDC9ujoP;atuK7KRm7v@JWu!woja32Jb4=n;maTijLl|pTBOV z`f0RC(e1y7)|+Cxg3FYsZ1Cy4Un(Z}3{+!ab>6#5o~W~BnZ4}Y6?xhZE%%a1tJ@w; z+5KpHFB%jC_w(1|Wjx$nxmKsOOK4=HEORrc9q*W7oA)fTg^h=Yv+HR})9m&tdc@=! zsf#a0spcfn!<%Oq3fO{F6yLBH0nIJx*#zn_(2fmHN`M;NUcfVTAPPaHtsjFCq$?ae zoSy|536Y{nG@2}z)5>`=Uj5M@(cnx!T({CL!Y!10@8)NtW|LasGU5cQcwK~z?T4>^ z*vM!-0sU86SQ6PcV&h@buP0Gj!Hk^)~$7v>?1kzKXkuQ$?S4?5YZn(x`LWZW`We#4=p~r{c??TL z1UujorJ=unm-ThZ`dyLparXW2n2U@bqB=6IH zIaKvYdLJjQ8!X#l|2jn;RYlKh$6BUFf(de%GgxnR%6dA|-6h7oJ_mWR|;FYW~zKwenpM~elhb&p9;%Jbh2KDZe##eNs3;DfrF zVX3|6COn2!RW)Yx)a2;)P8K{iKZ7%ToOf)mFZ~&KTuPz9*riaF_r0Et*?3V^=hERw zE#_i&p`O)1`ytucW$R65%Z`Z0m$SE6S#=u(dU+Y`^TNV>m+F-Y32+eeW;p#yYjZtZ z_)&-p0W|v_llnXyg12^xZgnq42Ivrh-kL%(zAkLvi|p%#L9`E-1U~Jrk7u|83H>A5 z*&Ur_huoAOx>(K|e8j0r=8P_~2aC4_<-m8NeGV7Ss3 zBPTGg91t@%v9K{H1BL0aQt*pmMgj)J%Ovzk4&I4yh|-MHR?7o=@4iGzPh-tCw#atW z>|8IRC}an0y)&R!+0QZ)lDHq2-4I}p!M1unDYTglv%>bhkx9K>$e8|d-sWWTSiE|~ zBK$4@4D(qx@P}zfLxh^9w%5jIlodJcc|gaVvBz2LKw!A>{7Th(UE8`C5F8&&dhTo$ zeIHf;)}Dt3d=W_8y=)k{Ym?g9UcWYMKGwx_IG!={9tn~P*lP_sBr_X7WAZw9fPBC| zbZ@a2kO0Bcprh8Je*fk_s2IfD(KajUyN@34R9-p`Q+^!8E96h;Fv&2yS{z2%Q|oGI zQTfMoSU*G&_NXW*Pyo|Mj#7%bli3fR;m)3}v|N5xG^iBC6A*=78u> zj`hw`HUU&A*!(O-y z4mU*t5(1K5ZWeWE6iFWyx$!CQ+_NyeIe$C-C`)w{KP^iLpMvWGe!W_U-G;0t9rK^W z#3{X6xv4|X8)P*x0QhoTmh8JE=Oc4lAQOxQ2x5x?wi)2SFhFoKLu%l#H%>8*=S#47 zz~j?H5HyP*cY};j_r|S3b{y((W?}&0UDdj#IP;t1UT(K`Rx#75jQj!}BsTO_NJ)!W zvtid%TS`kwnA~9sgRt_0u#8Zag{%6IcGKm-&=apy*WEfye(i0ci4iGpQkGrm&rY{f zOQk_ql3ZsN+ok$dSS}A?0#VvnSXei#?&GPq5Iqbq5+Z&hOvyqBJ-SIW@mU<`fB&8) zV7r$!qswoqLFi+81$Kdg%+ZLU1`ZVY!hEJz54jUzqzaJv#p4W}7G8`%eGdut1Nc`E zBUS!~f%VS@-@$kmQ7Lo7Tx%{4kUK-m9Su1gKF06)Ge1aoS=nd9SIDWxWM*orCB)h+ z1l)y^BhNBfY-5$El)WdPTTr@6F2h^1)ZyYCl~2%qj~a;-h8Sl!ICzk`OFdgwHV}P) zUY3y7+oz_Xt0rg1INGy{ZS$Wtyiys;jO+<7uGjhB*ME}mSG{e(H8AMPJ+Gw`npRyOW@xk2R&XkA&9BWEsBWCJAk_ZOh6 z_P&>bg`+tC6H0^QA`RQjpeV%M`)evP27brADr^e86h{jVOS%kTnBxn7YfkL2Z2th( z6XABf0HLwXu+)0{(}J*Et=VjS!###d*4@}VF3S!FsY`!=abG{N5vgqFO>3a*dALwXH-p--`GJc!Jniz5 zUhKoi#&7jaDITT1c=WP{i!8Y$?m?yqx*6J@kFxyJPT3Cw<-Ct#M`_v{!!+!4sDkYC zoCB8P1Fc5XC%=6DoKRbH&ysc@89YWD9Yw05QwD$;H9Y8T%$b*)j;_4Qs$no%o#l#I z-HzBmXuNhgzM+TsSUHA0p4-Wjsu||btXT_ZDZE^q2PMZmvYA)MLo@A&8@!*#bcf-U zJp|I*=s>e>c*4t}Ye7UF5w2Hd1y~RBxH<*E+@a}5s5!oCbAN=qm(rY(Va0_0hQF5<+i|92d;9k$J`IEodF;As=JFdx?m+Nd|aiA)f1Bm zb^0?*X=CZv!yPvWSrCZv;cSsVT|+ZVSO0rQZ$6ZAd44^owYq!jU|)6e35-kAj*baJ zzuN;U{9!_z=*a6(Wik0)#+Xag(Hy-Hr4sh<%&Zq7_GttJ`SqQwGnR_ZZ6#;@)M>;p z-}{M+T*OYttL?;b&Zhgmsy*3{;)Q~JUO;{68f96uE+pC>W!t=S^l34YSjGH6+kX84W5Pommf z&a5xt^EphPG)Bee@HV|;nucJRJ{s}Q$b>_+(W*NghWpHp!y6+Iz) zr#}ue_zfB`9U5lBR5bWxPRl^UuN{+y`tmsRqUnK3@VVK;-d@SRUPH3Nsqj;q52)UG zxG`hz>T|$IJz5sS8lU@hiZ?9+3yDaNF%9!lgXhHx$7+;;`xp3^Z!rPGlK^07wXKdk z3s2z!wzO>pP;}*2rzRBDT7K4?cR4w9$7W}}8X9aI`e{(cnu&NwdATjbd}J{=^RlV9 zyv{=koe`M&R!&#ZsCWYNMmZZx1h5sKba_2t#Fl6aFzYT~UWeInMcKW4JUtDZ8u>K! z<_`&+77}%X)&>TUi8J1Fojf`?Y^0i@CNwSL815<|2wDwjleaoQ#QU-G0es*x4cUQJUEn&WORrUhoE67X4Z<}xT z>x!Ozj#u3g3;5UkKUM=C>VGH+{G5M`3i=~g%aO`fjATexQY2b}Mkf1d!i{kMZ(@Yo z?eEWTl&42(?5l|rCa0^?rZ17>76zsa_W15XURPNYSjj|B>77r+DbCk4#4zfSPVe$O zLcFPej7Klvh4VEsfsbc~w>No~4j&jQbCls%-gcK9!0@Sfzmgq(vHu;iIR#L!P8Ox5 z5*%{jWw=^Uomv1*CVk9x8Q9>L?vd}YZO<+6qk+V^2wi*2$^ye)oYdVRfrutbP6^3B zC_nxYA`p+IEFXpDX{8pfEmD}d3<8p)(;{#8dp)@${V4!ZIuj+Gh=E}F68u8{*l)(S zb>~CscGn3d4f99WFN5^lN8HaV!vZcB*nsa>oXs4-pYbLB*%1KrJc1{^$2Hv>Zgg@Q zR}Dcj>xrHG$Z8ol1Wr#rRYOA<69V7litkagj!2Tp6+h;&6!SYsxUE>X#Wz}80GLdc z0lIhIrJLM=Ds*$ZoEa-}4JINYUKX8*wicjDxx#>}*uWyw_%DynXLJn}Bj9UAhq^s- z2$unnOZqnDY)<*HgTQ0=dsnrRHk6s9^BA@UkLb`eaEgk=F9RDxrj3PG8%?GS>rRV8b6^X9x&c~@mZfVobB4C{ zr76N$AE&!CnUw{b1CmxHWmBRUDo5zBU+vo7=1%JZRDE`mG$J2;wj_SDMNZVTE87a7 zQel+9`h|iD6T=khk8w`Jo7MyHy+_k%FjUWT($!Z^$br4Uvs?Q|G zOZQZ|$rChW`o2x_$rDKkdnymC@#tBMrV4Wmtc=MG+M-;r%rzBx&yp&Cjdn4(& zrcYhWjhq9lcTKS5s@Lu;kF8n6@4`?D8|k3-tz44KFz60EM7Qad zJ&Wr$Xtl1d&cs$6Le1@0Exl5_pD|%fBBINyd_v`xfL_V@V(mwThrDjXBix$88+%x+g@6ua*PMs70 zb^q7ji~jlNh{p}Da}%GBrWbsxV_n3M&xb!bSS)mE+rQU^2J#e(q-IuL%kMr8wKmzP zmMY{7WEz!8szK%)lX2%zyKE$xo*wy=sc?Zm%*tvIrv6fU^`_#jNNB87Xu7Ll1W}ln zviD^pA=@pGeR_XBmuz*ac@=lMK$_TE$6?N6T}hKb{L=8J%tZD>U((5-LS4BJ z`%!4w&J;NsS+G1!p09kI+?*@Bx_(QIgr>!En~02z6+31{cFXhcmnrJ5rFXE5pXC&j z(W=S0jUZYk@MtYs&RJEZxMhw?m z%f!+TK>`(Z1wOi2%Dabd5gt#@fpjpwK|2H24IIG>3t2-L2LkRMZR#a(PtH8ORB%#H zEwi&o+h1%_=2WSawAm89~dPNN{0wx(=Hd&_iI4nC24z}h#2#eLr-1H zHsuH*lS|nQ@9~jHpsRAw=`aj?N#Mf8mf%Ko1 zlb+;hTAtT~`|w_wst+k1&bKHv*QEKu(q3LVM`Ydf)tGeaI`>CD-?k@b zF*>W&S*1HVtNzaEEAKzoByZxpnJB-VbNO$V^myLp<;4Lp;R8Hq^d0DvZ--XYM678N z#*`oPGm;A(j=kjm7PPDTQL}jd*ud`{bo&^c*WSteIUR zACkT557Xh5zc!v50cxT`3~U+Iia7v~cAx&(g`s;6H)g(SNi6f8m+2DcGYzuYeG~S$ z7I|B2>GD1pzcR)zEt3>SnztAPyofnuSDBfzG@~;;aFPjpVbD@nT$%F>h@}I59#liF z4!4P$q(*#gyQSl8{o=R;%tc%aLsc@`L@FPv=g6dM`0U{IXn$WCh!L(BEK3@r9k00h zjlQB#5}pVRh!RdJHToQzbu-)h`tTqE$pU=ZI{ZywT--hgfIA%7!X%lL+kJxJqedO;tBcrN#hvMsK-HpIMg;B+IN5?Zt{RAe6BNjEo-_< z`#5hW)Az4mS2Qs#PIr-}61F5CCu%I-sRb`H@4z`Ndk-v6c?X-yg2d=`vAiGR$23t{epJ4# z>tuBHGGd0fXlAJsoRyfiy+)Ts(5C|MK0h>1E#Q|x`r2xfB2=y^)cg)_eJ0LAnZEWU zueS+%Z4M^vbZ?sAcK%_+w$HAeNm$mW1wT(W zA%Q1cEWR$sOEPy~Nv_&kIBQPmHgNLo<5VCc&!c6I4|OqYbbyvU{e;i`gcfC0R_;`a zb|v%R4k`T+B(4v+TJ`6*gZ9Fmn&l&s7--h8i8#vJCZ)6QGr;M})+-MZMr#IOA(iRn zTabIh_NqVt`ePN0sf-coiOTeiP#aTe!Q|DJX13Lp-2oD;slgn48>j!4RVoh-vbiCPW3Xp& zZTo{HRi0i69Q^lr9$};-6_Bcn`!sxmXbE7xshQ^LwN>F~+5`zOzA&1AtCD7ui!xcQ zy~Q!F6-^zPy#m_Ln&(eNY+e2t?n%hGYQ}l33F(G*IvSN|`5UBhS-s7l`_9O~cz7Tn z3N3p|mO)cJBWb$P1jkCVWM8w6{Ur665ScuU$(xIxi2e7C+D%5h1zJYbRGQG13xAbM z`RLOYR=j#ZrU2^(-*b=vW{j(DL|VqPTdeeT0lpf8*(8}jYH9elI9eE!hnk>X1C?*V zF-*vE`E?LB7GdbZR?&8xrs0icbZGn9cd6QV%6J^Y*>y#cWzADqH~LUOw}98iuEbjw zXKB$n{@YD{vnmRZes4_zAY6BXIXNu089!gNQG8r}%~mNp#dAM)*w9hA+Dk@6P0n1$ z&C4&9%PD6d-!9KGro{HfL=Us%@Tb zY_#)zGt%u!Q}|5@ogf{RAbVIdh0bvC>tP=hJTUD%M~!w7251daaq*44H=Xwv;o zNkjBEiK5fH>_3cYidLppoR_4y{8mZH$>Z%e{q5S+lVlTl{ZC`Jv5!N3g#D-nJBuUl z3%XP$r}AEIOwi1A+%1gY#*@<9e>|Eyjh0O^{2@<1a)i$C{IQ&$>GI!t99@5R;|MB1 z@aE#%A{$vo^^vdM6%412!#h{hxYS-a24z0Q1?=-k6CmQX(&i8)>f~{NB?`qm>DJoM z%h_nr8jJLdoO`?{JWU_~U*+nI;|;0^(F!;3~#$xX(OT>3w#zpOtbBqz6c_tmxVMm1#gSb3D8B`fUu#<09Itkh8A6B$^e z`O(_?l1?($SMljqm29MVCZT&83%gi#GtTzn4%Si5&q!B0EBG9j>mLFcpbmNSWXqix zW+NR!q0<>3q-O6Zx&#{&&(YC9PRXV9d>Ptiw#N$-@k6a$DStLKg(Rk>i;qMJ&UwFw zg~4dJEqOk!X!YoLDnOSMEixV%f2+ef-`c>iM@K5hah45(@KsngXw?mvJQR+IHr^b? zSofFc%L?nDwtXg`2w1^6{s{-j=b2tH>N2`eLJMnPymdRe_%&kK8od6Ms4nmw9XXKH-pC{$6KMETY>DNVA}L+&F+^I+VD312ddJ=+=CeApAuSCT~HKk-)hV{M^gB1quRxxpOpD?W|eOcx-2wnK6mlh*LTCw0-TePrx;;CNI zg<#NyU=amx*H-Nq2mp>gKkG2L_}HFzN@hbB_USQi90P}kH@5*NVS!bBA-tY>@6;mK zsYb!4Hx(EaMQ3A^LmU{9H>e7>R{zlNKG%0e7US68ugc+HT+ zoz7^4)7LZ(JLLet6V!m^jbB zlGZq-Z?95FU@Rm$C9Fmp4vz18Ed z>kP1fAW5)KX^6)Rvy%Dbb7yIY%?t0Zc(h(IdaGb(-r|`Eo3b!X7pjJF_syFfH$KnS z=(&E!7`XQ=diE-0oVb={GMYA(Hk|9i3?@az?Mej zYi&h{<0w#-tb6vRSoasa3TJ#`i-coCfn$y6-&Hx`{3oMvl)yu*58$nX@38@SiyE3Q zPL8Tpn%nX2SQGC&>aVOKZPBIm+TCIjbGF%1F18Ew*^Al&iqG2y_FzvEq&zqr{u}? zf%-pP(OttLeEo5n_e`!Tx>V19Rjlm#0(jUp^jnpS8X6 z`TMfjsE6n=6OArvC4DNhASB-t%OBxKGUrPgC(I-(kPzV!MH5{wN#ITPL-1$dK>^>z zB4=%5=j#!U%RXO)H-+^d^8RBeRSkUiLeM7&(ggC1PrCY_p^-;v*dqp#z$t%5uHci0~2Jc6GHz-GJPKDu4ZHKrOP-kCL7*kN0E zu+cDPgPe-lWSxQ~-`{tWTgD=2@qPjKd$ugToiUUaWuLlR;|-1?oemU+d|rHzt8J1H z3hb&iROjhGPPK9yu)-?b|14Z!A6i8w_(G+-x@q}BM?5F7OQCaqJA?WsdFUjHZxEU6 zjAcKIZ|D>!r{$gUESDf|8C8&mhjHk`-Ahr&P5ndXhL6SltPu3iYEy}gE4TM0`F9bO zN2HyYh23QQYIc)MWyjCHwlfqZkD5E!7w!|Pu-GB>w2NW4EdqEi1UQe2dM(fg0}X*l z9WOdxV~?fwRFhTle%*I0YvP<#ztpsB|IpHTtM>Wwfn-Xams6N@w_nU$J!;=!l(7sI z-+S1hwKB|*eMPqD@mk>{9ql__FNHkUEpRg)VYK)9NCv?mEdcF|vd*eyr^6dInzYBC z=l~{w#Ud(hqY_Fz8SOuMk4c@aZLIFHbgvoJ=bw%9&zPfFIoexsC?N(3bSkuOoMJDA zEw;A#H34+&pZXg`?hvi3f%oHwi=)5adB8bcm1WenSN^#+a(R2VVn_jVx6}a+O#=$5yopqtkjmTSE3z%dfHX;&p|?r_z(Q;0dJ=>k~tCP zc?_WCHn2vat?P3FgdFWT!EW7aTbMMeoY+0sE?%|0d^w!E(jcMIpuoL5Deeh%=@jVx z=uH^PwOX|PJ|5&@FjY>2uvos4fUhkX(K>qMFCL9R>8UJAdHglD^f$2@4*0j8<-{Zuxa4 zbY93_WCG-TcdzN=&?AoBzRRKKQ`FaWA0LEcEbKiLrt05I93+xinIsPFN>*CLuYZBu z&5iUJrb&c?LIBi87^Lahkz}UFxH^-ny&j)rrkZcn7`S+lkSFYUtvu5Rk1}9_W?xH5 zA~mb80wd9I)tNq*1==cFBYT&syBA?=!Ra$`dOAbSvi*QZU0{|!2N8#Wh zo&X(nhM|5lL88kH$z@;Hq`)hOI;>QeVnLLgWY%0 znPBx~;tb~w$rS|)TFMTSFIhTE6Z-63U7e57--|`)PU8#W_VA;T-n2e{{Zp$JM4AGm zl*K>`3}UZ9N-sp7!K|HT15_#7QRA(mw9;E?V@H8LGO@|-i+_j(_L92BofoN1zU@Jd zod;Tfq&+|$t3QJNo%yp%eG;xw0;qjM>(IUZdJJp#rX5}Z*LQlv!<}uny*BvnP-qW! z(CtU3zrmt}$EzQpfPP$_juf~NN%cN5Skt+EuTF6OCe!l_HLm}F3M{Fz%56$g5dV?9 z-YJJ_cv_N76(>QrwPss2#~(6qrfIr{v=_f0#THawHJDS84|+}mp*k7yAEd35TR!#S zDl=AGdeWNr-h}?v5m)KggmO8v%%k^*(r@Pnm_7&pH1}w>ZpSO+^6>jzjYNfv4a(_sYzlt{gr1R$Y*edAK#{!t^!JCynDu;#0Sft2QCvVU-?(HsuJ1Me zrO4A@z4mJtOw)myfO^1*Kckwu;V>>vC^8Te)gkQSyjMo@>l@0k>TkZx9U=FsNtS1= zTyF+nbYSA!)ni4PDl{U4KEzKE9WABHc!+bYkbjm`hpY#X?QEda6yj8?mX&d<%NR^O z%>I<{tP7MZ4l8~Uu9q&ORkV9O3p*z){+NzbO19kV#U_gL3+PV0W>~TWRhFx5ZM7rG z7f=E~=r@$*RQE@rEIgE(s4?NINskC#Y3pLy5u5QE0aAu!2tfb4@Gd~kli`LcQQsP9 zj9q%PA-y>EEwL3Z_ zW9t8J4t#>FkCbF)4HU_FpFdz?qP}@n+f7m^REUdXmF_2FcNMga0|whTC0e-hx2?7A z+McQol8m3x$<(1Htt*wLW|f}d(1g#Ui&VUrO7!`^IJHTWo(m1vtmwx7IJ*@%=BC0N znEb~7>l;70u>IYnGL<#;f#MUL&Ji9aP5Vlgj@Iia=;n5=TEJaGGcB3oWKHXH z#mJMN%!qYoMixwZCUbcW8M_>UueivTaOT5Ky09psU*KUCgf0OUA}Ik+-2UrT-M@tibJAaJ2O<**ddz zY^Go=oV0SbUfP81WQ_eS@)Ls*RpTyF<71R z6qGK%yP;*xL0!GfOzcYJm4=k3YhluU?m8)xgOJN&F1>SAS~Yhj?r-{6JTS$9pQEbc zxYR-OXjEEA9t&!yJBsGevTi znRWE5>tU9=ioY?4Z!QIS>OdG8PFHRmPw&6dWPM-jz11s$KTq`H_m@)`A8;{uLlpr zju0f`8q`Ld<7mKB;ZsA2jKD8da@bCF|}`3RIvI%Jg_JtOhg=4YdCJ@n40Blj;qyeAIiCfgE2$|x3E{K1IYz>2~|0;phqPzol z`weuxvW2awN-Hmp(rVTlV{KQ1(L-0$cyItZ2H%5P^I>x@ezx&ljw^~>+m}c&C8GQK zF;@~VU4Pf;c&lO(5Li2T6r)5t@_v!-Yo!(y4}d8ZQ#>qrSyg4!iB+-ecYD*HyuE1r zoYfRXZhAw^690KPd%sTd1?|fQTnmdg$pkb?=F7s4h<~A{_QmjyIC0va`;8sa)9K=? zC)v)rhM5Mgq|)aTELO;w?f{uwbMqno1Ov-8*_z7gEKbl`OlsA5JCl|WjZGyJ6GI}* zmjWFhm!oc(Y(8!iC#o<1m9ryHSw&8pHdh;d>0v>n)fd}57948%T?Xxkn74nSgvs&n zFc=KYB6bXNyBW3W=R_pGIO2JURHc3^IM~pJ18rF60iFwai+c=^dj220|E#}|1V+GA zX>C#pn?2X#Wj8ZvnNMi2-u-xJ3#-8PrRjs)V5M+9U!aqk{5SZ;4CgNYsJ~xkK3Q>$ zCSr5~Ct~Ey4b`yBay?fB#IT>Znpo~h1#IvbYu5M!G^zEubR_kRJJ+qb4BK{n;E zR+B;{lH0=zAw{6^|JUAkhBeteeX5A~h=?dfMBtGk9i;c7Gz9^X-UXzF(0fT#lwMSj z-g`%CfB;b-AT{&=0V2Hw5?W{hveD;#uYGs-zq?=d%U;+2OYS@Ob?$Q}bLPyMne&^8 zq-y{7Z%cJXu(@d|+fdURkPt<`Cu(FQs3A$pa{)TgcqOwfHtT7_)=v!Fh@i9_&d-@& zyg$r1?R=8OF^%*8#rK|v)BN=}q07`H;9)L+i!L`z9y98m`VY6fO|FpAmx{!fX5T9X z{8$<<8!fqhTlL`kUn2rjSoO7tu?Q|-aLX$+^SuUcY4GX02ps*N^*?^$l>^k)m@T;FlGZ7 z{8YWPvq#qh>;iW!>F8`NcZw?vY%X1kU0)5tf`-E~^f<44^H5gzb-XcK@${kmxv03S zS#7)d-|d~O;Dw~B_Sp2Lvli`u1$+>+Oj!TVnzHbk0pFl_Sb2R|Ex~KMac@$ei&ijz zkpE56!3w$7egxI#{yD@aS~s8j`SU|d^WMBXigttyz$W|?)OBN?$sQk>efUF%OlMhN zs-Wfj&ZnqsT#&1QIQb=KEh0A7E!46NqK=eBUj%4qIXwwrUSg(WF0f}-ZP=w8rCxmX z5B!L>TABUpj0BY6J=Pt9fSW=Q)PJ0Z$pxf`6u||T!0>yxS(J7^<{ zxhYEIkN)bow0(_9?T@sRGA}w_-~Fei#eePxf1qE#h3h|qLH`R_iv+8hcK)LAtOWAL z@k^~v#v&UDsTFycY?)kfV-#W^DgVfSZ*~HM6aY7}hG$0qp>rl{S!De4eV!oi0>2MJ z#RiWreKOj}2rlcwC&sW}m_DypByBeEtM0;=YhMdRzS4O63Fm9PT3MY#OZdZw>lU_` z17E){(@Uw?Kt8r|?Y=I=D$cZx_;})epIrG;g#J)T>%Ca&{8ywn4SVvymIL~8-QqgW zUEkkeHj?8McI6M6=xde%Cz&7BxbS^9HKy-n4Kc5}xeHX-t^hC1->fHDt(Pv{_V^v+ zCedsGxP1D<`gHu+i8OUBh$gB)%H8m-5pR9Ki_89 zK-{PF(ta@C65QLJP}D8*Wr&)1JWu5VlRNoncV?>>bWKKwKZqEsy82GkVQx}30x^G# zC!n{=Xop<1nZ?(O{!(s%!_}^qcg^JGgR2QkU`*WotHH^C2qT#r*?Lj{{*-5Q6$+my z56EkqGV>4ez>TYYE3(oo|Z573R?HfqoH2@f7I5g(64t zozTv>{@ z2Sb$ajt*F3?50+=Z9v(N5VfNz`SZdcuyOLMl7OE0fZYkRnX5<8JPcQQ(${-zc~=j> zj z#HxvOt_p6p+{>3P3ErW)P#6w-IQiHu+AXX1OJS29{Ij5G?7VSjC8McD^{?B=d7AW}L^wVMmdlzLip=y?kTtgOCu*2yhIW3hg?rBlL;<&-bMH(=E z$9KXKU&3lC1$-XYpP8DKA5HkBgRboJGI~?MI;pa|e`KJd;*zOG4AtAy!1rUYg!VhcWeaekZ%a=&@xMi&kU6?7c73r)>0K6 zF}9GnL}bs4h)79q1Z)@IyTx8X1o|Dx?-FQ(Qmna?K7CVC)sQBq`ZKwSd6O%0kRnFc z%Jy14F92H}_t)b0bZ%B|23_3z+)_@L!|HN!UT}QxK@=OV#k2*t(&}#2gs8!@7T>)^ z4fbCrG5Y$tsqnniP~OW##1e^kG#a%YSxjdRGsoUwH%QmuDcl)4JTeKB{8E4S%zadR{N z+-ji3D&+=gr;0)#I@OIk7J7vHJ~2d!cwRl2?kB8 z?BHJ)Wf2iJ@y&am!G~10doG&2e5C+*Oa{uH*i`)VFP4L!3Hd1TM*R@fGCB{JGxSbO zoJ0}4U_of%WunYg?Bn>5p<`_a41=zeo@xOkU18rA~J=gr+m!iZB-+w9ehIt;zek;hjwISIR9Ha17S)Wk%i(|_nIOhW{7`ggKWmLL;t%v$WQ6@R+4rTnZ ztO*H9O3Kp{^H*YuGS%OjX|8r08+xxLd&3vY$IG*ncJ0rvFBT5MkC>DjNoB~`l0@** zl~aVlr{7cA6j4GtyZ9}XzX`c2M*47XPelyc+z&8qQ6+SX9bP|dJVgy!N+itL$Xryr zS_t`V!0E7OB7XLty>xsxieeV8UHnZ)dheIegJI>xGXlir4J1wHq;%de!CAZ%#IkxF z?8I#29lay-YE#a?;Qa(?q_;39B?pGYYE;ma%PSy#!eB+(VA*qn@v^i@Z{;zcWHY}e zv2aZ!RencljVr0|og=DsaYnml=QsK<0MtD+(a^4~ipNfLQMu9g!t(CtIX@Cg?j74$ zFk9V*1a(%MaQaX>dZEA|5sx=NBl)0n1O>v{6mAsJX||DSHZSe7=d+-`c8+QEgIw(9 z+YXEKedN=67tggsOJ>jra5{l5J@@fu?mcRx~sXy3tjY?}=qgRbjrYqXOfuNfMz1cu1Ws zAM4Qd->fFK=TgQU2c*e%k-PRvy;_x{k12C}^5s6DB3@kL<2{Pt)``gH)EV%n_4VYq z)_Nrs75=&GGb}7Q;7qm00&xnwX!&8C>C(HOyuIZ4oAp;teb)BQKx4ZU#Yo~(>3AEJ>RTLRHnta7*KI}8K?5uq?M^@7!nI-6J8HqC!LA;dJhy}%pBGkUGa1j8 zJkO7^>0I$ne^Ca36%TguvZZT6gQX>5d4J=~&!wZ}^Llmw*L-5#M#QG{Kl!@j8tv*F z_-Bl~Jr8*sfBBw=nNQYfqUaZ%D9}5AX0Q{m;PXL55ZF)FEB`~TWZWjI#=s@V^9{Oe zgH?#TTIz(ps7SJH|DtkGi_6Y;w}Uu)+#P7t;8tpl)IMDwDp?USSM&(#dW&&moQD9t z-&XPi*7k74phmGl-ScGL`9UF8uO+%nGqrH?DO$LTutA$v4&Xdp^1|VX@L$v<;eSX` zkM%{I!*t{m7pNioH?Nnm#Mai-6Fvm!^6(XB)&<^LKSk^!ZpAl5Lpitv%JV$xhLRgL zXN#>My*jW)?z&C!5RI3_Vsv1u&L`Co=9AP?3eT;D!89w8z1&;d>2l{&4$!{x)A8{) z-DajzZW^HFX;XYA6w;AJ?c>0Gdxs|97D4nCV=__VbA&Wz@ zg3bqF~Mx3hTdpdCe3&OgWVTRUW=N@t~AQS3QG~lcZd(pvhsfFgK1gG zt$t(!*>vk;QsVQkr__!#9!a#IBW(R#C6 znL|`w3hN0>e5B1p+*?cE6Vq*7Qdhvw&dmiX9xPvk#Or952U}-*aCr2`F|QCD&UP?K zF|V{#_;ZuxPr92Fggf0>9(^~PsAe|iy&C==7x@8F>Q-#%$K&`2?UR6 z&EAYHxp@L3Bcp2~yw<+hNq=?os${inep9TstFwL&U}H~Gk7Bp?15C=?{9kOnr6C7Z z>ODZ+=Q@Zj8-8VkKt5R8tb@|w47bzLR8e)VA=(Y`#==pORnr^mb9K7q5(`C@y%T8j zp+$5(u;;_%piMrT^85Gfo%*``3ZSegshcFDrZG|?Itablz=%Hz^A3|0<=pO49S;)~ zf((CpO-&tNR-V!DW$~5r*_A6~`aDQg6@8)-IANU=UYG$Q?I%sfS4?#ii$bcL<=qP) zb4$;hK9;T1>)@t9M&b3)H$6u<28ZJ8^tXq2L)Dm$_wN%l#@y*?6C<*SptJ=L2`SPM#q zVyYIwiy3t{?Nm80^;{j)f^9Vr=!vyzWJ~o+`iCkR-|s*B)#j#n$~^3?Pg<5oA0@dY zMnd_ zY7fSqPczl_QIV`b!RYoPo$=jI;ty^WM{rxt&$Dr^x$|cR6@}E+6DdRQxCWk#rSqn@ zIO1B7TxUuo(8VQlj?2uq@1j?tbS?79=2~i`Faj;Rc~*Fy*|@~8;-7<79*Xi_Jn zcNoKdiL|?LE;=36m$RMXuE=;8l|cdfQ?3?Q90sJ#4ThuqdR&2Wv`f!O_SjeOJI;lu zC!gs_C1kilAQxR5E6xt@R8?X+lvCkqLBx~~676-hl=0|+H_lUhpXsWH{N$v*@1amf zAsG+uF}>mG?|&wf;ydn|@PZ0!2dW>0L&D+V zSGl9Um>3%_jLHN8MR)?=hm7qq9e+?GhisTDQjUp^iHPeeL-Dak`YI~=(6-G}>;KUd zbhe}EHb42PNVpbZOPrOK&FHy5HRfudLKSsg@h~Auz7{b@Y%7m0JpD2oiIJeY7iEMr z!)`M;)6?k6v0alpTOa;(joCtPA3OJcymnUEB>WDt+PEfg2|GSESr>6fY-b1+1iyAo z&A0imUDspP%agR^-$oBNYqFLn$Ai5o%b!nBEh)L`=A7&(5l`4exPK)x+cA62Y4N(z z&$f6C#o_TZ!_$1AX?JA|i*PQu++#{&!-oLCA_INQ;A`ZMrTclET>xkB?&u( zf8FSv#&&DvhstTqEKKNGW@o*^-|C}-N?^I$>NUqplF{)d%?;cI!0@EcpsE5Ys88zRS5E+7$z`3Loyz3a(!H1We7UoPk_-FWy49*GD)uU7onLEos@YHZ>zAp z+entOmfeP}b#-7VC);8reLN*Ay2r2dxjEb7-8%+4lwzc6cDu8QR*iN^L18^T>H!Sc zc^_kbgK3B5YA~^;ruxk7M|Z&;TuDkdHio$$1#6t(p^c$o4q?3w93bha1T^LidMT`mMp{GxoM}ekBNr^_m>R{nG0+`{J?ZD@)Un z#e`2^Pum!OyDZNlVTU|4pS$2;a5mftYoy=Q&-&AfE=jSePU3W*3L4tf!-!S5mW^BuIpu#bydR(nTY7t6>+8 zUu^cP&H@i-1B%b&*~2UhPT(njq5Gv}$wIu6KLwdd*)sEJ3G|jZ^A0QX-E>Qy;w6?p z4&KA>JdL~wS@rhjXcrS}G|4#^5b~-tM4H0ZMQgKDwvRrt6o49SsrmyEeH&ge^ zH%(M-Lf937$aZtcy_FMnz7hwLoL^i2=2e$+&4tz@)_$DPhdEqY~L z_-zBPO~G(o!vea>Qf{8q&Ah(`>RXjfZWU_6z_T^Dx@<}2pS6iIY%rW{$}HE^KK-V@_*rDi+Y9kbq`zbj`S+LH7KRO)yKC~ z8G{&*s&(uupx03pdvnkvS+2-uEp^K+TXZ)Rk;Ea($nm=;$(*rh^9@x&>bgaML5RyE z>N*?o(x`h;dH2F{Hx`FuJRz3r!x|Z3W@|H{_uU;T{r$FD+|nAH#^gk%kVVdM9ka^4 zL4K@1dBzx-fA=Ss!)MSGdjsN|QrC~~c7GuKEdc&*FotB(es9hbAzeuIlYcupw@HPR z|JCjP?DYH>|K~q%m4CnazkB~ruY`~Pq&`fpPH1%v+0m478R|K`fS zx$D z{qd+Grsy0|M`3SR5&Cjj5K?D0 zwX%v`eWX6WlXn?@qHXtmp9e))C`Cs!pE-`#g6X<%@Mk=NtqPQr+(M-&DI=BAHYY#VOhJl$Pc*nm!QSXtHoGSa?gE*rBd;<@80=7f7`w zraU5IlNBi~Xe?cx{>~sCCMsUA|EWq#PHS{^Pt#0Wb9v$RCG+ndh}#)iNsY%#4D?0{ zjoWAA3_3Rtq|*PqAub;@c1CsHi84!|ubM6wX8B_*4Gm-p`*pB;F;ZM=+Sz4$`b%i= z@y#V0nDJyN#onbMPoq*kx#~`u9dj8JS+Y^Ye&t2Ck>ZSDVkX>8m;YhrHMG>hsPClRGizBKk1**;(i_dastNaF6s|>c3fSa@PIMfM z%gasB20gRL>Z*Nbz z-@xoYHX$zqSEt}rKdMSS8d==i)1#VA#lF8IwYbtt-RL10u-(23U?$b2?>15(;M*#_ z?9Y#e5JwxiosV=F@3!s50v>Ux9WsM32J$nir_+7ZIAi=0g;kZdm)JqdJ&V9(7>yn1 zm;&SLU;#n9mM~Sb$N>G9EQQO{yRo#Uj2^iAhV>Zs~_>TEei~0#&A!V>4S1oFR4- z3H{+;ThTKwRU`y=1FMF8+|-XaABGj{S-?;-9c)_$$GZa zTpk7TcX6IBA*lHR7H4FS&KVq%4%>+HW>5(a&dRf2+d@Xnlg@#4qzQ2rh!YW)y^^(m zZgx&lul{L&8}J~ctpFwNi4#M$M76M72!aph2(l*|AA3=dDqhI7k1F-sbYLgq4`t9X zj?9W#uEr!alwy`Ro=?`X9kn1RpZ57PkTPyvZar+M&9qJaa3UZ)Hh~f`CR9e8a=@+6 zb+MTSJWur}7nd+fWYsBoI|ONyadj7kHfeh4)y=&V{wo>sVjTtTELcOErTFB^#Tm$% zTArVp)l@P$otR9Y*jdsDGpj2Oi_GH+J;o9;8NQ8$2Ojc)tJ7BnW`f8j zXcSZ9vl4&{2>XZ><-k}1PM++k0Jfu|jc`Y_wR5S3Dxk@0?L)6nj8&>bL_|`%ds7e5}S3a2|&rzi{#Z#SFjdn6w5-7jS zVQwnX*|{U<`)T-^uMqCuvPb6&%`!o@JmZG&Dk1RWftN!IPcZ>(Jh2pVyNgX(u>w(8 z?w{JyDHz7ddQ~J}_oMn-0T(Aacj75x7pHv(m=G2d1FsUBd?(_`6@WpOqr6H!%UdQe zm=aR2c2+#gjRCok53-Lai*G91n()ux`%!9@f@yv^qD>SPxXas)%ZH=l&fWA2FtB`1 zqGPZJ(6by^Ky)v zZZ?vW0P}HRafr7o#>9Dwse$?I=ODDPy&<J2 zas;WV(#1C}ZCCG!^u`#fGp9Wa{5i3G{xxqUp{K2NZ;Ff)uyou{A&EHQDV~J4JLvCb$vH!;4Bkf%i_6aNvjtiuKa7h0qa(Ss4`ht#?E#Hl@mP8hSG6M zcdJUq82^Cq^Orq~9n_)ye9;KCPdcSYix1qY34(gF5*+v1i`NP)|Ng7;n)*pK;i5Ml zG@Y?_2M)#i#J73cVkVLE?kA&0V1>P8@M5+Bks#U`!qNg!k0?F+j$m!y*=XTU<_3AN zW1DeRS?yfKliBm+@++^?H1CJ2g`06!no~JVbQs+fXA_(>d(KBoETU0}N(y+~&>=-X zY~w0Om8M|m9% zcJNVS*Y?WuF$Y0ZhDlLmL}BlD^(=6$(Rc(?;Q29Hwde4;^G^fFEhl?E6Uh}cLSSG* zb$mL+%6~t5u2P*I{Mc?{SKUH9&5dDVA>Y#e*-JnVtbDWCneWcn2E!&=WuG`VcjP;F z+rjKx!{zyH?U8C|OR#x`huqmsWpC9NY@Cdfen*qOk>WB}CWBe?{KHSMDSi_#{qH?Zwr_mrx|>2hCQqhpa(DWf5)TF3GYbum_h zn&oxw(g?tA!*$%DBB}YW))&_M;Dl(7K#T&}Y9-Ch$ zDSoRZ#+M zJ^3K&R2wrf$>@|08@3l}s;55&oOM@acHmys!Sxbt!vZ#m*i-`MQF!iDM|zmpKtG56 zc^_Zj{6+~%^#-*G?xc@o8BHrBo9+V_VnkJ3n5!O)%$60OA8d|`GxlibII2Um2#&qF zyW7Lnp>2B*#uE&wCO93!m#l@Ec+;tyBWK0{H@qGj;w3u6B<%nlbb^U*3yKS$GC_+x;O( z^yZGop{UA8c!M9o;JlI1t5v3LMc%ozc|FDe3MlW{&JLQZpEZBkKqE#usYT^Z+!7uN zw~d>qtNSi@AzQ8OcFfVb?So`PCG1xOIS0fQ*{gq_*-}2(A{RBXht8%lHn%uZtRgG} zRLZ5ixz+E&kqDj2i9zQ^f${Z!RN*`R$koyxWh9_1H>xTOCoy+_UjHnFOs8QrJX zIuG7h(9<%2musbS*R4k~HNG(l;&4oKG0R-v8k>Xpl)oH1tfyRlaL^mNppDMn{Bm8> z|IN4OQJd!>_H^mKdelIqDP|HaujIOQpQ$)_H(eU}dIA~X-nM9_nxcw=`r5Ym7Nt+D z3%+^8WpHog;pY(vPVpjDNCpWv0Ip-)|?aw9V#kv9V>v;-48DZho<-Shq=0(O0ZAV`uNF^l=K_ zTGL>5*!_Dmh7y<)`DQoT1=a48K2@?F@hj*1&PxN@Xh^oj=RSewd;+|pT?nCJtFN9G zDk^xY1IcJh)8Iu9aO@iDBlx%k!DurgOKxOfQnb=0?_BdRsP?Ofa==@#96Un%y3LR);vT7sLCFLG=u>enbakLT-5Xt;n z9aVdJ8pxZ+EdRx*bI?>1{n^+i!@le!u#_#(dZD-azNk4l`3f6Ip?#iVi^9f{lXE4^ zxRM^wMs%J)oezl9K_rAd$r70u44AZ`gbf-xNs7L&PgqFhC7=7&ZDQ^XUno4Hk%#?? ziNEXY&ycY}F^az|@YMF{AjM+Z3AFYIIM~lDU1H~@1JKnk=;hYd9F*2Gc1(N37BzaQ z^Yie&3h7imxp)Cj_{e0bq3lZOkV5i70)lw+rfgf{72t*RX{Znzq+xpqd@rLt-_&Y` z&vAFFGpsoP(f--_B#Ew{nyJA8kCzt!)Y^&5S@5_==w}&;F?~%2;bvVdhFcbIutonSZBomk@9 znGIqPloxyB9gSgqkSVgVwyio344)4PY2B1-vY(D5dn2%lA`dw(K{Mvfc*Dx{da!pI ze36j=&$AnDh1R}Dv2A5l0wWXh0S?VEY0Q@q=-f&*)Ke{zNiTQh!spP<=enb^UM(q` z|4V%(i>>6nYCq{l#sLC6zYBWy*}YfbRUhDd4O$C1JD5z$p0AlU1HUZPjDqNZtUYFci! zQm7~?YI)%Yr@#H$NJWUZjEGsiS^Q<`wlas*kTBfI_W{)35QcX~{#4Rc;StcgA#NBl z*Mr;;Q=xSe68bz^*G3sQ|1QynU4dIs`YO$Hb9uZh0m>z#rVa{3CGOIznhp#OEkm?v2+r!6%@52eWjFzn-&C_kHbK8^p8itE05Y0-YP3>At4f2Y@4e zw0BFjW7E1kCn+n@kp=!;=#!2F5tlvW|OuC<26C%z9^6*m3C?zyfUV=4!-0^hQkfJqf1Xp^EX1Kw4X z^-$c`*qt`dHmqa+6(CbT;cr#~WB+C4Kr{>oAJ^_|*9jD3~2S27zWK>_9d5sTw@OSqyN*<@xNCb+JUXObTBq48vGtEj-rBT##1Sm`ZPCnZbGB%@hPI%MZ~^S=n@1_^h@#)cd3sFP-*W zG3;nv=vsD_hCsk{REVJ0S)qk;<>09|<*OSKf>Xu>28&7W%t^a7_b`F-A1p2FmC8O) zn|c@AmId7t>FFY65@4F=C;qA5;G^1rW~1y<%TUDwtW+;y4+j+0X}NC6Q!ew|anHkw zrd*QJ1C4xMH{tU;2Ef04?Plb+CJ4gc_B3hWE)KgNUY8o>kZgu)8mQf;~ia?XqS5l52wLpW^B7c*ULdQh*czvY&>kFB zp=vL1i0*64*tAxJ3zmjS%I^R5Ek;@0TUZh&$@@cxj;79ScI60m@)D_sy-y23eYR~0 zGoz!?-5Tj+X(@$NFKSpE_E%jC;3->p+$P(3(aSn7D;kn~en`IhAk+XXQ9j=m)OmPC zd3V!uFr+O#g^zVU{3vkwN5Arf%q)36xAe-lsxUF!ZPYWJne5F;YMlJb61Dm*dTe=< z=&d*Fo)`DD@zEw(2%O#=+kyaZd&|fOdaZ)G4__}J4OXo}ENkPm@`yib`;+Priy4ufE2G|7*!aGW9aSwZ|L8dloXp#yyImvqU%} zgDLE8uM5PCPB*lp1K5)l)Tn;YbTln&0ro4Kr5(%Hv~Ae>nY&dnmku&(_eE9JzgKLp z4y^n~_ahkd$~C`?_c7+B)DwCVt3tjlM`L{g1|5~T62Gi5V%Shf=i#`!SqXyVNzcO!V1%`xA|8dB-Cbb|Q@LrM7al_w zWKJgJD>iO-l5aCu`#A`vDo7SOj|uldKo^tak96k8nzy%w+Jibrwh}Yg^px~nB9ePk z1M1-wm1cll4a%kUzTSlN@HnOv_f8n>enx7>k}EA8<5sD2GpSgWoX`qwIsh9*j_U66 zi(}l~V#rJw?UU=wA6F#?>@A_&rrLi?62}a53z^vO8X0?U_iTB7`r{~fQ%&J72yyTk z%)|Rgah?X!JU!f_&@P8jDyq}ffAg*QdDIc;Vm_Y0v_<~6TC>yQO=<#u#r8^kTg=}< z)jgT86zYwN(>im!+H}#h6>j}Z>1jegD>K^jSJewd)2+d?S*PcWaZ?d9XmSB|X*kcq z_6)?Z7A*DBB?9Db6$N+()m$7j)pqvWI05yIRe}_IEJ1cBA4t`GBfL5;jeS+j8YCzo z(TzEFFTneqC2K_K64apEqnlb<8vXp@;y0pW{@Sm5GxR*08 zGK4oep+shTzSjC2Egb$efqK?IlddmQfypsaaBP$0tRARjVhigxF-=Ub1uW$?5aODg z714q_o$BXN`-58^67E-d&_%d7l`#uazz+A1@tzRjpz?w`}f?*(wnZ{TrCJa!# zk5ZJn>coCmNHmGH;=;JYyj!c?1JVMj^W zh0nkllP^d7C-xil*>Sg0j~9Jd8rmtfPIv61EPyxqqWc#klOd4NxYncP#*dEAMz@{kcgWuYKowWwX z6R5}hJ8Q+sEhs4bo2iE^yuS@~n$|g)~1QsQZFO5k+9b1)n<`=%oz7-Y|~=0 z7xN-BJUK%!2hsXlywtFxl3X7Z2;VZgad?A(e1 z4ov~B#kJ3OyR8;vZdDGSZ6#+Zay7Ze`1#!&4Kd(cD`_EqbNbzkDWte;|=Dv)}UShhb zLB$yYs5jX%)XDmK*Y6x!yxaK{qs;}#&w^-}DH+#0?7mZ01xIUX2w&OOu$(4u6;LV* zv9E0!poRg6D`I?XwRZ63A5~YOUou?#bZ=4{&`w<68DrljCFkUVE@F{K`4l1C~>jmg3d=c;HwrV_^sxB?EnHz zXI0sm&AW#l`dOws3;HxDZWeWdQ^7=qZyUTXX*{ zhYah*v^4w%a)uo|l)>4{B`7R98`m-3720e{ysrLZWN$x&apGj=&8AP-GK=s^?MS8- zxK_ZtUR`OA;gY-St$Gdn(g-cu^22ql!uSiUm5&`>BcAn!P#m;u&7P&|<;)Q*U|?Q| z0r*13{MYSttCue6SoSK``H@$R`=|gWzLeYGHOBXpEF|oel%#=Epj%P+w#ZtE<6QBK z^s~WSR~-p3O@p9r&2op8^;{VrJF{SO4 z!PT_jgg>XgDk|?oujt?h|GsjcG^W(Y$x2fb{-jNGjWIva6n^(NHtjo)e^7wMWVM^W zamlnxqw#=sZti*D(ca$P>D=Hz3@Kgf(#zh?&V0bykV89_L(cCrZ_~g3xx8Gg4vEqw kwYcOQA^N*BC4n==$?iunkq=j)zaKwW)mEu``uhEU127R#{r~^~ literal 0 HcmV?d00001 From d29cfaf227eb5c80d6b26319c8ba54ef382b2702 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 1 Jun 2026 12:47:53 -0700 Subject: [PATCH 62/63] Use full URL for image. --- docs/how-to/inspect-your-backups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to/inspect-your-backups.md b/docs/how-to/inspect-your-backups.md index 662f0e20..797d965f 100644 --- a/docs/how-to/inspect-your-backups.md +++ b/docs/how-to/inspect-your-backups.md @@ -144,7 +144,7 @@ class="minilink minilink-addedin">Experimental feature borgmatic has an experimental console UI for browsing your repositories, archives, and files. Here's what it looks like: -borgmatic browse screenshot +borgmatic browse screenshot This feature is not intended to be a general-purpose Borg UI with every borgmatic feature, but rather it's for use cases like quickly looking at the From 3e7e7879bcb8caac422f543ee67d8ccfba6ec63c Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 1 Jun 2026 12:59:52 -0700 Subject: [PATCH 63/63] Remove some default system commands that aren't particular useful for this app. --- borgmatic/actions/browse/app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/borgmatic/actions/browse/app.py b/borgmatic/actions/browse/app.py index fd270ba4..30894d22 100644 --- a/borgmatic/actions/browse/app.py +++ b/borgmatic/actions/browse/app.py @@ -43,6 +43,18 @@ class Browse_app(textual.app.App): super().__init__() + def get_system_commands(self, screen): # pragma: no cover + ''' + Remove the screenshot system command because it produces broken screenshots. (Emoji + weirdness, etc.) Also remove minimize and maximize because they don't accomplish much with + out particular layout. + ''' + yield from ( + command + for command in super().get_system_commands(screen) + if command.title not in {'Screenshot', 'Minimize', 'Maximize'} + ) + def compose(self): ''' Compose a UI consisting of: