Add unmount command
This commit is contained in:
@@ -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
|
||||
|
||||
46
src/locutus/umount.py
Normal file
46
src/locutus/umount.py
Normal file
@@ -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 <mountpoint>', 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
|
||||
97
test/test_umount.py
Normal file
97
test/test_umount.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user