mirror of
https://github.com/certbot/certbot.git
synced 2026-06-07 15:52:08 -04:00
Merge pull request #2078 from letsencrypt/non-interactive
Implement non-interactive mode for the client.
This commit is contained in:
commit
90c7a73146
13 changed files with 394 additions and 100 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue