Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980913ee2a
|
||
|
|
0773a384dd
|
||
|
|
4bce0edba5
|
||
|
|
be93015136
|
||
|
|
c2ef47e4ea
|
||
|
|
ec27b8d77f
|
||
|
|
5e33781974
|
||
|
|
fdb2fc960a
|
||
|
|
c5cfe49758
|
||
|
|
8d0fd46f44
|
||
|
|
b65955f9f6
|
||
|
|
6d54019a18
|
||
|
|
7547db1ea8
|
||
|
|
37e87f31b5
|
||
|
|
4ac8b64085
|
||
|
|
34e3e41382
|
||
|
|
0ffb5c47ac
|
||
|
|
59a3f94ec8
|
||
|
|
408beefc66
|
||
|
|
b54f7e1817
|
||
|
|
9e6aadf7d3
|
||
|
|
ac59969d82
|
||
|
|
db4059ff36
|
||
|
|
dc50ef1b07
|
||
|
|
fa3ba2a6bd
|
||
|
|
e8b088f13e
|
||
|
|
535f7a33fe
|
||
|
|
85bce54b05
|
||
|
|
125b77a839
|
||
|
|
17c104c6c8
|
||
|
|
7dbb1bcaee
|
||
|
|
a7fd325fa7
|
||
|
|
8a479a8455
|
||
|
|
3e486eb3ab
|
||
|
|
331e72b373
|
@@ -1,3 +1,4 @@
|
||||
CLAUDE.md
|
||||
.idea/
|
||||
__pycache__/
|
||||
test/
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
pkgname=simple_backup
|
||||
pkgdesc='Simple backup script that uses rsync to copy files'
|
||||
pkgver=4.3.0
|
||||
pkgver=4.6.2
|
||||
pkgrel=1
|
||||
url="https://git.shouldnt.work/fuxino/${pkgname}"
|
||||
url="https://git.shouldnt.work/fuxino/$pkgname"
|
||||
arch=('any')
|
||||
license=('GPL-3.0-or-later')
|
||||
makedepends=('git'
|
||||
@@ -18,21 +18,31 @@ depends=('python>=3.10'
|
||||
optdepends=('python-systemd: use systemd log'
|
||||
'python-dbus: for desktop notifications'
|
||||
'python-paramiko: for remote backup through ssh')
|
||||
checkdepends=('python-pytest'
|
||||
'python-paramiko'
|
||||
'openssh')
|
||||
conflicts=('simple_backup-git')
|
||||
source=(git+${url}?signed#tag=${pkgver})
|
||||
source=("git+$url?signed#tag=$pkgver")
|
||||
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
||||
sha256sums=('7d9ea50d5829ac3c681a979745f1ae7afd2453eb46ceeae4f441ef5ae4540bd2')
|
||||
sha256sums=('bbe7b79669fe02ca4dc62fb08b8b5eaed809b5f1ece9586017af8ebf20d2faa0')
|
||||
|
||||
build()
|
||||
{
|
||||
cd ${srcdir}/${pkgname}
|
||||
cd "$srcdir"/"$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
check()
|
||||
{
|
||||
cd "$srcdir"/"$pkgname"
|
||||
python -m pytest
|
||||
}
|
||||
|
||||
package()
|
||||
{
|
||||
cd ${srcdir}/${pkgname}
|
||||
python -m installer --destdir=${pkgdir} dist/*.whl
|
||||
install -Dm644 ${pkgname}/${pkgname}.conf ${pkgdir}/usr/share/doc/${pkgname}/${pkgname}.conf
|
||||
install -Dm644 man/${pkgname}.1 ${pkgdir}/usr/share/man/man1/${pkgname}.1
|
||||
cd "$srcdir"/"$pkgname"
|
||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||
install -Dm644 "$pkgname"/"$pkgname".conf "$pkgdir"/usr/share/doc/"$pkgname"/"$pkgname".conf
|
||||
install -Dm644 README.md "$pkgdir"/usr/share/doc/"$pkgname"/README.md
|
||||
install -Dm644 man/"$pkgname".1 "$pkgdir"/usr/share/man/man1/"$pkgname".1
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -22,12 +23,15 @@ sudo pacman -Syu rsync
|
||||
|
||||
Optional dependencies are systemd-python to enable using systemd journal for logging, dbus-python for desktop notifications, and paramiko (version 3.2 or later) for remote backup.
|
||||
|
||||
## Install
|
||||
## Installation
|
||||
For Arch Linux and Arch-based distros, two packages are available in the AUR (aur.archlinux.org):
|
||||
- **simple_backup** for the release version
|
||||
- **simple_backup-git** for the git version
|
||||
|
||||
To install it from source, first clone the repository:
|
||||
Install one with your AUR helper, e.g. `aur sync simple_backup` with aurutils.
|
||||
|
||||
### Other distributions
|
||||
First clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://git.shouldnt.work/fuxino/simple_backup.git
|
||||
@@ -54,10 +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.
|
||||
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 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**
|
||||
@@ -66,6 +88,7 @@ The only rsync error that is ignored is return code 24 (source files vanished du
|
||||
It's possible to use a remote server as destination for the backup. Just use the --ssh-user and --ssh-host arguments (or set them in the configuration file).
|
||||
Both are required: if only one of them is set, simple_backup exits with an error.
|
||||
For this to work, rsync must be installed on the server too.
|
||||
The host can be an alias from `~/.ssh/config`: its HostName, Port and IdentityFile are used (ProxyJump and ProxyCommand are not supported).
|
||||
|
||||
The output directory is a path on the server. A leading '~' and relative paths refer to the user's home directory on the server.
|
||||
On the command line, quote '~' so that your local shell doesn't expand it:
|
||||
@@ -86,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.
|
||||
|
||||
+61
-32
@@ -1,31 +1,34 @@
|
||||
.TH SIMPLE_BACKUP 1 2026-10-14 SIMPLE_BACKUP 4.4.0
|
||||
.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
|
||||
@@ -84,15 +92,15 @@ Hostname of the server where to copy the files in case of remote backup through
|
||||
Username for connecting to the server in case of remote backup.
|
||||
.TP
|
||||
.B \-\-keyfile FILE
|
||||
Location of the SSH key for server authentication.
|
||||
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:
|
||||
@@ -145,6 +154,12 @@ needs to be allowed to run without password in the same way.
|
||||
.TP
|
||||
.B \-\-numeric\-ids
|
||||
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 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
|
||||
An example configuration file is provided at \(aq/usr/share/doc/simple_backup/simple_backup.conf\(aq.
|
||||
Copy it to the default location ($HOME/.config/simple_backup) and edit it as needed.
|
||||
@@ -152,6 +167,8 @@ Copy it to the default location ($HOME/.config/simple_backup) and edit it as nee
|
||||
It is possible to choose a directory on a remote server as destination for the backup. The files
|
||||
are copied by rsync through SSH. Server hostname and username must be specified, either in the
|
||||
configuration file, or on the command line (\(aq\-\-ssh\-host\(aq and \(aq\-\-ssh\-user\(aq options).
|
||||
The host can be an alias from \fI~/.ssh/config\fR: its HostName, Port and IdentityFile are used
|
||||
(ProxyJump and ProxyCommand are not supported).
|
||||
.P
|
||||
The output directory is a path on the server. A leading \(aq~\(aq and relative paths refer to the
|
||||
home directory of the user on the server. On the command line, quote \(aq~\(aq so that the local
|
||||
@@ -207,13 +224,25 @@ Backup failed because output directory for storing the backup does not exist.
|
||||
Permission denied to access the output directory.
|
||||
.TP
|
||||
.B 4
|
||||
rsync error (rsync returned a value other than 0 or 24).
|
||||
rsync error (rsync returned a value other than 0 or 24, or rsync is not installed).
|
||||
.TP
|
||||
.B 5
|
||||
SSH connection failed.
|
||||
.TP
|
||||
.B 6
|
||||
Bad configuration file or options (for example, only one of SSH host and SSH user specified).
|
||||
.SH FILES
|
||||
.TP
|
||||
.I $XDG_STATE_HOME/simple_backup/last_backup
|
||||
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), 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.
|
||||
.SH SEE ALSO
|
||||
.BR rsync (1)
|
||||
.SH AUTHORS
|
||||
|
||||
@@ -57,6 +57,12 @@ warn_unused_ignores = true
|
||||
module = ["paramiko", "systemd", "dbus"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
# Import the repo copy, not an installed one
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Init."""
|
||||
|
||||
__version__ = '4.4.0'
|
||||
__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=
|
||||
|
||||
+394
-199
@@ -14,27 +14,27 @@ Classes:
|
||||
# Avoid evaluating annotations at definition time, as they reference optional modules (paramiko)
|
||||
from __future__ import annotations
|
||||
|
||||
# Import libraries
|
||||
import sys
|
||||
import os
|
||||
import pwd
|
||||
import io
|
||||
import re
|
||||
from typing import Callable, Iterator, List, Optional, ParamSpec, TypedDict, TypeVar
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from shutil import get_terminal_size, rmtree, which
|
||||
import shlex
|
||||
import argparse
|
||||
import configparser
|
||||
import io
|
||||
import logging
|
||||
from logging import StreamHandler
|
||||
from timeit import default_timer
|
||||
from subprocess import Popen, PIPE, STDOUT
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import warnings
|
||||
from collections import deque
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from tempfile import mkstemp
|
||||
from getpass import GetPassWarning, getpass
|
||||
from glob import glob
|
||||
from logging import StreamHandler
|
||||
from shutil import get_terminal_size, rmtree, which
|
||||
from subprocess import PIPE, STDOUT, Popen
|
||||
from tempfile import mkstemp
|
||||
from timeit import default_timer
|
||||
from typing import Any, TypedDict
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
@@ -68,36 +68,23 @@ if journal:
|
||||
j_handler.setFormatter(j_format)
|
||||
logger.addHandler(j_handler)
|
||||
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
# Seconds to wait for the TCP connection to the SSH server
|
||||
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_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]:
|
||||
"""Decorator to measure execution time of a function
|
||||
def _format_duration(seconds: float) -> str:
|
||||
"""Format seconds as mm:ss, or h:mm:ss from one hour"""
|
||||
|
||||
Parameters:
|
||||
func: Function to decorate
|
||||
"""
|
||||
@wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
start = default_timer()
|
||||
s = round(seconds)
|
||||
|
||||
value = func(*args, **kwargs)
|
||||
if s >= 3600:
|
||||
return f'{s // 3600}:{s // 60 % 60:02d}:{s % 60:02d}'
|
||||
|
||||
end = default_timer()
|
||||
|
||||
logger.info('Elapsed time: %.3f seconds', end - start)
|
||||
|
||||
return value
|
||||
|
||||
return wrapper
|
||||
return f'{s // 60:02d}:{s % 60:02d}'
|
||||
|
||||
|
||||
class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
|
||||
@@ -107,12 +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"""
|
||||
|
||||
@@ -121,25 +123,39 @@ class _ProgressBar:
|
||||
if m is None:
|
||||
return False
|
||||
|
||||
size, percent, speed, chk = m.group(1), int(m.group(2)), m.group(3), m.group(4)
|
||||
size, chk = m.group(1), m.group(2)
|
||||
|
||||
# Records in the middle of a file have no counts, so keep the last ones
|
||||
if chk is not None:
|
||||
self._scanning = chk == 'ir'
|
||||
self._total = int(m.group(4))
|
||||
self._checked = self._total - int(m.group(3))
|
||||
|
||||
# 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}'
|
||||
# Count files, not bytes: with --link-dest unchanged files are linked, and rsync's percentage leaves them out
|
||||
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:
|
||||
text = f'{percent:3d}% {size} {speed}'
|
||||
percent = self._checked * 100 // self._total
|
||||
text = f'{percent:3d}% {self._checked:,}/{self._total:,} files {size} copied'
|
||||
|
||||
if self._scanning:
|
||||
text += ' (scanning)'
|
||||
if 0 < percent < 100:
|
||||
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
|
||||
if len(text) + PROGRESS_BAR_WIDTH + 4 <= width:
|
||||
# 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}'
|
||||
|
||||
@@ -197,11 +213,11 @@ class Backup:
|
||||
Perform the backup
|
||||
"""
|
||||
|
||||
def __init__(self, inputs: Optional[List[str]], output: Optional[str], exclude: Optional[List[str]],
|
||||
keep: Optional[int], options: str,
|
||||
ssh_host: Optional[str] = None, ssh_user: Optional[str] = None, ssh_keyfile: Optional[str] = None,
|
||||
def __init__(self, inputs: list[str] | None, output: str | None, exclude: list[str] | None,
|
||||
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 ''
|
||||
@@ -215,15 +231,20 @@ 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 = ''
|
||||
self._inputs_path = ''
|
||||
self._exclude_path = ''
|
||||
self._remote = False
|
||||
self._ssh: Optional[paramiko.client.SSHClient] = None
|
||||
self._password_auth = False
|
||||
self._ssh: paramiko.client.SSHClient | None = None
|
||||
# Passed to sshpass through rsync's environment only
|
||||
self._password: str | None = None
|
||||
self._removed_count = 0
|
||||
self._start_time = 0.0
|
||||
|
||||
def check_params(self, homedir: str = '') -> int:
|
||||
"""Check if parameters for the backup are valid"""
|
||||
@@ -274,7 +295,7 @@ class Backup:
|
||||
|
||||
return 0
|
||||
|
||||
def _resolve_remote_path(self, path: str) -> Optional[str]:
|
||||
def _resolve_remote_path(self, path: str) -> str | None:
|
||||
"""Return the absolute path of a directory on the server, or None if it doesn't exist"""
|
||||
|
||||
assert self._ssh is not None
|
||||
@@ -306,9 +327,50 @@ class Backup:
|
||||
if self._remote:
|
||||
self._server = f'{self.ssh_user}@{self.ssh_host}:'
|
||||
|
||||
@property
|
||||
def destination(self) -> str:
|
||||
"""The current backup directory, prefixed with 'user@host:' for remote backups"""
|
||||
|
||||
return f'{self._server}{self._output_dir}'
|
||||
|
||||
def remove_old_backups(self) -> None:
|
||||
"""Remove old backups if there are more than indicated by 'keep'"""
|
||||
|
||||
dirs = self._list_backups()
|
||||
|
||||
if dirs is None:
|
||||
return
|
||||
|
||||
n_backup = len(dirs)
|
||||
|
||||
# The new backup is already in the list, unless old ones are removed before it
|
||||
if not self._remove_before:
|
||||
n_backup -= 1
|
||||
|
||||
count = 0
|
||||
|
||||
if n_backup > self.keep:
|
||||
logger.info('Removing old backups...')
|
||||
dirs.sort()
|
||||
|
||||
n_remove = n_backup - self.keep
|
||||
|
||||
for i in range(n_remove):
|
||||
logger.info('Removing backup %s (%d/%d)...', dirs[i], i + 1, n_remove)
|
||||
|
||||
if self._remove_backup(dirs[i]):
|
||||
count += 1
|
||||
|
||||
self._removed_count = count
|
||||
|
||||
if count == 1:
|
||||
logger.info('Removed %d backup', count)
|
||||
elif count > 1:
|
||||
logger.info('Removed %d backups', count)
|
||||
|
||||
def _list_backups(self) -> list[str] | None:
|
||||
"""Names of the backup directories, or None if they can't be listed (then nothing is removed)"""
|
||||
|
||||
if self._remote:
|
||||
assert self._ssh is not None
|
||||
|
||||
@@ -326,85 +388,54 @@ class Backup:
|
||||
logger.error('Cannot list old backups on the server. Old backups will not be removed')
|
||||
logger.error(err)
|
||||
|
||||
return
|
||||
return None
|
||||
|
||||
dirs = [os.path.basename(d) for d in output.split('\n') if d != '']
|
||||
return [os.path.basename(d) for d in output.split('\n') if d != '']
|
||||
|
||||
n_backup = len(dirs)
|
||||
try:
|
||||
# Only count directories, so that stray files don't count as backups
|
||||
return [f.name for f in os.scandir(f'{self.output}/simple_backup') if f.is_dir(follow_symlinks=False)]
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except PermissionError:
|
||||
logger.error('Cannot list old backups. Permission denied. Old backups will not be removed')
|
||||
|
||||
if not self._remove_before:
|
||||
n_backup -= 1
|
||||
return None
|
||||
|
||||
count = 0
|
||||
def _remove_backup(self, name: str) -> bool:
|
||||
"""Remove one backup directory; errors are logged"""
|
||||
|
||||
if n_backup > self.keep:
|
||||
logger.info('Removing old backups...')
|
||||
dirs.sort()
|
||||
path = f'{self.output}/simple_backup/{name}'
|
||||
|
||||
n_remove = n_backup - self.keep
|
||||
if self._remote:
|
||||
assert self._ssh is not None
|
||||
|
||||
for i in range(n_remove):
|
||||
logger.info('Removing backup %s (%d/%d)...', dirs[i], i + 1, n_remove)
|
||||
sudo = 'sudo ' if self.remote_sudo else ''
|
||||
_, _, stderr = self._ssh.exec_command(f'{sudo}rm -r {shlex.quote(path)}')
|
||||
|
||||
if self.remote_sudo:
|
||||
_, _, stderr = self._ssh.exec_command(
|
||||
f'sudo rm -r {shlex.quote(f"{self.output}/simple_backup/{dirs[i]}")}')
|
||||
else:
|
||||
_, _, stderr = self._ssh.exec_command(
|
||||
f'rm -r {shlex.quote(f"{self.output}/simple_backup/{dirs[i]}")}')
|
||||
err = stderr.read().decode('utf-8').strip().split('\n')[0]
|
||||
|
||||
err = stderr.read().decode('utf-8').strip().split('\n')[0]
|
||||
if err != '':
|
||||
logger.error('Error while removing backup %s.', name)
|
||||
logger.error(err)
|
||||
|
||||
if err != '':
|
||||
logger.error(
|
||||
'Error while removing backup %s.', dirs[i])
|
||||
logger.error(err)
|
||||
else:
|
||||
count += 1
|
||||
else:
|
||||
try:
|
||||
# Only count directories, so that stray files don't count as backups
|
||||
dirs = [f.name for f in os.scandir(f'{self.output}/simple_backup')
|
||||
if f.is_dir(follow_symlinks=False)]
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except PermissionError:
|
||||
logger.error('Cannot list old backups. Permission denied. Old backups will not be removed')
|
||||
return False
|
||||
|
||||
return
|
||||
return True
|
||||
|
||||
n_backup = len(dirs)
|
||||
try:
|
||||
rmtree(path)
|
||||
|
||||
if not self._remove_before:
|
||||
n_backup -= 1
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.error('Error while removing backup %s. Directory not found', name)
|
||||
except PermissionError:
|
||||
logger.error('Error while removing backup %s. Permission denied', name)
|
||||
except OSError as e:
|
||||
# E.g. read-only filesystem or device busy
|
||||
logger.error('Error while removing backup %s. %s', name, e.strerror or e)
|
||||
|
||||
count = 0
|
||||
|
||||
if n_backup > self.keep:
|
||||
logger.info('Removing old backups...')
|
||||
dirs.sort()
|
||||
|
||||
n_remove = n_backup - self.keep
|
||||
|
||||
for i in range(n_remove):
|
||||
logger.info('Removing backup %s (%d/%d)...', dirs[i], i + 1, n_remove)
|
||||
|
||||
try:
|
||||
rmtree(f'{self.output}/simple_backup/{dirs[i]}')
|
||||
count += 1
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
'Error while removing backup %s. Directory not found', dirs[i])
|
||||
except PermissionError:
|
||||
logger.error(
|
||||
'Error while removing backup %s. Permission denied', dirs[i])
|
||||
|
||||
self._removed_count = count
|
||||
|
||||
if count == 1:
|
||||
logger.info('Removed %d backup', count)
|
||||
elif count > 1:
|
||||
logger.info('Removed %d backups', count)
|
||||
return False
|
||||
|
||||
def find_last_backup(self) -> None:
|
||||
"""Get path of last backup (from last_backup symlink) for rsync --link-dest"""
|
||||
@@ -443,7 +474,7 @@ class Backup:
|
||||
except IndexError:
|
||||
logger.info('No previous backups available')
|
||||
|
||||
def _ssh_connect(self, homedir: str = '') -> Optional[paramiko.client.SSHClient]:
|
||||
def _ssh_connect(self, homedir: str = '') -> paramiko.client.SSHClient | None:
|
||||
try:
|
||||
ssh = paramiko.SSHClient()
|
||||
except NameError:
|
||||
@@ -462,7 +493,37 @@ class Backup:
|
||||
if connected is None:
|
||||
ssh.close()
|
||||
|
||||
def _ssh_login(self, ssh: paramiko.client.SSHClient, homedir: str) -> Optional[paramiko.client.SSHClient]:
|
||||
def _ssh_config(self, homedir: str) -> dict[str, Any]:
|
||||
"""connect() arguments for ssh_host from ~/.ssh/config (HostName, Port, IdentityFile), as rsync's ssh uses it"""
|
||||
|
||||
assert self.ssh_host is not None
|
||||
|
||||
params: dict[str, Any] = {'hostname': self.ssh_host}
|
||||
|
||||
try:
|
||||
host = paramiko.SSHConfig.from_path(f'{homedir}/.ssh/config').lookup(self.ssh_host)
|
||||
|
||||
params['hostname'] = host['hostname']
|
||||
|
||||
if 'port' in host:
|
||||
params['port'] = int(host['port'])
|
||||
except FileNotFoundError:
|
||||
return params
|
||||
except (OSError, paramiko.SSHException, ImportError, ValueError) as e:
|
||||
# ImportError: 'Match exec' needs the invoke module
|
||||
logger.warning('Cannot read %s/.ssh/config: %s', homedir, e)
|
||||
|
||||
return {'hostname': self.ssh_host}
|
||||
|
||||
# Like ssh, skip identity files that don't exist
|
||||
keys = [k for k in host.get('identityfile', []) if os.path.isfile(k)]
|
||||
|
||||
if keys:
|
||||
params['key_filename'] = keys
|
||||
|
||||
return params
|
||||
|
||||
def _ssh_login(self, ssh: paramiko.client.SSHClient, homedir: str) -> paramiko.client.SSHClient | None:
|
||||
try:
|
||||
ssh.load_host_keys(filename=f'{homedir}/.ssh/known_hosts')
|
||||
except FileNotFoundError:
|
||||
@@ -470,11 +531,13 @@ class Backup:
|
||||
|
||||
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||
|
||||
host = self._ssh_config(homedir)
|
||||
|
||||
try:
|
||||
# WarningPolicy emits a UserWarning for unknown host keys
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', UserWarning)
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user, timeout=SSH_TIMEOUT)
|
||||
ssh.connect(**host, username=self.ssh_user, timeout=SSH_TIMEOUT)
|
||||
|
||||
return ssh
|
||||
except UserWarning:
|
||||
@@ -500,7 +563,7 @@ class Backup:
|
||||
pass
|
||||
|
||||
try:
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user, timeout=SSH_TIMEOUT)
|
||||
ssh.connect(**host, username=self.ssh_user, timeout=SSH_TIMEOUT)
|
||||
|
||||
return ssh
|
||||
except paramiko.SSHException:
|
||||
@@ -514,11 +577,9 @@ class Backup:
|
||||
password = getpass(
|
||||
f'{self.ssh_user}@{self.ssh_host}\'s password: ')
|
||||
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user, timeout=SSH_TIMEOUT,
|
||||
password=password)
|
||||
ssh.connect(**host, username=self.ssh_user, timeout=SSH_TIMEOUT, password=password)
|
||||
|
||||
self._password_auth = True
|
||||
os.environ['SSHPASS'] = password
|
||||
self._password = password
|
||||
|
||||
return ssh
|
||||
except GetPassWarning as e:
|
||||
@@ -538,7 +599,7 @@ class Backup:
|
||||
return None
|
||||
|
||||
try:
|
||||
ssh.connect(self.ssh_host, username=self.ssh_user, timeout=SSH_TIMEOUT, pkey=pkey)
|
||||
ssh.connect(**host, username=self.ssh_user, timeout=SSH_TIMEOUT, pkey=pkey)
|
||||
except paramiko.SSHException:
|
||||
logger.critical('SSH connection to server failed')
|
||||
|
||||
@@ -546,7 +607,7 @@ class Backup:
|
||||
|
||||
return ssh
|
||||
|
||||
def _load_ssh_key(self) -> Optional[paramiko.PKey]:
|
||||
def _load_ssh_key(self) -> paramiko.PKey | None:
|
||||
try:
|
||||
return paramiko.PKey.from_path(self.ssh_keyfile)
|
||||
except TypeError:
|
||||
@@ -620,14 +681,19 @@ class Backup:
|
||||
'Rsync error (return code %d) - Check rsync(1) for details', returncode)
|
||||
|
||||
# Function to read configuration file
|
||||
@timing
|
||||
def run(self) -> int:
|
||||
"""Perform the backup"""
|
||||
|
||||
self._start_time = default_timer()
|
||||
|
||||
try:
|
||||
return self._run()
|
||||
finally:
|
||||
self._remove_temp_files()
|
||||
logger.info('Elapsed time: %s', self._elapsed())
|
||||
|
||||
def _elapsed(self) -> str:
|
||||
return _format_duration(default_timer() - self._start_time)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the SSH connection, if open"""
|
||||
@@ -649,6 +715,16 @@ class Backup:
|
||||
|
||||
_notify('Starting backup...')
|
||||
|
||||
# Check before anything is removed (--remove-before-backup)
|
||||
rsync = which('rsync')
|
||||
|
||||
if rsync is None:
|
||||
logger.critical('rsync not found. Install rsync to perform the backup')
|
||||
|
||||
_notify('Backup failed (check log for details)')
|
||||
|
||||
return 4
|
||||
|
||||
self.define_backup_dir()
|
||||
|
||||
fd, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
|
||||
@@ -688,7 +764,7 @@ class Backup:
|
||||
logger.info('Copying files. This may take a long time...')
|
||||
|
||||
# Build the argument list directly, so paths with spaces or quotes are passed unchanged
|
||||
args = ['/usr/bin/rsync', *self.options.split()]
|
||||
args = [rsync, *self.options.split()]
|
||||
|
||||
if self._last_backup != '':
|
||||
args.append(f'--link-dest={self._last_backup}')
|
||||
@@ -696,11 +772,15 @@ class Backup:
|
||||
args.extend([f'--exclude-from={self._exclude_path}', f'--files-from={self._inputs_path}',
|
||||
'/', f'{self._server}{self._output_dir}'])
|
||||
|
||||
# None inherits our environment
|
||||
env: dict[str, str] | None = None
|
||||
|
||||
# rsync splits the -e command itself, so quote the parts that may contain spaces
|
||||
if self.ssh_keyfile is not None:
|
||||
ssh = f'ssh -i {shlex.quote(self.ssh_keyfile)} -o StrictHostKeyChecking=accept-new'
|
||||
elif self._password_auth and self.ssh_user is not None and which('sshpass'):
|
||||
elif self._password is not None and self.ssh_user is not None and which('sshpass'):
|
||||
ssh = f'sshpass -e ssh -l {shlex.quote(self.ssh_user)} -o StrictHostKeyChecking=accept-new'
|
||||
env = {**os.environ, 'SSHPASS': self._password}
|
||||
else:
|
||||
ssh = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
|
||||
@@ -713,13 +793,14 @@ 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) 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)
|
||||
p.stdin.close()
|
||||
|
||||
output = []
|
||||
# Without -v, only the summary (the last two lines) is logged
|
||||
output: list[str] | deque[str] = [] if self._verbose else deque(maxlen=2)
|
||||
|
||||
try:
|
||||
for r in _read_records(p.stdout):
|
||||
@@ -729,27 +810,23 @@ class Backup:
|
||||
if bar is not None:
|
||||
bar.clear()
|
||||
|
||||
try:
|
||||
del os.environ['SSHPASS']
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
returncode = p.wait()
|
||||
|
||||
if bar is not None:
|
||||
self.file_count = bar.total
|
||||
|
||||
if returncode == 0:
|
||||
if self._verbose:
|
||||
logger.info('rsync: %s', output)
|
||||
else:
|
||||
logger.info('rsync: %s', output[-2])
|
||||
logger.info('rsync: %s', output[-1])
|
||||
# Without -v, only the summary, which may be fewer than two lines (e.g. with --rsync-options q)
|
||||
for r in output:
|
||||
logger.info('rsync: %s', r)
|
||||
else:
|
||||
self._returncode_log(returncode)
|
||||
|
||||
if self._verbose:
|
||||
if returncode in [23, 24]:
|
||||
logger.warning(output)
|
||||
else:
|
||||
logger.error(output)
|
||||
level = logging.WARNING if returncode in [23, 24] else logging.ERROR
|
||||
|
||||
for r in output:
|
||||
logger.log(level, 'rsync: %s', r)
|
||||
|
||||
if self._removed_count > 0 and returncode not in [0, 24]:
|
||||
# With --remove-before-backup, old backups are already gone if the new one failed
|
||||
@@ -774,14 +851,14 @@ class Backup:
|
||||
logger.error(
|
||||
'Some errors occurred while performing the backup')
|
||||
|
||||
_notify(
|
||||
'Some errors occurred while performing the backup. Check log for details')
|
||||
_notify(f'Some errors occurred while performing the backup (elapsed time {self._elapsed()}). '
|
||||
'Check log for details')
|
||||
|
||||
return 4
|
||||
|
||||
logger.info('Backup completed')
|
||||
|
||||
_notify('Backup completed')
|
||||
_notify(f'Backup completed in {self._elapsed()}')
|
||||
|
||||
return 0
|
||||
|
||||
@@ -801,7 +878,7 @@ def _read_records(stream: io.BufferedIOBase) -> Iterator[str]:
|
||||
yield buffer.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
def _detect_user() -> Optional[str]:
|
||||
def _detect_user() -> str | None:
|
||||
if os.geteuid() == 0:
|
||||
return os.getenv('SUDO_USER') or os.getenv('DOAS_USER')
|
||||
|
||||
@@ -851,6 +928,8 @@ def _parse_arguments() -> argparse.Namespace:
|
||||
help='Run rsync on remote server with sudo if allowed')
|
||||
parser.add_argument('--numeric-ids', action='store_true',
|
||||
help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name)')
|
||||
parser.add_argument('--last-backup', action='store_true',
|
||||
help='Show when the last successful backup was made and exit')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -866,21 +945,24 @@ def _expand_home(path: str, user: str) -> str:
|
||||
return os.path.expanduser(path)
|
||||
|
||||
|
||||
def _expand_inputs(inputs: List[str], user: Optional[str] = None) -> List[str]:
|
||||
expanded_inputs: List[str] = []
|
||||
def _expand_inputs(inputs: list[str], user: str | None = None) -> list[str]:
|
||||
expanded_inputs: list[str] = []
|
||||
|
||||
for i in inputs:
|
||||
if i == '':
|
||||
continue
|
||||
|
||||
if user is not None:
|
||||
i_ex = glob(_expand_home(i, user))
|
||||
path = _expand_home(i, user)
|
||||
else:
|
||||
i_ex = glob(i)
|
||||
path = i
|
||||
|
||||
if i.startswith('~'):
|
||||
logger.warning('Cannot expand \'~\'. No user specified')
|
||||
|
||||
# An existing path is used as is, so names with glob characters (e.g. 'Photos [2020]') still work
|
||||
i_ex = [path] if os.path.lexists(path) else glob(path)
|
||||
|
||||
if len(i_ex) == 0:
|
||||
logger.warning(
|
||||
'No file or directory matching input %s. Skipping...', i)
|
||||
@@ -893,13 +975,13 @@ def _expand_inputs(inputs: List[str], user: Optional[str] = None) -> List[str]:
|
||||
class ConfigArgs(TypedDict):
|
||||
"""Values read from the configuration file"""
|
||||
|
||||
inputs: Optional[List[str]]
|
||||
output: Optional[str]
|
||||
exclude: Optional[List[str]]
|
||||
inputs: list[str] | None
|
||||
output: str | None
|
||||
exclude: list[str] | None
|
||||
keep: int
|
||||
ssh_host: Optional[str]
|
||||
ssh_user: Optional[str]
|
||||
ssh_keyfile: Optional[str]
|
||||
ssh_host: str | None
|
||||
ssh_user: str | None
|
||||
ssh_keyfile: str | None
|
||||
remote_sudo: bool
|
||||
numeric_ids: bool
|
||||
|
||||
@@ -912,7 +994,7 @@ def _get_bool(config: configparser.ConfigParser, key: str) -> bool:
|
||||
return config.getboolean('server', key)
|
||||
|
||||
|
||||
def _read_config(config_file: str, user: Optional[str] = None) -> ConfigArgs:
|
||||
def _read_config(config_file: str, user: str | None = None) -> ConfigArgs:
|
||||
config_args: ConfigArgs = {'inputs': None,
|
||||
'output': None,
|
||||
'exclude': None,
|
||||
@@ -936,19 +1018,22 @@ def _read_config(config_file: str, user: Optional[str] = None) -> ConfigArgs:
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
config.read(config_file)
|
||||
|
||||
section = 'backup'
|
||||
|
||||
# Allow compatibility with previous version of config file
|
||||
try:
|
||||
inputs = config.get(section, 'inputs')
|
||||
except configparser.NoSectionError:
|
||||
if config.has_section('backup'):
|
||||
section = 'backup'
|
||||
elif config.has_section('default'):
|
||||
section = 'default'
|
||||
inputs = config.get(section, 'inputs')
|
||||
else:
|
||||
raise configparser.NoSectionError('backup')
|
||||
|
||||
config_args['inputs'] = list(set(_expand_inputs([i.strip() for i in inputs.split(',')], user)))
|
||||
# inputs and backup_dir are optional, since -i and -o can give them. Missing ones are reported by check_params()
|
||||
inputs = config.get(section, 'inputs', fallback=None)
|
||||
|
||||
if inputs is not None:
|
||||
config_args['inputs'] = list(set(_expand_inputs([i.strip() for i in inputs.split(',')], user)))
|
||||
|
||||
# '~' is expanded later, since for remote backups it refers to the home on the server
|
||||
config_args['output'] = config.get(section, 'backup_dir')
|
||||
config_args['output'] = config.get(section, 'backup_dir', fallback=None)
|
||||
|
||||
exclude = config.get(section, 'exclude', fallback='')
|
||||
config_args['exclude'] = [e.strip() for e in exclude.split(',') if e.strip() != '']
|
||||
@@ -1020,31 +1105,135 @@ def _notify(text: str) -> None:
|
||||
os.seteuid(euid)
|
||||
|
||||
|
||||
def _state_owner(user: str | None) -> pwd.struct_passwd | None:
|
||||
"""The user whose state file is used when running as root on their behalf (sudo, doas, -u)"""
|
||||
|
||||
if os.geteuid() != 0 or user is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
owner = pwd.getpwnam(user)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
return owner if owner.pw_uid != 0 else None
|
||||
|
||||
|
||||
def _state_file(owner: pwd.struct_passwd | None) -> str:
|
||||
if owner is not None:
|
||||
# XDG_STATE_HOME belongs to root here
|
||||
base = os.path.join(owner.pw_dir, '.local', 'state')
|
||||
else:
|
||||
base = os.getenv('XDG_STATE_HOME', '')
|
||||
|
||||
# The XDG spec says to ignore relative paths
|
||||
if not os.path.isabs(base):
|
||||
base = os.path.expanduser('~/.local/state')
|
||||
|
||||
return os.path.join(base, 'simple_backup', 'last_backup')
|
||||
|
||||
|
||||
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
|
||||
if owner is not None:
|
||||
os.setegid(owner.pw_gid)
|
||||
os.seteuid(owner.pw_uid)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
# Replace the file in one step, so readers (e.g. status bar scripts) never see it half written
|
||||
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{count_line}')
|
||||
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
logger.warning('Cannot save the time of the last backup to %s: %s', path, e)
|
||||
|
||||
if tmp != '':
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if owner is not None:
|
||||
os.seteuid(euid)
|
||||
os.setegid(egid)
|
||||
|
||||
|
||||
def _show_last_backup(user: str | None) -> int:
|
||||
path = _state_file(_state_owner(user))
|
||||
|
||||
try:
|
||||
with open(path, encoding='utf-8') as fp:
|
||||
time, destination = fp.read().splitlines()[:2]
|
||||
|
||||
completed = datetime.fromisoformat(time).astimezone()
|
||||
except FileNotFoundError:
|
||||
print('No backup recorded yet')
|
||||
|
||||
return 1
|
||||
except (OSError, ValueError) as e:
|
||||
print(f'Cannot read {path}: {e}', file=sys.stderr)
|
||||
|
||||
return 1
|
||||
|
||||
days = (datetime.now().astimezone().date() - completed.date()).days
|
||||
|
||||
if days == 0:
|
||||
age = 'today'
|
||||
elif days == 1:
|
||||
age = 'yesterday'
|
||||
else:
|
||||
age = f'{days} days ago'
|
||||
|
||||
print(f'Last backup: {completed:%Y-%m-%d %H:%M} ({age})')
|
||||
print(f'Directory: {destination}')
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def simple_backup() -> int:
|
||||
"""Main"""
|
||||
|
||||
args = _parse_arguments()
|
||||
|
||||
if args.user:
|
||||
user = args.user
|
||||
homedir = os.path.expanduser(f'~{user}')
|
||||
user: str | None = args.user or _detect_user()
|
||||
|
||||
if args.last_backup:
|
||||
return _show_last_backup(user)
|
||||
|
||||
if not args.user and os.geteuid() != 0:
|
||||
homedir = os.getenv('HOME', '')
|
||||
elif user is not None:
|
||||
homedir = _expand_home('~', user)
|
||||
else:
|
||||
euid = os.geteuid()
|
||||
|
||||
if euid == 0:
|
||||
user = _detect_user()
|
||||
|
||||
if user is not None:
|
||||
homedir = os.path.expanduser(f'~{user}')
|
||||
else:
|
||||
logger.warning(
|
||||
'Failed to detect user. You can use -u/--user parameter to manually specify it')
|
||||
homedir = None
|
||||
else:
|
||||
user = os.getenv('USER')
|
||||
homedir = os.getenv('HOME')
|
||||
|
||||
if homedir is None:
|
||||
logger.warning('Failed to detect user. You can use -u/--user parameter to manually specify it')
|
||||
homedir = ''
|
||||
|
||||
if args.no_syslog:
|
||||
@@ -1053,13 +1242,8 @@ def simple_backup() -> int:
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
config_file = args.config
|
||||
|
||||
# Expand '~' to the home of the user running the backup, not root's
|
||||
if config_file.startswith('~/') and user is not None:
|
||||
config_file = f'~{user}{config_file[1:]}'
|
||||
|
||||
config_file = os.path.expanduser(config_file)
|
||||
config_file = _expand_home(args.config, user) if user is not None else os.path.expanduser(args.config)
|
||||
|
||||
try:
|
||||
config_args = _read_config(config_file, user)
|
||||
@@ -1075,6 +1259,11 @@ def simple_backup() -> int:
|
||||
ssh_host = args.ssh_host if args.ssh_host is not None else config_args['ssh_host']
|
||||
ssh_user = args.ssh_user if args.ssh_user is not None else config_args['ssh_user']
|
||||
|
||||
if keep < -1:
|
||||
logger.critical('Invalid number of backups to keep: %d (use -1 to keep all)', keep)
|
||||
|
||||
return 6
|
||||
|
||||
if (ssh_host is None) != (ssh_user is None):
|
||||
logger.critical('Both SSH host and SSH user are needed for remote backup')
|
||||
|
||||
@@ -1115,15 +1304,21 @@ 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)
|
||||
|
||||
if return_code != 0:
|
||||
return return_code
|
||||
|
||||
return_code = backup.run()
|
||||
|
||||
if return_code == 0:
|
||||
return backup.run()
|
||||
_write_state(user, backup.destination, backup.file_count)
|
||||
|
||||
return return_code
|
||||
finally:
|
||||
@@ -1131,4 +1326,4 @@ def simple_backup() -> int:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
simple_backup()
|
||||
sys.exit(simple_backup())
|
||||
|
||||
+27
-3
@@ -1,7 +1,9 @@
|
||||
"""Shared fixtures for the simple_backup tests"""
|
||||
|
||||
import sys
|
||||
from typing import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -9,13 +11,35 @@ from simple_backup import simple_backup as sb
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep tests out of the systemd journal and the desktop notifications"""
|
||||
def no_side_effects(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Keep tests out of the systemd journal, the desktop notifications and the user's state directory"""
|
||||
|
||||
if hasattr(sb, 'j_handler'):
|
||||
sb.logger.removeHandler(sb.j_handler)
|
||||
|
||||
monkeypatch.setattr(sb, '_notify', lambda text: None)
|
||||
monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'state'))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_now(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Give each backup a new timestamp, as real runs within the same second would collide"""
|
||||
|
||||
def times() -> Iterator[datetime]:
|
||||
t = datetime(2026, 10, 14, 10, 0, 0)
|
||||
|
||||
while True:
|
||||
yield t
|
||||
t += timedelta(hours=1)
|
||||
|
||||
it = times()
|
||||
|
||||
class FakeDatetime:
|
||||
@staticmethod
|
||||
def now() -> datetime:
|
||||
return next(it)
|
||||
|
||||
monkeypatch.setattr(sb, 'datetime', FakeDatetime)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
+84
-13
@@ -2,8 +2,11 @@
|
||||
|
||||
import os
|
||||
import pwd
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -23,11 +26,14 @@ class FakeBackup:
|
||||
|
||||
instances: list['FakeBackup'] = []
|
||||
check_code = 0
|
||||
run_code = 0
|
||||
destination = '/backups/simple_backup/2026-10-25_10-00-00'
|
||||
|
||||
def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str,
|
||||
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, progress: bool = False) -> 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, progress: bool = False,
|
||||
file_estimate: int = 0) -> None:
|
||||
self.inputs = inputs
|
||||
self.output = output
|
||||
self.exclude = exclude
|
||||
@@ -40,7 +46,9 @@ class FakeBackup:
|
||||
self.remove_before = remove_before
|
||||
self.verbose = verbose
|
||||
self.progress = progress
|
||||
self.homedir: Optional[str] = None
|
||||
self.file_estimate = file_estimate
|
||||
self.file_count: int | None = None
|
||||
self.homedir: str | None = None
|
||||
self.ran = False
|
||||
self.closed = False
|
||||
FakeBackup.instances.append(self)
|
||||
@@ -53,7 +61,7 @@ class FakeBackup:
|
||||
def run(self) -> int:
|
||||
self.ran = True
|
||||
|
||||
return 0
|
||||
return FakeBackup.run_code
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
@@ -63,6 +71,7 @@ class FakeBackup:
|
||||
def fake_backup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
FakeBackup.instances = []
|
||||
FakeBackup.check_code = 0
|
||||
FakeBackup.run_code = 0
|
||||
monkeypatch.setattr(sb, 'Backup', FakeBackup)
|
||||
|
||||
|
||||
@@ -80,19 +89,21 @@ def as_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run as root, with no sudo/doas variables set"""
|
||||
|
||||
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, file_count=None: None)
|
||||
|
||||
for var in ['SUDO_USER', 'SUDO_UID', 'DOAS_USER']:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_calls(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, Optional[str]]]:
|
||||
def config_calls(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str | None]]:
|
||||
"""Record the config file path and user instead of reading the file"""
|
||||
|
||||
calls: list[tuple[str, Optional[str]]] = []
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
read_config = sb._read_config
|
||||
|
||||
def record(config_file: str, user: Optional[str] = None) -> sb.ConfigArgs:
|
||||
def record(config_file: str, user: str | None = None) -> sb.ConfigArgs:
|
||||
calls.append((config_file, user))
|
||||
|
||||
return read_config('/nonexistent/simple_backup.conf', user)
|
||||
@@ -169,6 +180,15 @@ ssh_keyfile=/keys/id
|
||||
assert b.keep == 0
|
||||
assert (b.ssh_host, b.ssh_user, b.ssh_keyfile) == ('cli-host', 'cli-user', '/cli/key')
|
||||
|
||||
def test_inputs_and_output_only_from_cli(self, run_cli: RunCli, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / 'simple_backup.conf'
|
||||
config_file.write_text('[backup]\nkeep=3\n')
|
||||
|
||||
assert run_cli('-c', str(config_file), '-i', '/cli/in', '-o', '/cli/out') == 0
|
||||
|
||||
b = last_backup()
|
||||
assert (b.inputs, b.output, b.keep) == (['/cli/in'], '/cli/out', 3)
|
||||
|
||||
def test_ssh_host_and_user_from_cli_and_config(self, run_cli: RunCli, config: Callable[[str], str]) -> None:
|
||||
config_file = config('backup_dir=/backups\n[server]\nssh_user=user')
|
||||
|
||||
@@ -190,6 +210,18 @@ ssh_keyfile=/keys/id
|
||||
assert FakeBackup.instances == []
|
||||
assert 'Both SSH host and SSH user are needed' in caplog.text
|
||||
|
||||
@pytest.mark.parametrize('config_keep, argv', [
|
||||
('keep=-2', []),
|
||||
('', ['-k', '-2']),
|
||||
])
|
||||
def test_keep_below_minus_one(self, run_cli: RunCli, config: Callable[[str], str],
|
||||
caplog: pytest.LogCaptureFixture, config_keep: str, argv: list[str]) -> None:
|
||||
config_file = config(f'backup_dir=/backups\n{config_keep}')
|
||||
|
||||
assert run_cli('-c', config_file, *argv) == 6
|
||||
assert FakeBackup.instances == []
|
||||
assert 'Invalid number of backups to keep: -2' in caplog.text
|
||||
|
||||
def test_bad_config(self, run_cli: RunCli, config: Callable[[str], str],
|
||||
caplog: pytest.LogCaptureFixture) -> None:
|
||||
config_file = config('backup_dir=/backups\nkeep=abc')
|
||||
@@ -237,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
|
||||
|
||||
@@ -246,23 +289,37 @@ ssh_keyfile=/keys/id
|
||||
assert not b.ran
|
||||
assert b.closed
|
||||
|
||||
@pytest.mark.parametrize('check_code, run_code, saved', [(0, 0, True), (2, 0, False), (0, 4, False)])
|
||||
def test_state_saved_only_on_success(self, run_cli: RunCli, config: Callable[[str], str], tmp_path: Path,
|
||||
check_code: int, run_code: int, saved: bool) -> None:
|
||||
FakeBackup.check_code = check_code
|
||||
FakeBackup.run_code = run_code
|
||||
|
||||
assert run_cli('-c', config('backup_dir=/backups')) == (check_code or run_code)
|
||||
|
||||
state = tmp_path / 'state' / 'simple_backup' / 'last_backup'
|
||||
assert state.exists() is saved
|
||||
|
||||
if saved:
|
||||
assert state.read_text().splitlines()[1] == FakeBackup.destination
|
||||
|
||||
|
||||
class TestUser:
|
||||
@pytest.mark.usefixtures('as_user')
|
||||
def test_current_user(self, run_cli: RunCli, config_calls: list[tuple[str, Optional[str]]]) -> None:
|
||||
def test_current_user(self, run_cli: RunCli, config_calls: list[tuple[str, str | None]]) -> None:
|
||||
assert run_cli() == 0
|
||||
assert config_calls == [(f'{HOME}/.config/simple_backup/simple_backup.conf', USER)]
|
||||
assert last_backup().homedir == HOME
|
||||
|
||||
@pytest.mark.usefixtures('as_root')
|
||||
def test_explicit_user(self, run_cli: RunCli, config_calls: list[tuple[str, Optional[str]]]) -> None:
|
||||
def test_explicit_user(self, run_cli: RunCli, config_calls: list[tuple[str, str | None]]) -> None:
|
||||
assert run_cli('-u', USER, '-c', '~/other.conf') == 0
|
||||
assert config_calls == [(f'{HOME}/other.conf', USER)]
|
||||
assert last_backup().homedir == HOME
|
||||
|
||||
@pytest.mark.usefixtures('as_root')
|
||||
@pytest.mark.parametrize('var', ['SUDO_USER', 'DOAS_USER'])
|
||||
def test_sudo_or_doas_user(self, run_cli: RunCli, config_calls: list[tuple[str, Optional[str]]],
|
||||
def test_sudo_or_doas_user(self, run_cli: RunCli, config_calls: list[tuple[str, str | None]],
|
||||
monkeypatch: pytest.MonkeyPatch, var: str) -> None:
|
||||
monkeypatch.setenv(var, USER)
|
||||
|
||||
@@ -271,7 +328,7 @@ class TestUser:
|
||||
assert last_backup().homedir == HOME
|
||||
|
||||
@pytest.mark.usefixtures('as_root')
|
||||
def test_root_without_user(self, run_cli: RunCli, config_calls: list[tuple[str, Optional[str]]],
|
||||
def test_root_without_user(self, run_cli: RunCli, config_calls: list[tuple[str, str | None]],
|
||||
caplog: pytest.LogCaptureFixture) -> None:
|
||||
assert run_cli() == 0
|
||||
# '~' is left to expanduser, i.e. $HOME
|
||||
@@ -303,6 +360,20 @@ class TestDetectUser:
|
||||
assert sb._detect_user() == USER
|
||||
|
||||
|
||||
def test_run_as_script(tmp_path: Path) -> None:
|
||||
"""Running the module directly passes simple_backup()'s return code to the shell"""
|
||||
|
||||
repo = Path(sb.__file__).resolve().parents[1]
|
||||
env = {**os.environ, 'PYTHONPATH': str(repo), 'HOME': str(tmp_path)}
|
||||
|
||||
# No config file and no inputs: check_params() returns 1
|
||||
result = subprocess.run([sys.executable, '-m', 'simple_backup.simple_backup', '--no-syslog',
|
||||
'-c', str(tmp_path / 'missing.conf')], env=env, cwd=tmp_path,
|
||||
capture_output=True, check=False)
|
||||
|
||||
assert result.returncode == 1
|
||||
|
||||
|
||||
class TestTargetUid:
|
||||
@pytest.mark.usefixtures('as_user')
|
||||
def test_non_root(self) -> None:
|
||||
|
||||
+15
-2
@@ -45,6 +45,13 @@ def test_expand_inputs_glob(tmp_path: Path) -> None:
|
||||
assert sorted(result) == [f'{tmp_path}/a.txt', f'{tmp_path}/b.txt']
|
||||
|
||||
|
||||
def test_expand_inputs_glob_characters_in_name(tmp_path: Path) -> None:
|
||||
(tmp_path / 'Photos [2020]').mkdir()
|
||||
(tmp_path / 'Photos 2').mkdir()
|
||||
|
||||
assert sb._expand_inputs([f'{tmp_path}/Photos [2020]'], USER) == [f'{tmp_path}/Photos [2020]']
|
||||
|
||||
|
||||
def test_expand_inputs_no_match(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
assert sb._expand_inputs([f'{tmp_path}/missing'], USER) == []
|
||||
assert 'No file or directory matching input' in caplog.text
|
||||
@@ -217,9 +224,15 @@ backup_dir=/backups
|
||||
sb._read_config(config_file, USER)
|
||||
|
||||
|
||||
def test_inputs_and_backup_dir_optional(tmp_path: Path) -> None:
|
||||
config_args = sb._read_config(write_config(tmp_path, '[backup]\nkeep=2\n'), USER)
|
||||
|
||||
assert config_args['inputs'] is None
|
||||
assert config_args['output'] is None
|
||||
assert config_args['keep'] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', [
|
||||
'[backup]\nbackup_dir=/backups\n',
|
||||
'[backup]\ninputs=/tmp\n',
|
||||
'[other]\ninputs=/tmp\n',
|
||||
'inputs=/tmp\n',
|
||||
])
|
||||
|
||||
+135
-33
@@ -1,10 +1,12 @@
|
||||
"""Tests for local backups, with real rsync runs and with a fake Popen"""
|
||||
|
||||
import errno
|
||||
import io
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
from shutil import which
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -13,25 +15,7 @@ from simple_backup import simple_backup as sb
|
||||
OPTIONS = '-r -v --ignore-missing-args --mkpath -a -h -H -X -s'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_now(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Give each backup a new timestamp, as real runs within the same second would collide"""
|
||||
|
||||
def times() -> Iterator[datetime]:
|
||||
t = datetime(2026, 10, 14, 10, 0, 0)
|
||||
|
||||
while True:
|
||||
yield t
|
||||
t += timedelta(hours=1)
|
||||
|
||||
it = times()
|
||||
|
||||
class FakeDatetime:
|
||||
@staticmethod
|
||||
def now() -> datetime:
|
||||
return next(it)
|
||||
|
||||
monkeypatch.setattr(sb, 'datetime', FakeDatetime)
|
||||
pytestmark = pytest.mark.usefixtures('fake_now')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -52,10 +36,10 @@ def out(tmp_path: Path) -> Path:
|
||||
return out
|
||||
|
||||
|
||||
def make_backup(out: Path, inputs: list[str], keep: int = -1, exclude: Optional[list[str]] = None,
|
||||
remove_before: bool = False, progress: bool = False) -> sb.Backup:
|
||||
def make_backup(out: Path, inputs: list[str], keep: int = -1, exclude: list[str] | None = None,
|
||||
remove_before: bool = False, progress: bool = False, verbose: bool = False) -> sb.Backup:
|
||||
backup = sb.Backup(inputs, str(out), exclude or [], keep, OPTIONS, remove_before=remove_before,
|
||||
progress=progress)
|
||||
progress=progress, verbose=verbose)
|
||||
|
||||
assert backup.check_params() == 0
|
||||
|
||||
@@ -104,6 +88,18 @@ class TestRsync:
|
||||
assert 'Backup completed' in caplog.text
|
||||
assert 'rsync: sent' in caplog.text
|
||||
|
||||
def test_elapsed_time(self, src: Path, out: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture) -> None:
|
||||
notifications: list[str] = []
|
||||
monkeypatch.setattr(sb, '_notify', notifications.append)
|
||||
# Start, then the notification and the log line
|
||||
monkeypatch.setattr(sb, 'default_timer', iter([0.0, 863.187, 863.2]).__next__)
|
||||
|
||||
assert run_backup(out, [str(src)]) == 0
|
||||
|
||||
assert notifications[-1] == 'Backup completed in 14:23'
|
||||
assert 'Elapsed time: 14:23' in caplog.text
|
||||
|
||||
def test_hard_links(self, src: Path, out: Path) -> None:
|
||||
assert run_backup(out, [str(src)]) == 0
|
||||
(src / 'changed.txt').write_text('second')
|
||||
@@ -160,6 +156,19 @@ class TestRsync:
|
||||
# Nothing to link against, as the old backup was removed first
|
||||
assert copied(out, backup, src / 'same.txt').stat().st_nlink == 1
|
||||
|
||||
def test_removal_os_error(self, src: Path, out: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture) -> None:
|
||||
assert run_backup(out, [str(src)]) == 0
|
||||
|
||||
def busy(path: str) -> None:
|
||||
raise OSError(errno.EBUSY, os.strerror(errno.EBUSY), path)
|
||||
|
||||
monkeypatch.setattr(sb, 'rmtree', busy)
|
||||
|
||||
assert run_backup(out, [str(src)], keep=0) == 0
|
||||
assert backups(out) == ['2026-10-14_10-00-00', '2026-10-14_11-00-00']
|
||||
assert 'Error while removing backup 2026-10-14_10-00-00. Device or resource busy' in caplog.text
|
||||
|
||||
def test_stray_files_and_symlinks(self, src: Path, out: Path) -> None:
|
||||
assert run_backup(out, [str(src)]) == 0
|
||||
|
||||
@@ -198,13 +207,30 @@ 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')
|
||||
|
||||
for _ in range(2):
|
||||
assert run_cli('-c', config_file, '-i', str(src), '-o', str(out), '-k', '0') == 0
|
||||
|
||||
assert backups(out) == ['2026-10-14_11-00-00']
|
||||
# The fake clock also advances when the state file is written
|
||||
assert backups(out) == ['2026-10-14_12-00-00']
|
||||
state = (tmp_path / 'state' / 'simple_backup' / 'last_backup').read_text().splitlines()
|
||||
assert state[0].startswith('2026-10-14T13:00:00')
|
||||
assert state[1] == f'{out}/simple_backup/2026-10-14_12-00-00'
|
||||
|
||||
|
||||
class ChunkStream(io.BufferedIOBase):
|
||||
@@ -230,6 +256,13 @@ class TestReadRecords:
|
||||
assert list(sb._read_records(stream)) == ['file', '', 'namé', '�']
|
||||
|
||||
|
||||
@pytest.mark.parametrize(('seconds', 'text'), [
|
||||
(0.4, '00:00'), (59.6, '01:00'), (863.187, '14:23'), (3599.4, '59:59'), (3600, '1:00:00'), (37230, '10:20:30'),
|
||||
])
|
||||
def test_format_duration(seconds: float, text: str) -> None:
|
||||
assert sb._format_duration(seconds) == text
|
||||
|
||||
|
||||
class TestProgressBar:
|
||||
@pytest.fixture(autouse=True)
|
||||
def terminal(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -237,18 +270,52 @@ class TestProgressBar:
|
||||
# 30 s elapsed at each update
|
||||
monkeypatch.setattr(sb, 'default_timer', iter([0.0, 30.0]).__next__)
|
||||
|
||||
def test_scanning(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=1009/2632)')
|
||||
assert capsys.readouterr().err == '\r1,623 files checked 2.00M copied (scanning)\033[K'
|
||||
|
||||
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)')
|
||||
# With --link-dest rsync's percentage leaves out linked files, so the files are counted instead
|
||||
assert bar.update(' 4,000,000 1% 2.97MB/s 0:00:01 (xfr#2, to-chk=1/4)')
|
||||
assert capsys.readouterr().err == \
|
||||
'\r[#######-----------------------] 25% 2.00M 1.47MB/s ETA 0:01:30 (scanning)\033[K'
|
||||
'\r[######################--------] 75% 3/4 files 4,000,000 copied ETA 0:00:10\033[K'
|
||||
|
||||
def test_without_human_readable(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
def test_keeps_counts_within_file(self, monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str]) -> None:
|
||||
monkeypatch.setattr(sb, 'default_timer', iter([0.0, 30.0, 30.0]).__next__)
|
||||
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')
|
||||
assert bar.update(' 2.00M 1% 1.47MB/s 0:00:02 (xfr#1, to-chk=2/4)')
|
||||
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()
|
||||
@@ -264,7 +331,7 @@ class TestProgressBar:
|
||||
|
||||
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'
|
||||
assert capsys.readouterr().err == '\r100% 4/4 files 6.\033[K'
|
||||
|
||||
|
||||
class FakePopen:
|
||||
@@ -272,12 +339,13 @@ class FakePopen:
|
||||
|
||||
returncode = 0
|
||||
calls: list[list[str]] = []
|
||||
output = b'sending incremental file list\nsent 1 bytes received 2 bytes\ntotal size is 3\n'
|
||||
|
||||
def __init__(self, args: list[str], **kwargs: Any) -> None:
|
||||
FakePopen.calls.append(args)
|
||||
os.makedirs(args[args.index('/') + 1])
|
||||
self.stdin = io.BytesIO()
|
||||
self.stdout = io.BytesIO(b'sending incremental file list\nsent 1 bytes received 2 bytes\ntotal size is 3\n')
|
||||
self.stdout = io.BytesIO(FakePopen.output)
|
||||
|
||||
def __enter__(self) -> 'FakePopen':
|
||||
return self
|
||||
@@ -313,10 +381,44 @@ class TestReturnCodes:
|
||||
assert run_backup(out, [str(src)]) == 0
|
||||
|
||||
(args,) = fake_popen.calls
|
||||
assert args[0] == '/usr/bin/rsync'
|
||||
assert args[0] == which('rsync')
|
||||
assert f'--link-dest={out}/simple_backup/{old_backups[-1]}' in args
|
||||
assert args[args.index('/') + 1] == f'{out}/simple_backup/2026-10-14_10-00-00'
|
||||
|
||||
@pytest.mark.parametrize('lines, logged', [
|
||||
([f'file{n}' for n in range(5)] + ['sent 1 bytes', 'total size is 3'], ['sent 1 bytes', 'total size is 3']),
|
||||
([], []),
|
||||
(['total size is 3'], ['total size is 3']),
|
||||
])
|
||||
def test_summary_logged(self, src: Path, out: Path, fake_popen: type[FakePopen], monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture, lines: list[str], logged: list[str]) -> None:
|
||||
monkeypatch.setattr(fake_popen, 'output', ''.join(f'{line}\n' for line in lines).encode())
|
||||
|
||||
assert run_backup(out, [str(src)]) == 0
|
||||
assert [m[7:] for m in caplog.messages if m.startswith('rsync: ')] == logged
|
||||
|
||||
@pytest.mark.parametrize('returncode, level', [(0, 'INFO'), (23, 'WARNING'), (1, 'ERROR')])
|
||||
def test_verbose_one_line_per_message(self, src: Path, out: Path, fake_popen: type[FakePopen],
|
||||
caplog: pytest.LogCaptureFixture, returncode: int, level: str) -> None:
|
||||
fake_popen.returncode = returncode
|
||||
|
||||
run_backup(out, [str(src)], verbose=True)
|
||||
|
||||
rsync = [(r.levelname, r.getMessage()) for r in caplog.records if r.getMessage().startswith('rsync: ')]
|
||||
lines = ['sending incremental file list', 'sent 1 bytes received 2 bytes', 'total size is 3']
|
||||
assert rsync == [(level, f'rsync: {line}') for line in lines]
|
||||
|
||||
def test_rsync_missing(self, src: Path, out: Path, tmp_path: Path, fake_popen: type[FakePopen],
|
||||
old_backups: list[str], monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture) -> None:
|
||||
monkeypatch.setenv('PATH', str(tmp_path / 'empty'))
|
||||
|
||||
assert run_backup(out, [str(src)], keep=0, remove_before=True) == 4
|
||||
assert fake_popen.calls == []
|
||||
# Nothing was removed
|
||||
assert backups(out) == old_backups
|
||||
assert 'rsync not found' in caplog.text
|
||||
|
||||
@pytest.mark.parametrize('returncode, result, removed', [
|
||||
(0, 0, True),
|
||||
(24, 0, True),
|
||||
|
||||
+159
-17
@@ -10,7 +10,7 @@ import warnings
|
||||
from getpass import GetPassWarning
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -30,7 +30,7 @@ class FakeSSH:
|
||||
self.home = home
|
||||
self.bin_dir = bin_dir
|
||||
self.commands: list[str] = []
|
||||
self.fail_on: Optional[str] = None
|
||||
self.fail_on: str | None = None
|
||||
self.closed = False
|
||||
|
||||
def exec_command(self, command: str) -> tuple[None, io.BytesIO, io.BytesIO]:
|
||||
@@ -186,7 +186,7 @@ class TestRemoveOldBackups:
|
||||
assert [c for c in ssh.commands if 'rm -r' in c][0].startswith('sudo rm -r ')
|
||||
|
||||
@pytest.mark.parametrize('names', [[], None])
|
||||
def test_nothing_to_remove(self, remote_home: Path, names: Optional[list[str]]) -> None:
|
||||
def test_nothing_to_remove(self, remote_home: Path, names: list[str] | None) -> None:
|
||||
if names is not None:
|
||||
make_backups(remote_home, *names)
|
||||
|
||||
@@ -254,9 +254,11 @@ class RecordPopen:
|
||||
|
||||
returncode = 0
|
||||
calls: list[list[str]] = []
|
||||
envs: list[dict[str, str] | None] = []
|
||||
|
||||
def __init__(self, args: list[str], **kwargs: Any) -> None:
|
||||
RecordPopen.calls.append(args)
|
||||
RecordPopen.envs.append(kwargs.get('env'))
|
||||
self.stdin = io.BytesIO()
|
||||
self.stdout = io.BytesIO(b'sent 1 bytes received 2 bytes\ntotal size is 3\n')
|
||||
|
||||
@@ -276,6 +278,7 @@ class TestRsyncArgs:
|
||||
def record_popen(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
RecordPopen.returncode = 0
|
||||
RecordPopen.calls = []
|
||||
RecordPopen.envs = []
|
||||
monkeypatch.setattr(sb, 'Popen', RecordPopen)
|
||||
# The input must exist locally
|
||||
(tmp_path / 'input').mkdir()
|
||||
@@ -322,19 +325,26 @@ class TestRsyncArgs:
|
||||
|
||||
def test_password_auth(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sb, 'which', lambda cmd: f'/usr/bin/{cmd}')
|
||||
monkeypatch.setenv('SSHPASS', 'secret')
|
||||
monkeypatch.delenv('SSHPASS', raising=False)
|
||||
backup = connected_backup()
|
||||
backup.inputs = [str(tmp_path / 'input')]
|
||||
backup._password_auth = True
|
||||
backup._password = 'secret'
|
||||
|
||||
assert backup.run() == 0
|
||||
|
||||
(args,) = RecordPopen.calls
|
||||
assert shlex.split(self.ssh_command(args)) == ['sshpass', '-e', 'ssh', '-l', 'user', '-o',
|
||||
'StrictHostKeyChecking=accept-new']
|
||||
# The password must not stay in the environment
|
||||
# Only rsync gets the password, never our own environment
|
||||
(env,) = RecordPopen.envs
|
||||
assert env is not None and env['SSHPASS'] == 'secret'
|
||||
assert 'SSHPASS' not in os.environ
|
||||
|
||||
def test_no_password_env(self, tmp_path: Path) -> None:
|
||||
self.run(tmp_path)
|
||||
|
||||
assert RecordPopen.envs == [None]
|
||||
|
||||
def test_failure(self, tmp_path: Path, remote_home: Path) -> None:
|
||||
base = make_backups(remote_home, '2026-10-01_10-00-00')
|
||||
RecordPopen.returncode = 23
|
||||
@@ -348,11 +358,11 @@ class TestRsyncArgs:
|
||||
class FakeClient:
|
||||
"""paramiko.SSHClient stand-in; connect() takes its outcomes from a list"""
|
||||
|
||||
def __init__(self, outcomes: list[Optional[BaseException]]) -> None:
|
||||
def __init__(self, outcomes: list[BaseException | None]) -> None:
|
||||
self.outcomes = outcomes
|
||||
self.connects: list[dict[str, Any]] = []
|
||||
self.policies: list[str] = []
|
||||
self.known_hosts: Optional[str] = None
|
||||
self.known_hosts: str | None = None
|
||||
self.closed = False
|
||||
|
||||
def load_host_keys(self, filename: str) -> None:
|
||||
@@ -365,12 +375,12 @@ class FakeClient:
|
||||
self.policies.append(type(policy).__name__)
|
||||
|
||||
def connect(self, hostname: str, **kwargs: Any) -> None:
|
||||
self.connects.append(kwargs)
|
||||
self.connects.append({'hostname': hostname, **kwargs})
|
||||
outcome = self.outcomes.pop(0)
|
||||
|
||||
if isinstance(outcome, UserWarning):
|
||||
# Like WarningPolicy for an unknown host key
|
||||
warnings.warn(outcome)
|
||||
warnings.warn(outcome, stacklevel=2)
|
||||
elif outcome is not None:
|
||||
raise outcome
|
||||
|
||||
@@ -404,8 +414,35 @@ class TestLogin:
|
||||
assert remote_backup()._ssh_connect(str(tmp_path)) is client
|
||||
assert client.known_hosts == f'{tmp_path}/.ssh/known_hosts'
|
||||
assert client.connects[0]['timeout'] == sb.SSH_TIMEOUT
|
||||
assert client.connects[0]['hostname'] == 'server'
|
||||
assert 'port' not in client.connects[0]
|
||||
assert not client.closed
|
||||
|
||||
def test_ssh_config(self, client: FakeClient, tmp_path: Path) -> None:
|
||||
(tmp_path / '.ssh').mkdir()
|
||||
key = tmp_path / '.ssh' / 'id_backup'
|
||||
key.touch()
|
||||
(tmp_path / '.ssh' / 'config').write_text(
|
||||
f'Host server\n HostName backup.example.org\n Port 2222\n'
|
||||
f' IdentityFile {key}\n IdentityFile {tmp_path}/.ssh/missing\n')
|
||||
client.outcomes = [ssh_error(), None]
|
||||
|
||||
assert remote_backup()._ssh_connect(str(tmp_path)) is client
|
||||
|
||||
for params in client.connects:
|
||||
assert (params['hostname'], params['port']) == ('backup.example.org', 2222)
|
||||
assert params['key_filename'] == [str(key)]
|
||||
|
||||
def test_bad_ssh_config(self, client: FakeClient, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
(tmp_path / '.ssh').mkdir()
|
||||
(tmp_path / '.ssh' / 'config').write_text('Host server\n Port abc\n')
|
||||
client.outcomes = [None]
|
||||
|
||||
assert remote_backup()._ssh_connect(str(tmp_path)) is client
|
||||
assert client.connects[0]['hostname'] == 'server'
|
||||
assert 'port' not in client.connects[0]
|
||||
assert 'Cannot read' in caplog.text
|
||||
|
||||
@pytest.mark.parametrize('answer, connected', [('y', True), (' Yes', True), ('n', False), ('', False)])
|
||||
def test_unknown_host_key(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch,
|
||||
answer: str, connected: bool) -> None:
|
||||
@@ -444,13 +481,13 @@ class TestLogin:
|
||||
def test_password(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client.outcomes = [ssh_error(), ssh_error(), None]
|
||||
monkeypatch.setattr(sb, 'getpass', lambda prompt: 'secret')
|
||||
monkeypatch.setenv('SSHPASS', '')
|
||||
monkeypatch.delenv('SSHPASS', raising=False)
|
||||
backup = remote_backup()
|
||||
|
||||
assert backup._ssh_connect() is client
|
||||
assert client.connects[-1]['password'] == 'secret'
|
||||
assert backup._password_auth
|
||||
assert os.environ['SSHPASS'] == 'secret'
|
||||
assert backup._password == 'secret'
|
||||
assert 'SSHPASS' not in os.environ
|
||||
|
||||
def test_wrong_password(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client.outcomes = [ssh_error(), ssh_error(), ssh_error()]
|
||||
@@ -462,7 +499,7 @@ class TestLogin:
|
||||
def test_password_no_terminal(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture) -> None:
|
||||
def no_tty(prompt: str) -> str:
|
||||
warnings.warn('Can not control echo on the terminal.', GetPassWarning)
|
||||
warnings.warn('Can not control echo on the terminal.', GetPassWarning, stacklevel=2)
|
||||
|
||||
return ''
|
||||
|
||||
@@ -474,7 +511,7 @@ class TestLogin:
|
||||
|
||||
@pytest.mark.parametrize('outcome, connected', [(None, True), ('error', False)])
|
||||
def test_keyfile(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch,
|
||||
outcome: Optional[str], connected: bool) -> None:
|
||||
outcome: str | None, connected: bool) -> None:
|
||||
pkey = object()
|
||||
client.outcomes = [ssh_error(), ssh_error(), ssh_error() if outcome else None]
|
||||
monkeypatch.setattr(sb.Backup, '_load_ssh_key', lambda self: pkey)
|
||||
@@ -548,7 +585,7 @@ class TestLoadKey:
|
||||
def test_no_terminal(self, keys: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture) -> None:
|
||||
def no_tty(prompt: str) -> str:
|
||||
warnings.warn('Can not control echo on the terminal.', GetPassWarning)
|
||||
warnings.warn('Can not control echo on the terminal.', GetPassWarning, stacklevel=2)
|
||||
|
||||
return ''
|
||||
|
||||
@@ -558,7 +595,7 @@ class TestLoadKey:
|
||||
assert 'Unable to get passphrase' in caplog.text
|
||||
|
||||
@pytest.mark.parametrize('content', [None, 'not a key\n'])
|
||||
def test_bad_file(self, tmp_path: Path, caplog: pytest.LogCaptureFixture, content: Optional[str]) -> None:
|
||||
def test_bad_file(self, tmp_path: Path, caplog: pytest.LogCaptureFixture, content: str | None) -> None:
|
||||
keyfile = tmp_path / 'id'
|
||||
|
||||
if content is not None:
|
||||
@@ -566,3 +603,108 @@ class TestLoadKey:
|
||||
|
||||
assert remote_backup(ssh_keyfile=str(keyfile))._load_ssh_key() is None
|
||||
assert 'Unable to load SSH key' in caplog.text
|
||||
|
||||
|
||||
FAKE_SSH = """#!/bin/sh
|
||||
# Record the arguments, then run the remote command locally in the fake remote home
|
||||
for a in "$@"; do printf '%s\\n' "$a"; done >> "{log}"
|
||||
echo --- >> "{log}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-i|-l|-o|-p) shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# The host
|
||||
shift
|
||||
cd "{home}" && HOME="{home}" exec sh -c "$*"
|
||||
"""
|
||||
|
||||
FAKE_SSHPASS = """#!/bin/sh
|
||||
printf '%s' "${{SSHPASS-unset}}" > "{log}"
|
||||
# -e
|
||||
shift
|
||||
exec "$@"
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('connect', 'fake_now')
|
||||
class TestRsyncOverSsh:
|
||||
"""Whole remote backups: real rsync, with fake ssh and sshpass commands that run the remote side locally"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_ssh(self, ssh: FakeSSH, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
self.ssh_log = tmp_path / 'ssh.log'
|
||||
self.sshpass_log = tmp_path / 'sshpass.log'
|
||||
|
||||
for name, script, log in [('ssh', FAKE_SSH, self.ssh_log), ('sshpass', FAKE_SSHPASS, self.sshpass_log)]:
|
||||
path = ssh.bin_dir / name
|
||||
path.write_text(script.format(log=log, home=ssh.home))
|
||||
path.chmod(0o755)
|
||||
|
||||
monkeypatch.setenv('PATH', f'{ssh.bin_dir}:{os.environ["PATH"]}')
|
||||
monkeypatch.delenv('SSHPASS', raising=False)
|
||||
|
||||
self.src = tmp_path / 'src dir'
|
||||
self.src.mkdir()
|
||||
(self.src / 'same.txt').write_text('unchanged')
|
||||
(self.src / 'changed.txt').write_text('first')
|
||||
|
||||
def run(self, keep: int = -1, password: str | None = None, **kwargs: Any) -> int:
|
||||
backup = connected_backup(keep=keep, **kwargs)
|
||||
backup.inputs = [str(self.src)]
|
||||
backup._password = password
|
||||
|
||||
return backup.run()
|
||||
|
||||
def ssh_calls(self) -> list[list[str]]:
|
||||
return [c.strip('\n').split('\n') for c in self.ssh_log.read_text().split('---\n') if c != '']
|
||||
|
||||
def copied(self, remote_home: Path, backup: str, name: str) -> Path:
|
||||
return remote_home / 'backups' / 'simple_backup' / backup / str(self.src).lstrip('/') / name
|
||||
|
||||
def test_backups(self, remote_home: Path) -> None:
|
||||
assert self.run() == 0
|
||||
(self.src / 'changed.txt').write_text('second')
|
||||
assert self.run(keep=1) == 0
|
||||
assert self.run(keep=1) == 0
|
||||
|
||||
old, new = '2026-10-14_11-00-00', '2026-10-14_12-00-00'
|
||||
assert remote_dirs(remote_home / 'backups' / 'simple_backup') == [old, new]
|
||||
assert self.copied(remote_home, new, 'changed.txt').read_text() == 'second'
|
||||
# Unchanged files are hard-linked to the previous backup (--link-dest on the server)
|
||||
assert self.copied(remote_home, old, 'same.txt').stat().st_ino == \
|
||||
self.copied(remote_home, new, 'same.txt').stat().st_ino
|
||||
|
||||
for call in self.ssh_calls():
|
||||
assert call[:5] == ['-o', 'StrictHostKeyChecking=accept-new', '-l', 'user', 'server']
|
||||
# rsync passes the remote command as separate arguments
|
||||
assert call[5:7] == ['rsync', '--server']
|
||||
|
||||
def test_keyfile(self, remote_home: Path) -> None:
|
||||
assert self.run(ssh_keyfile='/keys/my key') == 0
|
||||
|
||||
(call,) = self.ssh_calls()
|
||||
assert call[:4] == ['-i', '/keys/my key', '-o', 'StrictHostKeyChecking=accept-new']
|
||||
|
||||
def test_password(self, remote_home: Path) -> None:
|
||||
assert self.run(password='secret') == 0
|
||||
|
||||
assert self.sshpass_log.read_text() == 'secret'
|
||||
assert 'SSHPASS' not in os.environ
|
||||
assert len(remote_dirs(remote_home / 'backups' / 'simple_backup')) == 1
|
||||
|
||||
def test_remote_sudo(self, remote_home: Path) -> None:
|
||||
assert self.run() == 0
|
||||
assert self.run(keep=0, remote_sudo=True) == 0
|
||||
|
||||
assert remote_dirs(remote_home / 'backups' / 'simple_backup') == ['2026-10-14_11-00-00']
|
||||
assert self.ssh_calls()[-1][5:7] == ['sudo rsync', '--server']
|
||||
|
||||
def test_progress(self, remote_home: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert self.run(progress=True) == 0
|
||||
|
||||
assert '100%' in capsys.readouterr().err
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests for the last backup state file and --last-backup"""
|
||||
|
||||
import os
|
||||
import pwd
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from simple_backup import simple_backup as sb
|
||||
|
||||
DESTINATION = '/backups/simple_backup/2026-10-25_10-00-00'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def as_user(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run as a non-root user (the tests may run as root in a chroot)"""
|
||||
|
||||
monkeypatch.setattr(os, 'geteuid', lambda: 1000)
|
||||
monkeypatch.setenv('USER', 'user')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(tmp_path: Path) -> Path:
|
||||
return tmp_path / 'state' / 'simple_backup' / 'last_backup'
|
||||
|
||||
|
||||
def write_state(state: Path, text: str) -> None:
|
||||
state.parent.mkdir(parents=True)
|
||||
state.write_text(text)
|
||||
|
||||
|
||||
def test_write(state: Path) -> None:
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
time, destination = state.read_text().splitlines()
|
||||
assert abs(datetime.now().astimezone() - datetime.fromisoformat(time)) < timedelta(minutes=1)
|
||||
assert destination == DESTINATION
|
||||
# No temporary file left behind
|
||||
assert os.listdir(state.parent) == ['last_backup']
|
||||
|
||||
|
||||
def test_overwrite(state: Path) -> None:
|
||||
write_state(state, '2026-10-01T10:00:00+02:00\n/old\n')
|
||||
|
||||
sb._write_state(None, 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:
|
||||
monkeypatch.setenv('XDG_STATE_HOME', 'relative')
|
||||
monkeypatch.setenv('HOME', str(tmp_path / 'home'))
|
||||
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists()
|
||||
|
||||
|
||||
def test_write_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
# A file where the state directory should be
|
||||
(tmp_path / 'not a dir').write_text('')
|
||||
monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'not a dir'))
|
||||
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
assert 'Cannot save the time of the last backup' in caplog.text
|
||||
|
||||
|
||||
def test_root_writes_as_user(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
calls: list[tuple[str, int]] = []
|
||||
egid = os.getegid()
|
||||
owner = pwd.struct_passwd(('user', 'x', 1000, 1001, '', str(tmp_path / 'home'), '/bin/sh'))
|
||||
|
||||
monkeypatch.setattr(os, 'geteuid', lambda: 0)
|
||||
monkeypatch.setattr(pwd, 'getpwnam', lambda name: owner)
|
||||
monkeypatch.setattr(os, 'seteuid', lambda uid: calls.append(('euid', uid)))
|
||||
monkeypatch.setattr(os, 'setegid', lambda gid: calls.append(('egid', gid)))
|
||||
|
||||
sb._write_state('user', DESTINATION)
|
||||
|
||||
# Root's XDG_STATE_HOME is not used
|
||||
assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists()
|
||||
assert calls == [('egid', 1001), ('euid', 1000), ('euid', 0), ('egid', egid)]
|
||||
|
||||
|
||||
class TestStateOwner:
|
||||
def test_non_root(self) -> None:
|
||||
assert sb._state_owner('user') is None
|
||||
|
||||
@pytest.mark.parametrize('name, expected', [('user', 'user'), ('root', None), ('unknown', None), (None, None)])
|
||||
def test_root(self, monkeypatch: pytest.MonkeyPatch, name: str | None, expected: str | None) -> None:
|
||||
users = {'user': pwd.struct_passwd(('user', 'x', 1000, 1000, '', '/home/user', '/bin/sh')),
|
||||
'root': pwd.struct_passwd(('root', 'x', 0, 0, '', '/root', '/bin/sh'))}
|
||||
|
||||
def getpwnam(name: str) -> pwd.struct_passwd:
|
||||
if name not in users:
|
||||
raise KeyError(name)
|
||||
|
||||
return users[name]
|
||||
|
||||
monkeypatch.setattr(os, 'geteuid', lambda: 0)
|
||||
monkeypatch.setattr(pwd, 'getpwnam', getpwnam)
|
||||
|
||||
owner = sb._state_owner(name)
|
||||
assert (owner.pw_name if owner is not None else None) == expected
|
||||
|
||||
|
||||
class TestShow:
|
||||
@pytest.mark.parametrize('days, age', [(0, 'today'), (1, 'yesterday'), (3, '3 days ago')])
|
||||
def test_age(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str],
|
||||
days: int, age: str) -> None:
|
||||
completed = (datetime.now() - timedelta(days=days)).replace(hour=12, minute=30).astimezone()
|
||||
write_state(state, f'{completed.isoformat(timespec="seconds")}\n{DESTINATION}\n')
|
||||
|
||||
assert run_cli('--last-backup') == 0
|
||||
assert capsys.readouterr().out == \
|
||||
f'Last backup: {completed:%Y-%m-%d} 12:30 ({age})\nDirectory: {DESTINATION}\n'
|
||||
|
||||
def test_after_write(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None:
|
||||
sb._write_state(None, DESTINATION)
|
||||
|
||||
assert run_cli('--last-backup') == 0
|
||||
assert '(today)' in capsys.readouterr().out
|
||||
|
||||
def test_no_backup(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert run_cli('--last-backup') == 1
|
||||
assert capsys.readouterr().out == 'No backup recorded yet\n'
|
||||
|
||||
@pytest.mark.parametrize('text', ['', '2026-10-25T10:00:00+02:00\n', 'yesterday\n/backups\n'])
|
||||
def test_bad_file(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str],
|
||||
text: str) -> None:
|
||||
write_state(state, text)
|
||||
|
||||
assert run_cli('--last-backup') == 1
|
||||
assert 'Cannot read' in capsys.readouterr().err
|
||||
Reference in New Issue
Block a user