Add a progress bar for rsync

On a terminal, run rsync with --info=progress2 and draw its updates as a
progress bar on stderr. The ETA is estimated locally, since rsync reports
the elapsed time when a file finishes. Disable it with --no-progress.
This commit is contained in:
Fuxino
2026-10-14 09:46:50 +02:00
parent d0fb5b65fb
commit 7cd90f8d11
4 changed files with 152 additions and 7 deletions
+6
View File
@@ -105,6 +105,12 @@ according to \-\-keep will be left, and the new backup may be incomplete. In par
.B \-\-no\-syslog .B \-\-no\-syslog
Don't use systemd journal for logging. Don't use systemd journal for logging.
.TP .TP
.B \-\-no\-progress
Don't show a progress bar while rsync copies the files. The progress bar is only shown when
the output is a terminal, so it never appears in the systemd journal or in redirected output.
While rsync is still looking for files, the bar shows \(aq(scanning)\(aq, and the percentage
and estimated time are only approximate.
.TP
.B \-\-rsync\-options OPTIONS [OPTION...] .B \-\-rsync\-options OPTIONS [OPTION...]
By default, the following rsync options are used: By default, the following rsync options are used:
.RS .RS
+82 -4
View File
@@ -23,7 +23,7 @@ import re
from typing import Callable, Iterator, List, Optional, ParamSpec, TypedDict, TypeVar from typing import Callable, Iterator, List, Optional, ParamSpec, TypedDict, TypeVar
import warnings import warnings
from functools import wraps from functools import wraps
from shutil import rmtree, which from shutil import get_terminal_size, rmtree, which
import shlex import shlex
import argparse import argparse
import configparser import configparser
@@ -74,6 +74,10 @@ R = TypeVar('R')
# Seconds to wait for the TCP connection to the SSH server # Seconds to wait for the TCP connection to the SSH server
SSH_TIMEOUT = 15 SSH_TIMEOUT = 15
# An rsync --info=progress2 update: size, percent, speed, time, then optionally (xfr#N, ir-chk/to-chk=N/N)
PROGRESS_RE = re.compile(r'\s*(\S+)\s+(\d+)%\s+(\S+)\s+\d+:\d\d:\d\d(?:\s+\(xfr#\d+, (ir|to)-chk=\d+/\d+\))?\s*')
PROGRESS_BAR_WIDTH = 30
def timing(func: Callable[P, R]) -> Callable[P, R]: def timing(func: Callable[P, R]) -> Callable[P, R]:
"""Decorator to measure execution time of a function """Decorator to measure execution time of a function
@@ -100,6 +104,59 @@ class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFo
"""Custom format for argparse help text""" """Custom format for argparse help text"""
class _ProgressBar:
"""Draws rsync --info=progress2 updates as a bar on stderr"""
def __init__(self) -> None:
self._start = default_timer()
# rsync reports ir-chk while incremental recursion is still finding files
self._scanning = True
self._shown = False
def update(self, record: str) -> bool:
"""Draw the bar if record is a progress update, and return whether it was"""
m = PROGRESS_RE.fullmatch(record)
if m is None:
return False
size, percent, speed, chk = m.group(1), int(m.group(2)), m.group(3), m.group(4)
if chk is not None:
self._scanning = chk == 'ir'
# rsync's own time field is the elapsed time on records ending a file, so estimate the ETA here
if 0 < percent < 100:
eta = int((default_timer() - self._start) * (100 - percent) / percent)
text = f'{percent:3d}% {size} {speed} ETA {eta // 3600}:{eta // 60 % 60:02d}:{eta % 60:02d}'
else:
text = f'{percent:3d}% {size} {speed}'
if self._scanning:
text += ' (scanning)'
width = get_terminal_size().columns - 1
# Fixed width, so the bar doesn't jump when the text changes length
if len(text) + PROGRESS_BAR_WIDTH + 4 <= width:
filled = PROGRESS_BAR_WIDTH * percent // 100
text = f'[{"#" * filled}{"-" * (PROGRESS_BAR_WIDTH - filled)}] {text}'
sys.stderr.write(f'\r{text[:width]}\033[K')
sys.stderr.flush()
self._shown = True
return True
def clear(self) -> None:
"""Remove the bar, so that log messages start on a clean line"""
if self._shown:
sys.stderr.write('\r\033[K')
sys.stderr.flush()
class Backup: class Backup:
"""Main class defining parameters and functions for performing backup """Main class defining parameters and functions for performing backup
@@ -124,6 +181,8 @@ class Backup:
Run remote rsync with sudo Run remote rsync with sudo
remove_before: bool remove_before: bool
Indicate if removing old backups will be performed before copying files Indicate if removing old backups will be performed before copying files
progress: bool
Show a progress bar while rsync runs
Methods: Methods:
check_params(): check_params():
@@ -141,7 +200,8 @@ class Backup:
def __init__(self, inputs: Optional[List[str]], output: Optional[str], exclude: Optional[List[str]], def __init__(self, inputs: Optional[List[str]], output: Optional[str], exclude: Optional[List[str]],
keep: Optional[int], options: str, keep: Optional[int], options: str,
ssh_host: Optional[str] = None, ssh_user: Optional[str] = None, ssh_keyfile: Optional[str] = None, ssh_host: Optional[str] = None, ssh_user: Optional[str] = None, ssh_keyfile: Optional[str] = None,
remote_sudo: bool = False, remove_before: bool = False, verbose: bool = False) -> None: remote_sudo: bool = False, remove_before: bool = False, verbose: bool = False,
progress: bool = False) -> None:
# Missing values are reported by check_params() # Missing values are reported by check_params()
self.inputs = inputs if inputs is not None else [] self.inputs = inputs if inputs is not None else []
self.output = output if output is not None else '' self.output = output if output is not None else ''
@@ -154,6 +214,7 @@ class Backup:
self.remote_sudo = remote_sudo self.remote_sudo = remote_sudo
self._remove_before = remove_before self._remove_before = remove_before
self._verbose = verbose self._verbose = verbose
self._progress = progress
self._last_backup = '' self._last_backup = ''
self._server = '' self._server = ''
self._output_dir = '' self._output_dir = ''
@@ -648,11 +709,25 @@ class Backup:
if self._remote and self.remote_sudo: if self._remote and self.remote_sudo:
args.append('--rsync-path=sudo rsync') args.append('--rsync-path=sudo rsync')
bar = None
if self._progress:
args.append('--info=progress2')
bar = _ProgressBar()
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p: with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p:
assert p.stdin is not None and isinstance(p.stdout, io.BufferedIOBase) assert p.stdin is not None and isinstance(p.stdout, io.BufferedIOBase)
p.stdin.close() p.stdin.close()
output = [r for r in _read_records(p.stdout) if r != ''] output = []
try:
for r in _read_records(p.stdout):
if r != '' and (bar is None or not bar.update(r)):
output.append(r)
finally:
if bar is not None:
bar.clear()
try: try:
del os.environ['SSHPASS'] del os.environ['SSHPASS']
@@ -766,6 +841,8 @@ def _parse_arguments() -> argparse.Namespace:
help='Remove old backups before executing the backup, instead of after') help='Remove old backups before executing the backup, instead of after')
parser.add_argument('--no-syslog', action='store_true', parser.add_argument('--no-syslog', action='store_true',
help='Disable systemd journal logging') help='Disable systemd journal logging')
parser.add_argument('--no-progress', action='store_true',
help='Don\'t show a progress bar (it is only shown on a terminal)')
parser.add_argument('--rsync-options', nargs='+', parser.add_argument('--rsync-options', nargs='+',
choices=['a', 'l', 'p', 't', 'g', 'o', choices=['a', 'l', 'p', 't', 'g', 'o',
'c', 'h', 's', 'D', 'H', 'X'], 'c', 'h', 's', 'D', 'H', 'X'],
@@ -1039,7 +1116,8 @@ def simple_backup() -> int:
rsync_options.append('--numeric-ids') rsync_options.append('--numeric-ids')
backup = Backup(inputs, output, exclude, keep, ' '.join(rsync_options), ssh_host, ssh_user, ssh_keyfile, backup = Backup(inputs, output, exclude, keep, ' '.join(rsync_options), ssh_host, ssh_user, ssh_keyfile,
remote_sudo, remove_before=args.remove_before_backup, verbose=args.verbose) remote_sudo, remove_before=args.remove_before_backup, verbose=args.verbose,
progress=not args.no_progress and sys.stderr.isatty())
try: try:
return_code = backup.check_params(homedir) return_code = backup.check_params(homedir)
+14 -1
View File
@@ -27,7 +27,7 @@ class FakeBackup:
def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str, def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str,
ssh_host: Optional[str] = None, ssh_user: Optional[str] = None, ssh_host: Optional[str] = None, ssh_user: Optional[str] = None,
ssh_keyfile: Optional[str] = None, remote_sudo: bool = False, ssh_keyfile: Optional[str] = None, remote_sudo: bool = False,
remove_before: bool = False, verbose: bool = False) -> None: remove_before: bool = False, verbose: bool = False, progress: bool = False) -> None:
self.inputs = inputs self.inputs = inputs
self.output = output self.output = output
self.exclude = exclude self.exclude = exclude
@@ -39,6 +39,7 @@ class FakeBackup:
self.remote_sudo = remote_sudo self.remote_sudo = remote_sudo
self.remove_before = remove_before self.remove_before = remove_before
self.verbose = verbose self.verbose = verbose
self.progress = progress
self.homedir: Optional[str] = None self.homedir: Optional[str] = None
self.ran = False self.ran = False
self.closed = False self.closed = False
@@ -224,6 +225,18 @@ ssh_keyfile=/keys/id
b = last_backup() b = last_backup()
assert b.remote_sudo and b.remove_before and b.verbose assert b.remote_sudo and b.remove_before and b.verbose
@pytest.mark.parametrize('tty, argv, progress', [
(True, [], True),
(True, ['--no-progress'], False),
(False, [], False),
])
def test_progress(self, run_cli: RunCli, config: Callable[[str], str], monkeypatch: pytest.MonkeyPatch,
tty: bool, argv: list[str], progress: bool) -> None:
monkeypatch.setattr(sb.sys.stderr, 'isatty', lambda: tty)
assert run_cli('-c', config('backup_dir=/backups'), *argv) == 0
assert last_backup().progress is progress
def test_check_params_failure(self, run_cli: RunCli, config: Callable[[str], str]) -> None: def test_check_params_failure(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
FakeBackup.check_code = 2 FakeBackup.check_code = 2
+50 -2
View File
@@ -53,8 +53,9 @@ def out(tmp_path: Path) -> Path:
def make_backup(out: Path, inputs: list[str], keep: int = -1, exclude: Optional[list[str]] = None, def make_backup(out: Path, inputs: list[str], keep: int = -1, exclude: Optional[list[str]] = None,
remove_before: bool = False) -> sb.Backup: remove_before: bool = False, progress: bool = False) -> sb.Backup:
backup = sb.Backup(inputs, str(out), exclude or [], keep, OPTIONS, remove_before=remove_before) backup = sb.Backup(inputs, str(out), exclude or [], keep, OPTIONS, remove_before=remove_before,
progress=progress)
assert backup.check_params() == 0 assert backup.check_params() == 0
@@ -187,6 +188,16 @@ class TestRsync:
assert run_backup(out, [str(src)], keep=0) == 0 assert run_backup(out, [str(src)], keep=0) == 0
assert backups(out) == ['2026-10-14_12-00-00'] assert backups(out) == ['2026-10-14_12-00-00']
def test_progress(self, src: Path, out: Path, caplog: pytest.LogCaptureFixture,
capsys: pytest.CaptureFixture[str]) -> None:
assert run_backup(out, [str(src)], progress=True) == 0
assert '100%' in capsys.readouterr().err
# Only the summary is logged, not the progress updates
assert 'rsync: sent' in caplog.text
assert 'rsync: total size' in caplog.text
assert '%' not in caplog.text
def test_cli(self, src: Path, out: Path, tmp_path: Path, run_cli: Callable[..., int]) -> None: def test_cli(self, src: Path, out: Path, tmp_path: Path, run_cli: Callable[..., int]) -> None:
config_file = str(tmp_path / 'missing.conf') config_file = str(tmp_path / 'missing.conf')
@@ -219,6 +230,43 @@ class TestReadRecords:
assert list(sb._read_records(stream)) == ['file', '', 'namé', '�'] assert list(sb._read_records(stream)) == ['file', '', 'namé', '�']
class TestProgressBar:
@pytest.fixture(autouse=True)
def terminal(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sb, 'get_terminal_size', lambda: os.terminal_size((81, 24)))
# 30 s elapsed at each update
monkeypatch.setattr(sb, 'default_timer', iter([0.0, 30.0]).__next__)
def test_update(self, capsys: pytest.CaptureFixture[str]) -> None:
bar = sb._ProgressBar()
assert bar.update(' 2.00M 25% 1.47MB/s 0:00:02 (xfr#1, ir-chk=2/4)')
assert capsys.readouterr().err == \
'\r[#######-----------------------] 25% 2.00M 1.47MB/s ETA 0:01:30 (scanning)\033[K'
def test_without_human_readable(self, capsys: pytest.CaptureFixture[str]) -> None:
bar = sb._ProgressBar()
assert bar.update(' 4,000,000 50% 2.97MB/s 0:00:01 (xfr#2, to-chk=1/4)')
assert capsys.readouterr().err.endswith('] 50% 4,000,000 2.97MB/s ETA 0:00:30\033[K')
def test_not_progress(self, capsys: pytest.CaptureFixture[str]) -> None:
bar = sb._ProgressBar()
for record in ['home/user/file 50% 1:00:00', 'sent 1 bytes received 2 bytes', 'total size is 3']:
assert not bar.update(record)
bar.clear()
assert capsys.readouterr().err == ''
def test_narrow_terminal(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
monkeypatch.setattr(sb, 'get_terminal_size', lambda: os.terminal_size((20, 24)))
assert sb._ProgressBar().update(' 6.00M 100% 1.48MB/s 0:00:03 (xfr#3, to-chk=0/4)')
# No room for the bar
assert capsys.readouterr().err == '\r100% 6.00M 1.48MB\033[K'
class FakePopen: class FakePopen:
"""Stands in for rsync: creates the destination and returns a chosen return code""" """Stands in for rsync: creates the destination and returns a chosen return code"""