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