Merge pull request #2741 from letsencrypt/reset_by_cli

Fix plugin default detection
This commit is contained in:
Peter Eckersley
2016-04-02 12:17:57 -07:00
4 changed files with 191 additions and 90 deletions
+109 -80
View File
@@ -87,6 +87,48 @@ More detailed help:
""" """
# These argparse parameters should be removed when detecting defaults.
ARGPARSE_PARAMS_TO_REMOVE = ("const", "nargs", "type",)
# These sets are used when to help detect options set by the user.
EXIT_ACTIONS = set(("help", "version",))
ZERO_ARG_ACTIONS = set(("store_const", "store_true",
"store_false", "append_const", "count",))
# Maps a config option to a set of config options that may have modified it.
# This dictionary is used recursively, so if A modifies B and B modifies C,
# it is determined that C was modified by the user if A was modified.
VAR_MODIFIERS = {"account": set(("server",)),
"server": set(("dry_run", "staging",)),
"webroot_map": set(("webroot_path",))}
def report_config_interaction(modified, modifiers):
"""Registers config option interaction to be checked by set_by_cli.
This function can be called by during the __init__ or
add_parser_arguments methods of plugins to register interactions
between config options.
:param modified: config options that can be modified by modifiers
:type modified: iterable or str
:param modifiers: config options that modify modified
:type modifiers: iterable or str
"""
if isinstance(modified, str):
modified = (modified,)
if isinstance(modifiers, str):
modifiers = (modifiers,)
for var in modified:
VAR_MODIFIERS.setdefault(var, set()).update(modifiers)
def usage_strings(plugins): def usage_strings(plugins):
"""Make usage strings late so that plugins can be initialised late""" """Make usage strings late so that plugins can be initialised late"""
if "nginx" in plugins: if "nginx" in plugins:
@@ -100,6 +142,22 @@ def usage_strings(plugins):
return USAGE % (apache_doc, nginx_doc), SHORT_USAGE return USAGE % (apache_doc, nginx_doc), SHORT_USAGE
class _Default(object):
"""A class to use as a default to detect if a value is set by a user"""
def __bool__(self):
return False
def __eq__(self, other):
return isinstance(other, _Default)
def __hash__(self):
return id(_Default)
def __nonzero__(self):
return self.__bool__()
def set_by_cli(var): def set_by_cli(var):
""" """
Return True if a particular config variable has been set by the user Return True if a particular config variable has been set by the user
@@ -116,30 +174,18 @@ def set_by_cli(var):
detector = set_by_cli.detector = prepare_and_parse_args( detector = set_by_cli.detector = prepare_and_parse_args(
plugins, reconstructed_args, detect_defaults=True) plugins, reconstructed_args, detect_defaults=True)
# propagate plugin requests: eg --standalone modifies config.authenticator # propagate plugin requests: eg --standalone modifies config.authenticator
auth, inst = plugin_selection.cli_plugin_requests(detector) detector.authenticator, detector.installer = (
detector.authenticator = auth if auth else "" plugin_selection.cli_plugin_requests(detector))
detector.installer = inst if inst else ""
logger.debug("Default Detector is %r", detector) logger.debug("Default Detector is %r", detector)
try: if not isinstance(getattr(detector, var), _Default):
# Is detector.var something that isn't false? return True
change_detected = getattr(detector, var)
except AttributeError:
logger.warning("Missing default analysis for %r", var)
return False
if change_detected: for modifier in VAR_MODIFIERS.get(var, []):
return True if set_by_cli(modifier):
# Special case: we actually want account to be set to "" if the server return True
# the account was on has changed
elif var == "account" and (detector.server or detector.dry_run or detector.staging): return False
return True
# Special case: vars like --no-redirect that get set True -> False
# default to None; False means they were set
elif var in detector.store_false_vars and change_detected is not None:
return True
else:
return False
# static housekeeping var # static housekeeping var
set_by_cli.detector = None set_by_cli.detector = None
@@ -188,21 +234,23 @@ def config_help(name, hidden=False):
return interfaces.IConfig[name].__doc__ return interfaces.IConfig[name].__doc__
class SilentParser(object): # pylint: disable=too-few-public-methods class HelpfulArgumentGroup(object):
"""Silent wrapper around argparse. """Emulates an argparse group for use with HelpfulArgumentParser.
A mini parser wrapper that doesn't print help for its This class is used in the add_group method of HelpfulArgumentParser.
arguments. This is needed for the use of callbacks to define Command line arguments can be added to the group, but help
arguments within plugins. suppression and default detection is applied by
HelpfulArgumentParser when necessary.
""" """
def __init__(self, parser): def __init__(self, helpful_arg_parser, topic):
self.parser = parser self._parser = helpful_arg_parser
self._topic = topic
def add_argument(self, *args, **kwargs): def add_argument(self, *args, **kwargs):
"""Wrap, but silence help""" """Add a new command line argument to the argument group."""
kwargs["help"] = argparse.SUPPRESS self._parser.add(self._topic, *args, **kwargs)
self.parser.add_argument(*args, **kwargs)
class HelpfulArgumentParser(object): class HelpfulArgumentParser(object):
"""Argparse Wrapper. """Argparse Wrapper.
@@ -235,15 +283,8 @@ class HelpfulArgumentParser(object):
# This is the only way to turn off overly verbose config flag documentation # This is the only way to turn off overly verbose config flag documentation
self.parser._add_config_file_help = False # pylint: disable=protected-access self.parser._add_config_file_help = False # pylint: disable=protected-access
self.silent_parser = SilentParser(self.parser)
# This setting attempts to force all default values to things that are
# pythonically false; it is used to detect when values have been
# explicitly set by the user, including when they are set to their
# normal default value
self.detect_defaults = detect_defaults self.detect_defaults = detect_defaults
if detect_defaults:
self.store_false_vars = {} # vars that use "store_false"
self.args = args self.args = args
self.determine_verb() self.determine_verb()
@@ -269,6 +310,9 @@ class HelpfulArgumentParser(object):
parsed_args.func = self.VERBS[self.verb] parsed_args.func = self.VERBS[self.verb]
parsed_args.verb = self.verb parsed_args.verb = self.verb
if self.detect_defaults:
return parsed_args
# Do any post-parsing homework here # Do any post-parsing homework here
# we get domains from -d, but also from the webroot map... # we get domains from -d, but also from the webroot map...
@@ -304,9 +348,6 @@ class HelpfulArgumentParser(object):
"cannot be used with --csr") "cannot be used with --csr")
self.handle_csr(parsed_args) self.handle_csr(parsed_args)
if self.detect_defaults: # plumbing
parsed_args.store_false_vars = self.store_false_vars
hooks.validate_hooks(parsed_args) hooks.validate_hooks(parsed_args)
return parsed_args return parsed_args
@@ -415,7 +456,7 @@ class HelpfulArgumentParser(object):
""" """
if self.detect_defaults: if self.detect_defaults:
kwargs = self.modify_arg_for_default_detection(self, *args, **kwargs) kwargs = self.modify_kwargs_for_default_detection(**kwargs)
if self.visible_topics[topic]: if self.visible_topics[topic]:
if topic in self.groups: if topic in self.groups:
@@ -427,39 +468,28 @@ class HelpfulArgumentParser(object):
kwargs["help"] = argparse.SUPPRESS kwargs["help"] = argparse.SUPPRESS
self.parser.add_argument(*args, **kwargs) self.parser.add_argument(*args, **kwargs)
def modify_kwargs_for_default_detection(self, **kwargs):
"""Modify an arg so we can check if it was set by the user.
def modify_arg_for_default_detection(self, *args, **kwargs): Changes the parameters given to argparse when adding an argument
""" so we can properly detect if the value was set by the user.
Adding an arg, but ensure that it has a default that evaluates to false,
so that set_by_cli can tell if it was set. Only called if detect_defaults==True.
:param list *args: the names of this argument flag :param dict kwargs: various argparse settings for this argument
:param dict **kwargs: various argparse settings for this argument
:returns: a modified versions of kwargs :returns: a modified versions of kwargs
:rtype: dict
""" """
# argument either doesn't have a default, or the default doesn't action = kwargs.get("action", None)
# isn't Pythonically false if action not in EXIT_ACTIONS:
if kwargs.get("default", True): kwargs["action"] = ("store_true" if action in ZERO_ARG_ACTIONS else
arg_type = kwargs.get("type", None) "store")
if arg_type == int or kwargs.get("action", "") == "count": kwargs["default"] = _Default()
kwargs["default"] = 0 for param in ARGPARSE_PARAMS_TO_REMOVE:
elif arg_type == read_file or "-c" in args: kwargs.pop(param, None)
kwargs["default"] = ""
kwargs["type"] = str
else:
kwargs["default"] = ""
# This doesn't matter at present (none of the store_false args
# are renewal-relevant), but implement it for future sanity:
# detect the setting of args whose presence causes True -> False
if kwargs.get("action", "") == "store_false":
kwargs["default"] = None
for var in args:
self.store_false_vars[var] = True
return kwargs return kwargs
def add_deprecated_argument(self, argument_name, num_args): def add_deprecated_argument(self, argument_name, num_args):
"""Adds a deprecated argument with the name argument_name. """Adds a deprecated argument with the name argument_name.
@@ -475,22 +505,22 @@ class HelpfulArgumentParser(object):
self.parser.add_argument, argument_name, num_args) self.parser.add_argument, argument_name, num_args)
def add_group(self, topic, **kwargs): def add_group(self, topic, **kwargs):
""" """Create a new argument group.
This has to be called once for every topic; but we leave those calls This method must be called once for every topic, however, calls
next to the argument definitions for clarity. Return something to this function are left next to the argument definitions for
arguments can be added to if necessary, either the parser or an argument clarity.
group.
:param str topic: Name of the new argument group.
:returns: The new argument group.
:rtype: `HelpfulArgumentGroup`
""" """
if self.visible_topics[topic]: if self.visible_topics[topic]:
#print("Adding visible group " + topic) self.groups[topic] = self.parser.add_argument_group(topic, **kwargs)
group = self.parser.add_argument_group(topic, **kwargs)
self.groups[topic] = group return HelpfulArgumentGroup(self, topic)
return group
else:
#print("Invisible group " + topic)
return self.silent_parser
def add_plugin_args(self, plugins): def add_plugin_args(self, plugins):
""" """
@@ -501,7 +531,6 @@ class HelpfulArgumentParser(object):
""" """
for name, plugin_ep in six.iteritems(plugins): for name, plugin_ep in six.iteritems(plugins):
parser_or_group = self.add_group(name, description=plugin_ep.description) parser_or_group = self.add_group(name, description=plugin_ep.description)
#print(parser_or_group)
plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name) plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name)
def determine_help_topics(self, chosen_topic): def determine_help_topics(self, chosen_topic):
+4 -5
View File
@@ -45,15 +45,14 @@ class Plugin(object):
def add_parser_arguments(cls, add): def add_parser_arguments(cls, add):
"""Add plugin arguments to the CLI argument parser. """Add plugin arguments to the CLI argument parser.
NOTE: If some of your flags interact with others, you can
use cli.report_config_interaction to register this to ensure
values are correctly saved/overridable during renewal.
:param callable add: Function that proxies calls to :param callable add: Function that proxies calls to
`argparse.ArgumentParser.add_argument` prepending options `argparse.ArgumentParser.add_argument` prepending options
with unique plugin name prefix. with unique plugin name prefix.
NOTE: if you add argpase arguments such that users setting them can
create a config entry that python's bool() would consider false (ie,
the use might set the variable to "", [], 0, etc), please ensure that
cli.set_by_cli() works for your variable.
""" """
@classmethod @classmethod
+3 -5
View File
@@ -102,16 +102,14 @@ def _restore_webroot_config(config, renewalparams):
form. form.
""" """
if "webroot_map" in renewalparams: if "webroot_map" in renewalparams:
# if the user does anything that would create a new webroot map on the if not cli.set_by_cli("webroot_map"):
# CLI, don't use the old one config.namespace.webroot_map = renewalparams["webroot_map"]
if not (cli.set_by_cli("webroot_map") or cli.set_by_cli("webroot_path")):
setattr(config.namespace, "webroot_map", renewalparams["webroot_map"])
elif "webroot_path" in renewalparams: elif "webroot_path" in renewalparams:
logger.info("Ancient renewal conf file without webroot-map, restoring webroot-path") logger.info("Ancient renewal conf file without webroot-map, restoring webroot-path")
wp = renewalparams["webroot_path"] wp = renewalparams["webroot_path"]
if isinstance(wp, str): # prior to 0.1.0, webroot_path was a string if isinstance(wp, str): # prior to 0.1.0, webroot_path was a string
wp = [wp] wp = [wp]
setattr(config.namespace, "webroot_path", wp) config.namespace.webroot_path = wp
def _restore_plugin_configs(config, renewalparams): def _restore_plugin_configs(config, renewalparams):
+75
View File
@@ -12,6 +12,7 @@ import unittest
import mock import mock
import six import six
from six.moves import reload_module # pylint: disable=import-error
from acme import jose from acme import jose
@@ -1023,5 +1024,79 @@ class DuplicativeCertsTest(storage_test.BaseRenewableCertTest):
self.assertEqual(result, (None, None)) self.assertEqual(result, (None, None))
class DefaultTest(unittest.TestCase):
"""Tests for letsencrypt.cli._Default."""
def setUp(self):
# pylint: disable=protected-access
self.default1 = cli._Default()
self.default2 = cli._Default()
def test_boolean(self):
self.assertFalse(self.default1)
self.assertFalse(self.default2)
def test_equality(self):
self.assertEqual(self.default1, self.default2)
def test_hash(self):
self.assertEqual(hash(self.default1), hash(self.default2))
class SetByCliTest(unittest.TestCase):
"""Tests for letsencrypt.set_by_cli and related functions."""
def setUp(self):
reload_module(cli)
def test_webroot_map(self):
args = '-w /var/www/html -d example.com'.split()
verb = 'renew'
self.assertTrue(_call_set_by_cli('webroot_map', args, verb))
def test_report_config_interaction_str(self):
cli.report_config_interaction('manual_public_ip_logging_ok',
'manual_test_mode')
cli.report_config_interaction('manual_test_mode', 'manual')
self._test_report_config_interaction_common()
def test_report_config_interaction_iterable(self):
cli.report_config_interaction(('manual_public_ip_logging_ok',),
('manual_test_mode',))
cli.report_config_interaction(('manual_test_mode',), ('manual',))
self._test_report_config_interaction_common()
def _test_report_config_interaction_common(self):
"""Tests implied interaction between manual flags.
--manual implies --manual-test-mode which implies
--manual-public-ip-logging-ok. These interactions don't actually
exist in the client, but are used here for testing purposes.
"""
args = ['--manual']
verb = 'renew'
for v in ('manual', 'manual_test_mode', 'manual_public_ip_logging_ok'):
self.assertTrue(_call_set_by_cli(v, args, verb))
cli.set_by_cli.detector = None
args = ['--manual-test-mode']
for v in ('manual_test_mode', 'manual_public_ip_logging_ok'):
self.assertTrue(_call_set_by_cli(v, args, verb))
self.assertFalse(_call_set_by_cli('manual', args, verb))
def _call_set_by_cli(var, args, verb):
with mock.patch('letsencrypt.cli.helpful_parser') as mock_parser:
mock_parser.args = args
mock_parser.verb = verb
return cli.set_by_cli(var)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() # pragma: no cover unittest.main() # pragma: no cover