Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125b77a839
|
||
|
|
17c104c6c8
|
||
|
|
7dbb1bcaee
|
||
|
|
a7fd325fa7
|
||
|
|
8a479a8455
|
||
|
|
3e486eb3ab
|
||
|
|
331e72b373
|
@@ -1,3 +1,4 @@
|
|||||||
|
CLAUDE.md
|
||||||
.idea/
|
.idea/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
test/
|
test/
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
pkgname=simple_backup
|
pkgname=simple_backup
|
||||||
pkgdesc='Simple backup script that uses rsync to copy files'
|
pkgdesc='Simple backup script that uses rsync to copy files'
|
||||||
pkgver=4.3.0
|
pkgver=4.4.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
url="https://git.shouldnt.work/fuxino/${pkgname}"
|
url="https://git.shouldnt.work/fuxino/$pkgname"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
license=('GPL-3.0-or-later')
|
license=('GPL-3.0-or-later')
|
||||||
makedepends=('git'
|
makedepends=('git'
|
||||||
@@ -18,21 +18,31 @@ depends=('python>=3.10'
|
|||||||
optdepends=('python-systemd: use systemd log'
|
optdepends=('python-systemd: use systemd log'
|
||||||
'python-dbus: for desktop notifications'
|
'python-dbus: for desktop notifications'
|
||||||
'python-paramiko: for remote backup through ssh')
|
'python-paramiko: for remote backup through ssh')
|
||||||
|
checkdepends=('python-pytest'
|
||||||
|
'python-paramiko'
|
||||||
|
'openssh')
|
||||||
conflicts=('simple_backup-git')
|
conflicts=('simple_backup-git')
|
||||||
source=(git+${url}?signed#tag=${pkgver})
|
source=("git+$url?signed#tag=$pkgver")
|
||||||
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
||||||
sha256sums=('7d9ea50d5829ac3c681a979745f1ae7afd2453eb46ceeae4f441ef5ae4540bd2')
|
sha256sums=('cc5b378ac489f7cf3e3a38855dfd530a79f0651a1891ea83355b952ed3a20aee')
|
||||||
|
|
||||||
build()
|
build()
|
||||||
{
|
{
|
||||||
cd ${srcdir}/${pkgname}
|
cd "$srcdir"/"$pkgname"
|
||||||
python -m build --wheel --no-isolation
|
python -m build --wheel --no-isolation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check()
|
||||||
|
{
|
||||||
|
cd "$srcdir"/"$pkgname"
|
||||||
|
python -m pytest
|
||||||
|
}
|
||||||
|
|
||||||
package()
|
package()
|
||||||
{
|
{
|
||||||
cd ${srcdir}/${pkgname}
|
cd "$srcdir"/"$pkgname"
|
||||||
python -m installer --destdir=${pkgdir} dist/*.whl
|
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||||
install -Dm644 ${pkgname}/${pkgname}.conf ${pkgdir}/usr/share/doc/${pkgname}/${pkgname}.conf
|
install -Dm644 "$pkgname"/"$pkgname".conf "$pkgdir"/usr/share/doc/"$pkgname"/"$pkgname".conf
|
||||||
install -Dm644 man/${pkgname}.1 ${pkgdir}/usr/share/man/man1/${pkgname}.1
|
install -Dm644 README.md "$pkgdir"/usr/share/doc/"$pkgname"/README.md
|
||||||
|
install -Dm644 man/"$pkgname".1 "$pkgdir"/usr/share/man/man1/"$pkgname".1
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
.TH SIMPLE_BACKUP 1 2026-10-14 SIMPLE_BACKUP 4.4.0
|
.TH SIMPLE_BACKUP 1 2026-10-15 SIMPLE_BACKUP 4.4.1
|
||||||
.SH NAME
|
.SH NAME
|
||||||
simple_backup \- Backup files and folders using rsync
|
simple_backup \- Backup files and folders using rsync
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ warn_unused_ignores = true
|
|||||||
module = ["paramiko", "systemd", "dbus"]
|
module = ["paramiko", "systemd", "dbus"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "B", "UP"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
# Import the repo copy, not an installed one
|
# Import the repo copy, not an installed one
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Init."""
|
"""Init."""
|
||||||
|
|
||||||
__version__ = '4.4.0'
|
__version__ = '4.4.1'
|
||||||
|
|||||||
@@ -14,27 +14,27 @@ Classes:
|
|||||||
# Avoid evaluating annotations at definition time, as they reference optional modules (paramiko)
|
# Avoid evaluating annotations at definition time, as they reference optional modules (paramiko)
|
||||||
from __future__ import annotations
|
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 argparse
|
||||||
import configparser
|
import configparser
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
from logging import StreamHandler
|
import os
|
||||||
from timeit import default_timer
|
import pwd
|
||||||
from subprocess import Popen, PIPE, STDOUT
|
import re
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from tempfile import mkstemp
|
from functools import wraps
|
||||||
from getpass import GetPassWarning, getpass
|
from getpass import GetPassWarning, getpass
|
||||||
from glob import glob
|
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 ParamSpec, TypedDict, TypeVar
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import paramiko
|
import paramiko
|
||||||
@@ -75,7 +75,7 @@ R = TypeVar('R')
|
|||||||
SSH_TIMEOUT = 15
|
SSH_TIMEOUT = 15
|
||||||
|
|
||||||
# An rsync --info=progress2 update: size, percent, speed, time, then optionally (xfr#N, ir-chk/to-chk=N/N)
|
# 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
|
PROGRESS_BAR_WIDTH = 30
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +111,8 @@ class _ProgressBar:
|
|||||||
self._start = default_timer()
|
self._start = default_timer()
|
||||||
# rsync reports ir-chk while incremental recursion is still finding files
|
# rsync reports ir-chk while incremental recursion is still finding files
|
||||||
self._scanning = True
|
self._scanning = True
|
||||||
|
self._checked = 0
|
||||||
|
self._total = 0
|
||||||
self._shown = False
|
self._shown = False
|
||||||
|
|
||||||
def update(self, record: str) -> bool:
|
def update(self, record: str) -> bool:
|
||||||
@@ -121,25 +123,30 @@ class _ProgressBar:
|
|||||||
if m is None:
|
if m is None:
|
||||||
return False
|
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:
|
if chk is not None:
|
||||||
self._scanning = chk == 'ir'
|
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
|
# Count files, not bytes: with --link-dest unchanged files are linked, and rsync's percentage leaves them out
|
||||||
if 0 < percent < 100:
|
if self._scanning or self._total == 0:
|
||||||
eta = int((default_timer() - self._start) * (100 - percent) / percent)
|
text = f'{self._checked:,} files checked {size} copied (scanning)'
|
||||||
text = f'{percent:3d}% {size} {speed} ETA {eta // 3600}:{eta // 60 % 60:02d}:{eta % 60:02d}'
|
percent = None
|
||||||
else:
|
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:
|
if 0 < percent < 100:
|
||||||
text += ' (scanning)'
|
eta = int((default_timer() - self._start) * (self._total - self._checked) / self._checked)
|
||||||
|
text += f' ETA {eta // 3600}:{eta // 60 % 60:02d}:{eta % 60:02d}'
|
||||||
|
|
||||||
width = get_terminal_size().columns - 1
|
width = get_terminal_size().columns - 1
|
||||||
|
|
||||||
# Fixed width, so the bar doesn't jump when the text changes length
|
# Fixed width, so the bar doesn't jump when the text changes length; no bar until the total is known
|
||||||
if len(text) + PROGRESS_BAR_WIDTH + 4 <= width:
|
if percent is not None and len(text) + PROGRESS_BAR_WIDTH + 4 <= width:
|
||||||
filled = PROGRESS_BAR_WIDTH * percent // 100
|
filled = PROGRESS_BAR_WIDTH * percent // 100
|
||||||
text = f'[{"#" * filled}{"-" * (PROGRESS_BAR_WIDTH - filled)}] {text}'
|
text = f'[{"#" * filled}{"-" * (PROGRESS_BAR_WIDTH - filled)}] {text}'
|
||||||
|
|
||||||
@@ -197,9 +204,9 @@ class Backup:
|
|||||||
Perform the backup
|
Perform the backup
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, inputs: Optional[List[str]], output: Optional[str], exclude: Optional[List[str]],
|
def __init__(self, inputs: list[str] | None, output: str | None, exclude: list[str] | None,
|
||||||
keep: Optional[int], options: str,
|
keep: int | None, options: str,
|
||||||
ssh_host: Optional[str] = None, ssh_user: Optional[str] = None, ssh_keyfile: Optional[str] = None,
|
ssh_host: str | None = None, ssh_user: str | None = None, ssh_keyfile: str | None = None,
|
||||||
remote_sudo: bool = False, remove_before: bool = False, verbose: bool = False,
|
remote_sudo: bool = False, remove_before: bool = False, verbose: bool = False,
|
||||||
progress: bool = False) -> None:
|
progress: bool = False) -> None:
|
||||||
# Missing values are reported by check_params()
|
# Missing values are reported by check_params()
|
||||||
@@ -221,7 +228,7 @@ class Backup:
|
|||||||
self._inputs_path = ''
|
self._inputs_path = ''
|
||||||
self._exclude_path = ''
|
self._exclude_path = ''
|
||||||
self._remote = False
|
self._remote = False
|
||||||
self._ssh: Optional[paramiko.client.SSHClient] = None
|
self._ssh: paramiko.client.SSHClient | None = None
|
||||||
self._password_auth = False
|
self._password_auth = False
|
||||||
self._removed_count = 0
|
self._removed_count = 0
|
||||||
|
|
||||||
@@ -274,7 +281,7 @@ class Backup:
|
|||||||
|
|
||||||
return 0
|
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"""
|
"""Return the absolute path of a directory on the server, or None if it doesn't exist"""
|
||||||
|
|
||||||
assert self._ssh is not None
|
assert self._ssh is not None
|
||||||
@@ -443,7 +450,7 @@ class Backup:
|
|||||||
except IndexError:
|
except IndexError:
|
||||||
logger.info('No previous backups available')
|
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:
|
try:
|
||||||
ssh = paramiko.SSHClient()
|
ssh = paramiko.SSHClient()
|
||||||
except NameError:
|
except NameError:
|
||||||
@@ -462,7 +469,7 @@ class Backup:
|
|||||||
if connected is None:
|
if connected is None:
|
||||||
ssh.close()
|
ssh.close()
|
||||||
|
|
||||||
def _ssh_login(self, ssh: paramiko.client.SSHClient, homedir: str) -> Optional[paramiko.client.SSHClient]:
|
def _ssh_login(self, ssh: paramiko.client.SSHClient, homedir: str) -> paramiko.client.SSHClient | None:
|
||||||
try:
|
try:
|
||||||
ssh.load_host_keys(filename=f'{homedir}/.ssh/known_hosts')
|
ssh.load_host_keys(filename=f'{homedir}/.ssh/known_hosts')
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -546,7 +553,7 @@ class Backup:
|
|||||||
|
|
||||||
return ssh
|
return ssh
|
||||||
|
|
||||||
def _load_ssh_key(self) -> Optional[paramiko.PKey]:
|
def _load_ssh_key(self) -> paramiko.PKey | None:
|
||||||
try:
|
try:
|
||||||
return paramiko.PKey.from_path(self.ssh_keyfile)
|
return paramiko.PKey.from_path(self.ssh_keyfile)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@@ -801,7 +808,7 @@ def _read_records(stream: io.BufferedIOBase) -> Iterator[str]:
|
|||||||
yield buffer.decode('utf-8', errors='replace')
|
yield buffer.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
|
||||||
def _detect_user() -> Optional[str]:
|
def _detect_user() -> str | None:
|
||||||
if os.geteuid() == 0:
|
if os.geteuid() == 0:
|
||||||
return os.getenv('SUDO_USER') or os.getenv('DOAS_USER')
|
return os.getenv('SUDO_USER') or os.getenv('DOAS_USER')
|
||||||
|
|
||||||
@@ -866,8 +873,8 @@ def _expand_home(path: str, user: str) -> str:
|
|||||||
return os.path.expanduser(path)
|
return os.path.expanduser(path)
|
||||||
|
|
||||||
|
|
||||||
def _expand_inputs(inputs: List[str], user: Optional[str] = None) -> List[str]:
|
def _expand_inputs(inputs: list[str], user: str | None = None) -> list[str]:
|
||||||
expanded_inputs: List[str] = []
|
expanded_inputs: list[str] = []
|
||||||
|
|
||||||
for i in inputs:
|
for i in inputs:
|
||||||
if i == '':
|
if i == '':
|
||||||
@@ -893,13 +900,13 @@ def _expand_inputs(inputs: List[str], user: Optional[str] = None) -> List[str]:
|
|||||||
class ConfigArgs(TypedDict):
|
class ConfigArgs(TypedDict):
|
||||||
"""Values read from the configuration file"""
|
"""Values read from the configuration file"""
|
||||||
|
|
||||||
inputs: Optional[List[str]]
|
inputs: list[str] | None
|
||||||
output: Optional[str]
|
output: str | None
|
||||||
exclude: Optional[List[str]]
|
exclude: list[str] | None
|
||||||
keep: int
|
keep: int
|
||||||
ssh_host: Optional[str]
|
ssh_host: str | None
|
||||||
ssh_user: Optional[str]
|
ssh_user: str | None
|
||||||
ssh_keyfile: Optional[str]
|
ssh_keyfile: str | None
|
||||||
remote_sudo: bool
|
remote_sudo: bool
|
||||||
numeric_ids: bool
|
numeric_ids: bool
|
||||||
|
|
||||||
@@ -912,7 +919,7 @@ def _get_bool(config: configparser.ConfigParser, key: str) -> bool:
|
|||||||
return config.getboolean('server', key)
|
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,
|
config_args: ConfigArgs = {'inputs': None,
|
||||||
'output': None,
|
'output': None,
|
||||||
'exclude': None,
|
'exclude': None,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
"""Shared fixtures for the simple_backup tests"""
|
"""Shared fixtures for the simple_backup tests"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from typing import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -25,8 +26,8 @@ class FakeBackup:
|
|||||||
check_code = 0
|
check_code = 0
|
||||||
|
|
||||||
def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str,
|
def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str,
|
||||||
ssh_host: Optional[str] = None, ssh_user: Optional[str] = None,
|
ssh_host: str | None = None, ssh_user: str | None = None,
|
||||||
ssh_keyfile: Optional[str] = None, remote_sudo: bool = False,
|
ssh_keyfile: str | None = None, remote_sudo: bool = False,
|
||||||
remove_before: bool = False, verbose: bool = False, progress: bool = False) -> None:
|
remove_before: bool = False, verbose: bool = False, progress: bool = False) -> None:
|
||||||
self.inputs = inputs
|
self.inputs = inputs
|
||||||
self.output = output
|
self.output = output
|
||||||
@@ -40,7 +41,7 @@ class FakeBackup:
|
|||||||
self.remove_before = remove_before
|
self.remove_before = remove_before
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.progress = progress
|
self.progress = progress
|
||||||
self.homedir: Optional[str] = None
|
self.homedir: str | None = None
|
||||||
self.ran = False
|
self.ran = False
|
||||||
self.closed = False
|
self.closed = False
|
||||||
FakeBackup.instances.append(self)
|
FakeBackup.instances.append(self)
|
||||||
@@ -86,13 +87,13 @@ def as_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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"""
|
"""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
|
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))
|
calls.append((config_file, user))
|
||||||
|
|
||||||
return read_config('/nonexistent/simple_backup.conf', user)
|
return read_config('/nonexistent/simple_backup.conf', user)
|
||||||
@@ -249,20 +250,20 @@ ssh_keyfile=/keys/id
|
|||||||
|
|
||||||
class TestUser:
|
class TestUser:
|
||||||
@pytest.mark.usefixtures('as_user')
|
@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 run_cli() == 0
|
||||||
assert config_calls == [(f'{HOME}/.config/simple_backup/simple_backup.conf', USER)]
|
assert config_calls == [(f'{HOME}/.config/simple_backup/simple_backup.conf', USER)]
|
||||||
assert last_backup().homedir == HOME
|
assert last_backup().homedir == HOME
|
||||||
|
|
||||||
@pytest.mark.usefixtures('as_root')
|
@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 run_cli('-u', USER, '-c', '~/other.conf') == 0
|
||||||
assert config_calls == [(f'{HOME}/other.conf', USER)]
|
assert config_calls == [(f'{HOME}/other.conf', USER)]
|
||||||
assert last_backup().homedir == HOME
|
assert last_backup().homedir == HOME
|
||||||
|
|
||||||
@pytest.mark.usefixtures('as_root')
|
@pytest.mark.usefixtures('as_root')
|
||||||
@pytest.mark.parametrize('var', ['SUDO_USER', 'DOAS_USER'])
|
@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: pytest.MonkeyPatch, var: str) -> None:
|
||||||
monkeypatch.setenv(var, USER)
|
monkeypatch.setenv(var, USER)
|
||||||
|
|
||||||
@@ -271,7 +272,7 @@ class TestUser:
|
|||||||
assert last_backup().homedir == HOME
|
assert last_backup().homedir == HOME
|
||||||
|
|
||||||
@pytest.mark.usefixtures('as_root')
|
@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:
|
caplog: pytest.LogCaptureFixture) -> None:
|
||||||
assert run_cli() == 0
|
assert run_cli() == 0
|
||||||
# '~' is left to expanduser, i.e. $HOME
|
# '~' is left to expanduser, i.e. $HOME
|
||||||
|
|||||||
+19
-8
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterator, Optional
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ def out(tmp_path: Path) -> Path:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def make_backup(out: Path, inputs: list[str], keep: int = -1, exclude: Optional[list[str]] = None,
|
def make_backup(out: Path, inputs: list[str], keep: int = -1, exclude: list[str] | None = None,
|
||||||
remove_before: bool = False, progress: bool = False) -> sb.Backup:
|
remove_before: bool = False, progress: bool = False) -> sb.Backup:
|
||||||
backup = sb.Backup(inputs, str(out), exclude or [], keep, OPTIONS, remove_before=remove_before,
|
backup = sb.Backup(inputs, str(out), exclude or [], keep, OPTIONS, remove_before=remove_before,
|
||||||
progress=progress)
|
progress=progress)
|
||||||
@@ -237,18 +238,28 @@ class TestProgressBar:
|
|||||||
# 30 s elapsed at each update
|
# 30 s elapsed at each update
|
||||||
monkeypatch.setattr(sb, 'default_timer', iter([0.0, 30.0]).__next__)
|
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:
|
def test_update(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
bar = sb._ProgressBar()
|
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 == \
|
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()
|
bar = sb._ProgressBar()
|
||||||
|
|
||||||
assert bar.update(' 4,000,000 50% 2.97MB/s 0:00:01 (xfr#2, to-chk=1/4)')
|
assert bar.update(' 2.00M 1% 1.47MB/s 0:00:02 (xfr#1, to-chk=2/4)')
|
||||||
assert capsys.readouterr().err.endswith('] 50% 4,000,000 2.97MB/s ETA 0:00:30\033[K')
|
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_not_progress(self, capsys: pytest.CaptureFixture[str]) -> None:
|
def test_not_progress(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
bar = sb._ProgressBar()
|
bar = sb._ProgressBar()
|
||||||
@@ -264,7 +275,7 @@ class TestProgressBar:
|
|||||||
|
|
||||||
assert sb._ProgressBar().update(' 6.00M 100% 1.48MB/s 0:00:03 (xfr#3, to-chk=0/4)')
|
assert sb._ProgressBar().update(' 6.00M 100% 1.48MB/s 0:00:03 (xfr#3, to-chk=0/4)')
|
||||||
# No room for the bar
|
# 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:
|
class FakePopen:
|
||||||
|
|||||||
+10
-10
@@ -10,7 +10,7 @@ import warnings
|
|||||||
from getpass import GetPassWarning
|
from getpass import GetPassWarning
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class FakeSSH:
|
|||||||
self.home = home
|
self.home = home
|
||||||
self.bin_dir = bin_dir
|
self.bin_dir = bin_dir
|
||||||
self.commands: list[str] = []
|
self.commands: list[str] = []
|
||||||
self.fail_on: Optional[str] = None
|
self.fail_on: str | None = None
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
|
||||||
def exec_command(self, command: str) -> tuple[None, io.BytesIO, io.BytesIO]:
|
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 ')
|
assert [c for c in ssh.commands if 'rm -r' in c][0].startswith('sudo rm -r ')
|
||||||
|
|
||||||
@pytest.mark.parametrize('names', [[], None])
|
@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:
|
if names is not None:
|
||||||
make_backups(remote_home, *names)
|
make_backups(remote_home, *names)
|
||||||
|
|
||||||
@@ -348,11 +348,11 @@ class TestRsyncArgs:
|
|||||||
class FakeClient:
|
class FakeClient:
|
||||||
"""paramiko.SSHClient stand-in; connect() takes its outcomes from a list"""
|
"""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.outcomes = outcomes
|
||||||
self.connects: list[dict[str, Any]] = []
|
self.connects: list[dict[str, Any]] = []
|
||||||
self.policies: list[str] = []
|
self.policies: list[str] = []
|
||||||
self.known_hosts: Optional[str] = None
|
self.known_hosts: str | None = None
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
|
||||||
def load_host_keys(self, filename: str) -> None:
|
def load_host_keys(self, filename: str) -> None:
|
||||||
@@ -370,7 +370,7 @@ class FakeClient:
|
|||||||
|
|
||||||
if isinstance(outcome, UserWarning):
|
if isinstance(outcome, UserWarning):
|
||||||
# Like WarningPolicy for an unknown host key
|
# Like WarningPolicy for an unknown host key
|
||||||
warnings.warn(outcome)
|
warnings.warn(outcome, stacklevel=2)
|
||||||
elif outcome is not None:
|
elif outcome is not None:
|
||||||
raise outcome
|
raise outcome
|
||||||
|
|
||||||
@@ -462,7 +462,7 @@ class TestLogin:
|
|||||||
def test_password_no_terminal(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch,
|
def test_password_no_terminal(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch,
|
||||||
caplog: pytest.LogCaptureFixture) -> None:
|
caplog: pytest.LogCaptureFixture) -> None:
|
||||||
def no_tty(prompt: str) -> str:
|
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 ''
|
return ''
|
||||||
|
|
||||||
@@ -474,7 +474,7 @@ class TestLogin:
|
|||||||
|
|
||||||
@pytest.mark.parametrize('outcome, connected', [(None, True), ('error', False)])
|
@pytest.mark.parametrize('outcome, connected', [(None, True), ('error', False)])
|
||||||
def test_keyfile(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch,
|
def test_keyfile(self, client: FakeClient, monkeypatch: pytest.MonkeyPatch,
|
||||||
outcome: Optional[str], connected: bool) -> None:
|
outcome: str | None, connected: bool) -> None:
|
||||||
pkey = object()
|
pkey = object()
|
||||||
client.outcomes = [ssh_error(), ssh_error(), ssh_error() if outcome else None]
|
client.outcomes = [ssh_error(), ssh_error(), ssh_error() if outcome else None]
|
||||||
monkeypatch.setattr(sb.Backup, '_load_ssh_key', lambda self: pkey)
|
monkeypatch.setattr(sb.Backup, '_load_ssh_key', lambda self: pkey)
|
||||||
@@ -548,7 +548,7 @@ class TestLoadKey:
|
|||||||
def test_no_terminal(self, keys: Path, monkeypatch: pytest.MonkeyPatch,
|
def test_no_terminal(self, keys: Path, monkeypatch: pytest.MonkeyPatch,
|
||||||
caplog: pytest.LogCaptureFixture) -> None:
|
caplog: pytest.LogCaptureFixture) -> None:
|
||||||
def no_tty(prompt: str) -> str:
|
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 ''
|
return ''
|
||||||
|
|
||||||
@@ -558,7 +558,7 @@ class TestLoadKey:
|
|||||||
assert 'Unable to get passphrase' in caplog.text
|
assert 'Unable to get passphrase' in caplog.text
|
||||||
|
|
||||||
@pytest.mark.parametrize('content', [None, 'not a key\n'])
|
@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'
|
keyfile = tmp_path / 'id'
|
||||||
|
|
||||||
if content is not None:
|
if content is not None:
|
||||||
|
|||||||
Reference in New Issue
Block a user