Compare commits

...
7 Commits
Author SHA1 Message Date
Fuxino 980913ee2a Bump version to 4.6.3 2026-11-08 14:38:01 +01:00
Fuxino 0773a384dd Fix stale man page options and sample config comments 2026-11-08 14:35:59 +01:00
Fuxino 4bce0edba5 Update stale README sections 2026-11-08 14:31:13 +01:00
Fuxino be93015136 Update PKGBUILD 2026-11-02 20:57:17 +01:00
Fuxino c2ef47e4ea Bump version to 4.6.2 2026-11-02 20:56:33 +01:00
Fuxino ec27b8d77f 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.
2026-11-02 20:56:12 +01:00
Fuxino 5e33781974 Update PKGBUILD 2026-11-02 20:26:10 +01:00
9 changed files with 199 additions and 57 deletions
+2 -2
View File
@@ -4,7 +4,7 @@
pkgname=simple_backup pkgname=simple_backup
pkgdesc='Simple backup script that uses rsync to copy files' pkgdesc='Simple backup script that uses rsync to copy files'
pkgver=4.6.0 pkgver=4.6.2
pkgrel=1 pkgrel=1
url="https://git.shouldnt.work/fuxino/$pkgname" url="https://git.shouldnt.work/fuxino/$pkgname"
arch=('any') arch=('any')
@@ -24,7 +24,7 @@ checkdepends=('python-pytest'
conflicts=('simple_backup-git') conflicts=('simple_backup-git')
source=("git+$url?signed#tag=$pkgver") source=("git+$url?signed#tag=$pkgver")
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514') validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('049c900b2d778434baa579409ccbb2b66ddb0968cf1dd23b3de0eda65ef3d2c6') sha256sums=('bbe7b79669fe02ca4dc62fb08b8b5eaed809b5f1ece9586017af8ebf20d2faa0')
build() build()
{ {
+13 -3
View File
@@ -14,6 +14,7 @@ simple_backup -h
to print all possible command line options. to print all possible command line options.
## Dependencies ## Dependencies
simple_backup requires Python 3.10 or later.
The script uses rsync to actually run the backup, so you will have to install it on your system. For example on Arch Linux: The script uses rsync to actually run the backup, so you will have to install it on your system. For example on Arch Linux:
```bash ```bash
@@ -57,21 +58,28 @@ To back up files that your user can't read, run simple_backup with sudo or doas.
In that case the user who ran the command is detected automatically, so that '~' (in the inputs and in the default configuration file path) refers to that user's home directory, not root's. In that case the user who ran the command is detected automatically, so that '~' (in the inputs and in the default configuration file path) refers to that user's home directory, not root's.
When running as root in other ways (e.g. from a systemd service), specify the user with the --user (or -u) argument. When running as root in other ways (e.g. from a systemd service), specify the user with the --user (or -u) argument.
## Progress
When run in a terminal, simple_backup shows a progress bar with the percentage of files copied and an estimated time to completion.
Disable it with --no-progress.
## Old backups ## Old backups
The number of old backups to keep can be set with the --keep (or -k) argument (or in the configuration file). The number of old backups to keep can be set with the --keep (or -k) argument (or in the configuration file).
Old backups are only removed if rsync completes successfully, so that they are not deleted when the new backup may be incomplete. By default, old backups are only removed if rsync completes successfully, so that they are not deleted when the new backup may be incomplete.
The only rsync error that is ignored is return code 24 (source files vanished during the transfer), which is common when backing up a live system. The only rsync error that is ignored is return code 24 (source files vanished during the transfer), which is common when backing up a live system.
To free space on the destination first, use --remove-before-backup: old backups are then removed before rsync starts, even if the new backup fails.
## Last backup ## Last backup
After each successful backup, simple_backup saves its time and directory to `~/.local/state/simple_backup/last_backup` (or `$XDG_STATE_HOME/simple_backup/last_backup`). After each successful backup, simple_backup saves its time and directory to `~/.local/state/simple_backup/last_backup` (or `$XDG_STATE_HOME/simple_backup/last_backup`).
It also saves the number of files, so that the next run can show progress and an estimated time while rsync is still looking for files.
When run with sudo or doas, the file goes to the home directory of the user who ran the command. When run with sudo or doas, the file goes to the home directory of the user who ran the command.
Print it with: To see when the last backup was made (e.g. `Last backup: 2026-10-25 14:03 (2 days ago)`), run:
```bash ```bash
simple_backup --last-backup simple_backup --last-backup
``` ```
The file has two lines: the completion time in ISO 8601 format (e.g. `2026-10-25T14:03:12+02:00`) and the backup directory, so scripts (e.g. a status bar indicator) can easily read it to remind you when a backup is overdue. The first line of the file is the completion time in ISO 8601 format (e.g. `2026-10-25T14:03:12+02:00`), the second the backup directory, and the third (if known) the number of files.
Scripts (e.g. a status bar indicator) can easily read the first line to remind you when a backup is overdue.
## Remote backup ## Remote backup
> **Warning** > **Warning**
@@ -101,4 +109,6 @@ sudo --preserve-env=SSH_AUTH_SOCK -s simple_backup [options]
or by editing the sudoers file. or by editing the sudoers file.
If SSH key authentication is not available, password authentication will be used instead. If SSH key authentication is not available, password authentication will be used instead.
In that case, if sshpass is installed, it is used to pass the password to rsync, so that you only have to enter it once; otherwise you will be asked for it more than once.
sshpass has some security risks (see sshpass(1)), so use SSH key authentication if possible.
Check the man page for more details. Check the man page for more details.
+44 -32
View File
@@ -1,31 +1,34 @@
.TH SIMPLE_BACKUP 1 2026-11-02 SIMPLE_BACKUP 4.6.1 .TH SIMPLE_BACKUP 1 2026-11-08 SIMPLE_BACKUP 4.6.3
.SH NAME .SH NAME
simple_backup \- Backup files and folders using rsync simple_backup \- Backup files and folders using rsync
.SH SYNOPSIS .SH SYNOPSIS
.B simple_backup .SY simple_backup
\-h, \-\-help \-h,\~\-\-help
.PD 0 .YS
.P .SY simple_backup
.PD \-\-last\-backup
.B simple_backup [\-u,\~\-\-user\~USERNAME]
[\-c, \-\-config FILE] .YS
[\-i, \-\-input INPUT [INPUT...]] .SY simple_backup
[\-o, \-\-output DIR] [\-v,\~\-\-verbose]
.PD 0 [\-c,\~\-\-config\~FILE]
.P [\-i,\~\-\-inputs\~INPUT\~[INPUT...]]
.PD [\-o,\~\-\-output\~DIR]
.RS 14 [\-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]] [\-e,\~\-\-exclude\~FILE|DIR|PATTERN\~[FILE|...]]
[\-k, \-\-keep N] [\-k,\~\-\-keep\~N]
[\-\-ssh\-host HOSTNAME] [\-u,\~\-\-user\~USERNAME]
[\-\-ssh\-user USERNAME] [\-\-ssh\-host\~HOSTNAME]
[\-\-keyfile FILE] [\-\-ssh\-user\~USERNAME]
.PD 0 [\-\-keyfile\~FILE]
.P [\-\-remote\-sudo]
.PD [\-\-numeric\-ids]
[\-s, \-\-checksum] [\-s,\~\-\-checksum]
[\-z, \-\-compress] [\-z,\~\-\-compress]
[\-\-rsync\-options\~OPTION\~[OPTION...]]
[\-\-remove\-before\-backup] [\-\-remove\-before\-backup]
.RE [\-\-no\-syslog]
[\-\-no\-progress]
.YS
.SH DESCRIPTION .SH DESCRIPTION
.B simple_backup .B simple_backup
is a python script for performing backup of files and folders. is a python script for performing backup of files and folders.
@@ -36,15 +39,20 @@ in a configuration file (default location $HOME/.config/simple_backup/simple_bac
or directly on the command line. or directly on the command line.
.P .P
Parameters specified on the command line will override those in the configuration file. Parameters specified on the command line will override those in the configuration file.
The exceptions are remote_sudo and numeric_ids: if enabled in the configuration file, they
can\(aqt be disabled on the command line.
.SH OPTIONS .SH OPTIONS
.TP .TP
.B \-h, \-\-help .B \-h, \-\-help
Print a short help message and exit. Print a short help message and exit.
.TP .TP
.B \-v, \-\-verbose
More verbose output.
.TP
.B \-c, \-\-config FILE .B \-c, \-\-config FILE
Specify the configuration file, useful to specify a different one from the default. Specify the configuration file, useful to specify a different one from the default.
.TP .TP
.B \-i, \-\-input INPUT [INPUT...] .B \-i, \-\-inputs INPUT [INPUT...]
Specify the files and directories to backup. Multiple inputs can be specified, just separate Specify the files and directories to backup. Multiple inputs can be specified, just separate
them with a space. If filenames or paths contain spaces, don't forget to escape them, them with a space. If filenames or paths contain spaces, don't forget to escape them,
or to use single or double quotes around them. or to use single or double quotes around them.
@@ -64,7 +72,7 @@ not preserved either.
.TP .TP
.B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]] .B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
Specify files, directories or patterns to exclude from the backup. Matching files and directories Specify files, directories or patterns to exclude from the backup. Matching files and directories
will not be copied. Multiple elements can be specified, in the same way as for the \-\-input option. will not be copied. Multiple elements can be specified, in the same way as for the \-\-inputs option.
.TP .TP
.B \-k, \-\-keep N .B \-k, \-\-keep N
Specify how many old backups (so excluding the current one) will be kept. The default behavior Specify how many old backups (so excluding the current one) will be kept. The default behavior
@@ -86,13 +94,13 @@ Username for connecting to the server in case of remote backup.
.B \-\-keyfile FILE .B \-\-keyfile FILE
Location of the SSH key for server authentication. Location of the SSH key for server authentication.
.TP .TP
.B \-s, \-\-checksums .B \-s, \-\-checksum
Same as rsync option \(aq\-\-checksum\(aq, use checksums instead of mod\-time and size Same as rsync option \(aq\-\-checksum\(aq, use checksums instead of mod\-time and size
to skip files. to skip files.
.TP .TP
.B \-z, \-\-compress .B \-z, \-\-compress
Compress data during transfer (rsync \(aq\-\-compress\(aq option). Useful for remote backup Compress data during transfer (rsync \(aq\-\-compress\(aq option). Useful for remote backup
if saving bandwith is needed. if saving bandwidth is needed.
.TP .TP
.B \-\-remove\-before\-backup .B \-\-remove\-before\-backup
Remove old backups (if necessary) before creating the new backup. Useful to free some space Remove old backups (if necessary) before creating the new backup. Useful to free some space
@@ -108,8 +116,9 @@ Don't use systemd journal for logging.
.B \-\-no\-progress .B \-\-no\-progress
Don't show a progress bar while rsync copies the files. The progress bar is only shown when 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. 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 While rsync is still looking for files, the percentage and estimated time (marked with
and estimated time are only approximate. \(aq~\(aq) are based on the number of files in the last backup, or the bar shows
\(aq(scanning)\(aq if that is unknown or already exceeded.
.TP .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:
@@ -147,7 +156,8 @@ needs to be allowed to run without password in the same way.
Use rsync \-\-numeric\-ids option. This causes rsync to use numeric uid/gid instead of trying to map uid/gid names from the local machine to the server. Use rsync \-\-numeric\-ids option. This causes rsync to use numeric uid/gid instead of trying to map uid/gid names from the local machine to the server.
.TP .TP
.B \-\-last\-backup .B \-\-last\-backup
Print the date and directory of the last successful backup, then exit. The exit status is 0, or 1 Print the date of the last successful backup and how long ago it was made (for example
\(aqLast backup: 2026\-10\-25 14:03 (2 days ago)\(aq), then exit. The exit status is 0, or 1
if no backup has been recorded yet or the state file can\(aqt be read (see if no backup has been recorded yet or the state file can\(aqt be read (see
.BR FILES ). .BR FILES ).
.SH CONFIGURATION .SH CONFIGURATION
@@ -227,7 +237,9 @@ Bad configuration file or options (for example, only one of SSH host and SSH use
Written after each successful backup (local or remote), replacing the previous one. The first line Written after each successful backup (local or remote), replacing the previous one. The first line
is the completion time in ISO 8601 format with the UTC offset (for example is the completion time in ISO 8601 format with the UTC offset (for example
\(aq2026\-10\-25T14:03:12+02:00\(aq), the second line the backup directory (prefixed with \(aq2026\-10\-25T14:03:12+02:00\(aq), the second line the backup directory (prefixed with
\(aquser@host:\(aq for remote backups). The default location is \(aquser@host:\(aq for remote backups), and the third line, if present, the number of files, used
for the progress bar estimate (only counted when the progress bar is shown, otherwise the previous
count is kept). The default location is
\fI~/.local/state/simple_backup/last_backup\fR. When running as root with sudo, doas or \-\-user, \fI~/.local/state/simple_backup/last_backup\fR. When running as root with sudo, doas or \-\-user,
the file is written to that user\(aqs \fI~/.local/state\fR and owned by them. Useful for scripts the file is written to that user\(aqs \fI~/.local/state\fR and owned by them. Useful for scripts
that remind you when a backup is overdue. that remind you when a backup is overdue.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Init.""" """Init."""
__version__ = '4.6.1' __version__ = '4.6.3'
+3 -2
View File
@@ -1,7 +1,7 @@
# Example config file for simple_backup # Example config file for simple_backup
[backup] [backup]
# Files and directories to backup. Multiple items can be separated using a comma (','). It is possible to use wildcards (i.e. '*' to match multiple characters and '~' for the user's home directory). # Files and directories to backup. Multiple items can be separated using a comma (','). It is possible to use wildcards (e.g. '*' to match multiple characters) and '~' for the user's home directory.
inputs=/home/user inputs=/home/user
# Output directory. For remote backups, this is a path on the server ('~' and relative paths refer to the home directory on the server). # Output directory. For remote backups, this is a path on the server ('~' and relative paths refer to the home directory on the server).
@@ -13,7 +13,8 @@ exclude=*.bak
# Number of old backups (i.e. excluding the one that's being created) to keep (use -1 to keep all) # Number of old backups (i.e. excluding the one that's being created) to keep (use -1 to keep all)
keep=-1 keep=-1
# Uncomment the following section to enable backup to remote server through ssh # Uncomment the following section to enable backup to remote server through ssh.
# ssh_host can be an alias from ~/.ssh/config (its HostName, Port and IdentityFile are used)
# [server] # [server]
# ssh_host= # ssh_host=
# ssh_user= # ssh_user=
+59 -14
View File
@@ -94,14 +94,27 @@ class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFo
class _ProgressBar: class _ProgressBar:
"""Draws rsync --info=progress2 updates as a bar on stderr""" """Draws rsync --info=progress2 updates as a bar on stderr"""
def __init__(self) -> None: def __init__(self, estimate: int = 0) -> None:
self._start = default_timer() self._start = default_timer()
# rsync reports ir-chk while incremental recursion is still finding files # rsync reports ir-chk while incremental recursion is still finding files
self._scanning = True self._scanning = True
self._checked = 0 self._checked = 0
self._total = 0 self._total = 0
# Total files of the last backup, used until rsync knows the real total
self._estimate = estimate
self._shown = False self._shown = False
@property
def total(self) -> int | None:
"""Total number of files, once rsync has found them all"""
return None if self._scanning or self._total == 0 else self._total
def _eta(self, total: int) -> str:
eta = int((default_timer() - self._start) * (total - self._checked) / self._checked)
return f'{eta // 3600}:{eta // 60 % 60:02d}:{eta % 60:02d}'
def update(self, record: str) -> bool: def update(self, record: str) -> bool:
"""Draw the bar if record is a progress update, and return whether it was""" """Draw the bar if record is a progress update, and return whether it was"""
@@ -119,20 +132,29 @@ class _ProgressBar:
self._checked = self._total - int(m.group(3)) self._checked = self._total - int(m.group(3))
# Count files, not bytes: with --link-dest unchanged files are linked, and rsync's percentage leaves them out # Count files, not bytes: with --link-dest unchanged files are linked, and rsync's percentage leaves them out
if self._scanning or self._total == 0: percent: int | None
text = f'{self._checked:,} files checked {size} copied (scanning)'
percent = None if self.total is None:
# Estimate from the last backup, unless this one already has more files
if 0 < self._checked < self._estimate:
percent = self._checked * 100 // self._estimate
text = f'{f"~{percent}":>3}% {self._checked:,}/~{self._estimate:,} files {size} copied'
if percent > 0:
text += f' ETA ~{self._eta(self._estimate)}'
else:
text = f'{self._checked:,} files checked {size} copied (scanning)'
percent = None
else: else:
percent = self._checked * 100 // self._total percent = self._checked * 100 // self._total
text = f'{percent:3d}% {self._checked:,}/{self._total:,} files {size} copied' text = f'{percent:3d}% {self._checked:,}/{self._total:,} files {size} copied'
if 0 < percent < 100: if 0 < percent < 100:
eta = int((default_timer() - self._start) * (self._total - self._checked) / self._checked) text += f' ETA {self._eta(self._total)}'
text += f' ETA {eta // 3600}:{eta // 60 % 60:02d}:{eta % 60:02d}'
width = get_terminal_size().columns - 1 width = get_terminal_size().columns - 1
# Fixed width, so the bar doesn't jump when the text changes length; no bar until the total is known # Fixed width, so the bar doesn't jump when the text changes length; no bar without a total or estimate
if percent is not None and len(text) + PROGRESS_BAR_WIDTH + 4 <= width: if percent is not None and len(text) + PROGRESS_BAR_WIDTH + 4 <= width:
filled = PROGRESS_BAR_WIDTH * percent // 100 filled = PROGRESS_BAR_WIDTH * percent // 100
text = f'[{"#" * filled}{"-" * (PROGRESS_BAR_WIDTH - filled)}] {text}' text = f'[{"#" * filled}{"-" * (PROGRESS_BAR_WIDTH - filled)}] {text}'
@@ -195,7 +217,7 @@ class Backup:
keep: int | None, options: str, keep: int | None, options: str,
ssh_host: str | None = None, ssh_user: str | None = None, ssh_keyfile: str | None = None, 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, remote_sudo: bool = False, remove_before: bool = False, verbose: bool = False,
progress: bool = False) -> None: progress: bool = False, file_estimate: int = 0) -> 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 ''
@@ -209,6 +231,9 @@ class Backup:
self._remove_before = remove_before self._remove_before = remove_before
self._verbose = verbose self._verbose = verbose
self._progress = progress self._progress = progress
self._file_estimate = file_estimate
# Files in the backup, known only with the progress bar
self.file_count: int | None = None
self._last_backup = '' self._last_backup = ''
self._server = '' self._server = ''
self._output_dir = '' self._output_dir = ''
@@ -768,7 +793,7 @@ class Backup:
if self._progress: if self._progress:
args.append('--info=progress2') args.append('--info=progress2')
bar = _ProgressBar() bar = _ProgressBar(self._file_estimate)
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False, env=env) as p: with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False, env=env) 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)
@@ -787,6 +812,9 @@ class Backup:
returncode = p.wait() returncode = p.wait()
if bar is not None:
self.file_count = bar.total
if returncode == 0: if returncode == 0:
# Without -v, only the summary, which may be fewer than two lines (e.g. with --rsync-options q) # Without -v, only the summary, which may be fewer than two lines (e.g. with --rsync-options q)
for r in output: for r in output:
@@ -1105,12 +1133,28 @@ def _state_file(owner: pwd.struct_passwd | None) -> str:
return os.path.join(base, 'simple_backup', 'last_backup') return os.path.join(base, 'simple_backup', 'last_backup')
def _write_state(user: str | None, destination: str) -> None: def _last_file_count(user: str | None) -> int:
"""Record the time and directory of the last successful backup (two lines)""" """Files in the last backup (third line of the state file), or 0 if unknown"""
try:
with open(_state_file(_state_owner(user)), encoding='utf-8') as fp:
return int(fp.read().splitlines()[2])
except (OSError, ValueError, IndexError):
return 0
def _write_state(user: str | None, destination: str, file_count: int | None = None) -> None:
"""Record the time, directory and file count of the last successful backup (one per line)"""
owner = _state_owner(user) owner = _state_owner(user)
path = _state_file(owner) path = _state_file(owner)
tmp = '' tmp = ''
# Without the progress bar the count is unknown, so keep the previous one
if file_count is None:
file_count = _last_file_count(user)
count_line = f'{file_count}\n' if file_count > 0 else ''
euid, egid = os.geteuid(), os.getegid() euid, egid = os.geteuid(), os.getegid()
# Create the file (and any missing directories) as the user, not root # Create the file (and any missing directories) as the user, not root
@@ -1125,7 +1169,7 @@ def _write_state(user: str | None, destination: str) -> None:
fd, tmp = mkstemp(prefix='.last_backup', dir=os.path.dirname(path), text=True) fd, tmp = mkstemp(prefix='.last_backup', dir=os.path.dirname(path), text=True)
with os.fdopen(fd, 'w', encoding='utf-8') as fp: with os.fdopen(fd, 'w', encoding='utf-8') as fp:
fp.write(f'{datetime.now().astimezone().isoformat(timespec="seconds")}\n{destination}\n') fp.write(f'{datetime.now().astimezone().isoformat(timespec="seconds")}\n{destination}\n{count_line}')
os.replace(tmp, path) os.replace(tmp, path)
except OSError as e: except OSError as e:
@@ -1260,9 +1304,10 @@ def simple_backup() -> int:
if args.numeric_ids or config_args['numeric_ids']: if args.numeric_ids or config_args['numeric_ids']:
rsync_options.append('--numeric-ids') rsync_options.append('--numeric-ids')
progress = not args.no_progress and sys.stderr.isatty()
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()) progress=progress, file_estimate=_last_file_count(user) if progress else 0)
try: try:
return_code = backup.check_params(homedir) return_code = backup.check_params(homedir)
@@ -1273,7 +1318,7 @@ def simple_backup() -> int:
return_code = backup.run() return_code = backup.run()
if return_code == 0: if return_code == 0:
_write_state(user, backup.destination) _write_state(user, backup.destination, backup.file_count)
return return_code return return_code
finally: finally:
+16 -2
View File
@@ -32,7 +32,8 @@ 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: str | None = None, ssh_user: str | None = None, ssh_host: str | None = None, ssh_user: str | None = None,
ssh_keyfile: str | None = None, remote_sudo: bool = False, 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.inputs = inputs
self.output = output self.output = output
self.exclude = exclude self.exclude = exclude
@@ -45,6 +46,8 @@ class FakeBackup:
self.remove_before = remove_before self.remove_before = remove_before
self.verbose = verbose self.verbose = verbose
self.progress = progress self.progress = progress
self.file_estimate = file_estimate
self.file_count: int | None = None
self.homedir: str | None = None self.homedir: str | None = None
self.ran = False self.ran = False
self.closed = False self.closed = False
@@ -87,7 +90,7 @@ def as_root(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(os, 'geteuid', lambda: 0) monkeypatch.setattr(os, 'geteuid', lambda: 0)
# Writing the state file as root would call seteuid() (covered in test_state.py) # 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']: for var in ['SUDO_USER', 'SUDO_UID', 'DOAS_USER']:
monkeypatch.delenv(var, raising=False) monkeypatch.delenv(var, raising=False)
@@ -266,6 +269,17 @@ ssh_keyfile=/keys/id
assert run_cli('-c', config('backup_dir=/backups'), *argv) == 0 assert run_cli('-c', config('backup_dir=/backups'), *argv) == 0
assert last_backup().progress is progress 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: def test_check_params_failure(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
FakeBackup.check_code = 2 FakeBackup.check_code = 2
+37
View File
@@ -207,6 +207,19 @@ class TestRsync:
assert 'rsync: total size' in caplog.text assert 'rsync: total size' in caplog.text
assert '%' not 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: 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')
@@ -280,6 +293,30 @@ class TestProgressBar:
assert bar.update(' 3.00M 2% 1.47MB/s 0:00:03') 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') 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: def test_not_progress(self, capsys: pytest.CaptureFixture[str]) -> None:
bar = sb._ProgressBar() bar = sb._ProgressBar()
+23
View File
@@ -49,6 +49,29 @@ def test_overwrite(state: Path) -> None:
assert state.read_text().splitlines()[1] == DESTINATION 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: def test_relative_xdg_state_home_ignored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv('XDG_STATE_HOME', 'relative') monkeypatch.setenv('XDG_STATE_HOME', 'relative')
monkeypatch.setenv('HOME', str(tmp_path / 'home')) monkeypatch.setenv('HOME', str(tmp_path / 'home'))