From 5de63963a46c5f3d970dcb0c21f5719d808e6326 Mon Sep 17 00:00:00 2001 From: Alexander Wainwright Date: Tue, 24 Jun 2025 15:48:31 +1000 Subject: [PATCH] Add info command --- src/locutus/info.py | 46 ++++++++++++++++++++ src/locutus/main.py | 9 ++++ test/test_info.py | 100 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/locutus/info.py create mode 100644 test/test_info.py diff --git a/src/locutus/info.py b/src/locutus/info.py new file mode 100644 index 0000000..8ca9a12 --- /dev/null +++ b/src/locutus/info.py @@ -0,0 +1,46 @@ +import argparse +import os +import subprocess +import sys + +from locutus.config import LocutusConfig + + +def run_info(args: argparse.Namespace) -> int: + """Show current config/profile summary and repository info.""" + cfg = LocutusConfig(args.config, args.profile) + print(f'Config: {cfg.toml_path}') + print(f'Profile: {cfg.rc_path}') + + repo = cfg.get_repo() + if repo: + print(f'Repo: {repo}') + else: + print('Repo: (not set)') + passphrase = cfg.get_passphrase() + if passphrase: + print('Passphrase: set') + else: + print('Passphrase: (not set)') + + print('\nIncludes:') + for inc in cfg.includes: + print(f' {inc}') + + print('\nExcludes:') + for exc in cfg.excludes: + print(f' {exc}') + + if cfg.prune: + prune_str = ', '.join(f'{k}={v}' for k, v in cfg.prune.items()) + print(f'\nPrune: {prune_str}') + + print('\n[borg info output below]\n') + if repo: + env = {**os.environ, **cfg.env} + try: + subprocess.run(['borg', 'info', repo], env=env, check=True) + except Exception as e: + print(f'borg info failed: {e}', file=sys.stderr) + + return 0 diff --git a/src/locutus/main.py b/src/locutus/main.py index f340bc4..ad8f48a 100644 --- a/src/locutus/main.py +++ b/src/locutus/main.py @@ -73,6 +73,11 @@ def parse_args() -> tuple[argparse.Namespace, argparse.ArgumentParser]: ) restore_parser.add_argument('targetdir', help='Directory to restore into') + # info + _info_parser = subparsers.add_parser( + 'info', help='Show current config and repository info' + ) + return parser.parse_args(), parser @@ -104,6 +109,10 @@ def main() -> int: from .restore import run_restore return run_restore(args) + case 'info': + from .info import run_info + + return run_info(args) case _: parser.print_help() return 1 diff --git a/test/test_info.py b/test/test_info.py new file mode 100644 index 0000000..0ed19dd --- /dev/null +++ b/test/test_info.py @@ -0,0 +1,100 @@ +import argparse +from unittest import mock +import subprocess +import pytest + +from locutus.info import run_info + + +def make_args(config, profile): + args = argparse.Namespace() + args.config = config + args.profile = profile + return args + + +@mock.patch('locutus.info.LocutusConfig') +@mock.patch('subprocess.run') +def test_info_basic_output(mock_run, mock_config, capsys): + mock_config.return_value.toml_path = '/tmp/test.toml' + mock_config.return_value.rc_path = '/tmp/test.rc' + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.get_passphrase.return_value = 'hunter2' + mock_config.return_value.includes = ['/etc', '/home'] + mock_config.return_value.excludes = ['*.cache'] + mock_config.return_value.prune = {'keep_last': 3, 'keep_daily': 1} + + args = make_args('/tmp/test.toml', '/tmp/test.rc') + mock_run.return_value.returncode = 0 + + run_info(args) + out = capsys.readouterr().out + + assert 'Config: /tmp/test.toml' in out + assert 'Profile: /tmp/test.rc' in out + assert 'Repo: /repo' in out + assert 'Passphrase: set' in out + assert 'Includes:' in out and '/etc' in out and '/home' in out + assert 'Excludes:' in out and '*.cache' in out + assert 'Prune: keep_last=3, keep_daily=1' in out + assert '[borg info output below]' in out + mock_run.assert_called_with( + ['borg', 'info', '/repo'], env=mock.ANY, check=True + ) + + +@mock.patch('locutus.info.LocutusConfig') +@mock.patch('subprocess.run') +def test_info_no_repo(mock_run, mock_config, capsys): + mock_config.return_value.toml_path = '/tmp/test.toml' + mock_config.return_value.rc_path = '/tmp/test.rc' + mock_config.return_value.get_repo.return_value = None + mock_config.return_value.get_passphrase.return_value = None + mock_config.return_value.includes = [] + mock_config.return_value.excludes = [] + mock_config.return_value.prune = {} + + args = make_args('/tmp/test.toml', '/tmp/test.rc') + run_info(args) + out = capsys.readouterr().out + assert 'Repo: (not set)' in out + assert 'Passphrase: (not set)' in out + assert '[borg info output below]' in out + mock_run.assert_not_called() + + +@mock.patch('locutus.info.LocutusConfig') +@mock.patch( + 'subprocess.run', + side_effect=subprocess.CalledProcessError(1, ['borg', 'info']), +) +def test_info_borg_info_fails(mock_run, mock_config, capsys): + mock_config.return_value.toml_path = '/tmp/test.toml' + mock_config.return_value.rc_path = '/tmp/test.rc' + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.get_passphrase.return_value = 'hunter2' + mock_config.return_value.includes = [] + mock_config.return_value.excludes = [] + mock_config.return_value.prune = {} + + args = make_args('/tmp/test.toml', '/tmp/test.rc') + run_info(args) + err = capsys.readouterr().err + assert 'borg info failed:' in err + + +@mock.patch('locutus.info.LocutusConfig') +@mock.patch('subprocess.run', side_effect=Exception('fail')) +def test_info_borg_info_generic_exception(mock_run, mock_config, capsys): + mock_config.return_value.toml_path = '/tmp/test.toml' + mock_config.return_value.rc_path = '/tmp/test.rc' + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.get_passphrase.return_value = 'hunter2' + mock_config.return_value.includes = [] + mock_config.return_value.excludes = [] + mock_config.return_value.prune = {} + + args = make_args('/tmp/test.toml', '/tmp/test.rc') + run_info(args) + err = capsys.readouterr().err + assert 'borg info failed: fail' in err