28 Commits
3.2.0 ... 3.4.1

Author SHA1 Message Date
4d23bde906 Check that inputs from command line exist
Check that the inputs specified on the command line (i.e. with the
option '-i' or '--input') exist and print a warning when they don't.
If no valid inputs are found, exit.
2023-06-15 18:44:22 +02:00
ff70358496 Version bump 2023-06-15 17:38:00 +02:00
6f1e91e2cd Add more return codes 2023-06-15 17:14:17 +02:00
b3fee0d022 Add some meaningful return codes 2023-06-15 16:58:56 +02:00
d63eb8f771 Improve handling of missing inputs 2023-06-15 15:34:00 +02:00
56df958c5b Add expansion of params in config file
Allow using wildcards (i.e. * to match any character and ~ to match the
user's home directory) in inputs and ouput variables in config file
2023-06-04 12:09:30 +02:00
d6d9fbf6e4 Add no-syslog option 2023-06-04 10:16:50 +02:00
ffbf8ece91 Remove last_backup link 2023-06-02 19:38:28 +02:00
6c07c147ae Version bump 2023-06-02 17:04:53 +02:00
5ddd374350 Fix notification bug
Correctly handle exception when trying to display notification if
dbus-python is not available
2023-06-02 17:01:37 +02:00
525f381094 Remove PKGBUILD
Removed PKGBUILD since the packages is available in the AUR. Added
a note about AUR in README.md
2023-06-01 17:23:58 +02:00
e1ba388296 Update version 2023-06-01 17:18:42 +02:00
e84cf81b13 Add man page 2023-06-01 17:08:31 +02:00
06620a4dba Fix bug when exclude pattern is None 2023-05-31 19:07:50 +02:00
0dd7b887f7 Update PKGBUILD to use release version 2023-05-28 11:07:03 +02:00
7684bc43b0 Add desktop notifications 2023-05-25 23:44:59 +02:00
a07fce1b6c Version bump 2023-05-25 19:01:50 +02:00
11c1ab0952 Add options to remove old backups before copying files 2023-05-25 19:01:19 +02:00
a42ade4f2b Version bump 2023-05-25 17:27:04 +02:00
c6432c1350 Check rsync return code 2023-05-25 17:20:32 +02:00
fa1d8f410e Remove old last_backup link after copying files 2023-05-25 17:10:38 +02:00
8b9087cdf6 Version bump 2023-05-11 20:21:00 +02:00
3c155e62a2 Version bump 2023-05-11 20:19:56 +02:00
5a4a26f9a7 Fix minor bugs and improve exception handling 2023-05-11 20:18:42 +02:00
ca0e3d133f Fix entry_point in setup.cfg 2023-05-05 19:41:32 +02:00
cc788735dd Fix README.md 2023-05-05 19:30:53 +02:00
631ffa85d3 Remove broken desktop notifications 2023-05-05 19:23:21 +02:00
fe2d66c24c Remove data_files from setup.cfg 2023-05-05 19:12:41 +02:00
10 changed files with 257 additions and 164 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.idea/ .idea/
__pycache__/
test/ test/

View File

@ -1,48 +0,0 @@
# PKGBUILD
# Maintainer: Daniele Fucini <dfucini@gmail.com>
pkgname=simple_backup
pkgver=3.1.2.r1.ga4c4b88
pkgrel=1
pkgdesc='Simple backup script that uses rsync to copy files'
arch=('any')
url="https://github.com/Fuxino/simple_backup.git"
license=('GPL3')
makedepends=('git'
'python-setuptools'
'python-build'
'python-installer'
'python-wheel')
depends=('python'
'rsync'
'python-dotenv'
'python-dbus'
'python-systemd')
install=${pkgname}.install
source=(git+https://github.com/Fuxino/${pkgname}.git)
sha256sums=('SKIP')
pkgver()
{
cd ${pkgname}
git describe --long --tags | sed 's/\([^-]*-g\)/r\1/;s/-/./g'
}
prepare()
{
git -C ${srcdir}/${pkgname} clean -dfx
}
build()
{
cd ${srcdir}/${pkgname}
python -m build --wheel --no-isolation
}
package()
{
cd ${srcdir}/${pkgname}
python -m installer --destdir=${pkgdir} dist/*.whl
install -Dm644 ${srcdir}/${pkgname}/${pkgname}.conf ${pkgdir}/etc/${pkgname}/${pkgname}.conf
}

View File

@ -4,7 +4,8 @@ A simple backup script
## Description ## Description
simple_backup is a Python script that allows you to backup your files. simple_backup is a Python script that allows you to backup your files.
Parameters like input files/directories, output directory etc. can be specified in a configuration file, or on the command line. Run: Parameters like input files/directories, output directory etc. can be specified in a configuration file, or on the command line.
Run:
```bash ```bash
simple_backup -h simple_backup -h
@ -19,6 +20,10 @@ The script uses rsync to actually run the backup, so you will have to install it
sudo pacman -Syu rsync sudo pacman -Syu rsync
``` ```
It's also required to have python-dotenv
Optional dependencies are systemd-python to enable using systemd journal for logging, and dbus-python for desktop notifications.
## Install ## Install
To install the program, first clone the repository: To install the program, first clone the repository:
@ -26,12 +31,21 @@ To install the program, first clone the repository:
git clone https://github.com/Fuxino/simple_backup.git git clone https://github.com/Fuxino/simple_backup.git
``` ```
Then run: Then install the tools required to build the package:
```bash
pip install --upgrade build wheel
```
Finally, run:
```bash ```bash
cd simple_backup cd simple_backup
python -m build --wheel python -m build --wheel
python -m installer dist/*.whl python -m pip install dist/*.whl
```
For Arch Linux, a PKGBUILD that automates this process is provided. 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

86
man/simple_backup.1 Normal file
View File

@ -0,0 +1,86 @@
.TH SIMPLE_BACKUP 1 2023-06-01 SIMPLE_BACKUP 3.2.6
.SH NAME
simple_backup \- Backup files and folders using rsync
.SH SYNOPSIS
.BR simple_backup
\-h, \-\-help
.PD 0
.P
.PD
.BR 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]
[\-s, \-\-checksum]
[\-\-remove\-before\-backup]
.RE
.SH DESCRIPTION
.BR simple_backup
is a python script for performing backup of files and folders. It uses rsync to copy the files to the specified location.
Parameters for the backup such as input files/directories, output location and files or folders to exclude can be specified
in a configuration file (default location $HOME/.config/simple_backup/simple_backup.conf) or directly on the command line.
Parameters specified on the command line will override those in the configuration file.
.SH OPTIONS
.TP
.B \-h, \-\-help
Print a short help message and exit
.TP
.B \-c, \-\-config FILE
Specify the configuration file, useful to specify a different one from the default.
.TP
.B \-i, \-\-input 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.
.TP
.B \-o, \-\-output DIR
Specify the directory where the files will be copied. The program will automatically create a subdirectory called
\(aqsimple_backup\(aq (if it does not already exist) and inside this directory the actual backup directory (using
the current date and time)
.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
.TP
.B \-k, \-\-keep N
Specify how many old backups (so excluding the current one) will be kept. The default behavior is to keep them all
(same as N=\-1)
.TP
.B \-s, \-\-checksums
Same as rsync option \(aq\-\-checksum\(aq, use checksums instead of mod\-time and size to skip files.
.TP
.B \-\-remove\-before\-backup
Remove old backups (if necessary) before creating the new backup. Useful to free some space before performing the backup.
Default behavior is to remove old backups after successfully completing the backup.
.TP
.B \-\-no\-syslog
Don't use systemd journal for logging
.SH CONFIGURATION
An example configuration file is provided at \(aq/etc/simple_backup/simple_backup.conf\(aq. Copy it to the default location
($HOME/.config/simple_backup) and edit it as needed.
.SH EXIT STATUS
.TP
.B 0
The backup was completed without errors
.TP
.B 1
No valid inputs selected for backup
.TP
.B 2
Backup failed because output directory for storing the backup does not exist
.TP
.B 3
Permission denied to access the output directory
.TP
.B 4
rsync error (rsync returned a non-zero value)
.SH SEE ALSO
.BR rsync (1)
.SH AUTHORS
.MT https://github.com/Fuxino
Daniele Fucini
.ME

View File

@ -25,11 +25,14 @@ packages = simple_backup
python_requires = >=3.7 python_requires = >=3.7
install_requires = install_requires =
python-dotenv python-dotenv
systemd-python
[options.extras_require]
JOURNAL =
systemd-python
NOTIFICATIONS =
dbus-python
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
simple_backup = simple_backup:simple_backup simple_backup = simple_backup.simple_backup:simple_backup
[options.data_files]
../etc/simple_backup = simple_backup/simple_backup.conf

View File

@ -1,14 +0,0 @@
#Example config file for simple_backup
[default]
#Input directories. Use a comma to separate items
inputs=/home/my_home,/etc
#Output directory
backup_dir=/media/Backup
#Exclude patterns. Use a comma to separate items
exclude=.gvfs,.local/share/gvfs-metadata,.cache,.dbus,.Trash,.local/share/Trash,.macromedia,.adobe,.recently-used,.recently-used.xbel,.thumbnails
#Number of snapshots to keep (use -1 to keep all)
keep=-1

View File

@ -1,8 +0,0 @@
post_install() {
echo "An example configuration file is found in /etc/simple_backup/simple_backup.conf."
echo "Copy it to ~/config/simple_backup/simple_backup.conf and modify it as needed"
}
post_upgrade() {
post_install
}

View File

@ -1,3 +1,3 @@
"""Init.""" """Init."""
__version__ = '3.1.2' __version__ = '3.4.1'

View File

@ -1,14 +1,14 @@
#Example config file for simple_backup # Example config file for simple_backup
[default] [default]
#Input directories. Use a comma to separate items # 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/my_home,/etc
#Output directory # Output directory.
backup_dir=/media/Backup backup_dir=/media/Backup
#Exclude patterns. Use a comma to separate items # Files, directories and patterns to exclude from the backup. Multiple items can be separated using a comma.
exclude=.gvfs,.local/share/gvfs-metadata,.cache,.dbus,.Trash,.local/share/Trash,.macromedia,.adobe,.recently-used,.recently-used.xbel,.thumbnails exclude=*.bak
#Number of snapshots to keep (use -1 to keep all) # Number of old backups (i.e. excluding the one that's being created) to keep (use -1 to keep all)
keep=-1 keep=-1

View File

@ -1,6 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
# Import libraries # Import libraries
import sys
import os import os
from functools import wraps from functools import wraps
from shutil import rmtree from shutil import rmtree
@ -12,11 +13,17 @@ from timeit import default_timer
from subprocess import Popen, PIPE, STDOUT from subprocess import Popen, PIPE, STDOUT
from datetime import datetime from datetime import datetime
from tempfile import mkstemp from tempfile import mkstemp
import dbus from glob import glob
from dotenv import load_dotenv from dotenv import load_dotenv
try: try:
from systemd import journal from systemd import journal
except ImportError:
journal = None
try:
import dbus
except ImportError: except ImportError:
pass pass
@ -25,9 +32,10 @@ load_dotenv()
euid = os.geteuid() euid = os.geteuid()
if euid == 0: if euid == 0:
user = os.getenv("SUDO_USER") user = os.getenv('SUDO_USER')
homedir = os.path.expanduser(f'~{user}') homedir = os.path.expanduser(f'~{user}')
else: else:
user = os.getenv('USER')
homedir = os.getenv('HOME') homedir = os.getenv('HOME')
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
@ -39,14 +47,12 @@ c_format = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
c_handler.setFormatter(c_format) c_handler.setFormatter(c_format)
logger.addHandler(c_handler) logger.addHandler(c_handler)
try: if journal:
j_handler = journal.JournalHandler() j_handler = journal.JournalHandler()
j_handler.setLevel(logging.INFO) j_handler.setLevel(logging.INFO)
j_format = logging.Formatter('%(levelname)s - %(message)s') j_format = logging.Formatter('%(levelname)s - %(message)s')
j_handler.setFormatter(j_format) j_handler.setFormatter(j_format)
logger.addHandler(j_handler) logger.addHandler(j_handler)
except NameError:
pass
def timing(_logger): def timing(_logger):
@ -74,12 +80,13 @@ class MyFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFo
class Backup: class Backup:
def __init__(self, inputs, output, exclude, keep, options): def __init__(self, inputs, output, exclude, keep, options, remove_before=False):
self.inputs = inputs self.inputs = inputs
self.output = output self.output = output
self.exclude = exclude self.exclude = exclude
self.options = options self.options = options
self.keep = keep self.keep = keep
self.remove_before = remove_before
self._last_backup = '' self._last_backup = ''
self._output_dir = '' self._output_dir = ''
self._inputs_path = '' self._inputs_path = ''
@ -88,37 +95,26 @@ class Backup:
def check_params(self): def check_params(self):
if self.inputs is None or len(self.inputs) == 0: if self.inputs is None or len(self.inputs) == 0:
logger.info('No files or directory specified for backup.') logger.info('No existing files or directories specified for backup. Nothing to do')
return False return 1
for i in self.inputs:
if os.path.islink(i):
try:
i_new = os.readlink(i)
logger.info(f'Input {i} is a symbolic link referencing {i_new}. Copying {i_new} instead')
self.inputs.remove(i)
self.inputs.append(i_new)
except Exception:
logger.warning(f'Input {i} is a link and cannot be read. Skipping')
self.inputs.remove(i)
if self.output is None: if self.output is None:
logger.critical('No output path specified. Use -o argument or specify output path in configuration file') logger.critical('No output path specified. Use -o argument or specify output path in configuration file')
return False return 2
if not os.path.isdir(self.output): if not os.path.isdir(self.output):
logger.critical('Output path for backup does not exist') logger.critical('Output path for backup does not exist')
return False return 2
self.output = os.path.abspath(self.output) self.output = os.path.abspath(self.output)
if self.keep is None: if self.keep is None:
self.keep = -1 self.keep = -1
return True return 0
# Function to create the actual backup directory # Function to create the actual backup directory
def create_backup_dir(self): def create_backup_dir(self):
@ -130,14 +126,9 @@ class Backup:
def remove_old_backups(self): def remove_old_backups(self):
try: try:
dirs = os.listdir(f'{self.output}/simple_backup') dirs = os.listdir(f'{self.output}/simple_backup')
except Exception: except FileNotFoundError:
logger.info('No older backups to remove')
return return
if dirs.count('last_backup') > 0:
dirs.remove('last_backup')
n_backup = len(dirs) - 1 n_backup = len(dirs) - 1
count = 0 count = 0
@ -149,8 +140,10 @@ class Backup:
try: try:
rmtree(f'{self.output}/simple_backup/{dirs[i]}') rmtree(f'{self.output}/simple_backup/{dirs[i]}')
count += 1 count += 1
except Exception: except FileNotFoundError:
logger.error(f'Error while removing backup {dirs[i]}') logger.error(f'Error while removing backup {dirs[i]}. Directory not found')
except PermissionError:
logger.error(f'Error while removing backup {dirs[i]}. Permission denied')
if count == 1: if count == 1:
logger.info(f'Removed {count} backup') logger.info(f'Removed {count} backup')
@ -158,34 +151,42 @@ class Backup:
logger.info(f'Removed {count} backups') logger.info(f'Removed {count} backups')
def find_last_backup(self): def find_last_backup(self):
if os.path.islink(f'{self.output}/simple_backup/last_backup'): try:
try: dirs = sorted([f.path for f in os.scandir(f'{self.output}/simple_backup') if f.is_dir(follow_symlinks=False)])
self._last_backup = os.readlink(f'{self.output}/simple_backup/last_backup') except FileNotFoundError:
except Exception:
logger.warning('Previous backup could not be read')
else:
logger.info('No previous backups available') logger.info('No previous backups available')
if not os.path.isdir(self._last_backup): return
logger.warning('Previous backup could not be read') except PermissionError:
logger.critical('Cannot access the backup directory. Permission denied')
try:
notify('Backup failed (check log for details)')
except NameError:
pass
sys.exit(3)
try:
self._last_backup = dirs[-1]
except IndexError:
logger.info('No previous backups available')
# Function to read configuration file # Function to read configuration file
@timing(logger) @timing(logger)
def run(self): def run(self):
logger.info('Starting backup...') logger.info('Starting backup...')
self.create_backup_dir() try:
self.find_last_backup() notify('Starting backup...')
except NameError:
pass
if os.path.islink(f'{self.output}/simple_backup/last_backup'): self.find_last_backup()
try: self.create_backup_dir()
os.remove(f'{self.output}/simple_backup/last_backup')
except Exception:
logger.error('Failed to remove last_backup link')
self._err_flag = True
_, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True) _, self._inputs_path = mkstemp(prefix='tmp_inputs', text=True)
_, self._exclude_path = mkstemp(prefix='tmp_exclude', text=True) count = 0
with open(self._inputs_path, 'w') as fp: with open(self._inputs_path, 'w') as fp:
for i in self.inputs: for i in self.inputs:
@ -194,11 +195,28 @@ class Backup:
else: else:
fp.write(i) fp.write(i)
fp.write('\n') fp.write('\n')
count += 1
if count == 0:
logger.info('No existing files or directories specified for backup. Nothing to do')
try:
notify('Backup finished. No files copied')
except NameError:
pass
return 1
_, self._exclude_path = mkstemp(prefix='tmp_exclude', text=True)
with open(self._exclude_path, 'w') as fp: with open(self._exclude_path, 'w') as fp:
for e in self.exclude: if self.exclude is not None:
fp.write(e) for e in self.exclude:
fp.write('\n') fp.write(e)
fp.write('\n')
if self.keep != -1 and self.remove_before:
self.remove_old_backups()
logger.info('Copying files. This may take a long time...') logger.info('Copying files. This may take a long time...')
@ -214,18 +232,15 @@ class Backup:
p = Popen(rsync, stdout=PIPE, stderr=STDOUT, shell=True) p = Popen(rsync, stdout=PIPE, stderr=STDOUT, shell=True)
output, _ = p.communicate() output, _ = p.communicate()
if p.returncode != 0:
self._err_flag = True
output = output.decode("utf-8").split('\n') output = output.decode("utf-8").split('\n')
logger.info(f'rsync: {output[-3]}') logger.info(f'rsync: {output[-3]}')
logger.info(f'rsync: {output[-2]}') logger.info(f'rsync: {output[-2]}')
try: if self.keep != -1 and not self.remove_before:
os.symlink(self._output_dir, f'{self.output}/simple_backup/last_backup')
except Exception:
logger.error('Failed to create last_backup link')
self._err_flag = True
if self.keep != -1:
self.remove_old_backups() self.remove_old_backups()
os.remove(self._inputs_path) os.remove(self._inputs_path)
@ -236,9 +251,19 @@ class Backup:
if self._err_flag: if self._err_flag:
logger.warning('Some errors occurred (check log for details)') logger.warning('Some errors occurred (check log for details)')
return 1 try:
notify('Backup finished with errors (check log for details)')
except NameError:
pass
return 0 return 4
else:
try:
notify('Backup finished')
except NameError:
pass
return 0
def _parse_arguments(): def _parse_arguments():
@ -255,12 +280,32 @@ def _parse_arguments():
parser.add_argument('-k', '--keep', type=int, help='Number of old backups to keep') parser.add_argument('-k', '--keep', type=int, help='Number of old backups to keep')
parser.add_argument('-s', '--checksum', action='store_true', parser.add_argument('-s', '--checksum', action='store_true',
help='Use checksum rsync option to compare files (MUCH SLOWER)') help='Use checksum rsync option to compare files (MUCH SLOWER)')
parser.add_argument('--remove-before-backup', action='store_true',
help='Remove old backups before executing the backup, instead of after')
parser.add_argument('--no-syslog', action='store_true', help='Disable systemd journal logging')
args = parser.parse_args() args = parser.parse_args()
return args return args
def _expand_inputs(inputs):
expanded_inputs = []
for i in inputs:
if i == '':
continue
i_ex = glob(os.path.expanduser(i.replace('~', f'~{user}')))
if len(i_ex) == 0:
logger.warning(f'No file or directory matching input {i}. Skipping...')
else:
expanded_inputs.extend(i_ex)
return expanded_inputs
def _read_config(config_file): def _read_config(config_file):
if not os.path.isfile(config_file): if not os.path.isfile(config_file):
logger.warning(f'Config file {config_file} does not exist') logger.warning(f'Config file {config_file} does not exist')
@ -272,7 +317,10 @@ def _read_config(config_file):
inputs = config.get('default', 'inputs') inputs = config.get('default', 'inputs')
inputs = inputs.split(',') inputs = inputs.split(',')
inputs = _expand_inputs(inputs)
inputs = list(set(inputs))
output = config.get('default', 'backup_dir') output = config.get('default', 'backup_dir')
output = os.path.expanduser(output.replace('~', f'~{user}'))
exclude = config.get('default', 'exclude') exclude = config.get('default', 'exclude')
exclude = exclude.split(',') exclude = exclude.split(',')
keep = config.getint('default', 'keep') keep = config.getint('default', 'keep')
@ -280,8 +328,33 @@ def _read_config(config_file):
return inputs, output, exclude, keep return inputs, output, exclude, keep
def notify(text):
euid = os.geteuid()
if euid == 0:
uid = os.getenv('SUDO_UID')
else:
uid = os.geteuid()
os.seteuid(int(uid))
os.environ['DBUS_SESSION_BUS_ADDRESS'] = f'unix:path=/run/user/{uid}/bus'
obj = dbus.SessionBus().get_object('org.freedesktop.Notifications', '/org/freedesktop/Notifications')
obj = dbus.Interface(obj, 'org.freedesktop.Notifications')
obj.Notify('', 0, '', 'simple_backup', text, [], {'urgency': 1}, 10000)
os.seteuid(int(euid))
def simple_backup(): def simple_backup():
args = _parse_arguments() args = _parse_arguments()
if args.no_syslog:
try:
logger.removeHandler(j_handler)
except NameError:
pass
inputs, output, exclude, keep = _read_config(args.config) inputs, output, exclude, keep = _read_config(args.config)
if args.input is not None: if args.input is not None:
@ -301,28 +374,14 @@ def simple_backup():
else: else:
backup_options = '-arvh -H -X' backup_options = '-arvh -H -X'
backup = Backup(inputs, output, exclude, keep, backup_options) backup = Backup(inputs, output, exclude, keep, backup_options, remove_before=args.remove_before_backup)
if backup.check_params(): return_code = backup.check_params()
try:
obj = dbus.SessionBus().get_object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
obj = dbus.Interface(obj, "org.freedesktop.Notifications")
obj.Notify("simple_backup", 0, "", "Starting backup...", "", [], {"urgency": 1}, 10000)
except dbus.exceptions.DBusException:
obj = None
status = backup.run() if return_code == 0:
return backup.run()
if obj is not None: return return_code
if status == 0:
obj.Notify("simple_backup", 0, "", "Backup finished.", "", [], {"urgency": 1}, 10000)
else:
obj.Notify("simple_backup", 0, "", "Backup finished. Some errors occurred.",
"", [], {"urgency": 1}, 10000)
return 0
return 1
if __name__ == '__main__': if __name__ == '__main__':