summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChris St. Pierre <chris.a.st.pierre@gmail.com>2014-09-16 15:50:04 -0700
committerChris St. Pierre <chris.a.st.pierre@gmail.com>2014-10-22 13:11:55 -0500
commit5c0b8c2b0229992671e076e74c1256a880381d62 (patch)
treed112dd9129e8e44fd17633fe13d2a359bdbeebbc
parent56d2b72b6f8b2e1a27a16dbfdab10f0b83816ceb (diff)
downloadbcfg2-5c0b8c2b0229992671e076e74c1256a880381d62.tar.gz
bcfg2-5c0b8c2b0229992671e076e74c1256a880381d62.tar.bz2
bcfg2-5c0b8c2b0229992671e076e74c1256a880381d62.zip
testsuite: Added unit tests for new option parsing
-rw-r--r--doc/development/option_parsing.txt15
-rw-r--r--src/lib/Bcfg2/Options/Actions.py12
-rw-r--r--src/lib/Bcfg2/Options/OptionGroups.py61
-rw-r--r--src/lib/Bcfg2/Options/Options.py128
-rw-r--r--src/lib/Bcfg2/Options/Parser.py94
-rw-r--r--src/lib/Bcfg2/Options/Subcommands.py153
-rw-r--r--src/lib/Bcfg2/Options/Types.py10
-rwxr-xr-xsrc/lib/Bcfg2/Reporting/Reports.py3
-rw-r--r--src/lib/Bcfg2/Server/Admin.py14
-rw-r--r--src/lib/Bcfg2/Server/Info.py25
-rw-r--r--src/lib/Bcfg2/Server/Plugins/Packages/YumHelper.py4
-rw-r--r--testsuite/Testsrc/Testlib/TestClient/TestTools/Test_init.py19
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/One.py6
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/TestComponents.py210
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/TestConfigFiles.py48
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/TestOptionGroups.py145
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/TestOptions.py469
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/TestSubcommands.py141
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/TestTypes.py111
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/TestWildcards.py47
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/Two.py6
-rw-r--r--testsuite/Testsrc/Testlib/TestOptions/__init__.py85
-rw-r--r--testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testbase.py1
-rw-r--r--testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py1
-rw-r--r--testsuite/Testsrc/Testlib/TestUtils.py4
-rw-r--r--testsuite/Testsrc/__init__.py0
26 files changed, 1593 insertions, 219 deletions
diff --git a/doc/development/option_parsing.txt b/doc/development/option_parsing.txt
index 091f43cdd..642b9a36c 100644
--- a/doc/development/option_parsing.txt
+++ b/doc/development/option_parsing.txt
@@ -174,28 +174,31 @@ The normal implementation pattern is this:
#. Define all of your subcommands as children of
:class:`Bcfg2.Options.Subcommand`.
-#. Define a :class:`Bcfg2.Options.CommandRegistry` object that will be
+#. Create a :class:`Bcfg2.Options.CommandRegistry` object that will be
used to register all of the commands. Registering a command
collect its options and adds it as a
:class:`Bcfg2.Options.Subparser` option group to the main option
parser.
#. Register your commands with
- :func:`Bcfg2.Options.register_commands`, parse options, and run.
+ :func:`Bcfg2.Options.register_commands`.
+#. Add options from the
+ :attr:`Bcfg2.Options.CommandRegistry.command_options`
+ attribute to the option parser.
+#. Parse options, and run.
:mod:`Bcfg2.Server.Admin` provides a fairly simple implementation,
-where the CLI class is itself the command registry:
+where the CLI class subclasses the command registry:
.. code-block:: python
class CLI(Bcfg2.Options.CommandRegistry):
def __init__(self):
Bcfg2.Options.CommandRegistry.__init__(self)
- Bcfg2.Options.register_commands(self.__class__,
- globals().values(),
- parent=AdminCmd)
+ self.register_commands(globals().values(), parent=AdminCmd)
parser = Bcfg2.Options.get_parser(
description="Manage a running Bcfg2 server",
components=[self])
+ parser.add_options(self.subcommand_options)
parser.parse()
In this case, commands are collected from amongst all global variables
diff --git a/src/lib/Bcfg2/Options/Actions.py b/src/lib/Bcfg2/Options/Actions.py
index 8b941f2bb..2916cc194 100644
--- a/src/lib/Bcfg2/Options/Actions.py
+++ b/src/lib/Bcfg2/Options/Actions.py
@@ -2,7 +2,8 @@
import sys
import argparse
-from Bcfg2.Options.Parser import get_parser
+from Bcfg2.Options.Parser import get_parser, OptionParserException
+from Bcfg2.Options.Options import _debug
__all__ = ["ConfigFileAction", "ComponentAction", "PluginsAction"]
@@ -101,7 +102,7 @@ class ComponentAction(FinalizableAction):
fail_silently = False
def __init__(self, *args, **kwargs):
- if self.mapping:
+ if self.mapping and not self.islist:
if 'choices' not in kwargs:
kwargs['choices'] = self.mapping.keys()
FinalizableAction.__init__(self, *args, **kwargs)
@@ -143,7 +144,7 @@ class ComponentAction(FinalizableAction):
if cls:
get_parser().add_component(cls)
elif not self.fail_silently:
- print("Could not load component %s" % name)
+ raise OptionParserException("Could not load component %s" % name)
return cls
def __call__(self, parser, namespace, values, option_string=None):
@@ -168,7 +169,10 @@ class ConfigFileAction(FinalizableAction):
``bcfg2-lint.conf``). """
def __call__(self, parser, namespace, values, option_string=None):
- parser.add_config_file(self.dest, values, reparse=False)
+ if values:
+ parser.add_config_file(self.dest, values, reparse=False)
+ else:
+ _debug("No config file passed for %s" % self)
FinalizableAction.__call__(self, parser, namespace, values,
option_string=option_string)
diff --git a/src/lib/Bcfg2/Options/OptionGroups.py b/src/lib/Bcfg2/Options/OptionGroups.py
index 465358fab..426ee5dc0 100644
--- a/src/lib/Bcfg2/Options/OptionGroups.py
+++ b/src/lib/Bcfg2/Options/OptionGroups.py
@@ -10,7 +10,7 @@ __all__ = ["OptionGroup", "ExclusiveOptionGroup", "Subparser",
"WildcardSectionGroup"]
-class OptionContainer(list):
+class _OptionContainer(list):
""" Parent class of all option groups """
def list_options(self):
@@ -29,7 +29,7 @@ class OptionContainer(list):
opt.add_to_parser(parser)
-class OptionGroup(OptionContainer):
+class OptionGroup(_OptionContainer):
""" Generic option group that is used only to organize options.
This uses :meth:`argparse.ArgumentParser.add_argument_group`
behind the scenes. """
@@ -43,16 +43,16 @@ class OptionGroup(OptionContainer):
:param description: A longer description of the option group
:param description: string
"""
- OptionContainer.__init__(self, items)
+ _OptionContainer.__init__(self, items)
self.title = kwargs.pop('title')
self.description = kwargs.pop('description', None)
def add_to_parser(self, parser):
group = parser.add_argument_group(self.title, self.description)
- OptionContainer.add_to_parser(self, group)
+ _OptionContainer.add_to_parser(self, group)
-class ExclusiveOptionGroup(OptionContainer):
+class ExclusiveOptionGroup(_OptionContainer):
""" Option group that ensures that only one argument in the group
is present. This uses
:meth:`argparse.ArgumentParser.add_mutually_exclusive_group`
@@ -66,15 +66,15 @@ class ExclusiveOptionGroup(OptionContainer):
specified.
:type required: boolean
"""
- OptionContainer.__init__(self, items)
+ _OptionContainer.__init__(self, items)
self.required = kwargs.pop('required', False)
def add_to_parser(self, parser):
group = parser.add_mutually_exclusive_group(required=self.required)
- OptionContainer.add_to_parser(self, group)
+ _OptionContainer.add_to_parser(self, group)
-class Subparser(OptionContainer):
+class Subparser(_OptionContainer):
""" Option group that adds options in it to a subparser. This
uses a lot of functionality tied to `argparse Sub-commands
<http://docs.python.org/dev/library/argparse.html#sub-commands>`_.
@@ -99,7 +99,7 @@ class Subparser(OptionContainer):
"""
self.name = kwargs.pop('name')
self.help = kwargs.pop('help', None)
- OptionContainer.__init__(self, items)
+ _OptionContainer.__init__(self, items)
def __repr__(self):
return "%s %s(%s)" % (self.__class__.__name__,
@@ -111,11 +111,11 @@ class Subparser(OptionContainer):
self._subparsers[parser] = parser.add_subparsers(dest='subcommand')
subparser = self._subparsers[parser].add_parser(self.name,
help=self.help)
- OptionContainer.add_to_parser(self, subparser)
+ _OptionContainer.add_to_parser(self, subparser)
-class WildcardSectionGroup(OptionContainer, Option):
- """ WildcardSectionGroups contain options that may exist in
+class WildcardSectionGroup(_OptionContainer, Option):
+ """WildcardSectionGroups contain options that may exist in
several different sections of the config that match a glob. It
works by creating options on the fly to match the sections
described in the glob. For example, consider:
@@ -134,7 +134,7 @@ class WildcardSectionGroup(OptionContainer, Option):
.. code-block:: python
>>> Bcfg2.Options.setup
- Namespace(myplugin_bar_description='Bar description', myplugin_bar_number=2, myplugin_foo_description='Foo description', myplugin_foo_number=1, myplugin_sections=['myplugin:foo', 'myplugin:bar'])
+ Namespace(myplugin_bar_description='Bar description', myplugin_myplugin_bar_number=2, myplugin_myplugin_foo_description='Foo description', myplugin_myplugin_foo_number=1, myplugin_sections=['myplugin:foo', 'myplugin:bar'])
All options must have the same section glob.
@@ -146,10 +146,10 @@ class WildcardSectionGroup(OptionContainer, Option):
``<destination>`` is the original `dest
<http://docs.python.org/dev/library/argparse.html#dest>`_ of the
option. ``<section>`` is the section that it's found in.
- ``<prefix>`` is automatically generated from the section glob by
- replacing all consecutive characters disallowed in Python variable
- names into underscores. (This can be overridden with the
- constructor.)
+ ``<prefix>`` is automatically generated from the section glob.
+ (This can be overridden with the constructor.) Both ``<section>``
+ and ``<prefix>`` have had all consecutive characters disallowed in
+ Python variable names replaced with underscores.
This group stores an additional option, the sections themselves,
in an option given by ``<prefix>sections``.
@@ -171,17 +171,17 @@ class WildcardSectionGroup(OptionContainer, Option):
that match the glob.
:param dest: string
"""
- OptionContainer.__init__(self, [])
+ _OptionContainer.__init__(self, [])
self._section_glob = items[0].cf[0]
# get a default destination
self._prefix = kwargs.get("prefix",
self._dest_re.sub('_', self._section_glob))
Option.__init__(self, dest=kwargs.get('dest',
self._prefix + "sections"))
- self._options = items
+ self.option_templates = items
def list_options(self):
- return [self] + OptionContainer.list_options(self)
+ return [self] + _OptionContainer.list_options(self)
def from_config(self, cfp):
sections = []
@@ -189,10 +189,12 @@ class WildcardSectionGroup(OptionContainer, Option):
if fnmatch.fnmatch(section, self._section_glob):
sections.append(section)
newopts = []
- for opt_tmpl in self._options:
+ for opt_tmpl in self.option_templates:
option = copy.deepcopy(opt_tmpl)
option.cf = (section, option.cf[1])
- option.dest = self._prefix + section + "_" + option.dest
+ option.dest = "%s%s_%s" % (self._prefix,
+ self._dest_re.sub('_', section),
+ option.dest)
newopts.append(option)
self.extend(newopts)
for parser in self.parsers:
@@ -201,4 +203,17 @@ class WildcardSectionGroup(OptionContainer, Option):
def add_to_parser(self, parser):
Option.add_to_parser(self, parser)
- OptionContainer.add_to_parser(self, parser)
+ _OptionContainer.add_to_parser(self, parser)
+
+ def __eq__(self, other):
+ return (_OptionContainer.__eq__(self, other) and
+ self.option_templates == getattr(other, "option_templates",
+ None))
+
+ def __repr__(self):
+ if len(self) == 0:
+ return "%s(%s)" % (self.__class__.__name__,
+ ", ".join(".".join(o.cf)
+ for o in self.option_templates))
+ else:
+ return _OptionContainer.__repr__(self)
diff --git a/src/lib/Bcfg2/Options/Options.py b/src/lib/Bcfg2/Options/Options.py
index 3874f810d..4efd76929 100644
--- a/src/lib/Bcfg2/Options/Options.py
+++ b/src/lib/Bcfg2/Options/Options.py
@@ -1,11 +1,15 @@
-""" The base :class:`Bcfg2.Options.Option` object represents an
-option. Unlike options in :mod:`argparse`, an Option object does not
-need to be associated with an option parser; it exists on its own."""
+"""Base :class:`Bcfg2.Options.Option` object to represent an option.
-import os
+Unlike options in :mod:`argparse`, an Option object does not need to
+be associated with an option parser; it exists on its own.
+"""
+
+import argparse
import copy
import fnmatch
-import argparse
+import os
+import sys
+
from Bcfg2.Options import Types
from Bcfg2.Compat import ConfigParser
@@ -19,8 +23,9 @@ def _debug(msg):
they're options, after all -- so option parsing verbosity is
enabled by changing this to True. The verbosity here is primarily
of use to developers. """
- if os.environ.get('BCFG2_OPTIONS_DEBUG', '0') == '1':
- print(msg)
+ if os.environ.get('BCFG2_OPTIONS_DEBUG', '0').lower() in ["true", "yes",
+ "on", "1"]:
+ sys.stderr.write("%s\n" % msg)
#: A dict that records a mapping of argparse action name (e.g.,
@@ -133,10 +138,11 @@ class Option(object):
self._dest = None
if 'dest' in self._kwargs:
self._dest = self._kwargs.pop('dest')
- elif self.cf is not None:
- self._dest = self.cf[1]
elif self.env is not None:
self._dest = self.env
+ elif self.cf is not None:
+ self._dest = self.cf[1]
+ self._dest = self._dest.lower().replace("-", "_")
kwargs = copy.copy(self._kwargs)
kwargs.pop("action", None)
self.actions[None] = action_cls(self._dest, self._dest, **kwargs)
@@ -175,6 +181,17 @@ class Option(object):
_debug("Finalizing %s" % self)
action.finalize(parser, namespace)
+ @property
+ def _type_func(self):
+ """get a function for converting a value to the option type.
+
+ this always returns a callable, even when ``type`` is None.
+ """
+ if self.type:
+ return self.type
+ else:
+ return lambda x: x
+
def from_config(self, cfp):
""" Get the value of this option from the given
:class:`ConfigParser.ConfigParser`. If it is not found in the
@@ -201,21 +218,33 @@ class Option(object):
self.cf[1])
if o not in exclude])
else:
- rv = dict()
+ rv = {}
else:
- if self.type:
- rtype = self.type
- else:
- rtype = lambda x: x
try:
- rv = rtype(cfp.getboolean(*self.cf))
- except ValueError:
- rv = rtype(cfp.get(*self.cf))
+ rv = self._type_func(self.get_config_value(cfp))
except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
rv = None
_debug("Setting %s from config file(s): %s" % (self, rv))
return rv
+ def get_config_value(self, cfp):
+ """fetch a value from the config file.
+
+ This is passed the config parser. Its result is passed to the
+ type function for this option. It can be overridden to, e.g.,
+ handle boolean options.
+ """
+ return cfp.get(*self.cf)
+
+ def get_environ_value(self, value):
+ """fetch a value from the environment.
+
+ This is passed the raw value from the environment variable,
+ and its result is passed to the type function for this
+ option. It can be overridden to, e.g., handle boolean options.
+ """
+ return value
+
def default_from_config(self, cfp):
""" Set the default value of this option from the config file
or from the environment.
@@ -224,7 +253,8 @@ class Option(object):
:type cfp: ConfigParser.ConfigParser
"""
if self.env and self.env in os.environ:
- self.default = os.environ[self.env]
+ self.default = self._type_func(
+ self.get_environ_value(os.environ[self.env]))
_debug("Setting the default of %s from environment: %s" %
(self, self.default))
else:
@@ -257,6 +287,13 @@ class Option(object):
for action in self.actions.values():
action.dest = value
+ def early_parsing_hook(self, early_opts): # pylint: disable=C0111
+ """Hook called at the end of early option parsing.
+
+ This can be used to save option values for macro fixup.
+ """
+ pass
+
#: The namespace destination of this option (see `dest
#: <http://docs.python.org/dev/library/argparse.html#dest>`_)
dest = property(_get_dest, _set_dest)
@@ -289,8 +326,8 @@ class PathOption(Option):
:meth:`Bcfg2.Options.Types.path` to transform the argument into a
canonical path.
- The type of a path option can also be overridden to return an
- option file-like object. For example:
+ The type of a path option can also be overridden to return a
+ file-like object. For example:
.. code-block:: python
@@ -301,27 +338,52 @@ class PathOption(Option):
"""
def __init__(self, *args, **kwargs):
- kwargs.setdefault('type', Types.path)
+ self._original_type = kwargs.pop('type', lambda x: x)
+ kwargs['type'] = self._type
kwargs.setdefault('metavar', '<path>')
Option.__init__(self, *args, **kwargs)
+ self.repository = None
+
+ def early_parsing_hook(self, early_opts):
+ self.repository = early_opts.repository
+
+ def _type(self, value):
+ """Type function that fixes up <repository> macros."""
+ if self.repository is None:
+ rv = value
+ else:
+ rv = value.replace("<repository>", self.repository)
+ return self._original_type(Types.path(rv))
class _BooleanOptionAction(argparse.Action):
- """ BooleanOptionAction sets a boolean value in the following ways:
+ """BooleanOptionAction sets a boolean value.
+
- if None is passed, store the default
- if the option_string is not None, then the option was passed on the
command line, thus store the opposite of the default (this is the
argparse store_true and store_false behavior)
- if a boolean value is passed, use that
+ Makes a copy of the initial default, because otherwise the default
+ can be changed by config file settings or environment
+ variables. For instance, if a boolean option that defaults to True
+ was set to False in the config file, specifying the option on the
+ CLI would then set it back to True.
+
Defined here instead of :mod:`Bcfg2.Options.Actions` because otherwise
- there is a circular import Options -> Actions -> Parser -> Options """
+ there is a circular import Options -> Actions -> Parser -> Options.
+ """
+
+ def __init__(self, *args, **kwargs):
+ argparse.Action.__init__(self, *args, **kwargs)
+ self.original = self.default
def __call__(self, parser, namespace, values, option_string=None):
if values is None:
setattr(namespace, self.dest, self.default)
elif option_string is not None:
- setattr(namespace, self.dest, not self.default)
+ setattr(namespace, self.dest, not self.original)
else:
setattr(namespace, self.dest, bool(values))
@@ -340,9 +402,25 @@ class BooleanOption(Option):
kwargs.setdefault('action', _BooleanOptionAction)
kwargs.setdefault('nargs', 0)
kwargs.setdefault('default', False)
-
Option.__init__(self, *args, **kwargs)
+ def get_environ_value(self, value):
+ if value.lower() in ["false", "no", "off", "0"]:
+ return False
+ elif value.lower() in ["true", "yes", "on", "1"]:
+ return True
+ else:
+ raise ValueError("Invalid boolean value %s" % value)
+
+ def get_config_value(self, cfp):
+ """fetch a value from the config file.
+
+ This is passed the config parser. Its result is passed to the
+ type function for this option. It can be overridden to, e.g.,
+ handle boolean options.
+ """
+ return cfp.getboolean(*self.cf)
+
class PositionalArgument(Option):
""" Shortcut for positional arguments. """
diff --git a/src/lib/Bcfg2/Options/Parser.py b/src/lib/Bcfg2/Options/Parser.py
index 677a69e4c..ec470f8d3 100644
--- a/src/lib/Bcfg2/Options/Parser.py
+++ b/src/lib/Bcfg2/Options/Parser.py
@@ -1,13 +1,15 @@
-""" The option parser """
+"""The option parser."""
+import argparse
import os
import sys
-import argparse
+
from Bcfg2.version import __version__
from Bcfg2.Compat import ConfigParser
from Bcfg2.Options import Option, PathOption, BooleanOption, _debug
-__all__ = ["setup", "OptionParserException", "Parser", "get_parser"]
+__all__ = ["setup", "OptionParserException", "Parser", "get_parser",
+ "new_parser"]
#: The repository option. This is specified here (and imported into
@@ -108,13 +110,28 @@ class Parser(argparse.ArgumentParser):
for component in components:
self.add_component(component)
+ def _check_duplicate_cf(self, option):
+ """Check for a duplicate config file option."""
+
def add_options(self, options):
""" Add an explicit list of options to the parser. When
possible, prefer :func:`Bcfg2.Options.Parser.add_component` to
add a whole component instead."""
+ _debug("Adding options: %s" % options)
self.parsed = False
for option in options:
if option not in self.option_list:
+ # check for duplicates
+ if (hasattr(option, "env") and option.env and
+ option.env in [o.env for o in self.option_list]):
+ raise OptionParserException(
+ "Duplicate environment variable option: %s" %
+ option.env)
+ if (hasattr(option, "cf") and option.cf and
+ option.cf in [o.cf for o in self.option_list]):
+ raise OptionParserException(
+ "Duplicate config file option: %s" % (option.cf,))
+
self.option_list.extend(option.list_options())
option.add_to_parser(self)
@@ -169,8 +186,8 @@ class Parser(argparse.ArgumentParser):
_debug("Resetting namespace")
for attr in dir(self.namespace):
if (not attr.startswith("_") and
- attr not in ['uri', 'version', 'name'] and
- attr not in self._config_files):
+ attr not in ['uri', 'version', 'name'] and
+ attr not in self._config_files):
_debug("Deleting %s" % attr)
delattr(self.namespace, attr)
@@ -210,7 +227,7 @@ class Parser(argparse.ArgumentParser):
"""
_debug("Parsing options")
if argv is None:
- argv = sys.argv[1:]
+ argv = sys.argv[1:] # pragma: nocover
if self.parsed and self.argv == argv:
_debug("Returning already parsed namespace")
return self.namespace
@@ -231,8 +248,8 @@ class Parser(argparse.ArgumentParser):
# phase 2: re-parse command line for early options; currently,
# that's database options
- _debug("Option parsing phase 2: Parse early options")
if not self._early:
+ _debug("Option parsing phase 2: Parse early options")
early_opts = argparse.Namespace()
early_parser = Parser(add_help=False, namespace=early_opts,
early=True)
@@ -250,6 +267,10 @@ class Parser(argparse.ArgumentParser):
if hasattr(component, "component_parsed_hook"):
_debug("Calling component_parsed_hook on %s" % component)
getattr(component, "component_parsed_hook")(early_opts)
+ for option in self.option_list:
+ option.early_parsing_hook(early_opts)
+ else:
+ _debug("Skipping parsing phase 2 in early mode")
# phase 3: re-parse command line, loading additional
# components, until all components have been loaded. On each
@@ -275,29 +296,24 @@ class Parser(argparse.ArgumentParser):
# namespace and _parse_config_options will never look at them again.
self._set_defaults_from_config()
self._parse_config_options()
+ remaining = []
while not self.parsed:
self.parsed = True
self._set_defaults_from_config()
- self.parse_known_args(args=self.argv, namespace=self.namespace)
+ _debug("Parsing known arguments")
+ try:
+ _, remaining = self.parse_known_args(args=self.argv,
+ namespace=self.namespace)
+ except OptionParserException:
+ self.error(sys.exc_info()[1])
self._parse_config_options()
self._finalize()
+ if len(remaining) and not self._early:
+ self.error("Unknown options: %s" % " ".join(remaining))
- # phase 4: fix up <repository> macros
- _debug("Option parsing phase 4: Fix up macros")
- repo = getattr(self.namespace, "repository", repository.default)
- for attr in dir(self.namespace):
- value = getattr(self.namespace, attr)
- if (not attr.startswith("_") and
- hasattr(value, "replace") and
- "<repository>" in value):
- setattr(self.namespace, attr,
- value.replace("<repository>", repo, 1))
- _debug("Fixing up macros in %s: %s -> %s" %
- (attr, value, getattr(self.namespace, attr)))
-
- # phase 5: call post-parsing hooks
- _debug("Option parsing phase 5: Call hooks")
+ # phase 4: call post-parsing hooks
if not self._early:
+ _debug("Option parsing phase 4: Call hooks")
for component in self.components:
if hasattr(component, "options_parsed_hook"):
_debug("Calling post-parsing hook on %s" % component)
@@ -311,23 +327,23 @@ class Parser(argparse.ArgumentParser):
_parser = Parser() # pylint: disable=C0103
+def new_parser():
+ """Create a new :class:`Bcfg2.Options.Parser` object.
+
+ The new object can be retrieved with
+ :func:`Bcfg2.Options.get_parser`. This is useful for unit
+ testing.
+ """
+ global _parser
+ _parser = Parser()
+
+
def get_parser(description=None, components=None, namespace=None):
- """ Get an existing :class:`Bcfg2.Options.Parser` object. (One is
- created at the module level when :mod:`Bcfg2.Options` is
- imported.) If no arguments are given, then the existing parser is
- simply fetched.
-
- If arguments are given, then one of two things happens:
-
- * If this is the first ``get_parser`` call with arguments, then
- the values given are set accordingly in the parser, and it is
- returned.
- * If this is not the first such call, then
- :class:`Bcfg2.Options.OptionParserException` is raised.
-
- That is, a ``get_parser`` call with options is considered to
- initialize the parser that already exists, and that can only
- happen once.
+ """Get an existing :class:`Bcfg2.Options.Parser` object.
+
+ A Parser is created at the module level when :mod:`Bcfg2.Options`
+ is imported. If any arguments are given, then the existing parser
+ is modified before being returned.
:param description: Set the parser description
:type description: string
diff --git a/src/lib/Bcfg2/Options/Subcommands.py b/src/lib/Bcfg2/Options/Subcommands.py
index 660bd5077..8972bde00 100644
--- a/src/lib/Bcfg2/Options/Subcommands.py
+++ b/src/lib/Bcfg2/Options/Subcommands.py
@@ -7,12 +7,13 @@ import sys
import copy
import shlex
import logging
+
from Bcfg2.Compat import StringIO
-from Bcfg2.Options import PositionalArgument
+from Bcfg2.Options import PositionalArgument, _debug
from Bcfg2.Options.OptionGroups import Subparser
from Bcfg2.Options.Parser import Parser, setup as master_setup
-__all__ = ["Subcommand", "HelpCommand", "CommandRegistry", "register_commands"]
+__all__ = ["Subcommand", "CommandRegistry"]
class Subcommand(object):
@@ -96,8 +97,8 @@ class Subcommand(object):
sio = StringIO()
self.parser.print_usage(file=sio)
usage = self._ws_re.sub(' ', sio.getvalue()).strip()[7:]
- doc = self._ws_re.sub(' ', getattr(self, "__doc__")).strip()
- if doc is None:
+ doc = self._ws_re.sub(' ', getattr(self, "__doc__") or "").strip()
+ if not doc:
self._usage = usage
else:
self._usage = "%s - %s" % (usage, doc)
@@ -119,120 +120,130 @@ class Subcommand(object):
command from the interactive shell.
:type setup: argparse.Namespace
"""
- raise NotImplementedError
+ raise NotImplementedError # pragma: nocover
def shutdown(self):
- """ Perform any necessary shtudown tasks for this command This
+ """ Perform any necessary shutdown tasks for this command This
is called to when the program exits (*not* when this command
is finished executing). """
- pass
+ pass # pragma: nocover
-class HelpCommand(Subcommand):
- """ Get help on a specific subcommand. This must be subclassed to
- create the actual help command by overriding
- :func:`Bcfg2.Options.HelpCommand.command_registry` and giving the
- command access to a :class:`Bcfg2.Options.CommandRegistry`. """
+class Help(Subcommand):
+ """List subcommands and usage, or get help on a specific subcommand."""
options = [PositionalArgument("command", nargs='?')]
# the interactive shell has its own help
interactive = False
- def command_registry(self):
- """ Return a :class:`Bcfg2.Options.CommandRegistry` class.
- All commands registered with the class will be included in the
- help message. """
- raise NotImplementedError
+ def __init__(self, registry):
+ Subcommand.__init__(self)
+ self._registry = registry
def run(self, setup):
- commands = self.command_registry()
+ commands = self._registry.commands
if setup.command:
try:
commands[setup.command].parser.print_help()
return 0
except KeyError:
print("No such command: %s" % setup.command)
+ return 1
for command in sorted(commands.keys()):
print(commands[command].usage())
class CommandRegistry(object):
- """ A ``CommandRegistry`` is used to register subcommands and
- provides a single interface to run them. It's also used by
- :class:`Bcfg2.Options.HelpCommand` to produce help messages for
- all available commands. """
+ """A ``CommandRegistry`` is used to register subcommands and provides
+ a single interface to run them. It's also used by
+ :class:`Bcfg2.Options.Subcommands.Help` to produce help messages
+ for all available commands.
+ """
+
+ def __init__(self):
+ #: A dict of registered commands. Keys are the class names,
+ #: lowercased (i.e., the command names), and values are instances
+ #: of the command objects.
+ self.commands = dict()
- #: A dict of registered commands. Keys are the class names,
- #: lowercased (i.e., the command names), and values are instances
- #: of the command objects.
- commands = dict()
+ #: A list of options that should be added to the option parser
+ #: in order to handle registered subcommands.
+ self.subcommand_options = []
- options = []
+ #: the help command
+ self.help = Help(self)
+ self.register_command(self.help)
def runcommand(self):
""" Run the single command named in
``Bcfg2.Options.setup.subcommand``, which is where
:class:`Bcfg2.Options.Subparser` groups store the
subcommand. """
+ _debug("Running subcommand %s" % master_setup.subcommand)
try:
return self.commands[master_setup.subcommand].run(master_setup)
finally:
self.shutdown()
def shutdown(self):
- """ Perform shutdown tasks. This calls the ``shutdown``
- method of all registered subcommands. """
+ """Perform shutdown tasks.
+
+ This calls the ``shutdown`` method of the subcommand that was
+ run.
+ """
+ _debug("Shutting down subcommand %s" % master_setup.subcommand)
self.commands[master_setup.subcommand].shutdown()
- @classmethod
- def register_command(cls, cmdcls):
+ def register_command(self, cls_or_obj):
""" Register a single command.
- :param cmdcls: The command class to register
- :type cmdcls: type
+ :param cls_or_obj: The command class or object to register
+ :type cls_or_obj: type or Subcommand
:returns: An instance of ``cmdcls``
"""
- cmd_obj = cmdcls()
+ if isinstance(cls_or_obj, type):
+ cmdcls = cls_or_obj
+ cmd_obj = cmdcls()
+ else:
+ cmd_obj = cls_or_obj
+ cmdcls = cmd_obj.__class__
name = cmdcls.__name__.lower()
- cls.commands[name] = cmd_obj
+ self.commands[name] = cmd_obj
# py2.5 can't mix *magic and non-magical keyword args, thus
# the **dict(...)
- cls.options.append(
+ self.subcommand_options.append(
Subparser(*cmdcls.options, **dict(name=name, help=cmdcls.__doc__)))
- if issubclass(cls, cmd.Cmd) and cmdcls.interactive:
- setattr(cls, "do_%s" % name, cmd_obj)
- setattr(cls, "help_%s" % name, cmd_obj.parser.print_help)
+ if issubclass(self.__class__, cmd.Cmd) and cmdcls.interactive:
+ setattr(self, "do_%s" % name, cmd_obj)
+ setattr(self, "help_%s" % name, cmd_obj.parser.print_help)
return cmd_obj
-
-def register_commands(registry, candidates, parent=Subcommand):
- """ Register all subcommands in ``candidates`` against the
- :class:`Bcfg2.Options.CommandRegistry` subclass given in
- ``registry``. A command is registered if and only if:
-
- * It is a subclass of the given ``parent`` (by default,
- :class:`Bcfg2.Options.Subcommand`);
- * It is not the parent class itself; and
- * Its name does not start with an underscore.
-
- :param registry: The :class:`Bcfg2.Options.CommandRegistry`
- subclass against which commands will be
- registered.
- :type registry: Bcfg2.Options.CommandRegistry
- :param candidates: A list of objects that will be considered for
- registration. Only objects that meet the
- criteria listed above will be registered.
- :type candidates: list
- :param parent: Specify a parent class other than
- :class:`Bcfg2.Options.Subcommand` that all
- registered commands must subclass.
- :type parent: type
- """
- for attr in candidates:
- try:
- if (issubclass(attr, parent) and
- attr != parent and
- not attr.__name__.startswith("_")):
- registry.register_command(attr)
- except TypeError:
- pass
+ def register_commands(self, candidates, parent=Subcommand):
+ """ Register all subcommands in ``candidates`` against the
+ :class:`Bcfg2.Options.CommandRegistry` subclass given in
+ ``registry``. A command is registered if and only if:
+
+ * It is a subclass of the given ``parent`` (by default,
+ :class:`Bcfg2.Options.Subcommand`);
+ * It is not the parent class itself; and
+ * Its name does not start with an underscore.
+
+ :param registry: The :class:`Bcfg2.Options.CommandRegistry`
+ subclass against which commands will be
+ registered.
+ :type registry: Bcfg2.Options.CommandRegistry
+ :param candidates: A list of objects that will be considered for
+ registration. Only objects that meet the
+ criteria listed above will be registered.
+ :type candidates: list
+ :param parent: Specify a parent class other than
+ :class:`Bcfg2.Options.Subcommand` that all
+ registered commands must subclass.
+ :type parent: type
+ """
+ for attr in candidates:
+ if (isinstance(attr, type) and
+ issubclass(attr, parent) and
+ attr != parent and
+ not attr.__name__.startswith("_")):
+ self.register_command(attr)
diff --git a/src/lib/Bcfg2/Options/Types.py b/src/lib/Bcfg2/Options/Types.py
index d11e54fba..1c04fede3 100644
--- a/src/lib/Bcfg2/Options/Types.py
+++ b/src/lib/Bcfg2/Options/Types.py
@@ -38,9 +38,11 @@ def comma_dict(value):
for item in items:
if '=' in item:
key, value = item.split(r'=', 1)
- try:
- result[key] = bool(value)
- except ValueError:
+ if value in ["true", "yes", "on"]:
+ result[key] = True
+ elif value in ["false", "no", "off"]:
+ result[key] = False
+ else:
try:
result[key] = int(value)
except ValueError:
@@ -107,8 +109,6 @@ def size(value):
""" Given a number of bytes in a human-readable format (e.g.,
'512m', '2g'), get the absolute number of bytes as an integer.
"""
- if value == -1:
- return value
mat = _bytes_re.match(value)
if not mat:
raise ValueError("Not a valid size", value)
diff --git a/src/lib/Bcfg2/Reporting/Reports.py b/src/lib/Bcfg2/Reporting/Reports.py
index 219d74584..3b9c83433 100755
--- a/src/lib/Bcfg2/Reporting/Reports.py
+++ b/src/lib/Bcfg2/Reporting/Reports.py
@@ -267,10 +267,11 @@ class CLI(Bcfg2.Options.CommandRegistry):
def __init__(self):
Bcfg2.Options.CommandRegistry.__init__(self)
- Bcfg2.Options.register_commands(self.__class__, globals().values())
+ self.register_commands(globals().values())
parser = Bcfg2.Options.get_parser(
description="Query the Bcfg2 reporting subsystem",
components=[self])
+ parser.add_options(self.subcommand_options)
parser.parse()
def run(self):
diff --git a/src/lib/Bcfg2/Server/Admin.py b/src/lib/Bcfg2/Server/Admin.py
index 0807fb2b0..b6dded175 100644
--- a/src/lib/Bcfg2/Server/Admin.py
+++ b/src/lib/Bcfg2/Server/Admin.py
@@ -439,15 +439,6 @@ class Compare(AdminCmd):
print("")
-class Help(AdminCmd, Bcfg2.Options.HelpCommand):
- """ Get help on a specific subcommand """
- def command_registry(self):
- return CLI.commands
-
- def run(self, setup):
- Bcfg2.Options.HelpCommand.run(self, setup)
-
-
class Init(AdminCmd):
"""Interactively initialize a new repository."""
@@ -1194,13 +1185,14 @@ class Xcmd(_ProxyAdminCmd):
class CLI(Bcfg2.Options.CommandRegistry):
""" CLI class for bcfg2-admin """
+
def __init__(self):
Bcfg2.Options.CommandRegistry.__init__(self)
- Bcfg2.Options.register_commands(self.__class__, globals().values(),
- parent=AdminCmd)
+ self.register_commands(globals().values(), parent=AdminCmd)
parser = Bcfg2.Options.get_parser(
description="Manage a running Bcfg2 server",
components=[self])
+ parser.add_options(self.subcommand_options)
parser.parse()
def run(self):
diff --git a/src/lib/Bcfg2/Server/Info.py b/src/lib/Bcfg2/Server/Info.py
index a5136f01d..0baa29a9f 100644
--- a/src/lib/Bcfg2/Server/Info.py
+++ b/src/lib/Bcfg2/Server/Info.py
@@ -123,15 +123,6 @@ class InfoCmd(Bcfg2.Options.Subcommand): # pylint: disable=W0223
list(self.core.metadata.groups.keys()))
-class Help(InfoCmd, Bcfg2.Options.HelpCommand):
- """ Get help on a specific subcommand """
- def command_registry(self):
- return self.core.commands
-
- def run(self, setup):
- Bcfg2.Options.HelpCommand.run(self, setup)
-
-
class Debug(InfoCmd):
""" Shell out to a Python interpreter """
interpreters, default_interpreter = load_interpreters()
@@ -805,15 +796,12 @@ if HAS_PROFILE:
display_trace(prof)
-class InfoCore(cmd.Cmd,
- Bcfg2.Server.Core.Core,
- Bcfg2.Options.CommandRegistry):
+class InfoCore(cmd.Cmd, Bcfg2.Server.Core.Core):
"""Main class for bcfg2-info."""
def __init__(self):
cmd.Cmd.__init__(self)
Bcfg2.Server.Core.Core.__init__(self)
- Bcfg2.Options.CommandRegistry.__init__(self)
self.prompt = 'bcfg2-info> '
def get_locals(self):
@@ -853,16 +841,17 @@ class InfoCore(cmd.Cmd,
Bcfg2.Server.Core.Core.shutdown(self)
-class CLI(object):
+class CLI(Bcfg2.Options.CommandRegistry):
""" The bcfg2-info CLI """
options = [Bcfg2.Options.BooleanOption("-p", "--profile", help="Profile")]
def __init__(self):
- Bcfg2.Options.register_commands(InfoCore, globals().values(),
- parent=InfoCmd)
+ Bcfg2.Options.CommandRegistry.__init__(self)
+ self.register_commands(globals().values(), parent=InfoCmd)
parser = Bcfg2.Options.get_parser(
description="Inspect a running Bcfg2 server",
components=[self, InfoCore])
+ parser.add_options(self.subcommand_options)
parser.parse()
if Bcfg2.Options.setup.profile and HAS_PROFILE:
@@ -874,11 +863,11 @@ class CLI(object):
print("Profiling functionality not available.")
self.core = InfoCore()
- for command in self.core.commands.values():
+ for command in self.commands.values():
command.core = self.core
def run(self):
""" Run bcfg2-info """
if Bcfg2.Options.setup.subcommand != 'help':
self.core.run()
- return self.core.runcommand()
+ return self.runcommand()
diff --git a/src/lib/Bcfg2/Server/Plugins/Packages/YumHelper.py b/src/lib/Bcfg2/Server/Plugins/Packages/YumHelper.py
index 48304d26e..f26d6ba18 100644
--- a/src/lib/Bcfg2/Server/Plugins/Packages/YumHelper.py
+++ b/src/lib/Bcfg2/Server/Plugins/Packages/YumHelper.py
@@ -383,10 +383,10 @@ class CLI(Bcfg2.Options.CommandRegistry):
def __init__(self):
Bcfg2.Options.CommandRegistry.__init__(self)
- Bcfg2.Options.register_commands(self.__class__, globals().values(),
- parent=HelperSubcommand)
+ self.register_commands(globals().values(), parent=HelperSubcommand)
parser = Bcfg2.Options.get_parser("Bcfg2 yum helper",
components=[self])
+ parser.add_options(self.subcommand_options)
parser.parse()
self.logger = logging.getLogger(parser.prog)
diff --git a/testsuite/Testsrc/Testlib/TestClient/TestTools/Test_init.py b/testsuite/Testsrc/Testlib/TestClient/TestTools/Test_init.py
index 0e9e3a141..b9de703ff 100644
--- a/testsuite/Testsrc/Testlib/TestClient/TestTools/Test_init.py
+++ b/testsuite/Testsrc/Testlib/TestClient/TestTools/Test_init.py
@@ -17,18 +17,17 @@ while path != "/":
path = os.path.dirname(path)
from common import *
-# try to find true
-if os.path.exists("/bin/true"):
- TRUE = "/bin/true"
-elif os.path.exists("/usr/bin/true"):
- TRUE = "/usr/bin/true"
-else:
- TRUE = None
-
class TestTool(Bcfg2TestCase):
test_obj = Tool
+ if os.path.exists("/bin/true"):
+ true = "/bin/true"
+ elif os.path.exists("/usr/bin/true"):
+ true = "/usr/bin/true"
+ else:
+ true = None
+
def setUp(self):
set_setup_default('command_timeout')
set_setup_default('interactive', False)
@@ -77,11 +76,11 @@ class TestTool(Bcfg2TestCase):
["/test"] + [e.get("name") for e in important])
t.getSupportedEntries.assert_called_with()
- @skipIf(TRUE is None, "/bin/true or equivalent not found")
+ @skipIf(true is None, "/bin/true or equivalent not found")
def test__check_execs(self):
t = self.get_obj()
if t.__execs__ == []:
- t.__execs__.append(TRUE)
+ t.__execs__.append(self.true)
@patch("os.stat")
def inner(mock_stat):
diff --git a/testsuite/Testsrc/Testlib/TestOptions/One.py b/testsuite/Testsrc/Testlib/TestOptions/One.py
new file mode 100644
index 000000000..dac7f4558
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/One.py
@@ -0,0 +1,6 @@
+"""Test module for component loading."""
+
+
+class One(object):
+ """Test class for component loading."""
+ pass
diff --git a/testsuite/Testsrc/Testlib/TestOptions/TestComponents.py b/testsuite/Testsrc/Testlib/TestOptions/TestComponents.py
new file mode 100644
index 000000000..d637d34c5
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/TestComponents.py
@@ -0,0 +1,210 @@
+"""test component loading."""
+
+import argparse
+import os
+
+from Bcfg2.Options import Option, BooleanOption, ComponentAction, get_parser, \
+ new_parser, Types, ConfigFileAction
+
+from testsuite.Testsrc.Testlib.TestOptions import make_config, One, Two, \
+ OptionTestCase
+
+
+# create a bunch of fake components for testing component loading options
+
+class ChildOne(object):
+ """fake component for testing component loading."""
+ options = [Option("--child-one")]
+
+
+class ChildTwo(object):
+ """fake component for testing component loading."""
+ options = [Option("--child-two")]
+
+
+class ChildComponentAction(ComponentAction):
+ """child component loader action."""
+ islist = False
+ mapping = {"one": ChildOne,
+ "two": ChildTwo}
+
+
+class ComponentOne(object):
+ """fake component for testing component loading."""
+ options = [BooleanOption("--one")]
+
+
+class ComponentTwo(object):
+ """fake component for testing component loading."""
+ options = [Option("--child", default="one", action=ChildComponentAction)]
+
+
+class ComponentThree(object):
+ """fake component for testing component loading."""
+ options = [BooleanOption("--three")]
+
+
+class ConfigFileComponent(object):
+ """fake component for testing component loading."""
+ options = [Option("--config2", action=ConfigFileAction),
+ Option(cf=("config", "test"), dest="config2_test",
+ default="bar")]
+
+
+class ParentComponentAction(ComponentAction):
+ """parent component loader action."""
+ mapping = {"one": ComponentOne,
+ "two": ComponentTwo,
+ "three": ComponentThree,
+ "config": ConfigFileComponent}
+
+
+class TestComponentOptions(OptionTestCase):
+ """test cases for component loading."""
+
+ def setUp(self):
+ self.options = [
+ Option("--parent", type=Types.comma_list,
+ default=["one", "two"], action=ParentComponentAction)]
+
+ self.result = argparse.Namespace()
+ new_parser()
+ self.parser = get_parser(components=[self], namespace=self.result,
+ description="component testing parser")
+
+ @make_config()
+ def test_loading_components(self, config_file):
+ """load a single component during option parsing."""
+ self.parser.parse(["-C", config_file, "--parent", "one"])
+ self.assertEqual(self.result.parent, [ComponentOne])
+
+ @make_config()
+ def test_component_option(self, config_file):
+ """use options from a component loaded during option parsing."""
+ self.parser.parse(["--one", "-C", config_file, "--parent", "one"])
+ self.assertEqual(self.result.parent, [ComponentOne])
+ self.assertTrue(self.result.one)
+
+ @make_config()
+ def test_multi_component_load(self, config_file):
+ """load multiple components during option parsing."""
+ self.parser.parse(["-C", config_file, "--parent", "one,three"])
+ self.assertEqual(self.result.parent, [ComponentOne, ComponentThree])
+
+ @make_config()
+ def test_multi_component_options(self, config_file):
+ """use options from multiple components during option parsing."""
+ self.parser.parse(["-C", config_file, "--three",
+ "--parent", "one,three", "--one"])
+ self.assertEqual(self.result.parent, [ComponentOne, ComponentThree])
+ self.assertTrue(self.result.one)
+ self.assertTrue(self.result.three)
+
+ @make_config()
+ def test_component_default_not_loaded(self, config_file):
+ """options from default but unused components not available."""
+ self.assertRaises(
+ SystemExit,
+ self.parser.parse,
+ ["-C", config_file, "--child", "one", "--parent", "one"])
+
+ @make_config()
+ def test_tiered_components(self, config_file):
+ """load child component."""
+ self.parser.parse(["-C", config_file, "--parent", "two",
+ "--child", "one"])
+ self.assertEqual(self.result.parent, [ComponentTwo])
+ self.assertEqual(self.result.child, ChildOne)
+
+ @make_config()
+ def test_options_tiered_components(self, config_file):
+ """use options from child component."""
+ self.parser.parse(["--child-one", "foo", "-C", config_file, "--parent",
+ "two", "--child", "one"])
+ self.assertEqual(self.result.parent, [ComponentTwo])
+ self.assertEqual(self.result.child, ChildOne)
+ self.assertEqual(self.result.child_one, "foo")
+
+ @make_config()
+ def test_bogus_component(self, config_file):
+ """error out with bad component name."""
+ self.assertRaises(SystemExit,
+ self.parser.parse,
+ ["-C", config_file, "--parent", "blargle"])
+
+ @make_config()
+ @make_config({"config": {"test": "foo"}})
+ def test_config_component(self, config1, config2):
+ """load component with alternative config file."""
+ self.parser.parse(["-C", config1, "--config2", config2,
+ "--parent", "config"])
+ self.assertEqual(self.result.config2, config2)
+ self.assertEqual(self.result.config2_test, "foo")
+
+ @make_config()
+ def test_config_component_no_file(self, config_file):
+ """load component with missing alternative config file."""
+ self.parser.parse(["-C", config_file, "--parent", "config"])
+ self.assertEqual(self.result.config2, None)
+
+
+class ImportComponentAction(ComponentAction):
+ """action that imports real classes for testing."""
+ islist = False
+ bases = ["testsuite.Testsrc.Testlib.TestOptions"]
+
+
+class ImportModuleAction(ImportComponentAction):
+ """action that only imports modules for testing."""
+ module = True
+
+
+class TestImportComponentOptions(OptionTestCase):
+ """test cases for component loading."""
+
+ def setUp(self):
+ self.options = [Option("--cls", action=ImportComponentAction),
+ Option("--module", action=ImportModuleAction)]
+
+ self.result = argparse.Namespace()
+ new_parser()
+ self.parser = get_parser(components=[self], namespace=self.result)
+
+ @make_config()
+ def test_import_component(self, config_file):
+ """load class components by importing."""
+ self.parser.parse(["-C", config_file, "--cls", "One"])
+ self.assertEqual(self.result.cls, One.One)
+
+ @make_config()
+ def test_import_module(self, config_file):
+ """load module components by importing."""
+ self.parser.parse(["-C", config_file, "--module", "One"])
+ self.assertEqual(self.result.module, One)
+
+ @make_config()
+ def test_import_full_path(self, config_file):
+ """load components by importing the full path."""
+ self.parser.parse(["-C", config_file, "--cls", "os.path"])
+ self.assertEqual(self.result.cls, os.path)
+
+ @make_config()
+ def test_import_bogus_class(self, config_file):
+ """fail to load class component that cannot be imported."""
+ self.assertRaises(SystemExit,
+ self.parser.parse,
+ ["-C", config_file, "--cls", "Three"])
+
+ @make_config()
+ def test_import_bogus_module(self, config_file):
+ """fail to load module component that cannot be imported."""
+ self.assertRaises(SystemExit,
+ self.parser.parse,
+ ["-C", config_file, "--module", "Three"])
+
+ @make_config()
+ def test_import_bogus_path(self, config_file):
+ """fail to load component that cannot be imported by full path."""
+ self.assertRaises(SystemExit,
+ self.parser.parse,
+ ["-C", config_file, "--cls", "Bcfg2.No.Such.Thing"])
diff --git a/testsuite/Testsrc/Testlib/TestOptions/TestConfigFiles.py b/testsuite/Testsrc/Testlib/TestOptions/TestConfigFiles.py
new file mode 100644
index 000000000..aee2ff666
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/TestConfigFiles.py
@@ -0,0 +1,48 @@
+"""test reading multiple config files."""
+
+import argparse
+
+from Bcfg2.Options import Option, PathOption, ConfigFileAction, get_parser, \
+ new_parser
+
+from testsuite.Testsrc.Testlib.TestOptions import make_config, OptionTestCase
+
+
+class TestConfigFiles(OptionTestCase):
+ def setUp(self):
+ self.options = [
+ PathOption(cf=("test", "config2"), action=ConfigFileAction),
+ PathOption(cf=("test", "config3"), action=ConfigFileAction),
+ Option(cf=("test", "foo")),
+ Option(cf=("test", "bar")),
+ Option(cf=("test", "baz"))]
+ self.results = argparse.Namespace()
+ new_parser()
+ self.parser = get_parser(components=[self], namespace=self.results)
+
+ @make_config({"test": {"baz": "baz"}})
+ def test_config_files(self, config3):
+ """read multiple config files."""
+ # Because make_config() generates temporary files for the
+ # configuration, we have to work backwards here. first we
+ # generate config3, then we generate config2 (which includes a
+ # reference to config3), then we finally generate the main
+ # config file, which contains a reference to config2. oh how
+ # I wish we could use context managers here...
+
+ @make_config({"test": {"bar": "bar", "config3": config3}})
+ def inner1(config2):
+ @make_config({"test": {"foo": "foo", "config2": config2}})
+ def inner2(config):
+ self.parser.parse(["-C", config])
+ self.assertEqual(self.results.foo, "foo")
+ self.assertEqual(self.results.bar, "bar")
+ self.assertEqual(self.results.baz, "baz")
+
+ inner2()
+
+ inner1()
+
+ def test_no_config_file(self):
+ """fail to read config file."""
+ self.assertRaises(SystemExit, self.parser.parse, [])
diff --git a/testsuite/Testsrc/Testlib/TestOptions/TestOptionGroups.py b/testsuite/Testsrc/Testlib/TestOptions/TestOptionGroups.py
new file mode 100644
index 000000000..de1abbb1b
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/TestOptionGroups.py
@@ -0,0 +1,145 @@
+"""test reading multiple config files."""
+
+import argparse
+
+from Bcfg2.Options import Option, BooleanOption, Parser, OptionGroup, \
+ ExclusiveOptionGroup, WildcardSectionGroup, new_parser, get_parser
+
+from testsuite.common import Bcfg2TestCase
+from testsuite.Testsrc.Testlib.TestOptions import make_config, OptionTestCase
+
+
+class TestOptionGroups(Bcfg2TestCase):
+ def setUp(self):
+ self.options = None
+
+ def _test_options(self, options):
+ """test helper."""
+ result = argparse.Namespace()
+ parser = Parser(components=[self], namespace=result)
+ parser.parse(options)
+ return result
+
+ def test_option_group(self):
+ """basic option group functionality."""
+ self.options = [OptionGroup(BooleanOption("--foo"),
+ BooleanOption("--bar"),
+ BooleanOption("--baz"),
+ title="group")]
+ result = self._test_options(["--foo", "--bar"])
+ self.assertTrue(result.foo)
+ self.assertTrue(result.bar)
+ self.assertFalse(result.baz)
+
+ def test_exclusive_option_group(self):
+ """parse options from exclusive option group."""
+ self.options = [
+ ExclusiveOptionGroup(BooleanOption("--foo"),
+ BooleanOption("--bar"),
+ BooleanOption("--baz"))]
+ result = self._test_options(["--foo"])
+ self.assertTrue(result.foo)
+ self.assertFalse(result.bar)
+ self.assertFalse(result.baz)
+
+ self.assertRaises(SystemExit,
+ self._test_options, ["--foo", "--bar"])
+
+ def test_required_exclusive_option_group(self):
+ """parse options from required exclusive option group."""
+ self.options = [
+ ExclusiveOptionGroup(BooleanOption("--foo"),
+ BooleanOption("--bar"),
+ BooleanOption("--baz"),
+ required=True)]
+ result = self._test_options(["--foo"])
+ self.assertTrue(result.foo)
+ self.assertFalse(result.bar)
+ self.assertFalse(result.baz)
+
+ self.assertRaises(SystemExit, self._test_options, [])
+
+ def test_option_group(self):
+ """nest option groups."""
+ self.options = [
+ OptionGroup(
+ BooleanOption("--foo"),
+ BooleanOption("--bar"),
+ OptionGroup(
+ BooleanOption("--baz"),
+ BooleanOption("--quux"),
+ ExclusiveOptionGroup(
+ BooleanOption("--test1"),
+ BooleanOption("--test2")),
+ title="inner"),
+ title="outer")]
+ result = self._test_options(["--foo", "--baz", "--test1"])
+ self.assertTrue(result.foo)
+ self.assertFalse(result.bar)
+ self.assertTrue(result.baz)
+ self.assertFalse(result.quux)
+ self.assertTrue(result.test1)
+ self.assertFalse(result.test2)
+
+ self.assertRaises(SystemExit,
+ self._test_options, ["--test1", "--test2"])
+
+
+class TestWildcardSectionGroups(OptionTestCase):
+ config = {
+ "four:one": {
+ "foo": "foo one",
+ "bar": "bar one",
+ "baz": "baz one"
+ },
+ "four:two": {
+ "foo": "foo two",
+ "bar": "bar two"
+ },
+ "five:one": {
+ "foo": "foo one",
+ "bar": "bar one"
+ },
+ "five:two": {
+ "foo": "foo two",
+ "bar": "bar two"
+ },
+ "five:three": {
+ "foo": "foo three",
+ "bar": "bar three"
+ }
+ }
+
+ def setUp(self):
+ self.options = [
+ WildcardSectionGroup(
+ Option(cf=("four:*", "foo")),
+ Option(cf=("four:*", "bar"))),
+ WildcardSectionGroup(
+ Option(cf=("five:*", "foo")),
+ Option(cf=("five:*", "bar")),
+ prefix="",
+ dest="sections")]
+ self.results = argparse.Namespace()
+ new_parser()
+ self.parser = get_parser(components=[self], namespace=self.results)
+
+ @make_config(config)
+ def test_wildcard_section_groups(self, config_file):
+ """parse options from wildcard section groups."""
+ self.parser.parse(["-C", config_file])
+ self.assertEqual(self.results.four_four_one_foo, "foo one")
+ self.assertEqual(self.results.four_four_one_bar, "bar one")
+ self.assertEqual(self.results.four_four_two_foo, "foo two")
+ self.assertEqual(self.results.four_four_two_bar, "bar two")
+ self.assertItemsEqual(self.results.four_sections,
+ ["four:one", "four:two"])
+
+ self.assertEqual(self.results.five_one_foo, "foo one")
+ self.assertEqual(self.results.five_one_bar, "bar one")
+ self.assertEqual(self.results.five_two_foo, "foo two")
+ self.assertEqual(self.results.five_two_bar, "bar two")
+ self.assertEqual(self.results.five_three_foo, "foo three")
+ self.assertEqual(self.results.five_three_bar, "bar three")
+ self.assertItemsEqual(self.results.sections,
+ ["five:one", "five:two", "five:three"])
diff --git a/testsuite/Testsrc/Testlib/TestOptions/TestOptions.py b/testsuite/Testsrc/Testlib/TestOptions/TestOptions.py
new file mode 100644
index 000000000..b76cd6d3a
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/TestOptions.py
@@ -0,0 +1,469 @@
+"""basic option parsing tests."""
+
+import argparse
+import os
+import tempfile
+
+import mock
+
+from Bcfg2.Compat import ConfigParser
+from Bcfg2.Options import Option, PathOption, BooleanOption, Parser, \
+ PositionalArgument, OptionParserException, Common, new_parser, get_parser
+from testsuite.Testsrc.Testlib.TestOptions import OptionTestCase, \
+ make_config, clean_environment
+
+
+class TestBasicOptions(OptionTestCase):
+ """test basic option parsing."""
+ def setUp(self):
+ # parsing options can modify the Option objects themselves.
+ # that's probably bad -- and it's definitely bad if we ever
+ # want to do real on-the-fly config changes -- but it's easier
+ # to leave it as is and set the options on each test.
+ self.options = [
+ BooleanOption("--test-true-boolean", env="TEST_TRUE_BOOLEAN",
+ cf=("test", "true_boolean"), default=True),
+ BooleanOption("--test-false-boolean", env="TEST_FALSE_BOOLEAN",
+ cf=("test", "false_boolean"), default=False),
+ BooleanOption(cf=("test", "true_config_boolean"),
+ default=True),
+ BooleanOption(cf=("test", "false_config_boolean"),
+ default=False),
+ Option("--test-option", env="TEST_OPTION", cf=("test", "option"),
+ default="foo"),
+ PathOption("--test-path-option", env="TEST_PATH_OPTION",
+ cf=("test", "path"), default="/test")]
+
+ @clean_environment
+ def _test_options(self, options=None, env=None, config=None):
+ """helper to test a set of options.
+
+ returns the namespace from parsing the given CLI options with
+ the given config and environment.
+ """
+ if config is not None:
+ config = {"test": config}
+ if options is None:
+ options = []
+
+ @make_config(config)
+ def inner(config_file):
+ """do the actual tests, since py2.4 lacks context managers."""
+ result = argparse.Namespace()
+ parser = Parser(components=[self], namespace=result)
+ parser.parse(argv=["-C", config_file] + options)
+ return result
+
+ if env is not None:
+ for name, value in env.items():
+ os.environ[name] = value
+
+ return inner()
+
+ def test_expand_path(self):
+ """expand ~ in path option."""
+ options = self._test_options(options=["--test-path-option",
+ "~/test"])
+ self.assertEqual(options.test_path_option,
+ os.path.expanduser("~/test"))
+
+ def test_canonicalize_path(self):
+ """get absolute path from path option."""
+ options = self._test_options(options=["--test-path-option",
+ "./test"])
+ self.assertEqual(options.test_path_option,
+ os.path.abspath("./test"))
+
+ def test_default_bool(self):
+ """use the default value of boolean options."""
+ options = self._test_options()
+ self.assertTrue(options.test_true_boolean)
+ self.assertFalse(options.test_false_boolean)
+ self.assertTrue(options.true_config_boolean)
+ self.assertFalse(options.false_config_boolean)
+
+ def test_default(self):
+ """use the default value of an option."""
+ options = self._test_options()
+ self.assertEqual(options.test_option, "foo")
+
+ def test_default_path(self):
+ """use the default value of a path option."""
+ options = self._test_options()
+ self.assertEqual(options.test_path_option, "/test")
+
+ def test_invalid_boolean(self):
+ """set boolean to invalid values."""
+ self.assertRaises(ValueError,
+ self._test_options,
+ config={"true_boolean": "you betcha"})
+ self.assertRaises(ValueError,
+ self._test_options,
+ env={"TEST_TRUE_BOOLEAN": "hell no"})
+
+ def test_set_boolean_in_config(self):
+ """set boolean options in config files."""
+ set_to_defaults = {"true_boolean": "1",
+ "false_boolean": "0",
+ "true_config_boolean": "yes",
+ "false_config_boolean": "no"}
+ options = self._test_options(config=set_to_defaults)
+ self.assertTrue(options.test_true_boolean)
+ self.assertFalse(options.test_false_boolean)
+ self.assertTrue(options.true_config_boolean)
+ self.assertFalse(options.false_config_boolean)
+
+ set_to_other = {"true_boolean": "false",
+ "false_boolean": "true",
+ "true_config_boolean": "off",
+ "false_config_boolean": "on"}
+ options = self._test_options(config=set_to_other)
+ self.assertFalse(options.test_true_boolean)
+ self.assertTrue(options.test_false_boolean)
+ self.assertFalse(options.true_config_boolean)
+ self.assertTrue(options.false_config_boolean)
+
+ def test_set_in_config(self):
+ """set options in config files."""
+ options = self._test_options(config={"option": "foo"})
+ self.assertEqual(options.test_option, "foo")
+
+ options = self._test_options(config={"option": "bar"})
+ self.assertEqual(options.test_option, "bar")
+
+ def test_set_path_in_config(self):
+ """set path options in config files."""
+ options = self._test_options(config={"path": "/test"})
+ self.assertEqual(options.test_path_option, "/test")
+
+ options = self._test_options(config={"path": "/foo"})
+ self.assertEqual(options.test_path_option, "/foo")
+
+ def test_set_boolean_in_env(self):
+ """set boolean options in environment."""
+ set_to_defaults = {"TEST_TRUE_BOOLEAN": "1",
+ "TEST_FALSE_BOOLEAN": "0"}
+ options = self._test_options(env=set_to_defaults)
+ self.assertTrue(options.test_true_boolean)
+ self.assertFalse(options.test_false_boolean)
+
+ set_to_other = {"TEST_TRUE_BOOLEAN": "false",
+ "TEST_FALSE_BOOLEAN": "true"}
+ options = self._test_options(env=set_to_other)
+ self.assertFalse(options.test_true_boolean)
+ self.assertTrue(options.test_false_boolean)
+
+ def test_set_in_env(self):
+ """set options in environment."""
+ options = self._test_options(env={"TEST_OPTION": "foo"})
+ self.assertEqual(options.test_option, "foo")
+
+ options = self._test_options(env={"TEST_OPTION": "bar"})
+ self.assertEqual(options.test_option, "bar")
+
+ def test_set_path_in_env(self):
+ """set path options in environment."""
+ options = self._test_options(env={"TEST_PATH_OPTION": "/test"})
+ self.assertEqual(options.test_path_option, "/test")
+
+ options = self._test_options(env={"TEST_PATH_OPTION": "/foo"})
+ self.assertEqual(options.test_path_option, "/foo")
+
+ def test_set_boolean_in_cli(self):
+ """set boolean options in CLI options."""
+ # passing the option yields the reverse of the default, no
+ # matter the default
+ options = self._test_options(options=["--test-true-boolean",
+ "--test-false-boolean"])
+ self.assertFalse(options.test_true_boolean)
+ self.assertTrue(options.test_false_boolean)
+
+ def test_set_in_cli(self):
+ """set options in CLI options."""
+ options = self._test_options(options=["--test-option", "foo"])
+ self.assertEqual(options.test_option, "foo")
+
+ options = self._test_options(options=["--test-option", "bar"])
+ self.assertEqual(options.test_option, "bar")
+
+ def test_set_path_in_cli(self):
+ """set path options in CLI options."""
+ options = self._test_options(options=["--test-path-option", "/test"])
+ self.assertEqual(options.test_path_option, "/test")
+
+ options = self._test_options(options=["--test-path-option", "/foo"])
+ self.assertEqual(options.test_path_option, "/foo")
+
+ def test_env_overrides_config_bool(self):
+ """setting boolean option in the environment overrides config file."""
+ config = {"true_boolean": "false",
+ "false_boolean": "true"}
+ env = {"TEST_TRUE_BOOLEAN": "yes",
+ "TEST_FALSE_BOOLEAN": "no"}
+ options = self._test_options(config=config, env=env)
+ self.assertTrue(options.test_true_boolean)
+ self.assertFalse(options.test_false_boolean)
+
+ def test_env_overrides_config(self):
+ """setting option in the environment overrides config file."""
+ options = self._test_options(config={"option": "bar"},
+ env={"TEST_OPTION": "baz"})
+ self.assertEqual(options.test_option, "baz")
+
+ def test_env_overrides_config_path(self):
+ """setting path option in the environment overrides config file."""
+ options = self._test_options(config={"path": "/foo"},
+ env={"TEST_PATH_OPTION": "/bar"})
+ self.assertEqual(options.test_path_option, "/bar")
+
+ def test_cli_overrides_config_bool(self):
+ """setting boolean option in the CLI overrides config file."""
+ config = {"true_boolean": "on",
+ "false_boolean": "off"}
+ options = ["--test-true-boolean", "--test-false-boolean"]
+ options = self._test_options(options=options, config=config)
+ self.assertFalse(options.test_true_boolean)
+ self.assertTrue(options.test_false_boolean)
+
+ def test_cli_overrides_config(self):
+ """setting option in the CLI overrides config file."""
+ options = self._test_options(options=["--test-option", "baz"],
+ config={"option": "bar"})
+ self.assertEqual(options.test_option, "baz")
+
+ def test_cli_overrides_config_path(self):
+ """setting path option in the CLI overrides config file."""
+ options = self._test_options(options=["--test-path-option", "/bar"],
+ config={"path": "/foo"})
+ self.assertEqual(options.test_path_option, "/bar")
+
+ def test_cli_overrides_env_bool(self):
+ """setting boolean option in the CLI overrides environment."""
+ env = {"TEST_TRUE_BOOLEAN": "0",
+ "TEST_FALSE_BOOLEAN": "1"}
+ options = ["--test-true-boolean", "--test-false-boolean"]
+ options = self._test_options(options=options, env=env)
+ self.assertFalse(options.test_true_boolean)
+ self.assertTrue(options.test_false_boolean)
+
+ def test_cli_overrides_env(self):
+ """setting option in the CLI overrides environment."""
+ options = self._test_options(options=["--test-option", "baz"],
+ env={"TEST_OPTION": "bar"})
+ self.assertEqual(options.test_option, "baz")
+
+ def test_cli_overrides_env_path(self):
+ """setting path option in the CLI overrides environment."""
+ options = self._test_options(options=["--test-path-option", "/bar"],
+ env={"TEST_PATH_OPTION": "/foo"})
+ self.assertEqual(options.test_path_option, "/bar")
+
+ def test_cli_overrides_all_bool(self):
+ """setting boolean option in the CLI overrides everything else."""
+ config = {"true_boolean": "no",
+ "false_boolean": "yes"}
+ env = {"TEST_TRUE_BOOLEAN": "0",
+ "TEST_FALSE_BOOLEAN": "1"}
+ options = ["--test-true-boolean", "--test-false-boolean"]
+ options = self._test_options(options=options, env=env)
+ self.assertFalse(options.test_true_boolean)
+ self.assertTrue(options.test_false_boolean)
+
+ def test_cli_overrides_all(self):
+ """setting option in the CLI overrides everything else."""
+ options = self._test_options(options=["--test-option", "baz"],
+ env={"TEST_OPTION": "bar"},
+ config={"test": "quux"})
+ self.assertEqual(options.test_option, "baz")
+
+ def test_cli_overrides_all_path(self):
+ """setting path option in the CLI overrides everything else."""
+ options = self._test_options(options=["--test-path-option", "/bar"],
+ env={"TEST_PATH_OPTION": "/foo"},
+ config={"path": "/baz"})
+ self.assertEqual(options.test_path_option, "/bar")
+
+ @make_config()
+ def _test_dest(self, *args, **kwargs):
+ """helper to test that ``dest`` is set properly."""
+ args = list(args)
+ expected = args.pop(0)
+ config_file = args.pop()
+
+ sentinel = object()
+ kwargs["default"] = sentinel
+
+ result = argparse.Namespace()
+ parser = Parser(namespace=result)
+ parser.add_options([Option(*args, **kwargs)])
+ parser.parse(["-C", config_file])
+
+ self.assertTrue(hasattr(result, expected))
+ self.assertEqual(getattr(result, expected), sentinel)
+
+ def test_explicit_dest(self):
+ """set the ``dest`` of an option explicitly."""
+ self._test_dest("bar", dest="bar")
+
+ def test_dest_from_env_var(self):
+ """set the ``dest`` of an option from the env var name."""
+ self._test_dest("foo", env="FOO")
+
+ def test_dest_from_cf(self):
+ """set the ``dest`` of an option from the config option."""
+ self._test_dest("foo_bar", cf=("test", "foo-bar"))
+
+ def test_dest_from_cli(self):
+ """set the ``dest`` of an option from the CLI option."""
+ self._test_dest("test_foo", "--test-foo")
+
+ def test_dest_from_all(self):
+ """set the ``dest`` of an option from the best of multiple sources."""
+ self._test_dest("foo_baz", cf=("test", "foo-bar"), env="FOO_BAZ")
+ self._test_dest("xyzzy",
+ "--xyzzy", cf=("test", "foo-bar"), env="FOO_BAZ")
+ self._test_dest("quux",
+ "--xyzzy", cf=("test", "foo-bar"), env="FOO_BAZ",
+ dest="quux")
+
+ @make_config()
+ def test_positional_args(self, config_file):
+ """get values from positional arguments."""
+ result = argparse.Namespace()
+ parser = Parser(namespace=result)
+ parser.add_options([PositionalArgument("single")])
+ parser.parse(["-C", config_file, "single"])
+ self.assertEqual(result.single, "single")
+
+ result = argparse.Namespace()
+ parser = Parser(namespace=result)
+ parser.add_options([PositionalArgument("one"),
+ PositionalArgument("two")])
+ parser.parse(["-C", config_file, "one", "two"])
+ self.assertEqual(result.one, "one")
+ self.assertEqual(result.two, "two")
+
+ def test_duplicate_cli_option(self):
+ """add duplicate CLI option."""
+ parser = Parser(components=[self])
+ self.assertRaises(
+ argparse.ArgumentError,
+ parser.add_options,
+ [Option("--test-option")])
+
+ def test_duplicate_env_option(self):
+ """add duplicate environment option."""
+ parser = Parser(components=[self])
+ self.assertRaises(
+ OptionParserException,
+ parser.add_options,
+ [Option(env="TEST_OPTION")])
+
+ def test_duplicate_cf_option(self):
+ """add duplicate config file option."""
+ parser = Parser(components=[self])
+ self.assertRaises(
+ OptionParserException,
+ parser.add_options,
+ [Option(cf=("test", "option"))])
+
+ @make_config()
+ def test_repository_macro(self, config_file):
+ """fix up <repository> macros."""
+ result = argparse.Namespace()
+ parser = Parser(namespace=result)
+ parser.add_options([PathOption("--test1"),
+ PathOption("--test2"),
+ Common.repository])
+ parser.parse(["-C", config_file, "-Q", "/foo/bar",
+ "--test1", "<repository>/test1",
+ "--test2", "<repository><repository>"])
+ self.assertEqual(result.repository, "/foo/bar")
+ self.assertEqual(result.test1, "/foo/bar/test1")
+ self.assertEqual(result.test2, "/foo/bar/foo/bar")
+
+ @make_config()
+ def test_file_like_path_option(self, config_file):
+ """get file-like object from PathOption."""
+ result = argparse.Namespace()
+ parser = Parser(namespace=result)
+ parser.add_options([PathOption("--test", type=argparse.FileType('r'))])
+
+ fd, name = tempfile.mkstemp()
+ fh = os.fdopen(fd, "w")
+ fh.write("test")
+ fh.close()
+
+ parser.parse(["-C", config_file, "--test", name])
+ self.assertEqual(result.test.name, name)
+ self.assertEqual(result.test.read(), "test")
+
+ @clean_environment
+ @make_config()
+ def test_unknown_options(self, config_file):
+ """error on unknown options."""
+ parser = Parser(components=[self])
+ self.assertRaises(SystemExit,
+ parser.parse,
+ ["-C", config_file, "--not-a-real-option"])
+
+ @clean_environment
+ @make_config()
+ def test_reparse(self, config_file):
+ """reparse options."""
+ result = argparse.Namespace()
+ parser = Parser(components=[self], namespace=result)
+ parser.parse(["-C", config_file])
+ self.assertFalse(result.test_false_boolean)
+
+ parser.parse(["-C", config_file])
+ self.assertFalse(result.test_false_boolean)
+
+ parser.reparse()
+ self.assertFalse(result.test_false_boolean)
+
+ parser.reparse(["-C", config_file, "--test-false-boolean"])
+ self.assertTrue(result.test_false_boolean)
+
+ cfp = ConfigParser.ConfigParser()
+ cfp.add_section("test")
+ cfp.set("test", "false_boolean", "on")
+ parser.parse(["-C", config_file])
+ cfp.write(open(config_file, "w"))
+ self.assertTrue(result.test_false_boolean)
+
+
+class TestParsingHooks(OptionTestCase):
+ """test option parsing hooks."""
+ def setUp(self):
+ self.options_parsed_hook = mock.Mock()
+ self.options = [BooleanOption("--test", default=False)]
+ self.results = argparse.Namespace()
+ new_parser()
+ self.parser = get_parser(components=[self], namespace=self.results)
+
+ @make_config()
+ def test_parsing_hooks(self, config_file):
+ """option parsing hooks are called."""
+ self.parser.parse(["-C", config_file])
+ self.options_parsed_hook.assert_called_with()
+
+
+class TestEarlyParsingHooks(OptionTestCase):
+ """test early option parsing hooks."""
+ parse_first = True
+
+ def setUp(self):
+ self.component_parsed_hook = mock.Mock()
+ self.options = [BooleanOption("--early-test", default=False)]
+ self.results = argparse.Namespace()
+ new_parser()
+ self.parser = get_parser(components=[self], namespace=self.results)
+
+ @make_config()
+ def test_parsing_hooks(self, config_file):
+ """early option parsing hooks are called."""
+ self.parser.parse(["-C", config_file, "--early-test"])
+ self.assertEqual(self.component_parsed_hook.call_count, 1)
+ early_opts = self.component_parsed_hook.call_args[0][0]
+ self.assertTrue(early_opts.early_test)
diff --git a/testsuite/Testsrc/Testlib/TestOptions/TestSubcommands.py b/testsuite/Testsrc/Testlib/TestOptions/TestSubcommands.py
new file mode 100644
index 000000000..35da909cb
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/TestSubcommands.py
@@ -0,0 +1,141 @@
+"""test subcommand option parsing."""
+
+import argparse
+import sys
+
+from Bcfg2.Compat import StringIO
+from Bcfg2.Options import Option, get_parser, new_parser, Subcommand, \
+ Subparser, CommandRegistry
+import Bcfg2.Options.Subcommands
+
+from testsuite.Testsrc.Testlib.TestOptions import make_config, OptionTestCase
+
+
+class MockSubcommand(Subcommand):
+ """fake subcommand that just records the options it was called with."""
+ run_options = None
+
+ def run(self, setup):
+ self.__class__.run_options = setup
+
+
+class One(MockSubcommand):
+ """fake subcommand for testing."""
+ options = [Option("--test-one")]
+
+
+class Two(MockSubcommand):
+ """fake subcommand for testing."""
+ options = [Option("--test-two")]
+
+
+def local_subclass(cls):
+ """get a subclass of ``cls`` that adds no functionality.
+
+ This can be used to subclass the various test classes above so
+ that their options don't get modified by option parsing.
+ """
+ return type("Local%s" % cls.__name__, (cls,), {})
+
+
+class TestSubcommands(OptionTestCase):
+ """tests for subcommands and subparsers."""
+
+ def setUp(self):
+ self.registry = CommandRegistry()
+
+ self.one = local_subclass(One)
+ self.two = local_subclass(Two)
+
+ self.registry.register_command(self.one)
+ self.registry.register_command(self.two)
+
+ self.result = argparse.Namespace()
+ Bcfg2.Options.Subcommands.master_setup = self.result
+
+ new_parser()
+ self.parser = get_parser(namespace=self.result,
+ components=[self])
+ self.parser.add_options(self.registry.subcommand_options)
+
+ def test_register_commands(self):
+ """register subcommands."""
+ registry = CommandRegistry()
+ registry.register_commands(globals().values(),
+ parent=MockSubcommand)
+ self.assertItemsEqual(registry.commands.keys(),
+ ["one", "two", "help"])
+ self.assertIsInstance(registry.commands['one'], One)
+ self.assertIsInstance(registry.commands['two'], Two)
+
+ @make_config()
+ def test_get_subcommand(self, config_file):
+ """parse simple subcommands."""
+ self.parser.parse(["-C", config_file, "localone"])
+ self.assertEqual(self.result.subcommand, "localone")
+
+ def test_subcommand_usage(self):
+ """sane usage message from subcommands."""
+ self.assertEqual(
+ One().usage(),
+ "one [--test-one TEST_ONE] - fake subcommand for testing.")
+
+ # subclasses do not inherit the docstring from the parent, so
+ # this tests a command subclass without a docstring, even
+ # though that should never happen due to the pylint tests.
+ self.assertEqual(self.one().usage().strip(),
+ "localone [--test-one TEST_ONE]")
+
+ @make_config()
+ def test_help(self, config_file):
+ """sane help message from subcommand registry."""
+ self.parser.parse(["-C", config_file, "help"])
+ old_stdout = sys.stdout
+ sys.stdout = StringIO()
+ self.assertIn(self.registry.runcommand(), [0, None])
+ help_message = sys.stdout.getvalue().splitlines()
+ sys.stdout = old_stdout
+
+ # the help message will look like:
+ #
+ # localhelp [<command>]
+ # localone [--test-one TEST_ONE]
+ # localtwo [--test-two TEST_TWO]
+ commands = []
+ command_help = {
+ "help": self.registry.help.usage(),
+ "localone": self.one().usage(),
+ "localtwo": self.two().usage()}
+ for line in help_message:
+ command = line.split()[0]
+ commands.append(command)
+ if command not in command_help:
+ self.fail("Got help for unknown command %s: %s" %
+ (command, line))
+ self.assertEqual(line, command_help[command])
+ self.assertItemsEqual(commands, command_help.keys())
+
+ @make_config()
+ def test_subcommand_help(self, config_file):
+ """get help message on a single command."""
+ self.parser.parse(["-C", config_file, "help", "localone"])
+ old_stdout = sys.stdout
+ sys.stdout = StringIO()
+ self.assertIn(self.registry.runcommand(), [0, None])
+ help_message = sys.stdout.getvalue().splitlines()
+ sys.stdout = old_stdout
+
+ self.assertEqual(help_message[0].strip(),
+ "usage: %s" % self.one().usage().strip())
+
+ @make_config()
+ def test_nonexistent_subcommand_help(self, config_file):
+ """get help message on a nonexistent command."""
+ self.parser.parse(["-C", config_file, "help", "blargle"])
+ old_stdout = sys.stdout
+ sys.stdout = StringIO()
+ self.assertNotEqual(self.registry.runcommand(), 0)
+ help_message = sys.stdout.getvalue().splitlines()
+ sys.stdout = old_stdout
+
+ self.assertIn("No such command", help_message[0])
diff --git a/testsuite/Testsrc/Testlib/TestOptions/TestTypes.py b/testsuite/Testsrc/Testlib/TestOptions/TestTypes.py
new file mode 100644
index 000000000..404d67fdc
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/TestTypes.py
@@ -0,0 +1,111 @@
+"""test builtin option types."""
+
+import argparse
+
+from mock import patch
+
+from Bcfg2.Options import Option, Types, Parser
+from testsuite.common import Bcfg2TestCase
+
+
+class TestOptionTypes(Bcfg2TestCase):
+ """test builtin option types."""
+ def setUp(self):
+ self.options = None
+
+ def _test_options(self, options):
+ """helper to test option types.
+
+ this expects that self.options is set to a single option named
+ test. The value of that option is returned.
+ """
+ result = argparse.Namespace()
+ parser = Parser(components=[self], namespace=result)
+ parser.parse(options)
+ return result.test
+
+ def test_comma_list(self):
+ """parse comma-list values."""
+ self.options = [Option("--test", type=Types.comma_list)]
+
+ expected = ["one", "two", "three"]
+ self.assertItemsEqual(self._test_options(["--test", "one,two,three"]),
+ expected)
+ self.assertItemsEqual(self._test_options(["--test",
+ "one, two, three"]),
+ expected)
+ self.assertItemsEqual(self._test_options(["--test",
+ "one , two ,three"]),
+ expected)
+ self.assertItemsEqual(self._test_options(["--test", "one two, three"]),
+ ["one two", "three"])
+
+ def test_colon_list(self):
+ """parse colon-list values."""
+ self.options = [Option("--test", type=Types.colon_list)]
+ self.assertItemsEqual(self._test_options(["--test", "one:two three"]),
+ ["one", "two three"])
+
+ def test_comma_dict(self):
+ """parse comma-dict values."""
+ self.options = [Option("--test", type=Types.comma_dict)]
+ expected = {
+ "one": True,
+ "two": 2,
+ "three": "three",
+ "four": False}
+ self.assertDictEqual(
+ self._test_options(["--test",
+ "one=yes, two=2 , three=three,four=no"]),
+ expected)
+
+ self.assertDictEqual(
+ self._test_options(["--test", "one,two=2,three=three,four=off"]),
+ expected)
+
+ def test_anchored_regex_list(self):
+ """parse regex lists."""
+ self.options = [Option("--test", type=Types.anchored_regex_list)]
+ self.assertItemsEqual(
+ [r.pattern for r in self._test_options(["--test", r'\d+ \s*'])],
+ [r'^\d+$', r'^\s*$'])
+ self.assertRaises(SystemExit,
+ self._test_options, ["--test", '(]'])
+
+ def test_octal(self):
+ """parse octal options."""
+ self.options = [Option("--test", type=Types.octal)]
+ self.assertEqual(self._test_options(["--test", "0777"]), 511)
+ self.assertEqual(self._test_options(["--test", "133114255"]), 23894189)
+
+ @patch("pwd.getpwnam")
+ def test_username(self, mock_getpwnam):
+ """parse username options."""
+ self.options = [Option("--test", type=Types.username)]
+ mock_getpwnam.return_value = ("test", '********', 1001, 1001,
+ "Test user", "/home/test", "/bin/bash")
+ self.assertEqual(self._test_options(["--test", "1001"]), 1001)
+ self.assertEqual(self._test_options(["--test", "test"]), 1001)
+
+ @patch("grp.getgrnam")
+ def test_groupname(self, mock_getpwnam):
+ """parse group name options."""
+ self.options = [Option("--test", type=Types.groupname)]
+ mock_getpwnam.return_value = ("test", '*', 1001, ["test"])
+ self.assertEqual(self._test_options(["--test", "1001"]), 1001)
+ self.assertEqual(self._test_options(["--test", "test"]), 1001)
+
+ def test_timeout(self):
+ """parse timeout options."""
+ self.options = [Option("--test", type=Types.timeout)]
+ self.assertEqual(self._test_options(["--test", "1.0"]), 1.0)
+ self.assertEqual(self._test_options(["--test", "1"]), 1.0)
+ self.assertEqual(self._test_options(["--test", "0"]), None)
+
+ def test_size(self):
+ """parse human-readable size options."""
+ self.options = [Option("--test", type=Types.size)]
+ self.assertEqual(self._test_options(["--test", "5k"]), 5120)
+ self.assertEqual(self._test_options(["--test", "5"]), 5)
+ self.assertRaises(SystemExit,
+ self._test_options, ["--test", "g5m"])
diff --git a/testsuite/Testsrc/Testlib/TestOptions/TestWildcards.py b/testsuite/Testsrc/Testlib/TestOptions/TestWildcards.py
new file mode 100644
index 000000000..da196a912
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/TestWildcards.py
@@ -0,0 +1,47 @@
+"""test wildcard options."""
+
+import argparse
+
+from Bcfg2.Options import Option, Parser
+from testsuite.Testsrc.Testlib.TestOptions import OptionTestCase, make_config
+
+
+class TestWildcardOptions(OptionTestCase):
+ """test parsing wildcard options."""
+ config = {
+ "foo": {
+ "test1": "test1",
+ "test2": "test2",
+ "thing1": "thing1",
+ "thing2": "thing2",
+ "foo": "foo"
+ }
+ }
+
+ def setUp(self):
+ # parsing options can modify the Option objects themselves.
+ # that's probably bad -- and it's definitely bad if we ever
+ # want to do real on-the-fly config changes -- but it's easier
+ # to leave it as is and set the options on each test.
+ self.options = [
+ Option(cf=("foo", "*"), dest="all"),
+ Option(cf=("foo", "test*"), dest="test"),
+ Option(cf=("foo", "bogus*"), dest="unmatched"),
+ Option(cf=("bar", "*"), dest="no_section"),
+ Option(cf=("foo", "foo"))]
+
+ @make_config(config)
+ def test_wildcard_options(self, config_file):
+ """parse wildcard options."""
+ result = argparse.Namespace()
+ parser = Parser(components=[self], namespace=result)
+ parser.parse(argv=["-C", config_file])
+
+ self.assertDictEqual(result.all, {"test1": "test1",
+ "test2": "test2",
+ "thing1": "thing1",
+ "thing2": "thing2"})
+ self.assertDictEqual(result.test, {"test1": "test1",
+ "test2": "test2"})
+ self.assertDictEqual(result.unmatched, {})
+ self.assertDictEqual(result.no_section, {})
diff --git a/testsuite/Testsrc/Testlib/TestOptions/Two.py b/testsuite/Testsrc/Testlib/TestOptions/Two.py
new file mode 100644
index 000000000..189e0817f
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/Two.py
@@ -0,0 +1,6 @@
+"""Test module for component loading."""
+
+
+class Two(object):
+ """Test class for component loading."""
+ pass
diff --git a/testsuite/Testsrc/Testlib/TestOptions/__init__.py b/testsuite/Testsrc/Testlib/TestOptions/__init__.py
new file mode 100644
index 000000000..b051f65e5
--- /dev/null
+++ b/testsuite/Testsrc/Testlib/TestOptions/__init__.py
@@ -0,0 +1,85 @@
+"""helper functions for option testing."""
+
+import os
+import tempfile
+
+from Bcfg2.Compat import wraps, ConfigParser
+from Bcfg2.Options import Parser
+from testsuite.common import Bcfg2TestCase
+
+
+class make_config(object): # pylint: disable=invalid-name
+ """decorator to create a temporary config file from a dict.
+
+ The filename of the temporary config file is added as the last
+ positional argument to the function call.
+ """
+ def __init__(self, config_data=None):
+ self.config_data = config_data or {}
+
+ def __call__(self, func):
+ @wraps(func)
+ def inner(*args, **kwargs):
+ """decorated function."""
+ cfp = ConfigParser.ConfigParser()
+ for section, options in self.config_data.items():
+ cfp.add_section(section)
+ for key, val in options.items():
+ cfp.set(section, key, val)
+ fd, name = tempfile.mkstemp()
+ config_file = os.fdopen(fd, 'w')
+ cfp.write(config_file)
+ config_file.close()
+
+ args = list(args) + [name]
+ rv = func(*args, **kwargs)
+ os.unlink(name)
+ return rv
+
+ return inner
+
+
+def clean_environment(func):
+ """decorator that unsets any environment variables used by options.
+
+ The list of options is taken from the first argument, which is
+ presumed to be ``self``. The variables are restored at the end of
+ the function.
+ """
+ @wraps(func)
+ def inner(self, *args, **kwargs):
+ """decorated function."""
+ envvars = {}
+ for opt in self.options:
+ if opt.env is not None:
+ envvars[opt.env] = os.environ.get(opt.env)
+ if opt.env in os.environ:
+ del os.environ[opt.env]
+ rv = func(self, *args, **kwargs)
+ for name, val in envvars.items():
+ if val is None and name in os.environ:
+ del os.environ[name]
+ elif val is not None:
+ os.environ[name] = val
+ return rv
+
+ return inner
+
+
+class OptionTestCase(Bcfg2TestCase):
+ """test case that doesn't mock out config file reading."""
+
+ @classmethod
+ def setUpClass(cls):
+ # ensure that the option parser actually reads config files
+ Parser.unit_test = False
+
+ @classmethod
+ def tearDownClass(cls):
+ Parser.unit_test = True
+
+
+
+# TODO:
+# * subcommands
+# * common options
diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testbase.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testbase.py
index f135a0197..290a7c092 100644
--- a/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testbase.py
+++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testbase.py
@@ -22,6 +22,7 @@ class TestPlugin(TestDebuggable):
def setUp(self):
TestDebuggable.setUp(self)
set_setup_default("filemonitor", MagicMock())
+ set_setup_default("repository", datastore)
def get_obj(self, core=None):
if core is None:
diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py
index 37beaa26c..5a82100d0 100644
--- a/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py
+++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py
@@ -55,6 +55,7 @@ class TestDatabaseBacked(TestPlugin):
def setUp(self):
TestPlugin.setUp(self)
set_setup_default("%s_db" % self.test_obj.__name__.lower(), False)
+ set_setup_default("db_engine", None)
@skipUnless(HAS_DJANGO, "Django not found")
def test__use_db(self):
diff --git a/testsuite/Testsrc/Testlib/TestUtils.py b/testsuite/Testsrc/Testlib/TestUtils.py
index 349d6cd40..4bed67248 100644
--- a/testsuite/Testsrc/Testlib/TestUtils.py
+++ b/testsuite/Testsrc/Testlib/TestUtils.py
@@ -1,9 +1,5 @@
import os
import sys
-import copy
-import lxml.etree
-import subprocess
-from mock import Mock, MagicMock, patch
from Bcfg2.Utils import *
# add all parent testsuite directories to sys.path to allow (most)
diff --git a/testsuite/Testsrc/__init__.py b/testsuite/Testsrc/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/testsuite/Testsrc/__init__.py