Merge pull request #2078 from letsencrypt/non-interactive

Implement non-interactive mode for the client.
This commit is contained in:
bmw 2016-01-25 18:51:56 -08:00
commit 90c7a73146
13 changed files with 394 additions and 100 deletions

View file

@ -4,6 +4,7 @@ import os
import zope.component
from letsencrypt import errors
from letsencrypt import interfaces
import letsencrypt.display.util as display_util
@ -78,12 +79,18 @@ def _vhost_menu(domain, vhosts):
name_size=disp_name_size)
)
code, tag = zope.component.getUtility(interfaces.IDisplay).menu(
"We were unable to find a vhost with a ServerName "
"or Address of {0}.{1}Which virtual host would you "
"like to choose?".format(
domain, os.linesep),
choices, help_label="More Info", ok_label="Select")
try:
code, tag = zope.component.getUtility(interfaces.IDisplay).menu(
"We were unable to find a vhost with a ServerName "
"or Address of {0}.{1}Which virtual host would you "
"like to choose?".format(domain, os.linesep),
choices, help_label="More Info", ok_label="Select")
except errors.MissingCommandlineFlag, e:
msg = ("Failed to run Apache plugin non-interactively{1}{0}{1}"
"(The best solution is to add ServerName or ServerAlias "
"entries to the VirtualHost directives of your apache "
"configuration files.)".format(e, os.linesep))
raise errors.MissingCommandlineFlag, msg
return code, tag

View file

@ -6,6 +6,7 @@ import mock
import zope.component
from letsencrypt.display import util as display_util
from letsencrypt import errors
from letsencrypt_apache import obj
@ -31,6 +32,14 @@ class SelectVhostTest(unittest.TestCase):
mock_util().menu.return_value = (display_util.OK, 3)
self.assertEqual(self.vhosts[3], self._call(self.vhosts))
@mock.patch("letsencrypt_apache.display_ops.zope.component.getUtility")
def test_noninteractive(self, mock_util):
mock_util().menu.side_effect = errors.MissingCommandlineFlag("no vhost default")
try:
self._call(self.vhosts)
except errors.MissingCommandlineFlag, e:
self.assertTrue("VirtualHost directives" in e.message)
@mock.patch("letsencrypt_apache.display_ops.zope.component.getUtility")
def test_more_info_cancel(self, mock_util):
mock_util().menu.side_effect = [

View file

@ -44,6 +44,15 @@ from letsencrypt.plugins import disco as plugins_disco
logger = logging.getLogger(__name__)
# For help strings, figure out how the user ran us.
# When invoked from letsencrypt-auto, sys.argv[0] is something like:
# "/home/user/.local/share/letsencrypt/bin/letsencrypt"
# Note that this won't work if the user set VENV_PATH or XDG_DATA_HOME before running
# letsencrypt-auto (and sudo stops us from seeing if they did), so it should only be used
# for purposes where inability to detect letsencrypt-auto fails safely
fragment = os.path.join(".local", "share", "letsencrypt")
cli_command = "letsencrypt-auto" if fragment in sys.argv[0] else "letsencrypt"
# Argparse's help formatting has a lot of unhelpful peculiarities, so we want
# to replace as much of it as we can...
@ -51,7 +60,7 @@ logger = logging.getLogger(__name__)
# This is the stub to include in help generated by argparse
SHORT_USAGE = """
letsencrypt [SUBCOMMAND] [options] [-d domain] [-d domain] ...
{0} [SUBCOMMAND] [options] [-d domain] [-d domain] ...
The Let's Encrypt agent can obtain and install HTTPS/TLS/SSL certificates. By
default, it will attempt to use a webserver both for obtaining and installing
@ -65,7 +74,7 @@ the cert. Major SUBCOMMANDS are:
config_changes Show changes made to server config during installation
plugins Display information about installed plugins
"""
""".format(cli_command)
# This is the short help for letsencrypt --help, where we disable argparse
# altogether
@ -155,12 +164,14 @@ def _determine_account(args, config):
"must agree in order to register with the ACME "
"server at {1}".format(
regr.terms_of_service, config.server))
return zope.component.getUtility(interfaces.IDisplay).yesno(
msg, "Agree", "Cancel")
obj = zope.component.getUtility(interfaces.IDisplay)
return obj.yesno(msg, "Agree", "Cancel", cli_flag="--agree-tos")
try:
acc, acme = client.register(
config, account_storage, tos_cb=_tos_cb)
except errors.MissingCommandlineFlag:
raise
except errors.Error as error:
logger.debug(error, exc_info=True)
raise errors.Error(
@ -282,7 +293,7 @@ def _handle_identical_cert_request(config, cert):
"Cancel this operation and do nothing"]
display = zope.component.getUtility(interfaces.IDisplay)
response = display.menu(question, choices, "OK", "Cancel")
response = display.menu(question, choices, "OK", "Cancel", default=0)
if response[0] == "cancel" or response[1] == 2:
# TODO: Add notification related to command-line options for
# skipping the menu for this case.
@ -317,7 +328,8 @@ def _handle_subset_cert_request(config, domains, cert):
", ".join(domains),
br=os.linesep)
if config.expand or config.renew_by_default or zope.component.getUtility(
interfaces.IDisplay).yesno(question, "Expand", "Cancel"):
interfaces.IDisplay).yesno(question, "Expand", "Cancel",
cli_flag="--expand (or in some cases, --duplicate)"):
return "renew", cert
else:
reporter_util = zope.component.getUtility(interfaces.IReporter)
@ -433,21 +445,6 @@ def _avoid_invalidating_lineage(config, lineage, original_server):
"a test certificate (domains: {0}). We will not do that "
"unless you use the --break-my-certs flag!".format(names))
def set_configurator(previously, now):
"""
Setting configurators multiple ways is okay, as long as they all agree
:param str previously: previously identified request for the installer/authenticator
:param str requested: the request currently being processed
"""
if now is None:
# we're not actually setting anything
return previously
if previously:
if previously != now:
msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}"
raise errors.PluginSelectionError(msg.format(repr(previously), repr(now)))
return now
def diagnose_configurator_problem(cfg_type, requested, plugins):
"""
@ -481,22 +478,28 @@ def diagnose_configurator_problem(cfg_type, requested, plugins):
raise errors.PluginSelectionError(msg)
def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable=too-many-branches
def set_configurator(previously, now):
"""
Figure out which configurator we're going to use
:raises error.PluginSelectionError if there was a problem
Setting configurators multiple ways is okay, as long as they all agree
:param str previously: previously identified request for the installer/authenticator
:param str requested: the request currently being processed
"""
if now is None:
# we're not actually setting anything
return previously
if previously:
if previously != now:
msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}"
raise errors.PluginSelectionError(msg.format(repr(previously), repr(now)))
return now
# Which plugins do we need?
need_inst = need_auth = (verb == "run")
if verb == "certonly":
need_auth = True
if verb == "install":
need_inst = True
if args.authenticator:
logger.warn("Specifying an authenticator doesn't make sense in install mode")
def cli_plugin_requests(args):
"""
Figure out which plugins the user requested with CLI and config options
# Which plugins did the user request?
:returns: (requested authenticator string or None, requested installer string or None)
:rtype: tuple
"""
req_inst = req_auth = args.configurator
req_inst = set_configurator(req_inst, args.installer)
req_auth = set_configurator(req_auth, args.authenticator)
@ -513,6 +516,40 @@ def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable
if args.manual:
req_auth = set_configurator(req_auth, "manual")
logger.debug("Requested authenticator %s and installer %s", req_auth, req_inst)
return req_auth, req_inst
noninstaller_plugins = ["webroot", "manual", "standalone"]
def choose_configurator_plugins(args, config, plugins, verb):
"""
Figure out which configurator we're going to use
:raises errors.PluginSelectionError if there was a problem
"""
req_auth, req_inst = cli_plugin_requests(args)
# Which plugins do we need?
if verb == "run":
need_inst = need_auth = True
if req_auth in noninstaller_plugins and not req_inst:
msg = ('With the {0} plugin, you probably want to use the "certonly" command, eg:{1}'
'{1} {2} certonly --{0}{1}{1}'
'(Alternatively, add a --installer flag. See https://eff.org/letsencrypt-plugins'
'{1} and "--help plugins" for more information.)'.format(
req_auth, os.linesep, cli_command))
raise errors.MissingCommandlineFlag, msg
else:
need_inst = need_auth = False
if verb == "certonly":
need_auth = True
if verb == "install":
need_inst = True
if args.authenticator:
logger.warn("Specifying an authenticator doesn't make sense in install mode")
# Try to meet the user's request and/or ask them to pick plugins
authenticator = installer = None
@ -607,6 +644,8 @@ def obtain_cert(args, config, plugins):
def install(args, config, plugins):
"""Install a previously obtained cert in a server."""
# XXX: Update for renewer/RenewableCert
# FIXME: be consistent about whether errors are raised or returned from
# this function ...
try:
installer, _ = choose_configurator_plugins(args, config,
@ -948,6 +987,12 @@ def prepare_and_parse_args(plugins, args):
helpful.add(
None, "-t", "--text", dest="text_mode", action="store_true",
help="Use the text output instead of the curses UI.")
helpful.add(
None, "-n", "--non-interactive", "--noninteractive",
dest="noninteractive_mode", action="store_true",
help="Run without ever asking for user input. This may require "
"additional command line flags; the client will try to explain "
"which ones are required if it finds one missing")
helpful.add(
None, "--register-unsafely-without-email", action="store_true",
help="Specifying this flag enables registering an account with no "
@ -1169,9 +1214,8 @@ def _plugins_parsing(helpful, plugins):
"plugins", description="Let's Encrypt client supports an "
"extensible plugins architecture. See '%(prog)s plugins' for a "
"list of all installed plugins and their names. You can force "
"a particular plugin by setting options provided below. Further "
"down this help message you will find plugin-specific options "
"(prefixed by --{plugin_name}).")
"a particular plugin by setting options provided below. Running "
"--help <plugin_name> will list flags specific to that plugin.")
helpful.add(
"plugins", "-a", "--authenticator", help="Authenticator plugin name.")
helpful.add(
@ -1383,7 +1427,9 @@ def main(cli_args=sys.argv[1:]):
sys.excepthook = functools.partial(_handle_exception, args=args)
# Displayer
if args.text_mode:
if args.noninteractive_mode:
displayer = display_util.NoninteractiveDisplay(sys.stdout)
elif args.text_mode:
displayer = display_util.FileDisplay(sys.stdout)
else:
displayer = display_util.NcursesDisplay()

View file

@ -48,7 +48,7 @@ def redirect_by_default():
code, selection = util(interfaces.IDisplay).menu(
"Please choose whether HTTPS access is required or optional.",
choices)
choices, default=0, cli_flag="--redirect / --no-redirect")
if code != display_util.OK:
return False

View file

@ -31,8 +31,8 @@ def choose_plugin(prepared, question):
for plugin_ep in prepared]
while True:
code, index = util(interfaces.IDisplay).menu(
question, opts, help_label="More Info")
disp = util(interfaces.IDisplay)
code, index = disp.menu(question, opts, help_label="More Info")
if code == display_util.OK:
plugin_ep = prepared[index]
@ -74,6 +74,16 @@ def pick_plugin(config, default, plugins, question, ifaces):
# throw more UX-friendly error if default not in plugins
filtered = plugins.filter(lambda p_ep: p_ep.name == default)
else:
if config.noninteractive_mode:
# it's really bad to auto-select the single available plugin in
# non-interactive mode, because an update could later add a second
# available plugin
raise errors.MissingCommandlineFlag, ("Missing command line flags. For non-interactive "
"execution, you will need to specify a plugin on the command line. Run with "
"'--help plugins' to see a list of options, and see "
" https://eff.org/letsencrypt-plugins for more detail on what the plugins "
"do and how to use them.")
filtered = plugins.visible().ifaces(ifaces)
filtered.init(config)
@ -143,7 +153,12 @@ def get_email(more=False, invalid=False):
msg += ('\n\nIf you really want to skip this, you can run the client with '
'--register-unsafely-without-email but make sure you backup your '
'account key from /etc/letsencrypt/accounts\n\n')
code, email = zope.component.getUtility(interfaces.IDisplay).input(msg)
try:
code, email = zope.component.getUtility(interfaces.IDisplay).input(msg)
except errors.MissingCommandlineFlag:
msg = ("You should register before running non-interactively, or provide --agree-tos"
" and --email <email_address> flags")
raise errors.MissingCommandlineFlag, msg
if code == display_util.OK:
if le_util.safe_email(email):
@ -197,7 +212,8 @@ def choose_names(installer):
"specify ServerNames in your config files in order to allow for "
"accurate installation of your certificate.{0}"
"If you do use the default vhost, you may specify the name "
"manually. Would you like to continue?{0}".format(os.linesep))
"manually. Would you like to continue?{0}".format(os.linesep),
default=True)
if manual:
return _choose_names_manually()
@ -242,7 +258,7 @@ def _filter_names(names):
"""
code, names = util(interfaces.IDisplay).checklist(
"Which names would you like to activate HTTPS for?",
tags=names)
tags=names, cli_flag="--domains")
return code, [str(s) for s in names]
@ -250,7 +266,8 @@ def _choose_names_manually():
"""Manually input names for those without an installer."""
code, input_ = util(interfaces.IDisplay).input(
"Please enter in your domain name(s) (comma and/or space separated) ")
"Please enter in your domain name(s) (comma and/or space separated) ",
cli_flag="--domains")
if code == display_util.OK:
invalid_domains = dict()

View file

@ -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):
"""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."""
@ -49,8 +63,8 @@ class NcursesDisplay(object):
"""
self.dialog.msgbox(message, height, width=self.width)
def menu(self, message, choices,
ok_label="OK", cancel_label="Cancel", help_label=""):
def menu(self, message, choices, ok_label="OK", cancel_label="Cancel",
help_label="", **unused_kwargs):
"""Display a menu.
:param str message: title of menu
@ -61,10 +75,11 @@ class NcursesDisplay(object):
:param str ok_label: label of the OK button
:param str help_label: label of the help button
:param dict unused_kwargs: absorbs default / cli_args
:returns: tuple of the form (`code`, `tag`) where
`code` - `str` display_util exit code
`tag` - `int` index corresponding to the item chosen
:returns: tuple of the form (`code`, `index`) where
`code` - int display exit code
`int` - index of the selected item
:rtype: tuple
"""
@ -97,20 +112,21 @@ class NcursesDisplay(object):
(str(i), choice) for i, choice in enumerate(choices, 1)
]
# pylint: disable=star-args
code, tag = self.dialog.menu(message, **menu_options)
code, index = self.dialog.menu(message, **menu_options)
if code == CANCEL:
return code, -1
return code, int(tag) - 1
return code, int(index) - 1
def input(self, message):
def input(self, message, **unused_kwargs):
"""Display an input box to the user.
:param str message: Message to display that asks for input.
:param dict _kwargs: absorbs default / cli_args
:returns: tuple of the form (code, string) where
:returns: tuple of the form (`code`, `string`) where
`code` - int display exit code
`string` - input entered by the user
@ -122,7 +138,7 @@ class NcursesDisplay(object):
return self.dialog.inputbox(message, width=self.width, height=height)
def yesno(self, message, yes_label="Yes", no_label="No"):
def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs):
"""Display a Yes/No dialog box.
Yes and No label must begin with different letters.
@ -130,6 +146,7 @@ class NcursesDisplay(object):
: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
:param dict _kwargs: absorbs default / cli_args
:returns: if yes_label was selected
:rtype: bool
@ -139,16 +156,17 @@ class NcursesDisplay(object):
message, self.height, self.width,
yes_label=yes_label, no_label=no_label)
def checklist(self, message, tags, default_status=True):
def checklist(self, message, tags, default_status=True, **unused_kwargs):
"""Displays a checklist.
:param message: Message to display before choices
:param list tags: where each is of type :class:`str` len(tags) > 0
:param bool default_status: If True, items are in a selected state by
default.
:param dict _kwargs: absorbs default / cli_args
:returns: tuple of the form (code, list_tags) where
:returns: tuple of the form (`code`, `list_tags`) where
`code` - int display exit code
`list_tags` - list of str tags selected by the user
@ -178,15 +196,15 @@ 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))
if pause:
raw_input("Press Enter to Continue")
def menu(self, message, choices,
ok_label="", cancel_label="", help_label=""):
def menu(self, message, choices, ok_label="", cancel_label="",
help_label="", **unused_kwargs):
# pylint: disable=unused-argument
"""Display a menu.
@ -197,10 +215,12 @@ class FileDisplay(object):
: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 default / cli_args
:returns: tuple of (`code`, `index`) where
`code` - str display exit code
`index` - int index of the user's selection
:returns: tuple of the form (code, tag) where
code - int display exit code
tag - str corresponding to the item chosen
:rtype: tuple
"""
@ -210,11 +230,12 @@ class FileDisplay(object):
return code, selection - 1
def input(self, message):
def input(self, message, **unused_kwargs):
# pylint: disable=no-self-use
"""Accept input from the user.
:param str message: message to display to the user
:param dict _kwargs: absorbs default / cli_args
:returns: tuple of (`code`, `input`) where
`code` - str display exit code
@ -230,7 +251,7 @@ class FileDisplay(object):
else:
return OK, ans
def yesno(self, message, yes_label="Yes", no_label="No"):
def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs):
"""Query the user with a yes/no question.
Yes and No label must begin with different letters, and must contain at
@ -239,6 +260,7 @@ class FileDisplay(object):
: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 default / cli_args
:returns: True for "Yes", False for "No"
:rtype: bool
@ -246,7 +268,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))
@ -265,13 +287,14 @@ class FileDisplay(object):
ans.startswith(no_label[0].upper())):
return False
def checklist(self, message, tags, default_status=True):
def checklist(self, message, tags, default_status=True, **unused_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 / cli_args
:returns: tuple of (`code`, `tags`) where
`code` - str display exit code
@ -352,21 +375,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.
@ -403,6 +411,118 @@ class FileDisplay(object):
return OK, selection
class NoninteractiveDisplay(object):
"""An iDisplay implementation that never asks for interactive user input"""
zope.interface.implements(interfaces.IDisplay)
def __init__(self, outfile):
super(NoninteractiveDisplay, self).__init__()
self.outfile = outfile
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 += message
if extra:
msg += "\n" + extra
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=False):
# pylint: disable=unused-argument
"""Displays a notification without waiting for user acceptance.
:param str message: Message to display to stdout
:param int height: No effect for NoninteractiveDisplay
:param bool pause: The NoninteractiveDisplay waits for no keyboard
"""
side_frame = "-" * 79
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=None, cancel_label=None,
default=None, cli_flag=None):
# pylint: disable=unused-argument,too-many-arguments
"""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 int default: the default choice
:param dict kwargs: absorbs various irrelevant labelling arguments
:returns: tuple of (`code`, `index`) where
`code` - str display exit code
`index` - int index of the user's selection
:rtype: tuple
:raises errors.MissingCommandlineFlag: if there was no default
"""
if default is None:
self._interaction_fail(message, cli_flag, "Choices: " + repr(choices))
return OK, default
def input(self, message, default=None, cli_flag=None):
"""Accept input from the user.
:param str message: message to display to the user
:returns: tuple of (`code`, `input`) where
`code` - str display exit code
`input` - str of the user's input
:rtype: tuple
:raises errors.MissingCommandlineFlag: if there was no default
"""
if default is None:
self._interaction_fail(message, cli_flag)
else:
return OK, default
def yesno(self, message, yes_label=None, no_label=None, default=None, cli_flag=None):
# pylint: disable=unused-argument
"""Decide Yes or No, without asking anybody
:param str message: question for the user
: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
"""
if default is None:
self._interaction_fail(message, cli_flag)
else:
return default
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 dict kwargs: absorbs default_status arg
:returns: tuple of (`code`, `tags`) where
`code` - str display exit code
`tags` - list of selected tags
:rtype: tuple
"""
if default is None:
self._interaction_fail(message, cli_flag, "? ".join(tags))
else:
return OK, default
def separate_list_input(input_):
"""Separate a comma or space separated list.

View file

@ -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"""

View file

@ -365,8 +365,8 @@ class IDisplay(zope.interface.Interface):
"""
def menu(message, choices,
ok_label="OK", cancel_label="Cancel", help_label=""):
def menu(message, choices, ok_label="OK", # pylint: disable=too-many-arguments
cancel_label="Cancel", help_label="", default=None, cli_flag=None):
"""Displays a generic menu.
:param str message: message to display
@ -377,14 +377,19 @@ class IDisplay(zope.interface.Interface):
:param str ok_label: label for OK button
:param str cancel_label: label for Cancel button
:param str help_label: label for Help button
:param int default: default (non-interactive) choice from the menu
:param str cli_flag: to automate choice from the menu, eg "--keep"
:returns: tuple of (`code`, `index`) where
`code` - str display exit code
`index` - int index of the user's selection
:raises errors.MissingCommandlineFlag: if called in non-interactive
mode without a default set
"""
def input(message):
def input(message, default=None, cli_args=None):
"""Accept input from the user.
:param str message: message to display to the user
@ -394,27 +399,45 @@ class IDisplay(zope.interface.Interface):
`input` - str of the user's input
:rtype: tuple
:raises errors.MissingCommandlineFlag: if called in non-interactive
mode without a default set
"""
def yesno(message, yes_label="Yes", no_label="No"):
def yesno(message, yes_label="Yes", no_label="No", default=None,
cli_args=None):
"""Query the user with a yes/no question.
Yes and No label must begin with different letters.
:param str message: question for the user
:param str default: default (non-interactive) choice from the menu
:param str cli_flag: to automate choice from the menu, eg "--redirect / --no-redirect"
:returns: True for "Yes", False for "No"
:rtype: bool
:raises errors.MissingCommandlineFlag: if called in non-interactive
mode without a default set
"""
def checklist(message, tags, default_state):
def checklist(message, tags, default_state, default=None, cli_args=None):
"""Allow for multiple selections from a menu.
:param str message: message to display to the user
:param list tags: where each is of type :class:`str` len(tags) > 0
:param bool default_status: If True, items are in a selected state by
default.
:param bool default_status: If True, items are in a selected state by default.
:param str default: default (non-interactive) state of the checklist
:param str cli_flag: to automate choice from the menu, eg "--domains"
:returns: tuple of the form (code, list_tags) where
`code` - int display exit code
`list_tags` - list of str tags selected by the user
:rtype: tuple
:raises errors.MissingCommandlineFlag: if called in non-interactive
mode without a default set
"""

View file

@ -165,7 +165,8 @@ s.serve_forever()" """
else:
if not self.conf("public-ip-logging-ok"):
if not zope.component.getUtility(interfaces.IDisplay).yesno(
self.IP_DISCLAIMER, "Yes", "No"):
self.IP_DISCLAIMER, "Yes", "No",
cli_flag="--manual-public-ip-logging-ok"):
raise errors.PluginError("Must agree to IP logging to proceed")
self._notify_and_wait(self.MESSAGE_TEMPLATE.format(

View file

@ -81,7 +81,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods
self.assertEqual(1, mock_run.call_count)
def _help_output(self, args):
"Run a help command, and return the help string for scrutiny"
"Run a command, and return the ouput string for scrutiny"
output = StringIO.StringIO()
with mock.patch('letsencrypt.cli.sys.stdout', new=output):
self.assertRaises(SystemExit, self._call_stdout, args)
@ -105,6 +105,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods
self.assertTrue("--checkpoints" not in out)
out = self._help_output(['-h'])
self.assertTrue("letsencrypt-auto" not in out) # test cli.cli_command
if "nginx" in plugins:
self.assertTrue("Use the Nginx plugin" in out)
else:
@ -130,6 +131,27 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods
out = self._help_output(['-h'])
self.assertTrue(cli.usage_strings(plugins)[0] in out)
def _cli_missing_flag(self, args, message):
"Ensure that a particular error raises a missing cli flag error containing message"
exc = None
try:
with mock.patch('letsencrypt.cli.sys.stderr'):
cli.main(self.standard_args + args[:]) # NOTE: parser can alter its args!
except errors.MissingCommandlineFlag, exc:
self.assertTrue(message in str(exc))
self.assertTrue(exc is not None)
def test_noninteractive(self):
args = ['-n', 'certonly']
self._cli_missing_flag(args, "specify a plugin")
args.extend(['--standalone', '-d', 'eg.is'])
self._cli_missing_flag(args, "register before running")
with mock.patch('letsencrypt.cli._auth_from_domains'):
with mock.patch('letsencrypt.cli.client.acme_from_config_key'):
args.extend(['--email', 'io@io.is'])
self._cli_missing_flag(args, "--agree-tos")
@mock.patch('letsencrypt.cli.client.acme_client.Client')
@mock.patch('letsencrypt.cli._determine_account')
@mock.patch('letsencrypt.cli.client.Client.obtain_and_enroll_certificate')
@ -210,6 +232,8 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods
ret, _, _, _ = self._call(args)
self.assertTrue("--webroot-path must be set" in ret)
self._cli_missing_flag(["--standalone"], "With the standalone plugin, you probably")
with mock.patch("letsencrypt.cli._init_le_client") as mock_init:
with mock.patch("letsencrypt.cli._auth_from_domains"):
self._call(["certonly", "--manual", "-d", "foo.bar"])

View file

@ -69,7 +69,7 @@ class PickPluginTest(unittest.TestCase):
"""Tests for letsencrypt.display.ops.pick_plugin."""
def setUp(self):
self.config = mock.Mock()
self.config = mock.Mock(noninteractive_mode=False)
self.default = None
self.reg = mock.MagicMock()
self.question = "Question?"

View file

@ -4,6 +4,8 @@ import unittest
import mock
import letsencrypt.errors as errors
from letsencrypt.display import util as display_util
@ -250,7 +252,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)
@ -278,6 +280,46 @@ class FileOutputDisplayTest(unittest.TestCase):
self.displayer._get_valid_int_ans(3),
(display_util.CANCEL, -1))
class NoninteractiveDisplayTest(unittest.TestCase):
"""Test non-interactive display.
These tests are pretty easy!
"""
def setUp(self):
super(NoninteractiveDisplayTest, self).setUp()
self.mock_stdout = mock.MagicMock()
self.displayer = display_util.NoninteractiveDisplay(self.mock_stdout)
def test_notification_no_pause(self):
self.displayer.notification("message", 10)
string = self.mock_stdout.write.call_args[0][0]
self.assertTrue("message" in string)
def test_input(self):
d = "an incomputable value"
ret = self.displayer.input("message", default=d)
self.assertEqual(ret, (display_util.OK, d))
self.assertRaises(errors.MissingCommandlineFlag, self.displayer.input, "message")
def test_menu(self):
ret = self.displayer.menu("message", CHOICES, default=1)
self.assertEqual(ret, (display_util.OK, 1))
self.assertRaises(errors.MissingCommandlineFlag, self.displayer.menu, "message", CHOICES)
def test_yesno(self):
d = False
ret = self.displayer.yesno("message", default=d)
self.assertEqual(ret, d)
self.assertRaises(errors.MissingCommandlineFlag, self.displayer.yesno, "message")
def test_checklist(self):
d = [1, 3]
ret = self.displayer.checklist("message", TAGS, default=d)
self.assertEqual(ret, (display_util.OK, d))
self.assertRaises(errors.MissingCommandlineFlag, self.displayer.checklist, "message", TAGS)
class SeparateListInputTest(unittest.TestCase):
"""Test Module functions."""

View file

@ -13,7 +13,7 @@ unset PIP_INDEX_URL
export PIP_EXTRA_INDEX_URL="$SAVE"
if ! ./letsencrypt-auto -v --debug --version | grep 0.1.1 ; then
if ! ./letsencrypt-auto -v --debug --version | grep 0.2.0 ; then
echo upgrade appeared to fail
exit 1
fi