mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-07-22 02:03:01 +02:00
Initial work on a browse action.
This commit is contained in:
@@ -14,6 +14,8 @@ pyyaml
|
||||
referencing
|
||||
requests
|
||||
requests-oauthlib
|
||||
rich
|
||||
rpds-py
|
||||
ruamel-yaml
|
||||
textual
|
||||
urllib3
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user