Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b6d77f30
|
||
|
|
980913ee2a
|
||
|
|
0773a384dd
|
||
|
|
4bce0edba5
|
||
|
|
be93015136
|
||
|
|
c2ef47e4ea
|
||
|
|
ec27b8d77f
|
||
|
|
5e33781974
|
@@ -4,7 +4,7 @@
|
||||
|
||||
pkgname=simple_backup
|
||||
pkgdesc='Simple backup script that uses rsync to copy files'
|
||||
pkgver=4.6.0
|
||||
pkgver=4.6.3
|
||||
pkgrel=1
|
||||
url="https://git.shouldnt.work/fuxino/$pkgname"
|
||||
arch=('any')
|
||||
@@ -24,7 +24,7 @@ checkdepends=('python-pytest'
|
||||
conflicts=('simple_backup-git')
|
||||
source=("git+$url?signed#tag=$pkgver")
|
||||
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
||||
sha256sums=('049c900b2d778434baa579409ccbb2b66ddb0968cf1dd23b3de0eda65ef3d2c6')
|
||||
sha256sums=('b1e7802fcd5e615c92f6007238b3699d69e3005e140d87a742283ef1cc6bd37f')
|
||||
|
||||
build()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ simple_backup -h
|
||||
to print all possible command line options.
|
||||
|
||||
## 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:
|
||||
|
||||
```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.
|
||||
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
|
||||
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.
|
||||
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
|
||||
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.
|
||||
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
|
||||
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
|
||||
> **Warning**
|
||||
@@ -101,4 +109,6 @@ sudo --preserve-env=SSH_AUTH_SOCK -s simple_backup [options]
|
||||
|
||||
or by editing the sudoers file.
|
||||
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.
|
||||
|
||||
+44
-32
@@ -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
|
||||
simple_backup \- Backup files and folders using rsync
|
||||
.SH SYNOPSIS
|
||||
.B simple_backup
|
||||
\-h, \-\-help
|
||||
.PD 0
|
||||
.P
|
||||
.PD
|
||||
.B simple_backup
|
||||
[\-c, \-\-config FILE]
|
||||
[\-i, \-\-input INPUT [INPUT...]]
|
||||
[\-o, \-\-output DIR]
|
||||
.PD 0
|
||||
.P
|
||||
.PD
|
||||
.RS 14 [\-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
|
||||
[\-k, \-\-keep N]
|
||||
[\-\-ssh\-host HOSTNAME]
|
||||
[\-\-ssh\-user USERNAME]
|
||||
[\-\-keyfile FILE]
|
||||
.PD 0
|
||||
.P
|
||||
.PD
|
||||
[\-s, \-\-checksum]
|
||||
[\-z, \-\-compress]
|
||||
.SY simple_backup
|
||||
\-h,\~\-\-help
|
||||
.YS
|
||||
.SY simple_backup
|
||||
\-\-last\-backup
|
||||
[\-u,\~\-\-user\~USERNAME]
|
||||
.YS
|
||||
.SY simple_backup
|
||||
[\-v,\~\-\-verbose]
|
||||
[\-c,\~\-\-config\~FILE]
|
||||
[\-i,\~\-\-inputs\~INPUT\~[INPUT...]]
|
||||
[\-o,\~\-\-output\~DIR]
|
||||
[\-e,\~\-\-exclude\~FILE|DIR|PATTERN\~[FILE|...]]
|
||||
[\-k,\~\-\-keep\~N]
|
||||
[\-u,\~\-\-user\~USERNAME]
|
||||
[\-\-ssh\-host\~HOSTNAME]
|
||||
[\-\-ssh\-user\~USERNAME]
|
||||
[\-\-keyfile\~FILE]
|
||||
[\-\-remote\-sudo]
|
||||
[\-\-numeric\-ids]
|
||||
[\-s,\~\-\-checksum]
|
||||
[\-z,\~\-\-compress]
|
||||
[\-\-rsync\-options\~OPTION\~[OPTION...]]
|
||||
[\-\-remove\-before\-backup]
|
||||
.RE
|
||||
[\-\-no\-syslog]
|
||||
[\-\-no\-progress]
|
||||
.YS
|
||||
.SH DESCRIPTION
|
||||
.B simple_backup
|
||||
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.
|
||||
.P
|
||||
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
|
||||
.TP
|
||||
.B \-h, \-\-help
|
||||
Print a short help message and exit.
|
||||
.TP
|
||||
.B \-v, \-\-verbose
|
||||
More verbose output.
|
||||
.TP
|
||||
.B \-c, \-\-config FILE
|
||||
Specify the configuration file, useful to specify a different one from the default.
|
||||
.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
|
||||
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.
|
||||
@@ -64,7 +72,7 @@ not preserved either.
|
||||
.TP
|
||||
.B \-e, \-\-exclude FILE|DIR|PATTERN [FILE|...]]
|
||||
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
|
||||
.B \-k, \-\-keep N
|
||||
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
|
||||
Location of the SSH key for server authentication.
|
||||
.TP
|
||||
.B \-s, \-\-checksums
|
||||
.B \-s, \-\-checksum
|
||||
Same as rsync option \(aq\-\-checksum\(aq, use checksums instead of mod\-time and size
|
||||
to skip files.
|
||||
.TP
|
||||
.B \-z, \-\-compress
|
||||
Compress data during transfer (rsync \(aq\-\-compress\(aq option). Useful for remote backup
|
||||
if saving bandwith is needed.
|
||||
if saving bandwidth is needed.
|
||||
.TP
|
||||
.B \-\-remove\-before\-backup
|
||||
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
|
||||
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.
|
||||
While rsync is still looking for files, the percentage and estimated time (marked with
|
||||
\(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
|
||||
.B \-\-rsync\-options OPTIONS [OPTION...]
|
||||
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.
|
||||
.TP
|
||||
.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
|
||||
.BR FILES ).
|
||||
.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
|
||||
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
|
||||
\(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,
|
||||
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.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Init."""
|
||||
|
||||
__version__ = '4.6.1'
|
||||
__version__ = '4.6.3'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Example config file for simple_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
|
||||
|
||||
# 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)
|
||||
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]
|
||||
# ssh_host=
|
||||
# ssh_user=
|
||||
|
||||
@@ -94,14 +94,27 @@ class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFo
|
||||
class _ProgressBar:
|
||||
"""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()
|
||||
# rsync reports ir-chk while incremental recursion is still finding files
|
||||
self._scanning = True
|
||||
self._checked = 0
|
||||
self._total = 0
|
||||
# Total files of the last backup, used until rsync knows the real total
|
||||
self._estimate = estimate
|
||||
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:
|
||||
"""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))
|
||||
|
||||
# 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:
|
||||
text = f'{self._checked:,} files checked {size} copied (scanning)'
|
||||
percent = None
|
||||
percent: int | 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:
|
||||
percent = self._checked * 100 // self._total
|
||||
text = f'{percent:3d}% {self._checked:,}/{self._total:,} files {size} copied'
|
||||
|
||||
if 0 < percent < 100:
|
||||
eta = int((default_timer() - self._start) * (self._total - self._checked) / self._checked)
|
||||
text += f' ETA {eta // 3600}:{eta // 60 % 60:02d}:{eta % 60:02d}'
|
||||
text += f' ETA {self._eta(self._total)}'
|
||||
|
||||
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:
|
||||
filled = PROGRESS_BAR_WIDTH * percent // 100
|
||||
text = f'[{"#" * filled}{"-" * (PROGRESS_BAR_WIDTH - filled)}] {text}'
|
||||
@@ -195,7 +217,7 @@ class Backup:
|
||||
keep: int | None, 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:
|
||||
progress: bool = False, file_estimate: int = 0) -> None:
|
||||
# Missing values are reported by check_params()
|
||||
self.inputs = inputs if inputs 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._verbose = verbose
|
||||
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._server = ''
|
||||
self._output_dir = ''
|
||||
@@ -768,7 +793,7 @@ class Backup:
|
||||
|
||||
if self._progress:
|
||||
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:
|
||||
assert p.stdin is not None and isinstance(p.stdout, io.BufferedIOBase)
|
||||
@@ -787,6 +812,9 @@ class Backup:
|
||||
|
||||
returncode = p.wait()
|
||||
|
||||
if bar is not None:
|
||||
self.file_count = bar.total
|
||||
|
||||
if returncode == 0:
|
||||
# Without -v, only the summary, which may be fewer than two lines (e.g. with --rsync-options q)
|
||||
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')
|
||||
|
||||
|
||||
def _write_state(user: str | None, destination: str) -> None:
|
||||
"""Record the time and directory of the last successful backup (two lines)"""
|
||||
def _last_file_count(user: str | None) -> int:
|
||||
"""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)
|
||||
path = _state_file(owner)
|
||||
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()
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
except OSError as e:
|
||||
@@ -1260,9 +1304,10 @@ def simple_backup() -> int:
|
||||
if args.numeric_ids or config_args['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,
|
||||
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:
|
||||
return_code = backup.check_params(homedir)
|
||||
@@ -1273,7 +1318,7 @@ def simple_backup() -> int:
|
||||
return_code = backup.run()
|
||||
|
||||
if return_code == 0:
|
||||
_write_state(user, backup.destination)
|
||||
_write_state(user, backup.destination, backup.file_count)
|
||||
|
||||
return return_code
|
||||
finally:
|
||||
|
||||
+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