Compare commits

...
3 Commits
Author SHA1 Message Date
Fuxino fdb2fc960a Bump version to 4.6.1 2026-11-02 20:18:52 +01:00
Fuxino c5cfe49758 Show elapsed time as (h:)mm:ss and add it to the notification
Replace the timing decorator with a start time kept by Backup.run(), so the final
notification can report how long the backup took.
2026-11-02 19:54:11 +01:00
Fuxino 8d0fd46f44 Update PKGBUILD 2026-10-25 12:42:54 +01:00
5 changed files with 41 additions and 30 deletions
+2 -2
View File
@@ -4,7 +4,7 @@
pkgname=simple_backup pkgname=simple_backup
pkgdesc='Simple backup script that uses rsync to copy files' pkgdesc='Simple backup script that uses rsync to copy files'
pkgver=4.5.0 pkgver=4.6.0
pkgrel=1 pkgrel=1
url="https://git.shouldnt.work/fuxino/$pkgname" url="https://git.shouldnt.work/fuxino/$pkgname"
arch=('any') arch=('any')
@@ -24,7 +24,7 @@ checkdepends=('python-pytest'
conflicts=('simple_backup-git') conflicts=('simple_backup-git')
source=("git+$url?signed#tag=$pkgver") source=("git+$url?signed#tag=$pkgver")
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514') validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('c0f7c38d99208e1327c0a6af1d58d5d13797d1cef54f99e1b2fcb4f4a9480609') sha256sums=('049c900b2d778434baa579409ccbb2b66ddb0968cf1dd23b3de0eda65ef3d2c6')
build() build()
{ {
+1 -1
View File
@@ -1,4 +1,4 @@
.TH SIMPLE_BACKUP 1 2026-10-25 SIMPLE_BACKUP 4.6.0 .TH SIMPLE_BACKUP 1 2026-11-02 SIMPLE_BACKUP 4.6.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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Init.""" """Init."""
__version__ = '4.6.0' __version__ = '4.6.1'
+18 -26
View File
@@ -25,9 +25,8 @@ import shlex
import sys import sys
import warnings import warnings
from collections import deque from collections import deque
from collections.abc import Callable, Iterator from collections.abc import Iterator
from datetime import datetime from datetime import datetime
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 logging import StreamHandler
@@ -35,7 +34,7 @@ from shutil import get_terminal_size, rmtree, which
from subprocess import PIPE, STDOUT, Popen from subprocess import PIPE, STDOUT, Popen
from tempfile import mkstemp from tempfile import mkstemp
from timeit import default_timer from timeit import default_timer
from typing import Any, ParamSpec, TypedDict, TypeVar from typing import Any, TypedDict
try: try:
import paramiko import paramiko
@@ -69,9 +68,6 @@ if journal:
j_handler.setFormatter(j_format) j_handler.setFormatter(j_format)
logger.addHandler(j_handler) logger.addHandler(j_handler)
P = ParamSpec('P')
R = TypeVar('R')
# Seconds to wait for the TCP connection to the SSH server # Seconds to wait for the TCP connection to the SSH server
SSH_TIMEOUT = 15 SSH_TIMEOUT = 15
@@ -80,25 +76,15 @@ PROGRESS_RE = re.compile(r'\s*(\S+)\s+\d+%\s+\S+\s+\d+:\d\d:\d\d(?:\s+\(xfr#\d+,
PROGRESS_BAR_WIDTH = 30 PROGRESS_BAR_WIDTH = 30
def timing(func: Callable[P, R]) -> Callable[P, R]: def _format_duration(seconds: float) -> str:
"""Decorator to measure execution time of a function """Format seconds as mm:ss, or h:mm:ss from one hour"""
Parameters: s = round(seconds)
func: Function to decorate
"""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = default_timer()
value = func(*args, **kwargs) if s >= 3600:
return f'{s // 3600}:{s // 60 % 60:02d}:{s % 60:02d}'
end = default_timer() return f'{s // 60:02d}:{s % 60:02d}'
logger.info('Elapsed time: %.3f seconds', end - start)
return value
return wrapper
class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter): class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
@@ -233,6 +219,7 @@ class Backup:
# Passed to sshpass through rsync's environment only # Passed to sshpass through rsync's environment only
self._password: str | None = None self._password: str | None = None
self._removed_count = 0 self._removed_count = 0
self._start_time = 0.0
def check_params(self, homedir: str = '') -> int: def check_params(self, homedir: str = '') -> int:
"""Check if parameters for the backup are valid""" """Check if parameters for the backup are valid"""
@@ -669,14 +656,19 @@ class Backup:
'Rsync error (return code %d) - Check rsync(1) for details', returncode) 'Rsync error (return code %d) - Check rsync(1) for details', returncode)
# Function to read configuration file # Function to read configuration file
@timing
def run(self) -> int: def run(self) -> int:
"""Perform the backup""" """Perform the backup"""
self._start_time = default_timer()
try: try:
return self._run() return self._run()
finally: finally:
self._remove_temp_files() 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: def close(self) -> None:
"""Close the SSH connection, if open""" """Close the SSH connection, if open"""
@@ -831,14 +823,14 @@ class Backup:
logger.error( logger.error(
'Some errors occurred while performing the backup') 'Some errors occurred while performing the backup')
_notify( _notify(f'Some errors occurred while performing the backup (elapsed time {self._elapsed()}). '
'Some errors occurred while performing the backup. Check log for details') 'Check log for details')
return 4 return 4
logger.info('Backup completed') logger.info('Backup completed')
_notify('Backup completed') _notify(f'Backup completed in {self._elapsed()}')
return 0 return 0
+19
View File
@@ -88,6 +88,18 @@ class TestRsync:
assert 'Backup completed' in caplog.text assert 'Backup completed' in caplog.text
assert 'rsync: sent' 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: def test_hard_links(self, src: Path, out: Path) -> None:
assert run_backup(out, [str(src)]) == 0 assert run_backup(out, [str(src)]) == 0
(src / 'changed.txt').write_text('second') (src / 'changed.txt').write_text('second')
@@ -231,6 +243,13 @@ class TestReadRecords:
assert list(sb._read_records(stream)) == ['file', '', 'namé', '�'] 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: class TestProgressBar:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def terminal(self, monkeypatch: pytest.MonkeyPatch) -> None: def terminal(self, monkeypatch: pytest.MonkeyPatch) -> None: