Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Catch and log errors in the CLI. #50

Merged
merged 6 commits into from
Mar 16, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backuppy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def assert_path(test, source_path, target_path):
with open(os.path.join(target_dir_path, child_file_name)) as target_f:
assert_file(test, source_f, target_f)
except Exception:
raise AssertionError('The paths `%` and `%s` and their contents are not equal.')
raise AssertionError(
'The paths `%` and `%s` and their contents are not equal.')


def assert_file(test, source_f, target_f):
Expand Down
121 changes: 97 additions & 24 deletions backuppy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import argparse
import json
import re
from logging import Handler, WARNING

import yaml

from backuppy import task
from backuppy.config import from_json, from_yaml
from backuppy.task import backup
from backuppy.notifier import StdioNotifier

FORMAT_JSON_EXTENSIONS = ('json',)
FORMAT_YAML_EXTENSIONS = ('yml', 'yaml')
Expand All @@ -26,22 +28,41 @@ def _input(prompt=None):
return input(prompt)


class StdioNotifierLoggingHandler(Handler):
"""Log warnings and more severe records to stdio."""

def __init__(self):
"""Initialize a new instance."""
Handler.__init__(self, WARNING)
self._notifier = StdioNotifier()

def emit(self, record):
"""Log a record.

:param record: logging.LogRecord
"""
self._notifier.alert(self.format(record))


class ConfigurationAction(argparse.Action):
"""Provide a Semantic Version action."""

def __init__(self, *args, **kwargs):
"""Initialize a new instance."""
argparse.Action.__init__(self, *args, required=True, help='The path to the back-up configuration file.',
**kwargs)
kwargs.setdefault('required', True)
kwargs.setdefault(
'help', 'The path to the back-up configuration file.')
argparse.Action.__init__(self, *args, **kwargs)

def __call__(self, parser, namespace, values, option_string=None):
"""Invoke the action."""
configuration_file_path = values

verbose = None
if namespace.quiet:
verbose = False
if namespace.verbose:
verbose = True
configuration_file_path = values
with open(configuration_file_path) as f:
if any(map(f.name.endswith, FORMAT_JSON_EXTENSIONS)):
configuration_factory = from_json
Expand All @@ -52,10 +73,20 @@ def __call__(self, parser, namespace, values, option_string=None):
'Configuration files must have *.json, *.yml, or *.yaml extensions.')
configuration = configuration_factory(f, verbose=verbose)

# Ensure at least some form of error logging is enabled.
logger = configuration.logger
logger.disabled = False
if logger.getEffectiveLevel() > WARNING:
logger.setLevel(WARNING)
if not logger.handlers:
configuration.notifier.inform(
'The configuration does not specify any logging handlers for "backuppy", so all log records about problems will be displayed here.')
logger.addHandler(StdioNotifierLoggingHandler())

setattr(namespace, self.dest, configuration)


def add_configuration_to_args(parser):
def add_configuration_to_parser(parser):
"""Add configuration options to a parser.

:param parser: argparse.ArgumentParser
Expand All @@ -80,6 +111,43 @@ def add_verbose_to_args(parser):
return parser


def add_backup_command_to_parser(parser):
"""Add the back-up command to a parser.

:param parser: argparse.ArgumentParser
:return: argparse.ArgumentParser
"""
backup_parser = parser.add_parser('backup', help='Starts a back-up.')
backup_parser.set_defaults(
func=lambda parsed_args: task.backup(parsed_args.configuration))
add_configuration_to_parser(backup_parser)
return parser


def add_init_command_to_parser(parser):
"""Add the configuration initialization command to a parser.

:param parser: argparse.ArgumentParser
:return: argparse.ArgumentParser
"""
init_parser = parser.add_parser(
'init', help='Initializes a new back-up configuration.')
init_parser.set_defaults(func=lambda parsed_args: init())
return parser


def add_commands_to_parser(parser):
"""Add Backuppy commands to a parser.

:param parser: argparse.ArgumentParser
:return: argparse.ArgumentParser
"""
subparsers = parser.add_subparsers()
add_backup_command_to_parser(subparsers)
add_init_command_to_parser(subparsers)
return parser


def ask_confirm(value_label, question=None, default=None):
"""Ask for a confirmation.

Expand All @@ -98,7 +166,8 @@ def ask_confirm(value_label, question=None, default=None):
while confirmation is None:
if question is not None:
print(question)
confirmation_input = _input('%s %s: ' % (value_label, options_label)).lower()
confirmation_input = _input('%s %s: ' % (
value_label, options_label)).lower()
if 'y' == confirmation_input:
confirmation = True
elif 'n' == confirmation_input:
Expand Down Expand Up @@ -146,7 +215,8 @@ def ask_option(value_label, options, question=None):

option = None
options_labels = []
indexed_options = [(index, value, label) for index, (value, label) in enumerate(options)]
indexed_options = [(index, value, label)
for index, (value, label) in enumerate(options)]
for index, _, option_label in indexed_options:
options_labels.append('%d) %s' % (index, option_label))
options_label = '0-%d' % (len(options) - 1)
Expand Down Expand Up @@ -175,8 +245,10 @@ def init():
required=False)
verbose = ask_confirm(
'Verbose output', question='Do you want back-ups to output verbose notifications?', default=True)
source_path = ask_any('Source path', question='What is the path to the directory you want to back up?')
target_path = ask_any('Target path', question='What is the path to the directory you want to back up your data to?')
source_path = ask_any(
'Source path', question='What is the path to the directory you want to back up?')
target_path = ask_any(
'Target path', question='What is the path to the directory you want to back up your data to?')
format_options = [
('yaml', 'YAML (https://en.wikipedia.org/wiki/YAML)'),
('json', 'JSON (https://en.wikipedia.org/wiki/JSON)'),
Expand Down Expand Up @@ -214,11 +286,13 @@ def init():
else:
file_path_extensions = FORMAT_YAML_EXTENSIONS
formatter = yaml.dump
file_path_extensions_label = ', '.join(map(lambda x: '*.' + x, file_path_extensions))
file_path_extensions_label = ', '.join(
map(lambda x: '*.' + x, file_path_extensions))

def _file_path_validator(path):
if not any(map(path.endswith, file_path_extensions)):
raise ValueError('Configuration files must have %s extensions.' % file_path_extensions_label)
raise ValueError(
'Configuration files must have %s extensions.' % file_path_extensions_label)
return path

saved = False
Expand All @@ -239,20 +313,19 @@ def main(args):
"""Provide the CLI entry point."""
parser = argparse.ArgumentParser(
description='Backuppy backs up and restores your data using rsync.')

subparsers = parser.add_subparsers()

backup_parser = subparsers.add_parser('backup', help='Starts a back-up.')
backup_parser.set_defaults(func=lambda subparser_cli_args: backup(
subparser_cli_args['configuration']))
add_configuration_to_args(backup_parser)

init_parser = subparsers.add_parser(
'init', help='Initializes a new back-up configuration.')
init_parser.set_defaults(func=lambda subparser_cli_args: init())
add_commands_to_parser(parser)

if args:
cli_args = parser.parse_args(args)
cli_args.func(vars(cli_args))
parsed_args = parser.parse_args(args)
try:
parsed_args.func(parsed_args)
except KeyboardInterrupt:
# Quit gracefully.
print('Quitting...')
except BaseException:
configuration = parsed_args.configuration
configuration.logger.exception('A fatal error occurred.')
configuration.notifier.alert(
'A fatal error occurred. Details have been logged as per your configuration.')
else:
parser.print_help()
14 changes: 9 additions & 5 deletions backuppy/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Provides configuration components."""
import json
import logging
import os
from logging import getLogger, config as logging_config
from logging import config as logging_config

import yaml

Expand All @@ -27,7 +28,7 @@ def __init__(self, name, working_directory=None, verbose=False):
self._source = None
self._target = None
self._notifier = None
self._logger = getLogger('backuppy')
self._logger = logging.getLogger('backuppy')

@property
def verbose(self):
Expand Down Expand Up @@ -165,7 +166,8 @@ def from_configuration_data(configuration_file_path, data, verbose=None):
if 'type' not in notifier_data:
raise ValueError('`notifiers[][type]` is required.')
notifier_configuration = notifier_data['configuration'] if 'configuration' in notifier_data else None
notifier.notifiers.append(new_notifier(configuration, notifier_data['type'], notifier_configuration))
notifier.notifiers.append(new_notifier(
configuration, notifier_data['type'], notifier_configuration))
if not configuration.verbose:
notifier = QuietNotifier(notifier)
configuration.notifier = notifier
Expand All @@ -175,14 +177,16 @@ def from_configuration_data(configuration_file_path, data, verbose=None):
if 'type' not in data['source']:
raise ValueError('`source[type]` is required.')
source_configuration = data['source']['configuration'] if 'configuration' in data['source'] else None
configuration.source = new_source(configuration, data['source']['type'], source_configuration)
configuration.source = new_source(
configuration, data['source']['type'], source_configuration)

if 'target' not in data:
raise ValueError('`target` is required.')
if 'type' not in data['target']:
raise ValueError('`target[type]` is required.')
target_configuration = data['target']['configuration'] if 'configuration' in data['target'] else None
configuration.target = new_target(configuration, data['target']['type'], target_configuration)
configuration.target = new_target(
configuration, data['target']['type'], target_configuration)

return configuration

Expand Down
6 changes: 4 additions & 2 deletions backuppy/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ def _new_snapshot_args(name):
return [
# If the given snapshot does not exist, prepopulate the new snapshot with an archived, linked, recursive copy of
# the previous snapshot if it exists, or create a new, empty snapshot otherwise.
['bash', '-c', '[ ! -d %s ] && [ -d latest ] && cp -al `readlink latest` %s' % (name, name)],
['bash', '-c', '[ ! -d %s ] && [ -d latest ] && cp -al `readlink latest` %s' %
(name, name)],

# Create the new snapshot directory if it does not exist.
['bash', '-c', '[ ! -d %s ] && mkdir %s' % (name, name)],
Expand Down Expand Up @@ -166,7 +167,8 @@ def is_available(self):
self._connect()
return True
except SSHException:
self._notifier.alert('Could not establish an SSH connection to the remote.')
self._notifier.alert(
'Could not establish an SSH connection to the remote.')
return False
except socket.timeout:
self._notifier.alert('The remote timed out.')
Expand Down
9 changes: 6 additions & 3 deletions backuppy/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ def __init__(self, state_args=None, inform_args=None, confirm_args=None, alert_a
:param fallback_args: Optional[Iterable[str]]
"""
if None in [state_args, inform_args, confirm_args, alert_args] and fallback_args is None:
raise ValueError('fallback_args must be given if one or more of the other arguments are omitted.')
raise ValueError(
'fallback_args must be given if one or more of the other arguments are omitted.')
self._state_args = state_args
self._inform_args = inform_args
self._confirm_args = confirm_args
Expand Down Expand Up @@ -177,7 +178,8 @@ def __init__(self, state_file=None, inform_file=None, confirm_file=None, alert_f
:param fallback_file: Optional[Iterable[str]]
"""
if None in [state_file, inform_file, confirm_file, alert_file] and fallback_file is None:
raise ValueError('fallback_file must be given if one or more of the other arguments are omitted.')
raise ValueError(
'fallback_file must be given if one or more of the other arguments are omitted.')
self._state_file = state_file
self._inform_file = inform_file
self._confirm_file = confirm_file
Expand Down Expand Up @@ -224,7 +226,8 @@ class StdioNotifier(Notifier):
def _print(self, message, color, file=None):
if file is None:
file = sys.stdout
print('\033[0;%dm \033[0;1;%dm %s\033[0m' % (color + 40, color + 30, message), file=file)
print('\033[0;%dm \033[0;1;%dm %s\033[0m' %
(color + 40, color + 30, message), file=file)

def state(self, message):
"""Send a notification that may be ignored.
Expand Down
24 changes: 16 additions & 8 deletions backuppy/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ def _new_ssh_target_from_configuration_data(configuration, configuration_data):

if 'port' in configuration_data:
if configuration_data['port'] < 0 or configuration_data['port'] > 65535:
raise ValueError('`port` must be an integer ranging from 0 to 65535.')
raise ValueError(
'`port` must be an integer ranging from 0 to 65535.')
kwargs['port'] = configuration_data['port']

return SshTarget(configuration.notifier, **kwargs)
Expand Down Expand Up @@ -122,7 +123,8 @@ def _new_command_notifier_from_configuration_data(configuration, configuration_d
alert_args = configuration_data['alert'] if 'alert' in configuration_data else None
fallback_args = configuration_data['fallback'] if 'fallback' in configuration_data else None
if None in [state_args, inform_args, confirm_args, alert_args] and fallback_args is None:
raise ValueError('`fallback` must be given if one or more of the other arguments are omitted.')
raise ValueError(
'`fallback` must be given if one or more of the other arguments are omitted.')

return CommandNotifier(state_args, inform_args, confirm_args, alert_args, fallback_args)

Expand All @@ -135,13 +137,19 @@ def _new_file_notifier_from_configuration_data(configuration, configuration_data
:return: CommandNotifier
:raise: ValueError
"""
state_file = open(configuration_data['state'], mode='a+t') if 'state' in configuration_data else None
inform_file = open(configuration_data['inform'], mode='a+t') if 'inform' in configuration_data else None
confirm_file = open(configuration_data['confirm'], mode='a+t') if 'confirm' in configuration_data else None
alert_file = open(configuration_data['alert'], mode='a+t') if 'alert' in configuration_data else None
fallback_file = open(configuration_data['fallback'], mode='a+t') if 'fallback' in configuration_data else None
state_file = open(
configuration_data['state'], mode='a+t') if 'state' in configuration_data else None
inform_file = open(
configuration_data['inform'], mode='a+t') if 'inform' in configuration_data else None
confirm_file = open(
configuration_data['confirm'], mode='a+t') if 'confirm' in configuration_data else None
alert_file = open(
configuration_data['alert'], mode='a+t') if 'alert' in configuration_data else None
fallback_file = open(
configuration_data['fallback'], mode='a+t') if 'fallback' in configuration_data else None
if None in [state_file, inform_file, confirm_file, alert_file] and fallback_file is None:
raise ValueError('`fallback` must be given if one or more of the other arguments are omitted.')
raise ValueError(
'`fallback` must be given if one or more of the other arguments are omitted.')

return FileNotifier(state_file, inform_file, confirm_file, alert_file, fallback_file)

Expand Down
3 changes: 2 additions & 1 deletion backuppy/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ def backup(configuration):
snapshot_name = new_snapshot_name()
target.snapshot(snapshot_name)

args = ['rsync', '-ar', '--numeric-ids', '-e', 'ssh -o "StrictHostKeyChecking no"']
args = ['rsync', '-ar', '--numeric-ids',
'-e', 'ssh -o "StrictHostKeyChecking no"']
if configuration.verbose:
args.append('--verbose')
args.append('--progress')
Expand Down
3 changes: 2 additions & 1 deletion backuppy/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os

CONFIGURATION_PATH = '/'.join((os.path.dirname(os.path.abspath(__file__)), 'resources', 'configuration'))
CONFIGURATION_PATH = '/'.join(
(os.path.dirname(os.path.abspath(__file__)), 'resources', 'configuration'))
Loading