mirror of
https://github.com/certbot/certbot.git
synced 2026-07-28 08:45:19 +02:00
Mitigate problems for people who run without -n (#3916)
* CLI flag for forcing interactivity * add --force-interactive * Add force_interactive error checking and tests * Add force_interactive parameter to FileDisplay * add _can_interact * Add _return_default * Add **unused_kwargs to NoninteractiveDisplay * improve _return_default assertion * Change IDisplay calls and write tests * Document force_interactive in interfaces.py * Don't force_interactive with a new prompt * Warn when skipping an interaction for the first time * add specific logger.debug message
This commit is contained in:
committed by
Peter Eckersley
parent
81fd0cd32c
commit
ae379568b1
@@ -463,7 +463,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
||||
zope.component.getUtility(interfaces.IDisplay).notification(
|
||||
"Apache mod_macro seems to be in use in file(s):\n{0}"
|
||||
"\n\nUnfortunately mod_macro is not yet supported".format(
|
||||
"\n ".join(vhost_macro)))
|
||||
"\n ".join(vhost_macro)), force_interactive=True)
|
||||
|
||||
return all_names
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ def _vhost_menu(domain, vhosts):
|
||||
"or Address of {0}.{1}Which virtual host would you "
|
||||
"like to choose?\n(note: conf files with multiple "
|
||||
"vhosts are not yet supported)".format(domain, os.linesep),
|
||||
choices, help_label="More Info", ok_label="Select")
|
||||
choices, help_label="More Info",
|
||||
ok_label="Select", force_interactive=True)
|
||||
except errors.MissingCommandlineFlag:
|
||||
msg = ("Encountered vhost ambiguity but unable to ask for user guidance in "
|
||||
"non-interactive mode. Currently Certbot needs each vhost to be "
|
||||
@@ -100,4 +101,5 @@ def _vhost_menu(domain, vhosts):
|
||||
def _more_info_vhost(vhost):
|
||||
zope.component.getUtility(interfaces.IDisplay).notification(
|
||||
"Virtual Host Information:{0}{1}{0}{2}".format(
|
||||
os.linesep, "-" * (display_util.WIDTH - 4), str(vhost)))
|
||||
os.linesep, "-" * (display_util.WIDTH - 4), str(vhost)),
|
||||
force_interactive=True)
|
||||
|
||||
@@ -17,7 +17,8 @@ class SelectVhostTest(unittest.TestCase):
|
||||
"""Tests for certbot_apache.display_ops.select_vhost."""
|
||||
|
||||
def setUp(self):
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
|
||||
False))
|
||||
self.base_dir = "/example_path"
|
||||
self.vhosts = util.get_vh_truth(
|
||||
self.base_dir, "debian_apache_2_4/multiple_vhosts")
|
||||
|
||||
@@ -64,7 +64,8 @@ class ParserTest(ApacheTest): # pytlint: disable=too-few-public-methods
|
||||
vhost_root="debian_apache_2_4/multiple_vhosts/apache2/sites-available"):
|
||||
super(ParserTest, self).setUp(test_dir, config_root, vhost_root)
|
||||
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
|
||||
False))
|
||||
|
||||
from certbot_apache.parser import ApacheParser
|
||||
self.aug = augeas.Augeas(
|
||||
|
||||
@@ -47,8 +47,9 @@ def rename_lineage(config):
|
||||
|
||||
new_certname = config.new_certname
|
||||
if not new_certname:
|
||||
code, new_certname = disp.input("Enter the new name for certificate {0}"
|
||||
.format(certname), flag="--updated-cert-name")
|
||||
code, new_certname = disp.input(
|
||||
"Enter the new name for certificate {0}".format(certname),
|
||||
flag="--updated-cert-name", force_interactive=True)
|
||||
if code != display_util.OK or not new_certname:
|
||||
raise errors.Error("User ended interaction.")
|
||||
|
||||
|
||||
@@ -521,8 +521,17 @@ class HelpfulArgumentParser(object):
|
||||
# Do any post-parsing homework here
|
||||
|
||||
if self.verb == "renew":
|
||||
if parsed_args.force_interactive:
|
||||
raise errors.Error(
|
||||
"{0} cannot be used with renew".format(
|
||||
constants.FORCE_INTERACTIVE_FLAG))
|
||||
parsed_args.noninteractive_mode = True
|
||||
|
||||
if parsed_args.force_interactive and parsed_args.noninteractive_mode:
|
||||
raise errors.Error(
|
||||
"Flag for non-interactive mode and {0} conflict".format(
|
||||
constants.FORCE_INTERACTIVE_FLAG))
|
||||
|
||||
if parsed_args.staging or parsed_args.dry_run:
|
||||
self.set_test_server(parsed_args)
|
||||
|
||||
@@ -808,6 +817,12 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis
|
||||
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", "run", "certonly"],
|
||||
constants.FORCE_INTERACTIVE_FLAG, action="store_true",
|
||||
help="Force Certbot to be interactive even if it detects it's not "
|
||||
"being run in a terminal. This flag cannot be used with the "
|
||||
"renew subcommand.")
|
||||
helpful.add(
|
||||
[None, "run", "certonly"],
|
||||
"-d", "--domains", "--domain", dest="domains",
|
||||
|
||||
@@ -92,3 +92,6 @@ TEMP_CHECKPOINT_DIR = "temp_checkpoint"
|
||||
|
||||
RENEWAL_CONFIGS_DIR = "renewal"
|
||||
"""Renewal configs directory, relative to `IConfig.config_dir`."""
|
||||
|
||||
FORCE_INTERACTIVE_FLAG = "--force-interactive"
|
||||
"""Flag to disable TTY checking in IDisplay."""
|
||||
|
||||
@@ -48,7 +48,8 @@ def redirect_by_default():
|
||||
|
||||
code, selection = util(interfaces.IDisplay).menu(
|
||||
"Please choose whether HTTPS access is required or optional.",
|
||||
choices, default=0, cli_flag="--redirect / --no-redirect")
|
||||
choices, default=0,
|
||||
cli_flag="--redirect / --no-redirect", force_interactive=True)
|
||||
|
||||
if code != display_util.OK:
|
||||
return False
|
||||
|
||||
@@ -46,7 +46,8 @@ def get_email(invalid=False, optional=True):
|
||||
while True:
|
||||
try:
|
||||
code, email = z_util(interfaces.IDisplay).input(
|
||||
invalid_prefix + msg if invalid else msg)
|
||||
invalid_prefix + msg if invalid else msg,
|
||||
force_interactive=True)
|
||||
except errors.MissingCommandlineFlag:
|
||||
msg = ("You should register before running non-interactively, "
|
||||
"or provide --agree-tos and --email <email_address> flags.")
|
||||
@@ -79,7 +80,7 @@ def choose_account(accounts):
|
||||
labels = [acc.slug for acc in accounts]
|
||||
|
||||
code, index = z_util(interfaces.IDisplay).menu(
|
||||
"Please choose an account", labels)
|
||||
"Please choose an account", labels, force_interactive=True)
|
||||
if code == display_util.OK:
|
||||
return accounts[index]
|
||||
else:
|
||||
@@ -157,7 +158,7 @@ def _filter_names(names):
|
||||
|
||||
code, names = z_util(interfaces.IDisplay).checklist(
|
||||
"Which names would you like to activate HTTPS for?",
|
||||
tags=sorted_names, cli_flag="--domains")
|
||||
tags=sorted_names, cli_flag="--domains", force_interactive=True)
|
||||
return code, [str(s) for s in names]
|
||||
|
||||
|
||||
@@ -173,7 +174,7 @@ def _choose_names_manually(prompt_prefix=""):
|
||||
code, input_ = z_util(interfaces.IDisplay).input(
|
||||
prompt_prefix +
|
||||
"Please enter in your domain name(s) (comma and/or space separated) ",
|
||||
cli_flag="--domains")
|
||||
cli_flag="--domains", force_interactive=True)
|
||||
|
||||
if code == display_util.OK:
|
||||
invalid_domains = dict()
|
||||
@@ -211,7 +212,8 @@ def _choose_names_manually(prompt_prefix=""):
|
||||
|
||||
if retry_message:
|
||||
# We had error in input
|
||||
retry = z_util(interfaces.IDisplay).yesno(retry_message)
|
||||
retry = z_util(interfaces.IDisplay).yesno(retry_message,
|
||||
force_interactive=True)
|
||||
if retry:
|
||||
return _choose_names_manually()
|
||||
else:
|
||||
|
||||
+120
-22
@@ -1,14 +1,18 @@
|
||||
"""Certbot display."""
|
||||
import logging
|
||||
import os
|
||||
import textwrap
|
||||
import sys
|
||||
|
||||
import six
|
||||
import zope.interface
|
||||
|
||||
from certbot import constants
|
||||
from certbot import interfaces
|
||||
from certbot import errors
|
||||
from certbot.display import completer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WIDTH = 72
|
||||
|
||||
@@ -50,19 +54,25 @@ def _wrap_lines(msg):
|
||||
@zope.interface.implementer(interfaces.IDisplay)
|
||||
class FileDisplay(object):
|
||||
"""File-based display."""
|
||||
# pylint: disable=too-many-arguments
|
||||
# see https://github.com/certbot/certbot/issues/3915
|
||||
|
||||
def __init__(self, outfile):
|
||||
def __init__(self, outfile, force_interactive):
|
||||
super(FileDisplay, self).__init__()
|
||||
self.outfile = outfile
|
||||
self.force_interactive = force_interactive
|
||||
self.skipped_interaction = False
|
||||
|
||||
def notification(self, message, pause=True, wrap=True):
|
||||
# pylint: disable=unused-argument
|
||||
def notification(self, message, pause=True,
|
||||
wrap=True, force_interactive=False):
|
||||
"""Displays a notification and waits for user acceptance.
|
||||
|
||||
:param str message: Message to display
|
||||
:param bool pause: Whether or not the program should pause for the
|
||||
user's confirmation
|
||||
:param bool wrap: Whether or not the application should wrap text
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
"""
|
||||
side_frame = "-" * 79
|
||||
@@ -72,10 +82,14 @@ class FileDisplay(object):
|
||||
"{line}{frame}{line}{msg}{line}{frame}{line}".format(
|
||||
line=os.linesep, frame=side_frame, msg=message))
|
||||
if pause:
|
||||
six.moves.input("Press Enter to Continue")
|
||||
if self._can_interact(force_interactive):
|
||||
six.moves.input("Press Enter to Continue")
|
||||
else:
|
||||
logger.debug("Not pausing for user confirmation")
|
||||
|
||||
def menu(self, message, choices, ok_label="", cancel_label="",
|
||||
help_label="", **unused_kwargs):
|
||||
help_label="", default=None,
|
||||
cli_flag=None, force_interactive=False, **unused_kwargs):
|
||||
# pylint: disable=unused-argument
|
||||
"""Display a menu.
|
||||
|
||||
@@ -86,7 +100,10 @@ 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
|
||||
:param default: default value to return (if one exists)
|
||||
:param str cli_flag: option used to set this value with the CLI
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: tuple of (`code`, `index`) where
|
||||
`code` - str display exit code
|
||||
@@ -95,18 +112,25 @@ class FileDisplay(object):
|
||||
:rtype: tuple
|
||||
|
||||
"""
|
||||
if self._return_default(message, default, cli_flag, force_interactive):
|
||||
return OK, default
|
||||
|
||||
self._print_menu(message, choices)
|
||||
|
||||
code, selection = self._get_valid_int_ans(len(choices))
|
||||
|
||||
return code, selection - 1
|
||||
|
||||
def input(self, message, **unused_kwargs):
|
||||
def input(self, message, default=None,
|
||||
cli_flag=None, force_interactive=False, **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
|
||||
:param default: default value to return (if one exists)
|
||||
:param str cli_flag: option used to set this value with the CLI
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: tuple of (`code`, `input`) where
|
||||
`code` - str display exit code
|
||||
@@ -114,6 +138,9 @@ class FileDisplay(object):
|
||||
:rtype: tuple
|
||||
|
||||
"""
|
||||
if self._return_default(message, default, cli_flag, force_interactive):
|
||||
return OK, default
|
||||
|
||||
ans = six.moves.input(
|
||||
textwrap.fill(
|
||||
"%s (Enter 'c' to cancel): " % message,
|
||||
@@ -126,7 +153,8 @@ class FileDisplay(object):
|
||||
else:
|
||||
return OK, ans
|
||||
|
||||
def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs):
|
||||
def yesno(self, message, yes_label="Yes", no_label="No", default=None,
|
||||
cli_flag=None, force_interactive=False, **unused_kwargs):
|
||||
"""Query the user with a yes/no question.
|
||||
|
||||
Yes and No label must begin with different letters, and must contain at
|
||||
@@ -135,12 +163,18 @@ 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
|
||||
:param default: default value to return (if one exists)
|
||||
:param str cli_flag: option used to set this value with the CLI
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: True for "Yes", False for "No"
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
if self._return_default(message, default, cli_flag, force_interactive):
|
||||
return default
|
||||
|
||||
side_frame = ("-" * 79) + os.linesep
|
||||
|
||||
message = _wrap_lines(message)
|
||||
@@ -162,14 +196,18 @@ class FileDisplay(object):
|
||||
ans.startswith(no_label[0].upper())):
|
||||
return False
|
||||
|
||||
def checklist(self, message, tags, default_status=True, **unused_kwargs):
|
||||
def checklist(self, message, tags, default_status=True, default=None,
|
||||
cli_flag=None, force_interactive=False, **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
|
||||
:param default: default value to return (if one exists)
|
||||
:param str cli_flag: option used to set this value with the CLI
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: tuple of (`code`, `tags`) where
|
||||
`code` - str display exit code
|
||||
@@ -177,6 +215,9 @@ class FileDisplay(object):
|
||||
:rtype: tuple
|
||||
|
||||
"""
|
||||
if self._return_default(message, default, cli_flag, force_interactive):
|
||||
return OK, default
|
||||
|
||||
while True:
|
||||
self._print_menu(message, tags)
|
||||
|
||||
@@ -197,10 +238,65 @@ class FileDisplay(object):
|
||||
else:
|
||||
return code, []
|
||||
|
||||
def directory_select(self, message, **unused_kwargs):
|
||||
def _return_default(self, prompt, default, cli_flag, force_interactive):
|
||||
"""Should we return the default instead of prompting the user?
|
||||
|
||||
:param str prompt: prompt for the user
|
||||
:param default: default answer to prompt
|
||||
:param str cli_flag: command line option for setting an answer
|
||||
to this question
|
||||
:param bool force_interactive: if interactivity is forced by the
|
||||
IDisplay call
|
||||
|
||||
:returns: True if we should return the default without prompting
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
msg = "Invalid IDisplay call for this prompt:\n{0}".format(prompt)
|
||||
if cli_flag:
|
||||
msg += ("\nYou can set an answer to "
|
||||
"this prompt with the {0} flag".format(cli_flag))
|
||||
assert default is not None or force_interactive, msg
|
||||
|
||||
if self._can_interact(force_interactive):
|
||||
return False
|
||||
else:
|
||||
logger.debug(
|
||||
"Falling back to default %s for the prompt:\n%s",
|
||||
default, prompt)
|
||||
return True
|
||||
|
||||
def _can_interact(self, force_interactive):
|
||||
"""Can we safely interact with the user?
|
||||
|
||||
:param bool force_interactive: if interactivity is forced by the
|
||||
IDisplay call
|
||||
|
||||
:returns: True if the display can interact with the user
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
if (self.force_interactive or force_interactive or
|
||||
sys.stdin.isatty() and self.outfile.isatty()):
|
||||
return True
|
||||
elif not self.skipped_interaction:
|
||||
logger.warning(
|
||||
"Skipped user interaction because Certbot doesn't appear to "
|
||||
"be running in a terminal. You should probably include "
|
||||
"--non-interactive or %s on the command line.",
|
||||
constants.FORCE_INTERACTIVE_FLAG)
|
||||
self.skipped_interaction = True
|
||||
return False
|
||||
|
||||
def directory_select(self, message, default=None, cli_flag=None,
|
||||
force_interactive=False, **unused_kwargs):
|
||||
"""Display a directory selection screen.
|
||||
|
||||
:param str message: prompt to give the user
|
||||
:param default: default value to return (if one exists)
|
||||
:param str cli_flag: option used to set this value with the CLI
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: tuple of the form (`code`, `string`) where
|
||||
`code` - display exit code
|
||||
@@ -208,7 +304,7 @@ class FileDisplay(object):
|
||||
|
||||
"""
|
||||
with completer.Completer():
|
||||
return self.input(message)
|
||||
return self.input(message, default, cli_flag, force_interactive)
|
||||
|
||||
def _scrub_checklist_input(self, indices, tags):
|
||||
# pylint: disable=no-self-use
|
||||
@@ -310,7 +406,7 @@ class FileDisplay(object):
|
||||
class NoninteractiveDisplay(object):
|
||||
"""An iDisplay implementation that never asks for interactive user input"""
|
||||
|
||||
def __init__(self, outfile):
|
||||
def __init__(self, outfile, *unused_args, **unused_kwargs):
|
||||
super(NoninteractiveDisplay, self).__init__()
|
||||
self.outfile = outfile
|
||||
|
||||
@@ -324,7 +420,7 @@ class NoninteractiveDisplay(object):
|
||||
msg += "\n\n(You can set this with the {0} flag)".format(cli_flag)
|
||||
raise errors.MissingCommandlineFlag(msg)
|
||||
|
||||
def notification(self, message, pause=False, wrap=True):
|
||||
def notification(self, message, pause=False, wrap=True, **unused_kwargs):
|
||||
# pylint: disable=unused-argument
|
||||
"""Displays a notification without waiting for user acceptance.
|
||||
|
||||
@@ -341,7 +437,7 @@ class NoninteractiveDisplay(object):
|
||||
line=os.linesep, frame=side_frame, msg=message))
|
||||
|
||||
def menu(self, message, choices, ok_label=None, cancel_label=None,
|
||||
help_label=None, default=None, cli_flag=None):
|
||||
help_label=None, default=None, cli_flag=None, *unused_kwargs):
|
||||
# pylint: disable=unused-argument,too-many-arguments
|
||||
"""Avoid displaying a menu.
|
||||
|
||||
@@ -364,7 +460,7 @@ class NoninteractiveDisplay(object):
|
||||
|
||||
return OK, default
|
||||
|
||||
def input(self, message, default=None, cli_flag=None):
|
||||
def input(self, message, default=None, cli_flag=None, **unused_kwargs):
|
||||
"""Accept input from the user.
|
||||
|
||||
:param str message: message to display to the user
|
||||
@@ -381,7 +477,8 @@ class NoninteractiveDisplay(object):
|
||||
else:
|
||||
return OK, default
|
||||
|
||||
def yesno(self, message, yes_label=None, no_label=None, default=None, cli_flag=None):
|
||||
def yesno(self, message, yes_label=None, no_label=None,
|
||||
default=None, cli_flag=None, **unused_kwargs):
|
||||
# pylint: disable=unused-argument
|
||||
"""Decide Yes or No, without asking anybody
|
||||
|
||||
@@ -398,8 +495,8 @@ class NoninteractiveDisplay(object):
|
||||
else:
|
||||
return default
|
||||
|
||||
def checklist(self, message, tags, default=None, cli_flag=None, **kwargs):
|
||||
# pylint: disable=unused-argument
|
||||
def checklist(self, message, tags, default=None,
|
||||
cli_flag=None, **unused_kwargs):
|
||||
"""Display a checklist.
|
||||
|
||||
:param str message: Message to display to user
|
||||
@@ -417,7 +514,8 @@ class NoninteractiveDisplay(object):
|
||||
else:
|
||||
return OK, default
|
||||
|
||||
def directory_select(self, message, default=None, cli_flag=None):
|
||||
def directory_select(self, message, default=None,
|
||||
cli_flag=None, **unused_kwargs):
|
||||
"""Simulate prompting the user for a directory.
|
||||
|
||||
This function returns default if it is not ``None``, otherwise,
|
||||
|
||||
+40
-7
@@ -361,21 +361,29 @@ class IInstaller(IPlugin):
|
||||
|
||||
class IDisplay(zope.interface.Interface):
|
||||
"""Generic display."""
|
||||
# pylint: disable=too-many-arguments
|
||||
# see https://github.com/certbot/certbot/issues/3915
|
||||
|
||||
def notification(message, pause, wrap=True):
|
||||
def notification(message, pause, wrap=True, force_interactive=False):
|
||||
"""Displays a string message
|
||||
|
||||
:param str message: Message to display
|
||||
:param bool pause: Whether or not the application should pause for
|
||||
confirmation (if available)
|
||||
:param bool wrap: Whether or not the application should wrap text
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
"""
|
||||
|
||||
def menu(message, choices, ok_label="OK", # pylint: disable=too-many-arguments
|
||||
cancel_label="Cancel", help_label="", default=None, cli_flag=None):
|
||||
def menu(message, choices, ok_label="OK",
|
||||
cancel_label="Cancel", help_label="",
|
||||
default=None, cli_flag=None, force_interactive=False):
|
||||
"""Displays a generic menu.
|
||||
|
||||
When not setting force_interactive=True, you must provide a
|
||||
default value.
|
||||
|
||||
:param str message: message to display
|
||||
|
||||
:param choices: choices
|
||||
@@ -386,6 +394,8 @@ class IDisplay(zope.interface.Interface):
|
||||
: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"
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: tuple of (`code`, `index`) where
|
||||
`code` - str display exit code
|
||||
@@ -396,10 +406,16 @@ class IDisplay(zope.interface.Interface):
|
||||
|
||||
"""
|
||||
|
||||
def input(message, default=None, cli_args=None):
|
||||
def input(message, default=None, cli_args=None, force_interactive=False):
|
||||
"""Accept input from the user.
|
||||
|
||||
When not setting force_interactive=True, you must provide a
|
||||
default value.
|
||||
|
||||
:param str message: message to display to the user
|
||||
:param str default: default (non-interactive) response to prompt
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: tuple of (`code`, `input`) where
|
||||
`code` - str display exit code
|
||||
@@ -412,14 +428,19 @@ class IDisplay(zope.interface.Interface):
|
||||
"""
|
||||
|
||||
def yesno(message, yes_label="Yes", no_label="No", default=None,
|
||||
cli_args=None):
|
||||
cli_args=None, force_interactive=False):
|
||||
"""Query the user with a yes/no question.
|
||||
|
||||
Yes and No label must begin with different letters.
|
||||
|
||||
When not setting force_interactive=True, you must provide a
|
||||
default value.
|
||||
|
||||
: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"
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: True for "Yes", False for "No"
|
||||
:rtype: bool
|
||||
@@ -429,14 +450,20 @@ class IDisplay(zope.interface.Interface):
|
||||
|
||||
"""
|
||||
|
||||
def checklist(message, tags, default_state, default=None, cli_args=None):
|
||||
def checklist(message, tags, default_state,
|
||||
default=None, cli_args=None, force_interactive=False):
|
||||
"""Allow for multiple selections from a menu.
|
||||
|
||||
When not setting force_interactive=True, you must provide a
|
||||
default value.
|
||||
|
||||
: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 str default: default (non-interactive) state of the checklist
|
||||
:param str cli_flag: to automate choice from the menu, eg "--domains"
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: tuple of the form (code, list_tags) where
|
||||
`code` - int display exit code
|
||||
@@ -448,15 +475,21 @@ class IDisplay(zope.interface.Interface):
|
||||
|
||||
"""
|
||||
|
||||
def directory_select(self, message, default=None, cli_flag=None):
|
||||
def directory_select(self, message, default=None,
|
||||
cli_flag=None, force_interactive=False):
|
||||
"""Display a directory selection screen.
|
||||
|
||||
When not setting force_interactive=True, you must provide a
|
||||
default value.
|
||||
|
||||
:param str message: prompt to give the user
|
||||
:param default: the default value to return, if one exists, when
|
||||
using the NoninteractiveDisplay
|
||||
:param str cli_flag: option used to set this value with the CLI,
|
||||
if one exists, to be included in error messages given by
|
||||
NoninteractiveDisplay
|
||||
:param bool force_interactive: True if it's safe to prompt the user
|
||||
because it won't cause any workflow regressions
|
||||
|
||||
:returns: tuple of the form (`code`, `string`) where
|
||||
`code` - int display exit code
|
||||
|
||||
+8
-4
@@ -139,7 +139,8 @@ def _handle_subset_cert_request(config, domains, cert):
|
||||
br=os.linesep)
|
||||
if config.expand or config.renew_by_default or zope.component.getUtility(
|
||||
interfaces.IDisplay).yesno(question, "Expand", "Cancel",
|
||||
cli_flag="--expand"):
|
||||
cli_flag="--expand",
|
||||
force_interactive=True):
|
||||
return "renew", cert
|
||||
else:
|
||||
reporter_util = zope.component.getUtility(interfaces.IReporter)
|
||||
@@ -188,7 +189,8 @@ def _handle_identical_cert_request(config, lineage):
|
||||
"Renew & replace the cert (limit ~5 per 7 days)"]
|
||||
|
||||
display = zope.component.getUtility(interfaces.IDisplay)
|
||||
response = display.menu(question, choices, "OK", "Cancel", default=0)
|
||||
response = display.menu(question, choices, "OK", "Cancel",
|
||||
default=0, force_interactive=True)
|
||||
if response[0] == display_util.CANCEL:
|
||||
# TODO: Add notification related to command-line options for
|
||||
# skipping the menu for this case.
|
||||
@@ -365,7 +367,8 @@ def _determine_account(config):
|
||||
"server at {1}".format(
|
||||
regr.terms_of_service, config.server))
|
||||
obj = zope.component.getUtility(interfaces.IDisplay)
|
||||
return obj.yesno(msg, "Agree", "Cancel", cli_flag="--agree-tos")
|
||||
return obj.yesno(msg, "Agree", "Cancel",
|
||||
cli_flag="--agree-tos", force_interactive=True)
|
||||
|
||||
try:
|
||||
acc, acme = client.register(
|
||||
@@ -788,7 +791,8 @@ def set_displayer(config):
|
||||
elif config.noninteractive_mode:
|
||||
displayer = display_util.NoninteractiveDisplay(sys.stdout)
|
||||
else:
|
||||
displayer = display_util.FileDisplay(sys.stdout)
|
||||
displayer = display_util.FileDisplay(sys.stdout,
|
||||
config.force_interactive)
|
||||
zope.component.provideUtility(displayer)
|
||||
|
||||
|
||||
|
||||
@@ -251,7 +251,8 @@ s.serve_forever()" """
|
||||
if not (self.conf("test-mode") or self.conf("public-ip-logging-ok")):
|
||||
if not zope.component.getUtility(interfaces.IDisplay).yesno(
|
||||
self.IP_DISCLAIMER, "Yes", "No",
|
||||
cli_flag="--manual-public-ip-logging-ok"):
|
||||
cli_flag="--manual-public-ip-logging-ok",
|
||||
force_interactive=True):
|
||||
raise errors.PluginError("Must agree to IP logging to proceed")
|
||||
else:
|
||||
self.config.namespace.manual_public_ip_logging_ok = True
|
||||
|
||||
@@ -111,7 +111,8 @@ def choose_plugin(prepared, question):
|
||||
|
||||
while True:
|
||||
disp = z_util(interfaces.IDisplay)
|
||||
code, index = disp.menu(question, opts, help_label="More Info")
|
||||
code, index = disp.menu(
|
||||
question, opts, help_label="More Info", force_interactive=True)
|
||||
|
||||
if code == display_util.OK:
|
||||
plugin_ep = prepared[index]
|
||||
@@ -127,7 +128,8 @@ def choose_plugin(prepared, question):
|
||||
msg = "Reported Error: %s" % prepared[index].prepare()
|
||||
else:
|
||||
msg = prepared[index].init().more_info()
|
||||
z_util(interfaces.IDisplay).notification(msg)
|
||||
z_util(interfaces.IDisplay).notification(msg,
|
||||
force_interactive=True)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@@ -110,7 +110,8 @@ class ChoosePluginTest(unittest.TestCase):
|
||||
"""Tests for certbot.plugins.selection.choose_plugin."""
|
||||
|
||||
def setUp(self):
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
|
||||
False))
|
||||
self.mock_apache = mock.Mock(
|
||||
description_with_name="a", misconfigured=True)
|
||||
self.mock_stand = mock.Mock(
|
||||
|
||||
@@ -243,13 +243,13 @@ class Authenticator(common.Plugin):
|
||||
"Could not bind TCP port {0} because you don't have "
|
||||
"the appropriate permissions (for example, you "
|
||||
"aren't running this program as "
|
||||
"root).".format(error.port))
|
||||
"root).".format(error.port), force_interactive=True)
|
||||
elif error.socket_error.errno == socket.errno.EADDRINUSE:
|
||||
display.notification(
|
||||
"Could not bind TCP port {0} because it is already in "
|
||||
"use by another process on this system (such as a web "
|
||||
"server). Please stop the program in question and then "
|
||||
"try again.".format(error.port))
|
||||
"try again.".format(error.port), force_interactive=True)
|
||||
else:
|
||||
raise # XXX: How to handle unknown errors in binding?
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ def already_listening_socket(port, renewer=False):
|
||||
"Port {0} is already in use by another process. This will "
|
||||
"prevent us from binding to that port. Please stop the "
|
||||
"process that is populating the port in question and try "
|
||||
"again. {1}".format(port, extra))
|
||||
"again. {1}".format(port, extra), force_interactive=True)
|
||||
return True
|
||||
finally:
|
||||
testsocket.close()
|
||||
@@ -151,7 +151,8 @@ def already_listening_psutil(port, renewer=False):
|
||||
"The program {0} (process ID {1}) is already listening "
|
||||
"on TCP port {2}. This will prevent us from binding to "
|
||||
"that port. Please stop the {0} program temporarily "
|
||||
"and then try again.{3}".format(name, pid, port, extra))
|
||||
"and then try again.{3}".format(name, pid, port, extra),
|
||||
force_interactive=True)
|
||||
return True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
# Perhaps the result of a race where the process could have
|
||||
|
||||
@@ -129,7 +129,8 @@ to serve all files under specified web root ({0})."""
|
||||
"public_html or webroot directory. The webroot "
|
||||
"plugin works by temporarily saving necessary "
|
||||
"resources in the HTTP server's webroot directory "
|
||||
"to pass domain validation challenges.")
|
||||
"to pass domain validation challenges.",
|
||||
force_interactive=True)
|
||||
else: # code == display_util.OK
|
||||
return None if index == 0 else known_webroots[index - 1]
|
||||
|
||||
@@ -138,7 +139,8 @@ to serve all files under specified web root ({0})."""
|
||||
|
||||
while True:
|
||||
code, webroot = display.directory_select(
|
||||
"Input the webroot for {0}:".format(domain))
|
||||
"Input the webroot for {0}:".format(domain),
|
||||
force_interactive=True)
|
||||
if code == display_util.HELP:
|
||||
# Displaying help is not currently implemented
|
||||
return None
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ class Reverter(object):
|
||||
if for_logging:
|
||||
return os.linesep.join(output)
|
||||
zope.component.getUtility(interfaces.IDisplay).notification(
|
||||
os.linesep.join(output))
|
||||
os.linesep.join(output), force_interactive=True)
|
||||
|
||||
def add_to_temp_checkpoint(self, save_files, save_notes):
|
||||
"""Add files to temporary checkpoint.
|
||||
|
||||
@@ -262,6 +262,12 @@ class ParseTest(unittest.TestCase):
|
||||
self.assertFalse(cli.option_was_set(
|
||||
config_dir_option, cli.flag_default(config_dir_option)))
|
||||
|
||||
def test_force_interactive(self):
|
||||
self.assertRaises(
|
||||
errors.Error, self.parse, "renew --force-interactive".split())
|
||||
self.assertRaises(
|
||||
errors.Error, self.parse, "-n --force-interactive".split())
|
||||
|
||||
|
||||
class DefaultTest(unittest.TestCase):
|
||||
"""Tests for certbot.cli._Default."""
|
||||
|
||||
@@ -84,7 +84,8 @@ class GetEmailTest(unittest.TestCase):
|
||||
class ChooseAccountTest(unittest.TestCase):
|
||||
"""Tests for certbot.display.ops.choose_account."""
|
||||
def setUp(self):
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
|
||||
False))
|
||||
|
||||
self.accounts_dir = tempfile.mkdtemp("accounts")
|
||||
self.account_keys_dir = os.path.join(self.accounts_dir, "keys")
|
||||
@@ -127,7 +128,8 @@ class ChooseAccountTest(unittest.TestCase):
|
||||
class GenSSLLabURLs(unittest.TestCase):
|
||||
"""Loose test of _gen_ssl_lab_urls. URL can change easily in the future."""
|
||||
def setUp(self):
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
|
||||
False))
|
||||
|
||||
@classmethod
|
||||
def _call(cls, domains):
|
||||
@@ -146,7 +148,8 @@ class GenSSLLabURLs(unittest.TestCase):
|
||||
class GenHttpsNamesTest(unittest.TestCase):
|
||||
"""Test _gen_https_names."""
|
||||
def setUp(self):
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
|
||||
False))
|
||||
|
||||
@classmethod
|
||||
def _call(cls, domains):
|
||||
@@ -193,7 +196,8 @@ class GenHttpsNamesTest(unittest.TestCase):
|
||||
class ChooseNamesTest(unittest.TestCase):
|
||||
"""Test choose names."""
|
||||
def setUp(self):
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
|
||||
zope.component.provideUtility(display_util.FileDisplay(sys.stdout,
|
||||
False))
|
||||
self.mock_install = mock.MagicMock()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -20,10 +20,11 @@ class FileOutputDisplayTest(unittest.TestCase):
|
||||
functions look to a user, uncomment the test_visual function.
|
||||
|
||||
"""
|
||||
# pylint:disable=too-many-public-methods
|
||||
def setUp(self):
|
||||
super(FileOutputDisplayTest, self).setUp()
|
||||
self.mock_stdout = mock.MagicMock()
|
||||
self.displayer = display_util.FileDisplay(self.mock_stdout)
|
||||
self.displayer = display_util.FileDisplay(self.mock_stdout, False)
|
||||
|
||||
def test_notification_no_pause(self):
|
||||
self.displayer.notification("message", False)
|
||||
@@ -33,56 +34,84 @@ class FileOutputDisplayTest(unittest.TestCase):
|
||||
|
||||
def test_notification_pause(self):
|
||||
with mock.patch("six.moves.input", return_value="enter"):
|
||||
self.displayer.notification("message")
|
||||
self.displayer.notification("message", force_interactive=True)
|
||||
|
||||
self.assertTrue("message" in self.mock_stdout.write.call_args[0][0])
|
||||
|
||||
def test_notification_noninteractive(self):
|
||||
self._force_noninteractive(self.displayer.notification, "message")
|
||||
string = self.mock_stdout.write.call_args[0][0]
|
||||
self.assertTrue("message" in string)
|
||||
|
||||
@mock.patch("certbot.display.util."
|
||||
"FileDisplay._get_valid_int_ans")
|
||||
def test_menu(self, mock_ans):
|
||||
mock_ans.return_value = (display_util.OK, 1)
|
||||
ret = self.displayer.menu("message", CHOICES)
|
||||
ret = self.displayer.menu("message", CHOICES, force_interactive=True)
|
||||
self.assertEqual(ret, (display_util.OK, 0))
|
||||
|
||||
def test_input_cancel(self):
|
||||
with mock.patch("six.moves.input", return_value="c"):
|
||||
code, _ = self.displayer.input("message")
|
||||
code, _ = self.displayer.input("message", force_interactive=True)
|
||||
|
||||
self.assertTrue(code, display_util.CANCEL)
|
||||
|
||||
def test_input_normal(self):
|
||||
with mock.patch("six.moves.input", return_value="domain.com"):
|
||||
code, input_ = self.displayer.input("message")
|
||||
code, input_ = self.displayer.input("message", force_interactive=True)
|
||||
|
||||
self.assertEqual(code, display_util.OK)
|
||||
self.assertEqual(input_, "domain.com")
|
||||
|
||||
def test_input_noninteractive(self):
|
||||
default = "foo"
|
||||
code, input_ = self._force_noninteractive(
|
||||
self.displayer.input, "message", default=default)
|
||||
|
||||
self.assertEqual(code, display_util.OK)
|
||||
self.assertEqual(input_, default)
|
||||
|
||||
def test_input_assertion_fail(self):
|
||||
self.assertRaises(AssertionError, self._force_noninteractive,
|
||||
self.displayer.input, "message", cli_flag="--flag")
|
||||
|
||||
def test_yesno(self):
|
||||
with mock.patch("six.moves.input", return_value="Yes"):
|
||||
self.assertTrue(self.displayer.yesno("message"))
|
||||
self.assertTrue(self.displayer.yesno(
|
||||
"message", force_interactive=True))
|
||||
with mock.patch("six.moves.input", return_value="y"):
|
||||
self.assertTrue(self.displayer.yesno("message"))
|
||||
self.assertTrue(self.displayer.yesno(
|
||||
"message", force_interactive=True))
|
||||
with mock.patch("six.moves.input", side_effect=["maybe", "y"]):
|
||||
self.assertTrue(self.displayer.yesno("message"))
|
||||
self.assertTrue(self.displayer.yesno(
|
||||
"message", force_interactive=True))
|
||||
with mock.patch("six.moves.input", return_value="No"):
|
||||
self.assertFalse(self.displayer.yesno("message"))
|
||||
self.assertFalse(self.displayer.yesno(
|
||||
"message", force_interactive=True))
|
||||
with mock.patch("six.moves.input", side_effect=["cancel", "n"]):
|
||||
self.assertFalse(self.displayer.yesno("message"))
|
||||
self.assertFalse(self.displayer.yesno(
|
||||
"message", force_interactive=True))
|
||||
|
||||
with mock.patch("six.moves.input", return_value="a"):
|
||||
self.assertTrue(self.displayer.yesno("msg", yes_label="Agree"))
|
||||
self.assertTrue(self.displayer.yesno(
|
||||
"msg", yes_label="Agree", force_interactive=True))
|
||||
|
||||
def test_yesno_noninteractive(self):
|
||||
self.assertTrue(self._force_noninteractive(
|
||||
self.displayer.yesno, "message", default=True))
|
||||
|
||||
@mock.patch("certbot.display.util.FileDisplay.input")
|
||||
def test_checklist_valid(self, mock_input):
|
||||
mock_input.return_value = (display_util.OK, "2 1")
|
||||
code, tag_list = self.displayer.checklist("msg", TAGS)
|
||||
code, tag_list = self.displayer.checklist(
|
||||
"msg", TAGS, force_interactive=True)
|
||||
self.assertEqual(
|
||||
(code, set(tag_list)), (display_util.OK, set(["tag1", "tag2"])))
|
||||
|
||||
@mock.patch("certbot.display.util.FileDisplay.input")
|
||||
def test_checklist_empty(self, mock_input):
|
||||
mock_input.return_value = (display_util.OK, "")
|
||||
code, tag_list = self.displayer.checklist("msg", TAGS)
|
||||
code, tag_list = self.displayer.checklist("msg", TAGS, force_interactive=True)
|
||||
self.assertEqual(
|
||||
(code, set(tag_list)), (display_util.OK, set(["tag1", "tag2", "tag3"])))
|
||||
|
||||
@@ -94,7 +123,7 @@ class FileOutputDisplayTest(unittest.TestCase):
|
||||
(display_util.OK, "1")
|
||||
]
|
||||
|
||||
ret = self.displayer.checklist("msg", TAGS)
|
||||
ret = self.displayer.checklist("msg", TAGS, force_interactive=True)
|
||||
self.assertEqual(ret, (display_util.OK, ["tag1"]))
|
||||
|
||||
@mock.patch("certbot.display.util.FileDisplay.input")
|
||||
@@ -103,9 +132,17 @@ class FileOutputDisplayTest(unittest.TestCase):
|
||||
(display_util.OK, "10"),
|
||||
(display_util.CANCEL, "1")
|
||||
]
|
||||
ret = self.displayer.checklist("msg", TAGS)
|
||||
ret = self.displayer.checklist("msg", TAGS, force_interactive=True)
|
||||
self.assertEqual(ret, (display_util.CANCEL, []))
|
||||
|
||||
def test_checklist_noninteractive(self):
|
||||
default = TAGS
|
||||
code, input_ = self._force_noninteractive(
|
||||
self.displayer.checklist, "msg", TAGS, default=default)
|
||||
|
||||
self.assertEqual(code, display_util.OK)
|
||||
self.assertEqual(input_, default)
|
||||
|
||||
def test_scrub_checklist_input_valid(self):
|
||||
# pylint: disable=protected-access
|
||||
indices = [
|
||||
@@ -125,12 +162,36 @@ class FileOutputDisplayTest(unittest.TestCase):
|
||||
|
||||
@mock.patch("certbot.display.util.FileDisplay.input")
|
||||
def test_directory_select(self, mock_input):
|
||||
message = "msg"
|
||||
result = (display_util.OK, "/var/www/html",)
|
||||
# pylint: disable=star-args
|
||||
args = ["msg", "/var/www/html", "--flag", True]
|
||||
result = (display_util.OK, "/var/www/html")
|
||||
mock_input.return_value = result
|
||||
|
||||
self.assertEqual(self.displayer.directory_select(message), result)
|
||||
mock_input.assert_called_once_with(message)
|
||||
self.assertEqual(self.displayer.directory_select(*args), result)
|
||||
mock_input.assert_called_once_with(*args)
|
||||
|
||||
def test_directory_select_noninteractive(self):
|
||||
default = "/var/www/html"
|
||||
code, input_ = self._force_noninteractive(
|
||||
self.displayer.directory_select, "msg", default=default)
|
||||
|
||||
self.assertEqual(code, display_util.OK)
|
||||
self.assertEqual(input_, default)
|
||||
|
||||
def _force_noninteractive(self, func, *args, **kwargs):
|
||||
skipped_interaction = self.displayer.skipped_interaction
|
||||
|
||||
with mock.patch("certbot.display.util.sys.stdin") as mock_stdin:
|
||||
mock_stdin.isatty.return_value = False
|
||||
with mock.patch("certbot.display.util.logger") as mock_logger:
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
if skipped_interaction:
|
||||
self.assertFalse(mock_logger.warning.called)
|
||||
else:
|
||||
self.assertEqual(mock_logger.warning.call_count, 1)
|
||||
|
||||
return result
|
||||
|
||||
def test_scrub_checklist_input_invalid(self):
|
||||
# pylint: disable=protected-access
|
||||
|
||||
+1
-1
@@ -18,5 +18,5 @@ def test_visual(displayer, choices):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
displayer = util.FileDisplay(sys.stdout):
|
||||
displayer = util.FileDisplay(sys.stdout, False)
|
||||
test_visual(displayer, util_test.CHOICES)
|
||||
|
||||
Reference in New Issue
Block a user