From 4f33a4dbb5e761f0f21b14c835cee2ff760f487a Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sun, 27 Dec 2015 15:24:52 -0800 Subject: [PATCH] Noninteractive iDisplay basic implementation (no tests or hooks, yet) --- letsencrypt/display/util.py | 244 +++++-------------------- letsencrypt/errors.py | 5 + letsencrypt/tests/display/util_test.py | 2 +- 3 files changed, 55 insertions(+), 196 deletions(-) diff --git a/letsencrypt/display/util.py b/letsencrypt/display/util.py index 303c078c3..0c7926bd4 100644 --- a/letsencrypt/display/util.py +++ b/letsencrypt/display/util.py @@ -6,7 +6,7 @@ import dialog import zope.interface from letsencrypt import interfaces - +from letsencrypt import errors WIDTH = 72 HEIGHT = 20 @@ -21,6 +21,20 @@ CANCEL = "cancel" HELP = "help" """Display exit code when for when the user requests more help.""" +def _wrap_lines(msg): # pylint: disable=no-self-use + """Format lines nicely to 80 chars. + + :param str msg: Original message + + :returns: Formatted message respecting newlines in message + :rtype: str + + """ + lines = msg.splitlines() + fixed_l = [] + for line in lines: + fixed_l.append(textwrap.fill(line, 80)) + return os.linesep.join(fixed_l) class NcursesDisplay(object): """Ncurses-based display.""" @@ -178,7 +192,7 @@ class FileDisplay(object): """ side_frame = "-" * 79 - message = self._wrap_lines(message) + message = _wrap_lines(message) self.outfile.write( "{line}{frame}{line}{msg}{line}{frame}{line}".format( line=os.linesep, frame=side_frame, msg=message)) @@ -246,7 +260,7 @@ class FileDisplay(object): """ side_frame = ("-" * 79) + os.linesep - message = self._wrap_lines(message) + message = _wrap_lines(message) self.outfile.write("{0}{frame}{msg}{0}{frame}".format( os.linesep, frame=side_frame, msg=message)) @@ -352,21 +366,6 @@ class FileDisplay(object): self.outfile.write(side_frame) - def _wrap_lines(self, msg): # pylint: disable=no-self-use - """Format lines nicely to 80 chars. - - :param str msg: Original message - - :returns: Formatted message respecting newlines in message - :rtype: str - - """ - lines = msg.splitlines() - fixed_l = [] - for line in lines: - fixed_l.append(textwrap.fill(line, 80)) - - return os.linesep.join(fixed_l) def _get_valid_int_ans(self, max_): """Get a numerical selection. @@ -409,20 +408,22 @@ class NoninteractiveDisplay(object): zope.interface.implements(interfaces.IDisplay) def __init__(self, outfile): - super(FileDisplay, self).__init__() + super(NoninteractiveDisplay, self).__init__() self.outfile = outfile - def _interaction_fail(self, message, extra=""): + def _interaction_fail(self, message, cli_flag, extra=""): "Error out in case of an attempt to interact in noninteractive mode" - msg ="Missing command line flag or config entry for this setting:\n" + msg = "Missing command line flag or config entry for this setting:\n" msg += message if extra: msg += "\n" + extra - raise MissingCommandlineFlag, msg + if cli_flag: + msg += "\n\n(You can set this with the {0} flag)".format(cli_flag) + raise errors.MissingCommandlineFlag, msg - def notification(self, message, height=10, pause=True): + def notification(self, message, height=10, pause=False): # pylint: disable=unused-argument - """Displays a notification and waits for user acceptance. + """Displays a notification without waiting for user acceptance. :param str message: Message to display to stdout :param int height: No effect for NoninteractiveDisplay @@ -430,23 +431,20 @@ class NoninteractiveDisplay(object): """ side_frame = "-" * 79 - message = self._wrap_lines(message) + message = _wrap_lines(message) self.outfile.write( "{line}{frame}{line}{msg}{line}{frame}{line}".format( line=os.linesep, frame=side_frame, msg=message)) - def menu(self, message, choices, ok_label="", cancel_label="", - help_label="", default=None): + def menu(self, message, choices, default=None, cli_flag=None, **kwargs): # pylint: disable=unused-argument - """Display a menu. - - .. todo:: This doesn't enable the help label/button (I wasn't sold on - any interface I came up with for this). It would be a nice feature + """Avoid displaying a menu. :param str message: title of menu :param choices: Menu lines, len must be > 0 :type choices: list of tuples (tag, item) or list of descriptions (tags will be enumerated) + :param dict kwargs: absorbs various irrelevant labelling arguments :returns: tuple of the form (code, tag) where code - int display exit code @@ -456,17 +454,11 @@ class NoninteractiveDisplay(object): """ if default is None: - self._interaction_fail(message, "Choices: " + repr(choices)) - if default == None: - msg ="Missing command line flag or config entry for this choice:\n" - msg += message - msg += "\nChoices: " + repr(choices) - raise MissingCommandlineFlag, msg + self._interaction_fail(message, cli_flag, "Choices: " + repr(choices)) return OK, choices.index(default) - def input(self, message, default=None): - # pylint: disable=no-self-use + def input(self, message, default=None, cli_flag=None): """Accept input from the user. :param str message: message to display to the user @@ -475,58 +467,39 @@ class NoninteractiveDisplay(object): `code` - str display exit code `input` - str of the user's input :rtype: tuple + :raises errors.MissingCommandlineFlag: if there was no default """ - ans = raw_input( - textwrap.fill("%s (Enter 'c' to cancel): " % message, 80)) - - if ans == "c" or ans == "C": - return CANCEL, "-1" + if default is None: + self._interaction_fail(message, cli_flag) else: - return OK, ans + return OK, default - def yesno(self, message, yes_label="Yes", no_label="No"): - """Query the user with a yes/no question. - Yes and No label must begin with different letters, and must contain at - least one letter each. + def yesno(self, message, default=None, cli_flag=None, **kwargs): + # pylint: disable=unused-argument + """Decide Yes or No, without asking anybody :param str message: question for the user - :param str yes_label: Label of the "Yes" parameter - :param str no_label: Label of the "No" parameter + :param dict kwargs: absorbs yes_label, no_label + :raises errors.MissingCommandlineFlag: if there was no default :returns: True for "Yes", False for "No" :rtype: bool """ - side_frame = ("-" * 79) + os.linesep + if default is None: + self._interaction_fail(message, cli_flag) + else: + return OK, default - message = self._wrap_lines(message) - - self.outfile.write("{0}{frame}{msg}{0}{frame}".format( - os.linesep, frame=side_frame, msg=message)) - - while True: - ans = raw_input("{yes}/{no}: ".format( - yes=_parens_around_char(yes_label), - no=_parens_around_char(no_label))) - - # Couldn't get pylint indentation right with elif - # elif doesn't matter in this situation - if (ans.startswith(yes_label[0].lower()) or - ans.startswith(yes_label[0].upper())): - return True - if (ans.startswith(no_label[0].lower()) or - ans.startswith(no_label[0].upper())): - return False - - def checklist(self, message, tags, default_status=True): + def checklist(self, message, tags, default=None, cli_flag=None, **kwargs): # pylint: disable=unused-argument """Display a checklist. :param str message: Message to display to user :param list tags: `str` tags to select, len(tags) > 0 - :param bool default_status: Not used for FileDisplay + :param dict kwargs: absorbs default_status arg :returns: tuple of (`code`, `tags`) where `code` - str display exit code @@ -534,129 +507,10 @@ class NoninteractiveDisplay(object): :rtype: tuple """ - while True: - self._print_menu(message, tags) - - code, ans = self.input("Select the appropriate numbers separated " - "by commas and/or spaces") - - if code == OK: - indices = separate_list_input(ans) - selected_tags = self._scrub_checklist_input(indices, tags) - if selected_tags: - return code, selected_tags - else: - self.outfile.write( - "** Error - Invalid selection **%s" % os.linesep) - else: - return code, [] - - def _scrub_checklist_input(self, indices, tags): - # pylint: disable=no-self-use - """Validate input and transform indices to appropriate tags. - - :param list indices: input - :param list tags: Original tags of the checklist - - :returns: valid tags the user selected - :rtype: :class:`list` of :class:`str` - - """ - # They should all be of type int - try: - indices = [int(index) for index in indices] - except ValueError: - return [] - - # Remove duplicates - indices = list(set(indices)) - - # Check all input is within range - for index in indices: - if index < 1 or index > len(tags): - return [] - # Transform indices to appropriate tags - return [tags[index - 1] for index in indices] - - def _print_menu(self, message, choices): - """Print a menu on the screen. - - :param str message: title of menu - :param choices: Menu lines - :type choices: list of tuples (tag, item) or - list of descriptions (tags will be enumerated) - - """ - # Can take either tuples or single items in choices list - if choices and isinstance(choices[0], tuple): - choices = ["%s - %s" % (c[0], c[1]) for c in choices] - - # Write out the message to the user - self.outfile.write( - "{new}{msg}{new}".format(new=os.linesep, msg=message)) - side_frame = ("-" * 79) + os.linesep - self.outfile.write(side_frame) - - # Write out the menu choices - for i, desc in enumerate(choices, 1): - self.outfile.write( - textwrap.fill("{num}: {desc}".format(num=i, desc=desc), 80)) - - # Keep this outside of the textwrap - self.outfile.write(os.linesep) - - self.outfile.write(side_frame) - - def _wrap_lines(self, msg): # pylint: disable=no-self-use - """Format lines nicely to 80 chars. - - :param str msg: Original message - - :returns: Formatted message respecting newlines in message - :rtype: str - - """ - lines = msg.splitlines() - fixed_l = [] - for line in lines: - fixed_l.append(textwrap.fill(line, 80)) - - return os.linesep.join(fixed_l) - - def _get_valid_int_ans(self, max_): - """Get a numerical selection. - - :param int max: The maximum entry (len of choices), must be positive - - :returns: tuple of the form (`code`, `selection`) where - `code` - str display exit code ('ok' or cancel') - `selection` - int user's selection - :rtype: tuple - - """ - selection = -1 - if max_ > 1: - input_msg = ("Select the appropriate number " - "[1-{max_}] then [enter] (press 'c' to " - "cancel): ".format(max_=max_)) + if default is None: + self._interaction_fail(message, cli_flag, "? ".join(tags)) else: - input_msg = ("Press 1 [enter] to confirm the selection " - "(press 'c' to cancel): ") - while selection < 1: - ans = raw_input(input_msg) - if ans.startswith("c") or ans.startswith("C"): - return CANCEL, -1 - try: - selection = int(ans) - if selection < 1 or selection > max_: - selection = -1 - raise ValueError - - except ValueError: - self.outfile.write( - "{0}** Invalid input **{0}".format(os.linesep)) - - return OK, selection + return OK, default def separate_list_input(input_): diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py index 1358d1048..99bb29d9d 100644 --- a/letsencrypt/errors.py +++ b/letsencrypt/errors.py @@ -102,3 +102,8 @@ class StandaloneBindError(Error): class ConfigurationError(Error): """Configuration sanity error.""" + +# NoninteractiveDisplay iDisplay plugin error: + +class MissingCommandlineFlag(Error): + """A command line argument was missing in noninteractive usage""" diff --git a/letsencrypt/tests/display/util_test.py b/letsencrypt/tests/display/util_test.py index 001a9e578..1d0c10d31 100644 --- a/letsencrypt/tests/display/util_test.py +++ b/letsencrypt/tests/display/util_test.py @@ -250,7 +250,7 @@ class FileOutputDisplayTest(unittest.TestCase): "This function is only meant to be for easy viewing{0}" "Test a really really really really really really really really " "really really really really long line...".format(os.linesep)) - text = self.displayer._wrap_lines(msg) + text = display_util._wrap_lines(msg) self.assertEqual(text.count(os.linesep), 3)