From 0bd9f7834643ebe47b56e97656406b8c85ba1909 Mon Sep 17 00:00:00 2001 From: Alexander Wainwright Date: Tue, 24 Jun 2025 14:21:02 +1000 Subject: [PATCH] Add unmount command --- src/locutus/main.py | 8 ++++ src/locutus/umount.py | 46 ++++++++++++++++++++ test/test_umount.py | 97 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 src/locutus/umount.py create mode 100644 test/test_umount.py diff --git a/src/locutus/main.py b/src/locutus/main.py index 4fd6893..3619ec7 100644 --- a/src/locutus/main.py +++ b/src/locutus/main.py @@ -60,6 +60,10 @@ def parse_args() -> tuple[argparse.Namespace, argparse.ArgumentParser]: 'mountpoint', help='Directory to mount the archive' ) + # umount + umount_parser = subparsers.add_parser('umount', help='Unmount a mountpoint') + umount_parser.add_argument('mountpoint', help='Mountpoint to unmount') + return parser.parse_args(), parser @@ -83,6 +87,10 @@ def main() -> int: from .mount import run_mount return run_mount(args) + case 'umount' | 'unmount': + from .umount import run_umount + + return run_umount(args) case _: parser.print_help() return 1 diff --git a/src/locutus/umount.py b/src/locutus/umount.py new file mode 100644 index 0000000..bcb54a2 --- /dev/null +++ b/src/locutus/umount.py @@ -0,0 +1,46 @@ +import argparse +import os +import subprocess +import sys + + +def run_umount(args: argparse.Namespace) -> int: + """Unmount a mountpoint (using borg umount or fusermount -u). + + Args: + args: argparse.Namespace with 'mountpoint' (str) + + Returns: + 0 on success, 1 on error + """ + if not hasattr(args, 'mountpoint'): + print('Usage: locutus umount ', file=sys.stderr) + return 1 + + mountpoint = args.mountpoint + if not os.path.isdir(mountpoint): + print( + f"Mountpoint '{mountpoint}' does not exist or is not a directory.", + file=sys.stderr, + ) + return 1 + + # Try borg umount first + cmd = ['borg', 'umount', mountpoint] + try: + subprocess.run(cmd, check=True) + print(f'Unmounted {mountpoint}') + return 0 + except subprocess.CalledProcessError: + # Fallback to fusermount -u + cmd = ['fusermount', '-u', mountpoint] + try: + subprocess.run(cmd, check=True) + print(f'Unmounted {mountpoint}') + return 0 + except Exception as e: + print(f'Failed to unmount {mountpoint}: {e}', file=sys.stderr) + return 1 + except Exception as e: + print(f'Failed to unmount {mountpoint}: {e}', file=sys.stderr) + return 2 diff --git a/test/test_umount.py b/test/test_umount.py new file mode 100644 index 0000000..f702d49 --- /dev/null +++ b/test/test_umount.py @@ -0,0 +1,97 @@ +import argparse +import pytest +import subprocess + +from unittest import mock + +from locutus.umount import run_umount + + +def make_args(mountpoint): + args = argparse.Namespace() + args.mountpoint = mountpoint + return args + + +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run') +def test_umount_borg_success(mock_run, mock_isdir, capsys): + mock_run.return_value.returncode = 0 + args = make_args('/mnt/test') + rc = run_umount(args) + assert rc == 0 + out = capsys.readouterr().out + assert 'Unmounted /mnt/test' in out + cmd_args = mock_run.call_args[0][0] + assert cmd_args == ['borg', 'umount', '/mnt/test'] + + +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run') +def test_umount_fusermount_success(mock_run, mock_isdir, capsys): + # Simulate borg umount fails, fusermount -u succeeds + def side_effect(cmd, **kwargs): + if cmd[0] == 'borg': + raise subprocess.CalledProcessError(1, cmd) + return mock.Mock(returncode=0) + + mock_run.side_effect = side_effect + + args = make_args('/mnt/backup') + rc = run_umount(args) + assert rc == 0 + out = capsys.readouterr().out + assert 'Unmounted /mnt/backup' in out + # Second call is fusermount -u + assert mock_run.call_args_list[1][0][0] == [ + 'fusermount', + '-u', + '/mnt/backup', + ] + + +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run', side_effect=Exception('fail')) +def test_umount_all_fail(mock_run, mock_isdir, capsys): + args = make_args('/mnt/doesnotunmount') + rc = run_umount(args) + assert rc == 2 + err = capsys.readouterr().err + assert 'Failed to unmount' in err + + +@mock.patch('os.path.isdir', return_value=False) +def test_umount_bad_mountpoint(mock_isdir, capsys): + args = make_args('/not/a/dir') + rc = run_umount(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'does not exist or is not a directory' in err + + +def test_umount_usage_message(capsys): + args = argparse.Namespace() + rc = run_umount(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'Usage:' in err + + +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run') +def test_umount_fusermount_inner_exception(mock_run, mock_isdir, capsys): + # borg umount fails (CalledProcessError), fusermount -u raises generic Exception + def side_effect(cmd, **kwargs): + if cmd[0] == 'borg': + raise subprocess.CalledProcessError(1, cmd) + if cmd[0] == 'fusermount': + raise Exception('fusermount explosion!') + return mock.Mock(returncode=0) + + mock_run.side_effect = side_effect + + args = make_args('/mnt/boom') + rc = run_umount(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'Failed to unmount /mnt/boom: fusermount explosion!' in err