7 Commits

Author SHA1 Message Date
e62a668f3e Update metadata 2025-03-30 14:39:59 +02:00
bd8934d57f Update version tag 2025-03-30 14:38:56 +02:00
96fe3c7813 Update man page 2025-03-30 14:38:29 +02:00
9fdb959540 Improve handling of user detection failure 2025-03-30 14:37:30 +02:00
eb889beee7 Add Python 3.13 to setup.cfg 2025-03-30 12:16:04 +02:00
2a37eb4172 Add PKGBUILD 2024-11-13 19:38:12 +01:00
45a07205a1 Add type hints plus minor code fixes 2024-09-28 09:47:33 +02:00
6 changed files with 93 additions and 35 deletions

40
PKGBUILD Normal file
View File

@ -0,0 +1,40 @@
# PKGBUILD
# Maintainer: Daniele Fucini <dfucini [at] gmail [dot] com>
pkgname=simple_backup
pkgdesc='Simple backup script that uses rsync to copy files'
pkgver=4.1.5
pkgrel=1
url="https://github.com/Fuxino/${pkgname}"
arch=('any')
license=('GPL-3.0-or-later')
makedepends=('git'
'python-setuptools'
'python-build'
'python-installer'
'python-wheel')
depends=('python>=3.10'
'rsync'
'python-dotenv')
optdepends=('python-systemd: use systemd log'
'python-dbus: for desktop notifications'
'python-paramiko: for remote backup through ssh')
conflicts=('simple_backup-git')
source=(git+${url}?signed#tag=${pkgver})
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('4da838282fff813f82ee0408996c989078a206eabce07112b4e3ee8b057e34cf')
build()
{
cd ${srcdir}/${pkgname}
python -m build --wheel --no-isolation
}
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
}

View File

@ -1,4 +1,4 @@
.TH SIMPLE_BACKUP 1 2023-06-15 SIMPLE_BACKUP 3.2.6
.TH SIMPLE_BACKUP 1 2025-03-30 SIMPLE_BACKUP 4.1.6
.SH NAME
simple_backup \- Backup files and folders using rsync
.SH SYNOPSIS
@ -187,6 +187,6 @@ Bad configuration file.
.SH SEE ALSO
.BR rsync (1)
.SH AUTHORS
.MT https://github.com/Fuxino
.MT https://git.shouldnt.work/fuxino
Daniele Fucini
.ME

View File

@ -6,7 +6,7 @@ long_description = file: README.md
author = Daniele Fucini
author_email = dfucini@gmail.com
license = GPL3
url = https://github.com/Fuxino/simple_backup
url = https://git.shouldnt.work/fuxino
classifiers =
Development Status :: 4 - Beta
Environment :: Console
@ -16,6 +16,7 @@ classifiers =
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
Programming Language :: Python :: 3.13
Topic :: System :: Archiving :: Backup
[options]

View File

@ -1,3 +1,3 @@
"""Init."""
__version__ = '4.1.2'
__version__ = '4.1.6'

View File

@ -2,7 +2,7 @@
[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).
inputs=/home/my_home,/etc
inputs=/home/user
# Output directory.
backup_dir=/media/Backup

View File

@ -14,6 +14,7 @@ Classes:
# Import libraries
import sys
import os
from typing import Callable, List, Optional, ParamSpec, TypeVar, Union
import warnings
from functools import wraps
from shutil import rmtree, which
@ -67,29 +68,29 @@ if journal:
j_handler.setFormatter(j_format)
logger.addHandler(j_handler)
P = ParamSpec('P')
R = TypeVar('R')
def timing(_logger):
def timing(func: Callable[P, R]) -> Callable[P, R]:
"""Decorator to measure execution time of a function
Parameters:
_logger: Logger object
func: Function to decorate
"""
def decorator_timing(func):
@wraps(func)
def wrapper_timing(*args, **kwargs):
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = default_timer()
value = func(*args, **kwargs)
end = default_timer()
_logger.info(f'Elapsed time: {end - start:.3f} seconds')
logger.info('Elapsed time: %.3f seconds', end - start)
return value
return wrapper_timing
return decorator_timing
return wrapper
class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
@ -134,8 +135,9 @@ class Backup:
Perform the backup
"""
def __init__(self, inputs, output, exclude, keep, options, ssh_host=None, ssh_user=None,
ssh_keyfile=None, remote_sudo=False, remove_before=False, verbose=False):
def __init__(self, inputs: List[str], output: str, exclude: List[str], keep: int, 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) -> None:
self.inputs = inputs
self.output = output
self.exclude = exclude
@ -152,12 +154,12 @@ class Backup:
self._output_dir = ''
self._inputs_path = ''
self._exclude_path = ''
self._remote = None
self._remote = False
self._ssh = None
self._password_auth = False
self._password = None
def check_params(self, homedir=''):
def check_params(self, homedir: str = '') -> int:
"""Check if parameters for the backup are valid"""
if self.inputs is None or len(self.inputs) == 0:
@ -201,7 +203,7 @@ class Backup:
return 0
# Function to create the actual backup directory
def define_backup_dir(self):
def define_backup_dir(self) -> None:
"""Define the actual backup dir"""
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self._output_dir = f'{self.output}/simple_backup/{now}'
@ -209,10 +211,12 @@ class Backup:
if self._remote:
self._server = f'{self.ssh_user}@{self.ssh_host}:'
def remove_old_backups(self):
def remove_old_backups(self) -> None:
"""Remove old backups if there are more than indicated by 'keep'"""
if self._remote:
assert self._ssh is not None
_, stdout, _ = self._ssh.exec_command(f'ls {self.output}/simple_backup')
dirs = stdout.read().decode('utf-8').strip().split('\n')
@ -272,7 +276,7 @@ class Backup:
elif count > 1:
logger.info('Removed %d backups', count)
def find_last_backup(self):
def find_last_backup(self) -> None:
"""Get path of last backup (from last_backup symlink) for rsync --link-dest"""
if self._remote:
@ -298,7 +302,7 @@ class Backup:
logger.critical('Cannot access the backup directory. Permission denied')
try:
notify('Backup failed (check log for details)')
_notify('Backup failed (check log for details)')
except NameError:
pass
@ -309,17 +313,18 @@ class Backup:
except IndexError:
logger.info('No previous backups available')
def _ssh_connect(self, homedir=''):
def _ssh_connect(self, homedir: str = '') -> paramiko.client.SSHClient:
try:
ssh = paramiko.SSHClient()
except NameError:
logger.error('Install paramiko for ssh support')
return None
try:
ssh.load_host_keys(filename=f'{homedir}/.ssh/known_hosts')
except FileNotFoundError:
logger.warning(f'Cannot find file {homedir}/.ssh/known_hosts')
logger.warning('Cannot find file %s/.ssh/known_hosts', homedir)
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
@ -417,7 +422,7 @@ class Backup:
return ssh
def _returncode_log(self, returncode):
def _returncode_log(self, returncode: int) -> None:
match returncode:
case 2:
logger.error('Rsync error (return code 2) - Protocol incompatibility')
@ -447,8 +452,8 @@ class Backup:
logger.error('Rsync error (return code %d) - Check rsync(1) for details', returncode)
# Function to read configuration file
@timing(logger)
def run(self):
@timing
def run(self) -> int:
"""Perform the backup"""
logger.info('Starting backup...')
@ -477,7 +482,7 @@ class Backup:
logger.info('No existing files or directories specified for backup. Nothing to do')
try:
notify('Backup finished. No files copied')
_notify('Backup finished. No files copied')
except NameError:
pass
@ -518,6 +523,7 @@ class Backup:
args = shlex.split(rsync)
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False) as p:
output: Union[bytes, List[str]]
output, _ = p.communicate()
try:
@ -551,6 +557,8 @@ class Backup:
os.remove(self._exclude_path)
if self._remote:
assert self._ssh is not None
_, stdout, _ = self._ssh.exec_command(f'if [ -d "{self._output_dir}" ]; then echo "ok"; fi')
output = stdout.read().decode('utf-8').strip()
@ -671,7 +679,10 @@ def _read_config(config_file, user=None):
'numeric_ids': False}
if not os.path.isfile(config_file):
if user is not None:
logger.warning('Config file %s does not exist', config_file)
else:
logger.warning('User not specified. Can\'t read configuration file')
return config_args
@ -785,7 +796,12 @@ def simple_backup():
if euid == 0:
user = os.getenv('SUDO_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')
@ -803,6 +819,7 @@ def simple_backup():
config_args = _read_config(args.config, user)
except (configparser.NoSectionError, configparser.NoOptionError):
logger.critical('Bad configuration file')
return 6
inputs = args.inputs if args.inputs is not None else config_args['inputs']