Estimate progress while rsync scans from the last backup's file count
With incremental recursion rsync only knows the total near the end, so the bar showed no ETA for most of the backup. Save the file count as a third line of the state file and use it as an estimate until rsync reports the real total.
This commit is contained in:
+16
-2
@@ -32,7 +32,8 @@ class FakeBackup:
|
||||
def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str,
|
||||
ssh_host: str | None = None, ssh_user: str | None = None,
|
||||
ssh_keyfile: str | None = None, remote_sudo: bool = False,
|
||||
remove_before: bool = False, verbose: bool = False, progress: bool = False) -> None:
|
||||
remove_before: bool = False, verbose: bool = False, progress: bool = False,
|
||||
file_estimate: int = 0) -> None:
|
||||
self.inputs = inputs
|
||||
self.output = output
|
||||
self.exclude = exclude
|
||||
@@ -45,6 +46,8 @@ class FakeBackup:
|
||||
self.remove_before = remove_before
|
||||
self.verbose = verbose
|
||||
self.progress = progress
|
||||
self.file_estimate = file_estimate
|
||||
self.file_count: int | None = None
|
||||
self.homedir: str | None = None
|
||||
self.ran = False
|
||||
self.closed = False
|
||||
@@ -87,7 +90,7 @@ def as_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
monkeypatch.setattr(os, 'geteuid', lambda: 0)
|
||||
# Writing the state file as root would call seteuid() (covered in test_state.py)
|
||||
monkeypatch.setattr(sb, '_write_state', lambda user, destination: None)
|
||||
monkeypatch.setattr(sb, '_write_state', lambda user, destination, file_count=None: None)
|
||||
|
||||
for var in ['SUDO_USER', 'SUDO_UID', 'DOAS_USER']:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
@@ -266,6 +269,17 @@ ssh_keyfile=/keys/id
|
||||
assert run_cli('-c', config('backup_dir=/backups'), *argv) == 0
|
||||
assert last_backup().progress is progress
|
||||
|
||||
@pytest.mark.parametrize('tty, estimate', [(True, 1234), (False, 0)])
|
||||
def test_file_estimate(self, run_cli: RunCli, config: Callable[[str], str], monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path, tty: bool, estimate: int) -> None:
|
||||
monkeypatch.setattr(sb.sys.stderr, 'isatty', lambda: tty)
|
||||
state = tmp_path / 'state' / 'simple_backup' / 'last_backup'
|
||||
state.parent.mkdir(parents=True)
|
||||
state.write_text('2026-10-25T10:00:00+02:00\n/backups/old\n1234\n')
|
||||
|
||||
assert run_cli('-c', config('backup_dir=/backups')) == 0
|
||||
assert last_backup().file_estimate == estimate
|
||||
|
||||
def test_check_params_failure(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
|
||||
FakeBackup.check_code = 2
|
||||
|
||||
|
||||
@@ -207,6 +207,19 @@ class TestRsync:
|
||||
assert 'rsync: total size' in caplog.text
|
||||
assert '%' not in caplog.text
|
||||
|
||||
def test_file_count(self, src: Path, out: Path) -> None:
|
||||
backup = make_backup(out, [str(src)], progress=True)
|
||||
|
||||
assert backup.run() == 0
|
||||
# The source folder, its two files and the implied parent folders
|
||||
assert backup.file_count is not None and backup.file_count >= 3
|
||||
|
||||
def test_no_file_count_without_progress(self, src: Path, out: Path) -> None:
|
||||
backup = make_backup(out, [str(src)])
|
||||
|
||||
assert backup.run() == 0
|
||||
assert backup.file_count is None
|
||||
|
||||
def test_cli(self, src: Path, out: Path, tmp_path: Path, run_cli: Callable[..., int]) -> None:
|
||||
config_file = str(tmp_path / 'missing.conf')
|
||||
|
||||
@@ -280,6 +293,30 @@ class TestProgressBar:
|
||||
assert bar.update(' 3.00M 2% 1.47MB/s 0:00:03')
|
||||
assert capsys.readouterr().err.endswith('] 50% 2/4 files 3.00M copied ETA 0:00:30\033[K')
|
||||
|
||||
def test_estimate_while_scanning(self, monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str]) -> None:
|
||||
monkeypatch.setattr(sb, 'get_terminal_size', lambda: os.terminal_size((100, 24)))
|
||||
bar = sb._ProgressBar(estimate=4000)
|
||||
|
||||
assert bar.update(' 2.00M 25% 1.47MB/s 0:00:02 (xfr#1, ir-chk=1000/2000)')
|
||||
assert bar.total is None
|
||||
assert capsys.readouterr().err == \
|
||||
'\r[#######-----------------------] ~25% 1,000/~4,000 files 2.00M copied ETA ~0:01:30\033[K'
|
||||
|
||||
def test_estimate_exceeded(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
bar = sb._ProgressBar(estimate=1000)
|
||||
|
||||
# More files than in the last backup: back to plain scanning
|
||||
assert bar.update(' 2.00M 25% 1.47MB/s 0:00:02 (xfr#1, ir-chk=500/2000)')
|
||||
assert capsys.readouterr().err == '\r1,500 files checked 2.00M copied (scanning)\033[K'
|
||||
|
||||
def test_real_total_replaces_estimate(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
bar = sb._ProgressBar(estimate=4000)
|
||||
|
||||
assert bar.update(' 4,000,000 1% 2.97MB/s 0:00:01 (xfr#2, to-chk=1/4)')
|
||||
assert bar.total == 4
|
||||
assert ' 75% 3/4 files' in capsys.readouterr().err
|
||||
|
||||
def test_not_progress(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
bar = sb._ProgressBar()
|
||||
|
||||
|
||||
@@ -49,6 +49,29 @@ def test_overwrite(state: Path) -> None:
|
||||
assert state.read_text().splitlines()[1] == DESTINATION
|
||||
|
||||
|
||||
def test_file_count(state: Path) -> None:
|
||||
sb._write_state(None, DESTINATION, 1234)
|
||||
|
||||
assert state.read_text().splitlines()[2] == '1234'
|
||||
assert sb._last_file_count(None) == 1234
|
||||
|
||||
|
||||
def test_file_count_kept_when_unknown(state: Path) -> None:
|
||||
write_state(state, '2026-10-01T10:00:00+02:00\n/old\n1234\n')
|
||||
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
assert state.read_text().splitlines()[1:] == [DESTINATION, '1234']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', [None, '2026-10-01T10:00:00+02:00\n/old\n', '2026-10-01T10:00:00+02:00\n/old\nx\n'])
|
||||
def test_no_file_count(state: Path, text: str | None) -> None:
|
||||
if text is not None:
|
||||
write_state(state, text)
|
||||
|
||||
assert sb._last_file_count(None) == 0
|
||||
|
||||
|
||||
def test_relative_xdg_state_home_ignored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv('XDG_STATE_HOME', 'relative')
|
||||
monkeypatch.setenv('HOME', str(tmp_path / 'home'))
|
||||
|
||||
Reference in New Issue
Block a user