Skip to content

Commit

Permalink
Merge pull request #59 from bartfeenstra/interactive
Browse files Browse the repository at this point in the history
Allow the back-up command to be run interactively
  • Loading branch information
bartfeenstra committed May 28, 2018
2 parents 6ca2fc6 + 2696b4e commit 831f38d
Show file tree
Hide file tree
Showing 16 changed files with 313 additions and 228 deletions.
Empty file added backuppy/cli/__init__.py
Empty file.
130 changes: 17 additions & 113 deletions backuppy/cli.py → backuppy/cli/cli.py
Expand Up @@ -3,12 +3,12 @@

import argparse
import json
import re
from logging import Handler, WARNING

import yaml

from backuppy import task
from backuppy.cli.input import ask_any, ask_confirm, ask_option
from backuppy.config import from_json, from_yaml
from backuppy.location import FilePath, DirectoryPath
from backuppy.notifier import StdioNotifier
Expand All @@ -17,18 +17,6 @@
FORMAT_YAML_EXTENSIONS = ('yml', 'yaml')


def _input(prompt=None):
"""Wrap input() and raw_input() on Python 3 and 2 respectively.
:param prompt: Optional[str]
:return: str
"""
try:
return raw_input(prompt)
except NameError:
return input(prompt)


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

Expand Down Expand Up @@ -72,7 +60,8 @@ def __call__(self, parser, namespace, values, option_string=None):
else:
raise ValueError(
'Configuration files must have *.json, *.yml, or *.yaml extensions.')
configuration = configuration_factory(f, verbose=verbose)
configuration = configuration_factory(
f, verbose=verbose, interactive=namespace.interactive)

# Ensure at least some form of error logging is enabled.
logger = configuration.logger
Expand Down Expand Up @@ -123,6 +112,7 @@ def add_configuration_to_parser(parser):
"""
parser.add_argument('-c', '--configuration', action=ConfigurationAction)
add_verbose_to_args(parser)
add_interactivity_to_args(parser)
return parser


Expand All @@ -140,15 +130,18 @@ def add_verbose_to_args(parser):
return parser


def add_force_to_args(parser):
"""Add force/non-interactivity options to a parser.
def add_interactivity_to_args(parser):
"""Add interactivity options to a parser.
:param parser: argparse.ArgumentParser
:return: argparse.ArgumentParser
"""
parser.add_argument('-f', '--force', dest='force', action='store_true',
help='Do not ask for confirmations to perform possible destructive tasks. This makes the command non-interactive, which is useful for automated scripts.')
parser.set_defaults(force=False)
interactivity_parser = parser.add_mutually_exclusive_group()
interactivity_parser.add_argument('--interactive', dest='interactive', action='store_true',
help='Always ask for confirmation before performing possibly risky tasks.')
interactivity_parser.add_argument('--non-interactive', dest='interactive', action='store_false',
help='Do not ask for confirmation before performing possibly risky tasks. This makes the command non-interactive, which is useful for automated scripts.')
parser.set_defaults(interactive=None)
return parser


Expand All @@ -160,7 +153,8 @@ def add_path_to_args(parser):
"""
path_parser = parser.add_mutually_exclusive_group()
path_parser.add_argument('--file', dest='path', action=FilePathAction,)
path_parser.add_argument('--dir', '--directory', dest='path', action=DirectoryPathAction,)
path_parser.add_argument('--dir', '--directory',
dest='path', action=DirectoryPathAction,)
return parser


Expand All @@ -186,10 +180,9 @@ def add_restore_command_to_parser(parser):
"""
restore_parser = parser.add_parser('restore', help='Restores a back-up.')
restore_parser.set_defaults(func=lambda parsed_args: restore(
parsed_args.configuration, parsed_args.force, parsed_args.path))
parsed_args.configuration, parsed_args.path))
add_configuration_to_parser(restore_parser)
add_path_to_args(restore_parser)
add_force_to_args(restore_parser)
return parser


Expand Down Expand Up @@ -218,93 +211,6 @@ def add_commands_to_parser(parser):
return parser


def ask_confirm(value_label, question=None, default=None):
"""Ask for a confirmation.
:param value_label: str
:param question: Optional[None]
:param default: Optional[bool]
:return: bool
"""
if default is None:
options_label = '(y/n)'
elif default:
options_label = '[Y/n]'
else:
options_label = '[y/N]'
confirmation = None
while confirmation is None:
if question is not None:
print(question)
confirmation_input = _input('%s %s: ' % (
value_label, options_label)).lower()
if 'y' == confirmation_input:
confirmation = True
elif 'n' == confirmation_input:
confirmation = False
elif '' == confirmation_input and default is not None:
confirmation = default
else:
print('That is not a valid confirmation. Enter "y" or "n".')
return confirmation


def ask_any(value_label, question=None, required=True, validator=None):
"""Ask for any value.
:param value_label: str
:param question: Optional[None]
:param required: Optional[bool]
:param validator: Optional[Callable]
:return: bool
"""
string = None
while string is None:
if question is not None:
print(question)
string_input = _input(value_label + ': ')
if validator:
string = validator(string_input)
elif not required or len(string_input):
string = string_input
else:
print('You are required to enter a value.')
return string


def ask_option(value_label, options, question=None):
"""Ask for a single item to be chosen from a collection.
:param value_label: str
:param options: Iterable[Tuple[Any, str]]
:param question: Optional[None]
:return: bool
"""
if len(options) == 1:
return options[0][0]

option = None
options_labels = []
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)
while option is None:
if question is not None:
print(question)
print('\n'.join(options_labels))
option_input = _input('%s (%s): ' % (value_label, options_label))
try:
if re.search('^\d+$', option_input) is None:
raise IndexError()
index_input = int(option_input)
option = indexed_options[index_input][1]
except IndexError:
print('That is not a valid option. Enter %s.' % options_label)
return option


def init():
"""Run a wizard to initialize a new back-up configuration file."""
print('Welcome to Backuppy!\n')
Expand Down Expand Up @@ -380,17 +286,16 @@ def _file_path_validator(path):
'Your new back-up configuration has been saved. Start backing up your data by running the following command: backuppy -c %s' % configuration_file_path)


def restore(configuration, force=False, path=''):
def restore(configuration, path=''):
"""Handle the back-up restoration command.
:param configuration: Configuration
:param force: bool
:param path: str
:return: bool
"""
confirm_label = 'Restore my back-up, possibly overwriting newer files.'
confirm_question = 'Restoring back-ups may result in (newer) files on the source location being overwritten by (older) files from your back-ups. Confirm that this is indeed your intention.'
if not force and not ask_confirm(confirm_label, question=confirm_question):
if configuration.interactive and not ask_confirm(confirm_label, question=confirm_question):
configuration.notifier.confirm('Aborting back-up restoration...')
return True

Expand All @@ -408,7 +313,6 @@ def main(args):
if not args:
parser.print_help()
return

parsed_args = parser.parse_args(args)
try:
parsed_args.func(parsed_args)
Expand Down
101 changes: 101 additions & 0 deletions backuppy/cli/input.py
@@ -0,0 +1,101 @@
"""Parse command-line input."""
import re


def _input(prompt=None):
"""Wrap input() and raw_input() on Python 3 and 2 respectively.
:param prompt: Optional[str]
:return: str
"""
try:
return raw_input(prompt)
except NameError:
return input(prompt)


def ask_confirm(value_label, question=None, default=None):
"""Ask for a confirmation.
:param value_label: str
:param question: Optional[None]
:param default: Optional[bool]
:return: bool
"""
if default is None:
options_label = '(y/n)'
elif default:
options_label = '[Y/n]'
else:
options_label = '[y/N]'
confirmation = None
while confirmation is None:
if question is not None:
print(question)
confirmation_input = _input('%s %s: ' % (
value_label, options_label)).lower()
if 'y' == confirmation_input:
confirmation = True
elif 'n' == confirmation_input:
confirmation = False
elif '' == confirmation_input and default is not None:
confirmation = default
else:
print('That is not a valid confirmation. Enter "y" or "n".')
return confirmation


def ask_any(value_label, question=None, required=True, validator=None):
"""Ask for any value.
:param value_label: str
:param question: Optional[None]
:param required: Optional[bool]
:param validator: Optional[Callable]
:return: bool
"""
string = None
while string is None:
if question is not None:
print(question)
string_input = _input(value_label + ': ')
if validator:
string = validator(string_input)
elif not required or len(string_input):
string = string_input
else:
print('You are required to enter a value.')
return string


def ask_option(value_label, options, question=None):
"""Ask for a single item to be chosen from a collection.
:param value_label: str
:param options: Iterable[Tuple[Any, str]]
:param question: Optional[None]
:return: bool
"""
if len(options) == 1:
return options[0][0]

option = None
options_labels = []
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)
while option is None:
if question is not None:
print(question)
print('\n'.join(options_labels))
option_input = _input('%s (%s): ' % (value_label, options_label))
try:
if re.search('^\d+$', option_input) is None:
raise IndexError()
index_input = int(option_input)
option = indexed_options[index_input][1]
except IndexError:
print('That is not a valid option. Enter %s.' % options_label)
return option

0 comments on commit 831f38d

Please sign in to comment.