diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index 867675495..fa8b43ce3 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -1,4 +1,5 @@ """Lets Encrypt display.""" +import os import textwrap import dialog @@ -14,7 +15,13 @@ HEIGHT = 20 class CommonDisplayMixin(object): # pylint: disable=too-few-public-methods """Mixin with methods common to classes implementing IDisplay.""" - def redirect_by_default(self): # pylint: disable=missing-docstring + def redirect_by_default(self): + """Determines whether the user would like to redirect to HTTPS. + + :returns: True if redirect is desired, False otherwise + :rtype: bool + + """ choices = [ ("Easy", "Allow both HTTP and HTTPS access to these sites"), ("Secure", "Make all requests redirect to secure HTTPS access")] @@ -41,12 +48,29 @@ class NcursesDisplay(CommonDisplayMixin): self.width = width self.height = height - def generic_notification(self, message): - # pylint: disable=missing-docstring - self.dialog.msgbox(message, width=self.width) + def generic_notification(self, message, height=10): + """Display a notification to the user and wait for user acceptance. + + :param str message: Message to display + :param int height: Height of the dialog box + + """ + self.dialog.msgbox(message, height, width=self.width) def generic_menu(self, message, choices, unused_input_text=""): - # pylint: disable=missing-docstring + """Display a menu. + + :param str message: title of menu + :param choices: Menu lines + :type choices: list of tuples (tag, item) or + list of items (tags will be enumerated) + + :returns: tuple of the form (code, tag) where + code is a display exit code + tag is the tag string corresponding to the item chosen + :rtype: tuple + + """ # Can accept either tuples or just the actual choices if choices and isinstance(choices[0], tuple): code, selection = self.dialog.menu( @@ -57,18 +81,46 @@ class NcursesDisplay(CommonDisplayMixin): code, tag = self.dialog.menu( message, choices=choices, width=self.width, height=self.height) - return code(int(tag) - 1) + return code, int(tag) - 1 - def generic_input(self, message): # pylint: disable=missing-docstring + def generic_input(self, message): + """Display an input box to the user. + + :param str message: Message to display that asks for input. + + :returns: tuple of the form (code, string) where + code is a display exit code + string is the input entered by the user + + """ return self.dialog.inputbox(message) def generic_yesno(self, message, yes_label="Yes", no_label="No"): - # pylint: disable=missing-docstring + """Display a Yes/No dialog box + + :param str message: message to display to user + :param str yes_label: label on the 'yes' button + :param str no_label: label on the 'no' button + + :returns: if yes_label was selected + :rtype: bool + + """ return self.dialog.DIALOG_OK == self.dialog.yesno( message, self.height, self.width, yes_label=yes_label, no_label=no_label) - def filter_names(self, names): # pylint: disable=missing-docstring + def filter_names(self, names): + """Determine which names the user would like to select from a list. + + :param list names: domain names + + :returns: tuple of the form (code, names) where + code is a display exit code + names is a list of names selected + :rtype: tuple + + """ choices = [(n, "", 0) for n in names] code, names = self.dialog.checklist( "Which names would you like to activate HTTPS for?", @@ -76,12 +128,26 @@ class NcursesDisplay(CommonDisplayMixin): return code, [str(s) for s in names] def success_installation(self, domains): - # pylint: disable=missing-docstring + """Display a box confirming the installation of HTTPS. + + :param list domains: domain names which were enabled + + """ self.dialog.msgbox( "\nCongratulations! You have successfully enabled " + gen_https_names(domains) + "!", width=self.width) - def display_certs(self, certs): # pylint: disable=missing-docstring + def display_certs(self, certs): + """Display certificates for revocation. + + :param list certs: `list` of `dict` used throughout revoker.py + + :returns: tuple of the form (code, selection) where + code is a display exit code + selection is the user's int selection + :rtype: tuple + + """ list_choices = [ (str(i+1), "%s | %s | %s" % (str(c["cn"].ljust(self.width - 39)), @@ -98,7 +164,15 @@ class NcursesDisplay(CommonDisplayMixin): tag = -1 return code, (int(tag) - 1) - def confirm_revocation(self, cert): # pylint: disable=missing-docstring + def confirm_revocation(self, cert): + """Confirm revocation screen. + + :param dict cert: cert dict used throughout revoker.py + + :returns: True if user would like to revoke, False otherwise + :rtype: bool + + """ text = ("Are you sure you would like to revoke the following " "certificate:\n") text += cert_info_frame(cert) @@ -106,10 +180,14 @@ class NcursesDisplay(CommonDisplayMixin): return self.dialog.DIALOG_OK == self.dialog.yesno( text, width=self.width, height=self.height) - def more_info_cert(self, cert): # pylint: disable=missing-docstring + def more_info_cert(self, cert): + """Displays more information about the certificate. + + :param dict cert: cert dict used throughout revoker.py + + """ text = "Certificate Information:\n" text += cert_info_frame(cert) - print text self.dialog.msgbox(text, width=self.width, height=self.height) @@ -122,15 +200,36 @@ class FileDisplay(CommonDisplayMixin): super(FileDisplay, self).__init__() self.outfile = outfile - def generic_notification(self, message): - # pylint: disable=missing-docstring + def generic_notification(self, message, unused_height): + """Displays a notification and waits for user acceptance. + + :param str message: Message to display + + """ side_frame = '-' * 79 - msg = textwrap.fill(message, 80) - self.outfile.write("\n%s\n%s\n%s\n" % (side_frame, msg, side_frame)) + lines = message.splitlines() + fixed_l = [] + for line in lines: + fixed_l.append(textwrap.fill(line, 80)) + self.outfile.write( + "{0}{1}{0}{2}{0}{1}{0}".format( + os.linesep, side_frame, os.linesep.join(fixed_l))) raw_input("Press Enter to Continue") def generic_menu(self, message, choices, input_text=""): - # pylint: disable=missing-docstring + """Display a menu. + + :param str message: title of menu + :param choices: Menu lines + :type choices: list of tuples (tag, item) or + list of items (tags will be enumerated) + + :returns: tuple of the form (code, tag) where + code is a display exit code + tag is the tag string corresponding to the item chosen + :rtype: tuple + + """ # 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] @@ -145,27 +244,54 @@ class FileDisplay(CommonDisplayMixin): self.outfile.write("%s\n" % side_frame) - code, selection = self.__get_valid_int_ans( + code, selection = self._get_valid_int_ans( "%s (c to cancel): " % input_text) return code, (selection - 1) def generic_input(self, message): - # pylint: disable=no-self-use,missing-docstring + # pylint: disable=no-self-use + """Accept input from the user + + :param str message: message to display to the user + + :returns: tuple of (code, input) where + code is a display exit code + input is a str of the user's input + :rtype: tuple + + """ ans = raw_input("%s (Enter c to cancel)\n" % message) - if ans.startswith('c') or ans.startswith('C'): - return CANCEL, -1 + if ans == 'c' or ans == 'C': + return CANCEL, "-1" else: return OK, ans def generic_yesno(self, message, unused_yes_label="", unused_no_label=""): - # pylint: disable=missing-docstring + """Query the user with a yes/no question. + + :param str message: question for the user + + :returns: True for 'Yes', False for 'No" + :rtype: bool + + """ self.outfile.write("\n%s\n" % textwrap.fill(message, 80)) ans = raw_input("y/n: ") return ans.startswith('y') or ans.startswith('Y') - def filter_names(self, names): # pylint: disable=missing-docstring + def filter_names(self, names): + """Determine which names the user would like to select from a list. + + :param list names: domain names + + :returns: tuple of the form (code, names) where + code is a display exit code + names is a list of names selected + :rtype: tuple + + """ code, tag = self.generic_menu( "Choose the names would you like to upgrade to HTTPS?", names, "Select the number of the name: ") @@ -173,7 +299,29 @@ class FileDisplay(CommonDisplayMixin): # Make sure to return a list... return code, [names[tag]] - def display_certs(self, certs): # pylint: disable=missing-docstring + def success_installation(self, domains): + """Display a box confirming the installation of HTTPS. + + :param list domains: domain names which were enabled + + """ + side_frame = '*' * 79 + msg = textwrap.fill("Congratulations! You have successfully " + "enabled %s!" % gen_https_names(domains)) + self.outfile.write("%s\n%s\n%s\n" % (side_frame, msg, side_frame)) + + + def display_certs(self, certs): + """Display certificates for revocation. + + :param list certs: `list` of `dict` used throughout revoker.py + + :returns: tuple of the form (code, selection) where + code is a display exit code + selection is the user's int selection + :rtype: tuple + + """ menu_choices = [(str(i+1), str(c["cn"]) + " - " + c["pub_key"] + " - " + str(c["not_before"])[:-6]) for i, c in enumerate(certs)] @@ -183,11 +331,20 @@ class FileDisplay(CommonDisplayMixin): self.outfile.write(textwrap.fill( "%s: %s - %s Signed (UTC): %s\n" % choice[:4])) - return self.__get_valid_int_ans("Revoke Number (c to cancel): ") - 1 + return self._get_valid_int_ans("Revoke Number (c to cancel): ") - 1 - def __get_valid_int_ans(self, input_string): + def _get_valid_int_ans(self, input_string): + """Get a numerical selection. + + :param str input_string: Instructions for the user to make a selection. + + :returns: tuple of the form (code, selection) where + code is a display exit code + selection is the user's int selection + :rtype: tuple + + """ valid_ans = False - e_msg = "Please input a number or the letter c to cancel\n" while not valid_ans: @@ -210,14 +367,15 @@ class FileDisplay(CommonDisplayMixin): return code, selection - def success_installation(self, domains): - # pylint: disable=missing-docstring - side_frame = '*' * 79 - msg = textwrap.fill("Congratulations! You have successfully " - "enabled %s!" % gen_https_names(domains)) - self.outfile.write("%s\n%s\n%s\n" % (side_frame, msg, side_frame)) + def confirm_revocation(self, cert): + """Confirm revocation screen. - def confirm_revocation(self, cert): # pylint: disable=missing-docstring + :param dict cert: cert dict used throughout revoker.py + + :returns: True if user would like to revoke, False otherwise + :rtype: bool + + """ self.outfile.write("Are you sure you would like to revoke " "the following certificate:\n") self.outfile.write(cert_info_frame(cert)) @@ -225,39 +383,49 @@ class FileDisplay(CommonDisplayMixin): ans = raw_input("y/n") return ans.startswith('y') or ans.startswith('Y') - def more_info_cert(self, cert): # pylint: disable=missing-docstring + def more_info_cert(self, cert): + """Displays more info about the cert. + + :param dict cert: cert dict used throughout revoker.py + + """ self.outfile.write("\nCertificate Information:\n") self.outfile.write(cert_info_frame(cert)) +# Display exit codes OK = "ok" CANCEL = "cancel" HELP = "help" -def cert_info_frame(cert): # pylint: disable=missing-docstring - text = "-" * (WIDTH - 4) + "\n" +def cert_info_frame(cert): + """Nicely frames a cert dict used in revoker.py""" + text = "-" * (WIDTH - 4) + os.linesep text += cert_info_string(cert) text += "-" * (WIDTH - 4) return text -def cert_info_string(cert): # pylint: disable=missing-docstring - text = "Subject: %s\n" % cert["subject"] - text += "SAN: %s\n" % cert["san"] - text += "Issuer: %s\n" % cert["issuer"] - text += "Public Key: %s\n" % cert["pub_key"] - text += "Not Before: %s\n" % str(cert["not_before"]) - text += "Not After: %s\n" % str(cert["not_after"]) - text += "Serial Number: %s\n" % cert["serial"] - text += "SHA1: %s\n" % cert["fingerprint"] - text += "Installed: %s\n" % cert["installed"] - return text +def cert_info_string(cert): + """Turn a cert dict into a string.""" + text = [] + text.append("Subject: %s" % cert["subject"]) + text.append("SAN: %s" % cert["san"]) + text.append("Issuer: %s" % cert["issuer"]) + text.append("Public Key: %s" % cert["pub_key"]) + text.append("Not Before: %s" % str(cert["not_before"])) + text.append("Not After: %s" % str(cert["not_after"])) + text.append("Serial Number: %s" % cert["serial"]) + text.append("SHA1: %s" % cert["fingerprint"]) + text.append("Installed: %s" % cert["installed"]) + return os.linesep.join(text) def gen_https_names(domains): """Returns a string of the https domains. Domains are formatted nicely with https:// prepended to each. + """ result = "" if len(domains) > 2: diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index b119d1ba6..4bb2bd46c 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -4,8 +4,12 @@ import os import shutil import time +import zope.component + from letsencrypt.client import CONFIG +from letsencrypt.client import display from letsencrypt.client import errors +from letsencrypt.client import interfaces from letsencrypt.client import le_util @@ -100,26 +104,30 @@ class Reverter(object): raise errors.LetsEncryptReverterError( "Invalid directories in {0}".format(self.direc['backup'])) + output = [] for bkup in backups: - print time.ctime(float(bkup)) + output.append(time.ctime(float(bkup))) cur_dir = os.path.join(self.direc['backup'], bkup) with open(os.path.join(cur_dir, "CHANGES_SINCE")) as changes_fd: - print changes_fd.read() + output.append(changes_fd.read()) - print "Affected files:" + output.append("Affected files:") with open(os.path.join(cur_dir, "FILEPATHS")) as paths_fd: filepaths = paths_fd.read().splitlines() for path in filepaths: - print " {0}".format(path) + output.append(" {0}".format(path)) if os.path.isfile(os.path.join(cur_dir, "NEW_FILES")): with open(os.path.join(cur_dir, "NEW_FILES")) as new_fd: - print "New Configuration Files:" + output.append("New Configuration Files:") filepaths = new_fd.read().splitlines() for path in filepaths: - print " {0}".format(path) + output.append(" {0}".format(path)) - print "{0}".format(os.linesep) + output.append(os.linesep) + + zope.component.getUtility(interfaces.IDisplay).generic_notification( + os.linesep.join(output), display.HEIGHT) def add_to_temp_checkpoint(self, save_files, save_notes): """Add files to temporary checkpoint diff --git a/letsencrypt/client/tests/reverter_test.py b/letsencrypt/client/tests/reverter_test.py index 3213bfea5..e7766e331 100644 --- a/letsencrypt/client/tests/reverter_test.py +++ b/letsencrypt/client/tests/reverter_test.py @@ -331,12 +331,17 @@ class TestFullCheckpointsReverter(unittest.TestCase): self.assertEqual(read_in(self.config2), "directive-dir2") self.assertFalse(os.path.isfile(config3)) - def test_view_config_changes(self): + @mock.patch("letsencrypt.client.client.zope.component.getUtility") + def test_view_config_changes(self, mock_output): """This is not strict as this is subject to change.""" self._setup_three_checkpoints() - # Just make sure it doesn't throw any errors. + + # Make sure it doesn't throw any errors self.reverter.view_config_changes() + # Make sure notification is output + self.assertEqual(mock_output().generic_notification.call_count, 1) + @mock.patch("letsencrypt.client.reverter.logging") def test_view_config_changes_no_backups(self, mock_logging): self.reverter.view_config_changes()