Source code for pypsi.wizard

#
# Copyright (c) 2015, Adam Meily <meily.adam@gmail.com>
# Pypsi - https://github.com/ameily/pypsi
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#

'''
Command line input wizards.
'''

import os
import readline
import re
from pypsi.os import path_completer
from pypsi.cmdline import StatementParser, StringToken
from pypsi.ansi import AnsiCodes
from pypsi.format import title_str
from pypsi.namespace import Namespace


HOSTNAME_RE = re.compile(r'^[a-zA-Z0-9.\-]+$')
IPV4_RE = re.compile(
    r'^[1-9](?:[0-9]{0,2})\.(?:[0-9]{1,3})\.(?:[0-9]{1,3})\.(?:[0-9]{1,3})'
    r'(?:/\d{1,2})?$'
)
MODULE_NAME_RE = re.compile(
    r'^(?:[a-zA-Z_][a-zA-Z0-9_]*)(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*'
)
PACKAGE_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]+$')


def wizard_step_path_completer(shell, args, prefix):
    return [
        i.replace('\0', '')
        for i in path_completer(args[-1] if args else prefix)
    ]


[docs]def required_validator(ns, value): ''' Required value wizard validator. Raises ValueError on validation error. :param pypsi.namespace.Namespace ns: active namespace :param value: input value :returns: validated value ''' if value is None: raise ValueError("Value is required") if not isinstance(value, str): return value value = value.strip() if not value: raise ValueError("Value is required") return value
[docs]def int_validator(min=None, max=None): ''' Integer value wizard validator creator. :param int min: minimum value, :const:`None` if no minimum :param int max: maximum value, :const:`None` if no maximum :returns: validator function ''' def validator(ns, value): if not value: return value value = int(value) if max is not None and value > max: raise ValueError("Value must be less than " + str(max)) if min is not None and value < min: raise ValueError("Value must be greater than " + str(min)) return value return validator
[docs]def file_validator(ns, value): ''' File path validator. Raises ValueError on validation error. :param pypsi.namespace.Namespace ns: active namespace :param value: input value :returns: validated value ''' if value and (not os.path.exists(value) or not os.path.isfile(value)): raise ValueError("File does not exist") return value
[docs]def directory_validator(ns, value): ''' Directory path validator. Raises ValueError on validation error. :param pypsi.namespace.Namespace ns: active namespace :param value: input value :returns: validated value ''' if value and (not os.path.exists(value) or not os.path.isdir(value)): raise ValueError("Directory does not exist") return value
[docs]def hostname_or_ip_validator(ns, value): ''' Network hostname or IPv4 address validator. Raises ValueError on validation error. :param pypsi.namespace.Namespace ns: active namespace :param value: input value :returns: validated value ''' if value is None: return value value = value.strip() if not value: return value if value[0].isdigit(): if not IPV4_RE.match(value): raise ValueError("Invalid IPv4 address") else: if not HOSTNAME_RE.match(value): raise ValueError("Invalid hostname") return value
[docs]def module_name_validator(type_str): ''' Python module name validator. Raises ValueError on validation error. :param str type_str: the input type to reference when raising validation errors. :returns: validator function ''' def validator(ns, value): if not isinstance(value, str): return value value = value.strip() if not value: return value if not MODULE_NAME_RE.match(value): raise ValueError("Invalid "+type_str) return value return validator
[docs]def package_name_validator(type_str): ''' Python package name validator. Raises ValueError on validation error. :param str type_str: the input type to reference when raising validation errors. :returns: validator function ''' def validator(ns, value): if not isinstance(value, str): return value value = value.strip() if not value: return value if not PACKAGE_NAME_RE.match(value): raise ValueError("Invalid "+type_str) return value return validator
[docs]def choice_validator(choices): ''' String choice validator. Raises ValueError if input isn't a valid choice. :param list choices: valid choices :returns: validator function ''' def validator(ns, value): if not isinstance(value, str): return value value = value.strip() if not value: return value if value not in choices: raise ValueError('Invalid choice') return value return validator
[docs]def boolean_validator(ns, value): ''' Boolean validator. Raises ValueError if input isn't a boolean string. :param pypsi.namespace.Namespace ns: active namespace :param value: input value :returns bool: validated value ''' if isinstance(value, bool): return value if isinstance(value, str): t = value.lower() if t in ('true', 't', '1', 'y', 'yes'): return True elif t in ('false', 'f', '0', 'n', 'no'): return False raise ValueError("Value is not true or false")
[docs]def lowercase_validator(ns, value): ''' Converts input string to lowercase. :param pypsi.namespace.Namespace ns: active namespace :param value: input value :returns: validated value (in lowercase) ''' return value.lower() if value else ''
[docs]class WizardStep(object): ''' A single input step in a prompt wizard. ''' def __init__(self, id, name, help, default=None, completer=None, validators=None): ''' :param str id: the step io, used for referencing the step's value :param str name: the name to display for input to the user :param str help: the help message to display to the user :param str default: the default value if the user immediately hits "Return" :param completer: a completion function :param validators: a single or a list of validators ''' self.id = id self.name = name self.help = help self.default = default self.completer = completer if isinstance(validators, (list, tuple)): self.validators = validators elif validators: self.validators = [validators] else: self.validators = []
[docs] def validate(self, ns, value): ''' Validate the input value. This will call the local validators (``self.validators``) sequentially with the following arguments: - ns (:class:`~pypsi.namespace.Namespace`) - the current input values - value - the value to validate Validators may change the value in place (if it is a mutable object) or may return the validated value that will be passed to the remaining validators. If a validation error occurs, raise a ValueError exception. :param pypsi.namespace.Namespace ns: current input values :param value: current input value :returns: validated value ''' if self.validators: v = value for c in self.validators: v = c(ns, v) return v return value
[docs] def complete(self, wizard, args, prefix): ''' Get the list of possible completions for input. This will call the local completer function (``self.completer`` with the arguments: (``wizard``, ``args``, ``prefix``). - wizard (:class:`PromptWizard`) - the active wizard - args (``list``) - the list of input arguments - prefix (``str``) - the input prefix This function mirrors the command :meth:`~pypsi.core.Command.complete` function. ''' return self.completer(wizard, args, prefix) if self.completer else []
[docs]class PromptWizard(object): ''' A user input prompt wizards. PromptWizards will walk the user through a series of questions (:class:`WizardStep`) and accept input. The user may at any time enter the ``?`` key to get help regarding the current step. Each step can have validators that determine if a value is valid before proceeding to the next step. Also, each step can have a default value that is saved if the user hits ``Return`` with no input. Once complete, the wizard will return a :class:`~pypsi.namespace.Namespace` object that contains all the user's answers. Each step contains an ``id`` attribute that determines what variable is set in the returned namespace. For example, a step may have an id of ``"ip_addr"``. When the user enters ``"192.168.0.1"`` for this step, the input can be retrieved through the namespace's ``ip_addr`` attribute. ''' def __init__(self, name, description, steps=None, features=None): ''' :param str name: the prompt wizard name to display to the user :param str description: a short description of what the wizard does :param list steps: a list of :class:`WizardStep` objects ''' self.name = name self.description = description self.steps = steps self.values = Namespace() self.features = features
[docs] def run(self, shell, print_header=True): ''' Execute the wizard, prompting the user for input. :param pypsi.shell.Shell shell: the active shell :returns: a :class:`~pypsi.namespace.Namespace` object containing all the answers on success, :const:`None` if the user exited the wizard ''' self.old_completer = readline.get_completer() readline.set_completer(self.complete) if print_header: print( title_str("Entering " + self.name + " Wizard", width=shell.width, box=True, align='center'), '\n', self.description, '\n\n', "To exit, enter either Ctrl+C, Ctrl+D, or 'quit'. For help " "about the current step, enter 'help' or '?'.", sep='' ) for step in self.steps: self.active_step = step valid = False while not valid: print() raw = None prompt = step.name if step.default is not None: d = step.default if callable(d): d = d(self.values) prompt += ' [{}]'.format(d) prompt += ': ' try: raw = input(prompt) except (KeyboardInterrupt, EOFError): print() print(AnsiCodes.red, "Wizard canceled", AnsiCodes.reset, sep='') readline.set_completer(self.old_completer) return None if raw.lower() == 'quit': print(AnsiCodes.red, "Exiting wizard", AnsiCodes.reset, sep='') readline.set_completer(self.old_completer) return None elif raw.lower() in ('?', 'help'): print(step.help) else: if not raw.strip() and step.default is not None: raw = step.default try: value = step.validate(self.values, raw) except ValueError as e: print(AnsiCodes.red, "Error: ", str(e), AnsiCodes.reset, sep='') print(AnsiCodes.yellow, step.name, ": ", step.help, AnsiCodes.reset, sep='') else: self.values[step.id] = value valid = True readline.set_completer(self.old_completer) return self.values
[docs] def complete(self, text, state): ''' Tab complete for the current step. ''' if state == 0: parser = StatementParser(self.features) begidx = readline.get_begidx() endidx = readline.get_endidx() line = readline.get_line_buffer() prefix = line[begidx:endidx] if line else '' line = line[:endidx] tokens = parser.tokenize(line) tokens = parser.condense(tokens) args = [t.text for t in tokens if isinstance(t, StringToken)] self.completions = self.active_step.complete(self, args, prefix) if state < len(self.completions): return self.completions[state] else: return None