#!/usr/bin/python import argparse import configparser import logging import os import shlex import subprocess import sys class BackupError(Exception): '''Raised when an error occurs backing up an item''' class Backup(object): RSYNC = os.environ.get('RSYNC', 'rsync') RSYNC_DEFAULT_ARGS = ['-aO', '--partial', '--delete'] RSYNC_EXTRA_ARGS = shlex.split(os.environ.get('RSYNC_EXTRA_ARGS', '')) DEFAULT_CONFIG_FILENAME = 'backups.ini' log = logging.getLogger('backup') def __init__(self, destination, config=None, pretend=False): self.config = configparser.ConfigParser() print(config) if not config: config = open(self.default_config) self.config.read_file(config) self.config.filename = config.name self.destination = destination self.pretend = pretend self.stdout = sys.stdout self.stderr = sys.stderr @property def default_config(self): try: config_dir = os.environ['XDG_CONFIG_DIR'] except KeyError: config_dir = os.path.expanduser('~/.config') return os.path.join(config_dir, self.DEFAULT_CONFIG_FILENAME) def logsetup(self, log_file=None, log_level='INFO'): if not log_file: return logger = logging.getLogger() logger.setLevel(logging.NOTSET) if log_file in ('-', '/dev/stdout', ''): handler = logging.StreamHandler(sys.stdout) elif log_file in ('/dev/stderr', ''): handler = logging.StreamHandler(sys.stderr) else: handler = logging.FileHandler(log_file) handler.setFormatter(logging.Formatter( '%(asctime)s [%(name)s] %(levelname)s %(message)s')) handler.setLevel(getattr(logging, log_level, logging.INFO)) logger.addHandler(handler) self.stdout = self.stderr = handler.stream def backup_all(self, include=None, exclude=None): success = True self.log.info('Starting backup') if self.pretend: self.log.warning('Pretend mode: no files will be copied!') for section in self.config: if section != self.config.default_section: if exclude and section in exclude: self.log.debug('Excluded section {}'.format(section)) continue if include and section not in include: self.log.debug('Section {} not included'.format(section)) continue try: self.backup(section) except BackupError as e: self.log.error('{}'.format(e)) success = False except: self.log.exception( 'Unexpected error backing up {}'.format(section)) success = False if success: self.log.info('Backup completed successfully') else: self.log.warning('Backup completed with at least one error') return success def backup(self, section): log = logging.getLogger('backup.{}'.format(section)) try: item = self.config[section] except KeyError: raise BackupError('Unknown backup item {}'.format(section)) try: source = item['source'] except KeyError: raise BackupError('Missing source for {}'.format(s.name)) try: destination = item['destination'] except KeyError: src_path = source.split(':', 1)[-1] if src_path.endswith('/'): src_path = src_path.rstrip('/') destination = os.path.basename(src_path) destination = os.path.join(self.destination, destination) args = [self.RSYNC] args.extend(self.RSYNC_DEFAULT_ARGS) args.extend(self.RSYNC_EXTRA_ARGS) try: exclude = item['exclude'] except KeyError: pass else: for e in shlex.split(exclude): args.extend(('--exclude', e)) args.extend((source, destination)) if self.pretend: args.append('-n') log.info('Backing up {} to {}'.format(source, destination)) self._run(*args) log.info('Backup complete') def _run(self, *cmd): self.log.debug('Running command: {}'.format(' '.join(cmd))) try: subprocess.check_call(cmd, stdin=open(os.devnull), stdout=self.stdout, stderr=self.stderr) except (OSError, subprocess.CalledProcessError) as e: raise BackupError('Error executing rsync: {}'.format(e)) except KeyboardInterrupt: raise BackupError('rsync interrupted') def _parse_args(): parser = argparse.ArgumentParser() parser.add_argument('--log-file', '-l', default='-', metavar='FILENAME', help='Log file path') parser.add_argument('--log-level', '-D', default='INFO', metavar='LEVEL', help='Log level') parser.add_argument('--quiet', '-q', action='store_true', default=False, help='Do not print informational messages') parser.add_argument('--pretend', '-p', action='store_true', default=False, help='Execute a dry run') parser.add_argument('--include', '-I', action='append', help='Only back up specific items') parser.add_argument('--exclude', '-X', action='append', help='Do not back up specific items') parser.add_argument('--config', '-c', type=argparse.FileType('r'), metavar='FILENAME', help='Path to configuration file') parser.add_argument('destination', help='Backup destination directory') return parser.parse_args() def main(): args = _parse_args() backup = Backup(args.destination, args.config, args.pretend) if not args.quiet: print('Backing up to {} using configuration from {}'.format( args.destination, backup.config.filename)) backup.logsetup(args.log_file, args.log_level) if not backup.backup_all(args.include, args.exclude): sys.stderr.write('Errors occurred during backup\n') if args.log_file and args.log_file != '-': sys.stderr.write('See {} for details\n'.format(args.log_file)) raise SystemExit(1) if not args.quiet: print('Backup completed successfully') if __name__ == '__main__': main()