Bug 1247836 - Add classes for python configure options handling draft
authorMike Hommey <mh+mozilla@glandium.org>
Thu, 03 Mar 2016 15:42:19 +0900
changeset 336454 80d142fc4710661033c905301528bd810b00029d
parent 336453 58afbcb11fd4d498cc9d5000d5b6300f5ec888d8
child 336455 eec3e9b47c31fad30a81357caaf8d1892930446f
child 336467 d98cdb1916abb0e10416400b3d7adf0f19cb75c4
push id12065
push userbmo:mh+mozilla@glandium.org
push dateThu, 03 Mar 2016 06:46:31 +0000
bugs1247836
milestone47.0a1
Bug 1247836 - Add classes for python configure options handling
python/moz.build
python/mozbuild/mozbuild/configure/options.py
python/mozbuild/mozbuild/test/configure/test_options.py
--- a/python/moz.build
+++ b/python/moz.build
@@ -37,16 +37,17 @@ PYTHON_UNIT_TESTS += [
     'mozbuild/mozbuild/test/backend/test_android_eclipse.py',
     'mozbuild/mozbuild/test/backend/test_build.py',
     'mozbuild/mozbuild/test/backend/test_configenvironment.py',
     'mozbuild/mozbuild/test/backend/test_recursivemake.py',
     'mozbuild/mozbuild/test/backend/test_visualstudio.py',
     'mozbuild/mozbuild/test/common.py',
     'mozbuild/mozbuild/test/compilation/__init__.py',
     'mozbuild/mozbuild/test/compilation/test_warnings.py',
+    'mozbuild/mozbuild/test/configure/test_options.py',
     'mozbuild/mozbuild/test/controller/__init__.py',
     'mozbuild/mozbuild/test/controller/test_ccachestats.py',
     'mozbuild/mozbuild/test/controller/test_clobber.py',
     'mozbuild/mozbuild/test/frontend/__init__.py',
     'mozbuild/mozbuild/test/frontend/test_context.py',
     'mozbuild/mozbuild/test/frontend/test_emitter.py',
     'mozbuild/mozbuild/test/frontend/test_namespaces.py',
     'mozbuild/mozbuild/test/frontend/test_reader.py',
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/configure/options.py
@@ -0,0 +1,446 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+import sys
+import types
+from collections import OrderedDict
+
+
+def istupleofstrings(obj):
+    return isinstance(obj, tuple) and len(obj) and all(
+        isinstance(o, types.StringTypes) for o in obj)
+
+
+class OptionValue(tuple):
+    '''Represents the value of a configure option.
+
+    This class is not meant to be used directly. Use its subclasses instead.
+
+    The `origin` attribute holds where the option comes from (e.g. environment,
+    command line, or default)
+    '''
+    def __new__(cls, values=(), origin='unknown'):
+        return super(OptionValue, cls).__new__(cls, values)
+
+    def __init__(self, values=(), origin='unknown'):
+        self.origin = origin
+
+    def format(self, option):
+        if option.startswith('--'):
+            prefix, name, values = Option.split_option(option)
+            assert values == ()
+            for prefix_set in (
+                    ('disable', 'enable'),
+                    ('without', 'with'),
+            ):
+                if prefix in prefix_set:
+                    prefix = prefix_set[int(bool(self))]
+                    break
+            if prefix:
+                option = '--%s-%s' % (prefix, name)
+            elif self:
+                option = '--%s' % name
+            else:
+                return ''
+            if len(self):
+                return '%s=%s' % (option, ','.join(self))
+            return option
+        elif self and not len(self):
+            return '%s=1' % option
+        return '%s=%s' % (option, ','.join(self))
+
+    def __eq__(self, other):
+        if type(other) != type(self):
+            return False
+        return super(OptionValue, self).__eq__(other)
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __repr__(self):
+        return '%s%s' % (self.__class__.__name__,
+                         super(OptionValue, self).__repr__())
+
+
+class PositiveOptionValue(OptionValue):
+    '''Represents the value for a positive option (--enable/--with/--foo)
+    in the form of a tuple for when values are given to the option (in the form
+    --option=value[,value2...].
+    '''
+    def __nonzero__(self):
+        return True
+
+
+class NegativeOptionValue(OptionValue):
+    '''Represents the value for a negative option (--disable/--without)
+
+    This is effectively an empty tuple with a `origin` attribute.
+    '''
+    def __new__(cls, origin='unknown'):
+        return super(NegativeOptionValue, cls).__new__(cls, origin=origin)
+
+    def __init__(self, origin='unknown'):
+        return super(NegativeOptionValue, self).__init__(origin=origin)
+
+
+class InvalidOption(Exception):
+    pass
+
+
+class ConflictingOption(InvalidOption):
+    def __init__(self, message, **format_data):
+        if format_data:
+            message = message.format(**format_data)
+        super(ConflictingOption, self).__init__(message)
+        for k, v in format_data.iteritems():
+            setattr(self, k, v)
+
+
+class Option(object):
+    '''Represents a configure option
+
+    A configure option can be a command line flag or an environment variable
+    or both.
+
+    - `name` is the full command line flag (e.g. --enable-foo).
+    - `env` is the environment variable name (e.g. ENV)
+    - `nargs` is the number of arguments the option may take. It can be a
+      number or the special values '?' (0 or 1), '*' (0 or more), or '+' (1 or
+      more).
+    - `default` can be used to give a default value to the option. When the
+      `name` of the option starts with '--enable-' or '--with-', the implied
+      default is an empty PositiveOptionValue. When it starts with '--disable-'
+      or '--without-', the implied default is a NegativeOptionValue.
+    - `choices` restricts the set of values that can be given to the option.
+    - `help` is the option description for use in the --help output.
+    '''
+    __slots__ = (
+        'id', 'prefix', 'name', 'env', 'nargs', 'default', 'choices', 'help',
+    )
+
+    def __init__(self, name=None, env=None, nargs=None, default=None,
+                 choices=None, help=None):
+        if name:
+            if not isinstance(name, types.StringTypes):
+                raise InvalidOption('Option must be a string')
+            if not name.startswith('--'):
+                raise InvalidOption('Option must start with `--`')
+            if '=' in name:
+                raise InvalidOption('Option must not contain an `=`')
+            if not name.islower():
+                raise InvalidOption('Option must be all lowercase')
+        if env:
+            if not isinstance(env, types.StringTypes):
+                raise InvalidOption(
+                    'Environment variable name must be a string')
+            if not env.isupper():
+                raise InvalidOption(
+                    'Environment variable name must be all uppercase')
+        if not name and not env:
+            raise InvalidOption('At least an option name or an environment '
+                                'variable name must be given')
+        if nargs not in (None, '?', '*', '+') and not (
+                isinstance(nargs, int) and nargs >= 0):
+            raise InvalidOption(
+                "nargs must be a positive integer, '?', '*' or '+'")
+        if (not isinstance(default, types.StringTypes) and
+                not isinstance(default, (bool, types.NoneType)) and
+                not istupleofstrings(default)):
+            raise InvalidOption(
+                'default must be a bool, a string or a tuple of strings')
+        if choices and not istupleofstrings(choices):
+            raise InvalidOption(
+                'choices must be a tuple of strings')
+        if not help:
+            raise InvalidOption('A help string must be provided')
+
+        if name:
+            prefix, name, values = self.split_option(name)
+            assert values == ()
+
+            # --disable and --without options mean the default is enabled.
+            # --enable and --with options mean the default is disabled.
+            # However, we allow a default to be given so that the default
+            # can be affected by other factors.
+            if prefix:
+                if default is None:
+                    default = prefix in ('disable', 'without')
+                elif default is False:
+                    prefix = {
+                        'disable': 'enable',
+                        'without': 'with',
+                    }.get(prefix, prefix)
+                elif default is True:
+                    prefix = {
+                        'enable': 'disable',
+                        'with': 'without',
+                    }.get(prefix, prefix)
+        else:
+            prefix = ''
+
+        self.prefix = prefix
+        self.name = name
+        self.env = env
+        if default in (None, False):
+            self.default = NegativeOptionValue(origin='default')
+        elif isinstance(default, tuple):
+            self.default = PositiveOptionValue(default, origin='default')
+        elif default is True:
+            self.default = PositiveOptionValue(origin='default')
+        else:
+            self.default = PositiveOptionValue((default,), origin='default')
+        if nargs is None:
+            nargs = 0
+            if len(self.default) == 1:
+                nargs = '?'
+            elif len(self.default) > 1:
+                nargs = '*'
+            elif choices:
+                nargs = 1
+        self.nargs = nargs
+        has_choices = choices is not None
+        if isinstance(self.default, PositiveOptionValue):
+            if not self._validate_nargs(len(self.default)):
+                raise InvalidOption(
+                    "The given `default` doesn't satisfy `nargs`")
+            if has_choices and len(self.default) == 0:
+                raise InvalidOption(
+                    'A `default` must be given along with `choices`')
+            if has_choices and not all(d in choices for d in self.default):
+                raise InvalidOption(
+                    'The `default` value must be one of the `choices`')
+        elif has_choices:
+            if len(choices) < self.maxargs:
+                raise InvalidOption('Not enough `choices` for `nargs`')
+            if self.minargs == 0:
+                raise InvalidOption(
+                    '%s is not a valid `nargs` when `choices` are given'
+                    % str(nargs))
+        self.choices = choices
+        self.help = help
+
+    @staticmethod
+    def split_option(option):
+        if not isinstance(option, types.StringTypes):
+            raise InvalidOption('Option must be a string')
+
+        elements = option.split('=', 1)
+        name = elements[0]
+        values = tuple(elements[1].split(',')) if len(elements) == 2 else ()
+        if name.startswith('--'):
+            name = name[2:]
+            if not name.islower():
+                raise InvalidOption('Option must be all lowercase')
+            elements = name.split('-', 1)
+            prefix = elements[0]
+            if len(elements) == 2 and prefix in ('enable', 'disable',
+                                                 'with', 'without'):
+                return prefix, elements[1], values
+        else:
+            if name.startswith('-'):
+                raise InvalidOption(
+                    'Option must start with two dashes instead of one')
+            if name.islower():
+                raise InvalidOption(
+                    'Environment variable name must be all uppercase')
+        return '', name, values
+
+    @staticmethod
+    def _join_option(prefix, name, values=()):
+        # The constraints around name and env in __init__ make it so that
+        # we can distinguish between flags and environment variables with
+        # islower/isupper.
+        if name.isupper():
+            assert not prefix
+            option = name
+        elif prefix:
+            option = '--%s-%s' % (prefix, name)
+        else:
+            option = '--%s' % name
+        if not values:
+            return option
+        return '%s=%s' % (option, ','.join(values))
+
+    @property
+    def option(self):
+        if self.prefix or self.name:
+            return self._join_option(self.prefix, self.name)
+        else:
+            return self.env
+
+    @property
+    def minargs(self):
+        if isinstance(self.nargs, int):
+            return self.nargs
+        return 1 if self.nargs == '+' else 0
+
+    @property
+    def maxargs(self):
+        if isinstance(self.nargs, int):
+            return self.nargs
+        return 1 if self.nargs == '?' else -1
+
+    def _validate_nargs(self, num):
+        minargs, maxargs = self.minargs, self.maxargs
+        return num >= minargs and (maxargs == -1 or num <= maxargs)
+
+    def get_value(self, option=None, origin='unknown'):
+        '''Given a full command line option (e.g. --enable-foo=bar) or a
+        variable assignment (FOO=bar), returns the corresponding OptionValue.
+
+        Note: variable assignments can come from either the environment or
+        from the command line (e.g. `../configure CFLAGS=-O2`)
+        '''
+        if not option:
+            return self.default
+
+        prefix, name, values = self.split_option(option)
+        option = self._join_option(prefix, name)
+
+        assert name in (self.name, self.env)
+
+        if prefix in ('disable', 'without'):
+            if values != ():
+                raise InvalidOption('Cannot pass a value to %s' % option)
+            return NegativeOptionValue(origin=origin)
+
+        if name == self.env:
+            if values == ('',):
+                return NegativeOptionValue(origin=origin)
+            if self.nargs in (0, '?', '*') and values == ('1',):
+                return PositiveOptionValue(origin=origin)
+
+        values = PositiveOptionValue(values, origin=origin)
+
+        if not self._validate_nargs(len(values)):
+            raise InvalidOption('%s takes %s value%s' % (
+                option,
+                {
+                    '?': '0 or 1',
+                    '*': '0 or more',
+                    '+': '1 or more',
+                }.get(self.nargs, str(self.nargs)),
+                's' if (not isinstance(self.nargs, int) or
+                        self.nargs != 1) else ''
+            ))
+
+        if len(values) and self.choices:
+            for val in values:
+                if val not in self.choices:
+                    raise InvalidOption(
+                        "'%s' is not one of %s"
+                        % (val, ', '.join("'%s'" % c for c in self.choices)))
+
+        return values
+
+
+class CommandLineHelper(object):
+    '''Helper class to handle the various ways options can be given either
+    on the command line of through the environment.
+
+    For instance, an Option('--foo', env='FOO') can be passed as --foo on the
+    command line, or as FOO=1 in the environment *or* on the command line.
+
+    If multiple variants are given, command line is prefered over the
+    environment, and if different values are given on the command line, the
+    last one wins. (This mimicks the behavior of autoconf, avoiding to break
+    existing mozconfigs using valid options in weird ways)
+
+    Extra options can be added afterwards through API calls. For those,
+    conflicting values will raise an exception.
+    '''
+    def __init__(self, environ=os.environ, argv=sys.argv):
+        self._environ = dict(environ)
+        self._args = OrderedDict()
+        self._extra_args = OrderedDict()
+        self._origins = {}
+
+        for position, arg in enumerate(argv[1:]):
+            prefix, name, values = Option.split_option(arg)
+            self._args[name] = arg, position
+
+    def add(self, arg, origin='command-line'):
+        assert origin != 'default'
+        prefix, name, values = Option.split_option(arg)
+        if name in self._extra_args:
+            old_arg = self._extra_args[name][0]
+            old_prefix, _, old_values = Option.split_option(old_arg)
+            if prefix != old_prefix or values != old_values:
+                raise ConflictingOption(
+                    "Cannot add '{arg}' to the {origin} set because it "
+                    "conflicts with '{old_arg}' that was added earlier",
+                    arg=arg, origin=origin, old_arg=old_arg,
+                    old_origin=self._origins[old_arg])
+        self._extra_args[name] = arg, -1 - len(self._extra_args)
+        self._origins[arg] = origin
+
+    def _prepare(self, option, args):
+        arg, pos = None, 0
+        origin = 'command-line'
+        from_name = args.get(option.name)
+        from_env = args.get(option.env)
+        if from_name and from_env:
+            arg1, pos1 = from_name
+            arg2, pos2 = from_env
+            arg, pos = (arg1, pos1) if abs(pos1) > abs(pos2) else (arg2, pos2)
+            if args is self._extra_args and (option.get_value(arg1) !=
+                                             option.get_value(arg2)):
+                origin = self._origins[arg]
+                old_arg = arg2 if abs(pos1) > abs(pos2) else arg1
+                raise ConflictingOption(
+                    "Cannot add '{arg}' to the {origin} set because it "
+                    "conflicts with '{old_arg}' that was added earlier",
+                    arg=arg, origin=origin, old_arg=old_arg,
+                    old_origin=self._origins[old_arg])
+        elif from_name or from_env:
+            arg, pos = from_name if from_name else from_env
+        elif option.env and args is self._args:
+            env = self._environ.get(option.env)
+            if env is not None:
+                arg = '%s=%s' % (option.env, env)
+                origin = 'environment'
+
+        if args is self._extra_args:
+            origin = self._origins.get(arg, origin)
+
+        for k in (option.name, option.env):
+            try:
+                del args[k]
+            except KeyError:
+                pass
+
+        return arg, origin
+
+    def handle(self, option):
+        '''Return the OptionValue corresponding to the given Option instance,
+        depending on the command line, environment, and extra arguments, and
+        the actual option or variable that set it.
+        Only works once for a given Option.
+        '''
+        assert isinstance(option, Option)
+
+        arg, origin = self._prepare(option, self._args)
+        ret = option.get_value(arg, origin)
+
+        extra_arg, extra_origin = self._prepare(option, self._extra_args)
+        extra_ret = option.get_value(extra_arg, extra_origin)
+
+        if extra_ret.origin == 'default':
+            return ret, arg
+
+        if ret.origin != 'default' and extra_ret != ret:
+            raise ConflictingOption(
+                "Cannot add '{arg}' to the {origin} set because it conflicts "
+                "with {old_arg} from the {old_origin} set", arg=extra_arg,
+                origin=extra_ret.origin, old_arg=arg, old_origin=ret.origin)
+
+        return extra_ret, extra_arg
+
+    def __iter__(self):
+        for d in (self._args, self._extra_args):
+            for arg, pos in d.itervalues():
+                yield arg
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/configure/test_options.py
@@ -0,0 +1,662 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import unittest
+
+from mozunit import main
+
+from mozbuild.configure.options import (
+    CommandLineHelper,
+    ConflictingOption,
+    InvalidOption,
+    NegativeOptionValue,
+    Option,
+    PositiveOptionValue,
+)
+
+
+class Option(Option):
+    def __init__(self, *args, **kwargs):
+        kwargs['help'] = 'Dummy help'
+        super(Option, self).__init__(*args, **kwargs)
+
+
+class TestOption(unittest.TestCase):
+    def test_option(self):
+        option = Option('--option')
+        self.assertEquals(option.prefix, '')
+        self.assertEquals(option.name, 'option')
+        self.assertEquals(option.env, None)
+        self.assertFalse(option.default)
+
+        option = Option('--enable-option')
+        self.assertEquals(option.prefix, 'enable')
+        self.assertEquals(option.name, 'option')
+        self.assertEquals(option.env, None)
+        self.assertFalse(option.default)
+
+        option = Option('--disable-option')
+        self.assertEquals(option.prefix, 'disable')
+        self.assertEquals(option.name, 'option')
+        self.assertEquals(option.env, None)
+        self.assertTrue(option.default)
+
+        option = Option('--with-option')
+        self.assertEquals(option.prefix, 'with')
+        self.assertEquals(option.name, 'option')
+        self.assertEquals(option.env, None)
+        self.assertFalse(option.default)
+
+        option = Option('--without-option')
+        self.assertEquals(option.prefix, 'without')
+        self.assertEquals(option.name, 'option')
+        self.assertEquals(option.env, None)
+        self.assertTrue(option.default)
+
+        option = Option('--without-option-foo', env='MOZ_OPTION')
+        self.assertEquals(option.env, 'MOZ_OPTION')
+
+        option = Option(env='MOZ_OPTION')
+        self.assertEquals(option.prefix, '')
+        self.assertEquals(option.name, None)
+        self.assertEquals(option.env, 'MOZ_OPTION')
+        self.assertFalse(option.default)
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=0, default=('a',))
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=1, default=())
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=1, default=True)
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=1, default=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=2, default=())
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=2, default=True)
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=2, default=('a',))
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs='?', default=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs='+', default=())
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs='+', default=True)
+
+        # Those need defaults
+        with self.assertRaises(InvalidOption):
+            Option('--disable-option', nargs=1)
+
+        with self.assertRaises(InvalidOption):
+            Option('--disable-option', nargs='+')
+
+        # Test nargs inference from default value
+        option = Option('--with-foo', default=True)
+        self.assertEquals(option.nargs, 0)
+
+        option = Option('--with-foo', default=False)
+        self.assertEquals(option.nargs, 0)
+
+        option = Option('--with-foo', default='a')
+        self.assertEquals(option.nargs, '?')
+
+        option = Option('--with-foo', default=('a',))
+        self.assertEquals(option.nargs, '?')
+
+        option = Option('--with-foo', default=('a', 'b'))
+        self.assertEquals(option.nargs, '*')
+
+        option = Option(env='FOO', default=True)
+        self.assertEquals(option.nargs, 0)
+
+        option = Option(env='FOO', default=False)
+        self.assertEquals(option.nargs, 0)
+
+        option = Option(env='FOO', default='a')
+        self.assertEquals(option.nargs, '?')
+
+        option = Option(env='FOO', default=('a',))
+        self.assertEquals(option.nargs, '?')
+
+        option = Option(env='FOO', default=('a', 'b'))
+        self.assertEquals(option.nargs, '*')
+
+    def test_option_option(self):
+        for option in (
+            '--option',
+            '--enable-option',
+            '--disable-option',
+            '--with-option',
+            '--without-option',
+        ):
+            self.assertEquals(Option(option).option, option)
+            self.assertEquals(Option(option, env='FOO').option, option)
+
+            opt = Option(option, default=False)
+            self.assertEquals(opt.option,
+                              option.replace('-disable-', '-enable-')
+                                    .replace('-without-', '-with-'))
+
+            opt = Option(option, default=True)
+            self.assertEquals(opt.option,
+                              option.replace('-enable-', '-disable-')
+                                    .replace('-with-', '-without-'))
+
+        self.assertEquals(Option(env='FOO').option, 'FOO')
+
+    def test_option_choices(self):
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=0, choices=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=3, choices=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs='?', choices=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs='*', choices=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--without-option', nargs=1, choices=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--without-option', nargs='+', choices=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--without-option', default='c', choices=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--without-option', default=('a', 'c',), choices=('a', 'b'))
+
+        with self.assertRaises(InvalidOption):
+            Option('--without-option', default=('c',), choices=('a', 'b'))
+
+        option = Option('--without-option', nargs='*', default='a',
+                        choices=('a', 'b'))
+        with self.assertRaises(InvalidOption):
+            option.get_value('--with-option=c')
+
+        value = option.get_value('--with-option=b,a')
+        self.assertTrue(value)
+        self.assertEquals(PositiveOptionValue(('b', 'a')), value)
+
+        # Test nargs inference from choices
+        option = Option('--with-option', choices=('a', 'b'))
+        self.assertEqual(option.nargs, 1)
+
+    def test_option_value_format(self):
+        val = PositiveOptionValue()
+        self.assertEquals('--with-value', val.format('--with-value'))
+        self.assertEquals('--with-value', val.format('--without-value'))
+        self.assertEquals('--enable-value', val.format('--enable-value'))
+        self.assertEquals('--enable-value', val.format('--disable-value'))
+        self.assertEquals('--value', val.format('--value'))
+        self.assertEquals('VALUE=1', val.format('VALUE'))
+
+        val = PositiveOptionValue(('a',))
+        self.assertEquals('--with-value=a', val.format('--with-value'))
+        self.assertEquals('--with-value=a', val.format('--without-value'))
+        self.assertEquals('--enable-value=a', val.format('--enable-value'))
+        self.assertEquals('--enable-value=a', val.format('--disable-value'))
+        self.assertEquals('--value=a', val.format('--value'))
+        self.assertEquals('VALUE=a', val.format('VALUE'))
+
+        val = PositiveOptionValue(('a', 'b'))
+        self.assertEquals('--with-value=a,b', val.format('--with-value'))
+        self.assertEquals('--with-value=a,b', val.format('--without-value'))
+        self.assertEquals('--enable-value=a,b', val.format('--enable-value'))
+        self.assertEquals('--enable-value=a,b', val.format('--disable-value'))
+        self.assertEquals('--value=a,b', val.format('--value'))
+        self.assertEquals('VALUE=a,b', val.format('VALUE'))
+
+        val = NegativeOptionValue()
+        self.assertEquals('--without-value', val.format('--with-value'))
+        self.assertEquals('--without-value', val.format('--without-value'))
+        self.assertEquals('--disable-value', val.format('--enable-value'))
+        self.assertEquals('--disable-value', val.format('--disable-value'))
+        self.assertEquals('', val.format('--value'))
+        self.assertEquals('VALUE=', val.format('VALUE'))
+
+    def test_option_value(self, name='option', nargs=0, default=None):
+        disabled = name.startswith(('disable-', 'without-'))
+        if disabled:
+            negOptionValue = PositiveOptionValue
+            posOptionValue = NegativeOptionValue
+        else:
+            posOptionValue = PositiveOptionValue
+            negOptionValue = NegativeOptionValue
+        defaultValue = (PositiveOptionValue(default)
+                        if default else negOptionValue())
+
+        option = Option('--%s' % name, nargs=nargs, default=default)
+
+        if nargs in (0, '?', '*') or disabled:
+            value = option.get_value('--%s' % name, 'option')
+            self.assertEquals(value, posOptionValue())
+            self.assertEquals(value.origin, 'option')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('--%s' % name)
+
+        value = option.get_value('')
+        self.assertEquals(value, defaultValue)
+        self.assertEquals(value.origin, 'default')
+
+        value = option.get_value(None)
+        self.assertEquals(value, defaultValue)
+        self.assertEquals(value.origin, 'default')
+
+        with self.assertRaises(AssertionError):
+            value = option.get_value('MOZ_OPTION=', 'environment')
+
+        with self.assertRaises(AssertionError):
+            value = option.get_value('MOZ_OPTION=1', 'environment')
+
+        with self.assertRaises(AssertionError):
+            value = option.get_value('--foo')
+
+        if nargs in (1, '?', '*', '+') and not disabled:
+            value = option.get_value('--%s=' % name, 'option')
+            self.assertEquals(value, PositiveOptionValue(('',)))
+            self.assertEquals(value.origin, 'option')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('--%s=' % name)
+
+        if nargs in (1, '?', '*', '+') and not disabled:
+            value = option.get_value('--%s=foo' % name, 'option')
+            self.assertEquals(value, PositiveOptionValue(('foo',)))
+            self.assertEquals(value.origin, 'option')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('--%s=foo' % name)
+
+        if nargs in (2, '*', '+') and not disabled:
+            value = option.get_value('--%s=foo,bar' % name, 'option')
+            self.assertEquals(value, PositiveOptionValue(('foo', 'bar')))
+            self.assertEquals(value.origin, 'option')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('--%s=foo,bar' % name, 'option')
+
+        option = Option('--%s' % name, env='MOZ_OPTION', nargs=nargs,
+                        default=default)
+        if nargs in (0, '?', '*') or disabled:
+            value = option.get_value('--%s' % name, 'option')
+            self.assertEquals(value, posOptionValue())
+            self.assertEquals(value.origin, 'option')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('--%s' % name)
+
+        value = option.get_value('')
+        self.assertEquals(value, defaultValue)
+        self.assertEquals(value.origin, 'default')
+
+        value = option.get_value(None)
+        self.assertEquals(value, defaultValue)
+        self.assertEquals(value.origin, 'default')
+
+        value = option.get_value('MOZ_OPTION=', 'environment')
+        self.assertEquals(value, NegativeOptionValue())
+        self.assertEquals(value.origin, 'environment')
+
+        if nargs in (0, '?', '*'):
+            value = option.get_value('MOZ_OPTION=1', 'environment')
+            self.assertEquals(value, PositiveOptionValue())
+            self.assertEquals(value.origin, 'environment')
+        elif nargs in (1, '+'):
+            value = option.get_value('MOZ_OPTION=1', 'environment')
+            self.assertEquals(value, PositiveOptionValue(('1',)))
+            self.assertEquals(value.origin, 'environment')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('MOZ_OPTION=1', 'environment')
+
+        if nargs in (1, '?', '*', '+') and not disabled:
+            value = option.get_value('--%s=' % name, 'option')
+            self.assertEquals(value, PositiveOptionValue(('',)))
+            self.assertEquals(value.origin, 'option')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('--%s=' % name, 'option')
+
+        with self.assertRaises(AssertionError):
+            value = option.get_value('--foo', 'option')
+
+        if nargs in (1, '?', '*', '+'):
+            value = option.get_value('MOZ_OPTION=foo', 'environment')
+            self.assertEquals(value, PositiveOptionValue(('foo',)))
+            self.assertEquals(value.origin, 'environment')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('MOZ_OPTION=foo', 'environment')
+
+        if nargs in (2, '*', '+'):
+            value = option.get_value('MOZ_OPTION=foo,bar', 'environment')
+            self.assertEquals(value, PositiveOptionValue(('foo', 'bar')))
+            self.assertEquals(value.origin, 'environment')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('MOZ_OPTION=foo,bar', 'environment')
+
+        if disabled:
+            return option
+
+        env_option = Option(env='MOZ_OPTION', nargs=nargs, default=default)
+        with self.assertRaises(AssertionError):
+            env_option.get_value('--%s' % name)
+
+        value = env_option.get_value('')
+        self.assertEquals(value, defaultValue)
+        self.assertEquals(value.origin, 'default')
+
+        value = env_option.get_value('MOZ_OPTION=', 'environment')
+        self.assertEquals(value, negOptionValue())
+        self.assertEquals(value.origin, 'environment')
+
+        if nargs in (0, '?', '*'):
+            value = env_option.get_value('MOZ_OPTION=1', 'environment')
+            self.assertEquals(value, posOptionValue())
+            self.assertTrue(value)
+            self.assertEquals(value.origin, 'environment')
+        elif nargs in (1, '+'):
+            value = env_option.get_value('MOZ_OPTION=1', 'environment')
+            self.assertEquals(value, PositiveOptionValue(('1',)))
+            self.assertEquals(value.origin, 'environment')
+        else:
+            with self.assertRaises(InvalidOption):
+                env_option.get_value('MOZ_OPTION=1', 'environment')
+
+        with self.assertRaises(AssertionError):
+            env_option.get_value('--%s' % name)
+
+        with self.assertRaises(AssertionError):
+            value = env_option.get_value('--foo')
+
+        if nargs in (1, '?', '*', '+'):
+            value = env_option.get_value('MOZ_OPTION=foo', 'environment')
+            self.assertEquals(value, PositiveOptionValue(('foo',)))
+            self.assertEquals(value.origin, 'environment')
+        else:
+            with self.assertRaises(InvalidOption):
+                env_option.get_value('MOZ_OPTION=foo', 'environment')
+
+        if nargs in (2, '*', '+'):
+            value = env_option.get_value('MOZ_OPTION=foo,bar', 'environment')
+            self.assertEquals(value, PositiveOptionValue(('foo', 'bar')))
+            self.assertEquals(value.origin, 'environment')
+        else:
+            with self.assertRaises(InvalidOption):
+                env_option.get_value('MOZ_OPTION=foo,bar', 'environment')
+
+        return option
+
+    def test_option_value_enable(self, enable='enable', disable='disable',
+                                 nargs=0, default=None):
+        option = self.test_option_value('%s-option' % enable, nargs=nargs,
+                                        default=default)
+
+        value = option.get_value('--%s-option' % disable, 'option')
+        self.assertEquals(value, NegativeOptionValue())
+        self.assertEquals(value.origin, 'option')
+
+        option = self.test_option_value('%s-option' % disable, nargs=nargs,
+                                        default=default)
+
+        if nargs in (0, '?', '*'):
+            value = option.get_value('--%s-option' % enable, 'option')
+            self.assertEquals(value, PositiveOptionValue())
+            self.assertEquals(value.origin, 'option')
+        else:
+            with self.assertRaises(InvalidOption):
+                option.get_value('--%s-option' % enable, 'option')
+
+    def test_option_value_with(self):
+        self.test_option_value_enable('with', 'without')
+
+    def test_option_value_invalid_nargs(self):
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs='foo')
+
+        with self.assertRaises(InvalidOption):
+            Option('--option', nargs=-2)
+
+    def test_option_value_nargs_1(self):
+        self.test_option_value(nargs=1)
+        self.test_option_value(nargs=1, default=('a',))
+        self.test_option_value_enable(nargs=1, default=('a',))
+
+        # A default is required
+        with self.assertRaises(InvalidOption):
+            Option('--disable-option', nargs=1)
+
+    def test_option_value_nargs_2(self):
+        self.test_option_value(nargs=2)
+        self.test_option_value(nargs=2, default=('a', 'b'))
+        self.test_option_value_enable(nargs=2, default=('a', 'b'))
+
+        # A default is required
+        with self.assertRaises(InvalidOption):
+            Option('--disable-option', nargs=2)
+
+    def test_option_value_nargs_0_or_1(self):
+        self.test_option_value(nargs='?')
+        self.test_option_value(nargs='?', default=('a',))
+        self.test_option_value_enable(nargs='?')
+        self.test_option_value_enable(nargs='?', default=('a',))
+
+    def test_option_value_nargs_0_or_more(self):
+        self.test_option_value(nargs='*')
+        self.test_option_value(nargs='*', default=('a',))
+        self.test_option_value(nargs='*', default=('a', 'b'))
+        self.test_option_value_enable(nargs='*')
+        self.test_option_value_enable(nargs='*', default=('a',))
+        self.test_option_value_enable(nargs='*', default=('a', 'b'))
+
+    def test_option_value_nargs_1_or_more(self):
+        self.test_option_value(nargs='+')
+        self.test_option_value(nargs='+', default=('a',))
+        self.test_option_value(nargs='+', default=('a', 'b'))
+        self.test_option_value_enable(nargs='+', default=('a',))
+        self.test_option_value_enable(nargs='+', default=('a', 'b'))
+
+        # A default is required
+        with self.assertRaises(InvalidOption):
+            Option('--disable-option', nargs='+')
+
+
+class TestCommandLineHelper(unittest.TestCase):
+    def test_basic(self):
+        helper = CommandLineHelper({}, ['cmd', '--foo', '--bar'])
+
+        self.assertEquals(['--foo', '--bar'], list(helper))
+
+        helper.add('--enable-qux')
+
+        self.assertEquals(['--foo', '--bar', '--enable-qux'], list(helper))
+
+        value, option = helper.handle(Option('--bar'))
+        self.assertEquals(['--foo', '--enable-qux'], list(helper))
+        self.assertEquals(PositiveOptionValue(), value)
+        self.assertEquals('--bar', option)
+
+        value, option = helper.handle(Option('--baz'))
+        self.assertEquals(['--foo', '--enable-qux'], list(helper))
+        self.assertEquals(NegativeOptionValue(), value)
+        self.assertEquals(None, option)
+
+    def test_precedence(self):
+        foo = Option('--with-foo', nargs='*')
+        helper = CommandLineHelper({}, ['cmd', '--with-foo=a,b'])
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b')), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('--with-foo=a,b', option)
+
+        helper = CommandLineHelper({}, ['cmd', '--with-foo=a,b',
+                                        '--without-foo'])
+        value, option = helper.handle(foo)
+        self.assertEquals(NegativeOptionValue(), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('--without-foo', option)
+
+        helper = CommandLineHelper({}, ['cmd', '--without-foo',
+                                        '--with-foo=a,b'])
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b')), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('--with-foo=a,b', option)
+
+        foo = Option('--with-foo', env='FOO', nargs='*')
+        helper = CommandLineHelper({'FOO': ''}, ['cmd', '--with-foo=a,b'])
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b')), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('--with-foo=a,b', option)
+
+        helper = CommandLineHelper({'FOO': 'a,b'}, ['cmd', '--without-foo'])
+        value, option = helper.handle(foo)
+        self.assertEquals(NegativeOptionValue(), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('--without-foo', option)
+
+        helper = CommandLineHelper({'FOO': ''}, ['cmd', '--with-bar=a,b'])
+        value, option = helper.handle(foo)
+        self.assertEquals(NegativeOptionValue(), value)
+        self.assertEquals('environment', value.origin)
+        self.assertEquals('FOO=', option)
+
+        helper = CommandLineHelper({'FOO': 'a,b'}, ['cmd', '--without-bar'])
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b')), value)
+        self.assertEquals('environment', value.origin)
+        self.assertEquals('FOO=a,b', option)
+
+        helper = CommandLineHelper({}, ['cmd', '--with-foo=a,b', 'FOO='])
+        value, option = helper.handle(foo)
+        self.assertEquals(NegativeOptionValue(), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('FOO=', option)
+
+        helper = CommandLineHelper({}, ['cmd', '--without-foo', 'FOO=a,b'])
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b')), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('FOO=a,b', option)
+
+        helper = CommandLineHelper({}, ['cmd', 'FOO=', '--with-foo=a,b'])
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b')), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('--with-foo=a,b', option)
+
+        helper = CommandLineHelper({}, ['cmd', 'FOO=a,b', '--without-foo'])
+        value, option = helper.handle(foo)
+        self.assertEquals(NegativeOptionValue(), value)
+        self.assertEquals('command-line', value.origin)
+        self.assertEquals('--without-foo', option)
+
+    def test_extra_args(self):
+        foo = Option('--with-foo', env='FOO', nargs='*')
+        helper = CommandLineHelper({}, ['cmd'])
+        helper.add('FOO=a,b,c', 'implied')
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b', 'c')), value)
+        self.assertEquals('implied', value.origin)
+        self.assertEquals('FOO=a,b,c', option)
+
+        helper = CommandLineHelper({}, ['cmd'])
+        helper.add('FOO=a,b,c', 'implied')
+        helper.add('--with-foo=a,b,c', 'implied')
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b', 'c')), value)
+        self.assertEquals('implied', value.origin)
+        self.assertEquals('--with-foo=a,b,c', option)
+
+        # Adding conflicting options is not allowed.
+        helper = CommandLineHelper({}, ['cmd'])
+        helper.add('FOO=a,b,c', 'implied')
+        with self.assertRaises(ConflictingOption) as cm:
+            helper.add('FOO=', 'implied')
+        self.assertEqual('FOO=', cm.exception.arg)
+        self.assertEqual('implied', cm.exception.origin)
+        self.assertEqual('FOO=a,b,c', cm.exception.old_arg)
+        self.assertEqual('implied', cm.exception.old_origin)
+        with self.assertRaises(ConflictingOption) as cm:
+            helper.add('FOO=a,b', 'implied')
+        self.assertEqual('FOO=a,b', cm.exception.arg)
+        self.assertEqual('implied', cm.exception.origin)
+        self.assertEqual('FOO=a,b,c', cm.exception.old_arg)
+        self.assertEqual('implied', cm.exception.old_origin)
+        # But adding the same is allowed.
+        helper.add('FOO=a,b,c', 'implied')
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b', 'c')), value)
+        self.assertEquals('implied', value.origin)
+        self.assertEquals('FOO=a,b,c', option)
+
+        # The same rule as above applies when using the option form vs. the
+        # variable form. But we can't detect it when .add is called.
+        helper = CommandLineHelper({}, ['cmd'])
+        helper.add('FOO=a,b,c', 'implied')
+        helper.add('--without-foo', 'implied')
+        with self.assertRaises(ConflictingOption) as cm:
+            helper.handle(foo)
+        self.assertEqual('--without-foo', cm.exception.arg)
+        self.assertEqual('implied', cm.exception.origin)
+        self.assertEqual('FOO=a,b,c', cm.exception.old_arg)
+        self.assertEqual('implied', cm.exception.old_origin)
+        helper = CommandLineHelper({}, ['cmd'])
+        helper.add('FOO=a,b,c', 'implied')
+        helper.add('--with-foo=a,b', 'implied')
+        with self.assertRaises(ConflictingOption) as cm:
+            helper.handle(foo)
+        self.assertEqual('--with-foo=a,b', cm.exception.arg)
+        self.assertEqual('implied', cm.exception.origin)
+        self.assertEqual('FOO=a,b,c', cm.exception.old_arg)
+        self.assertEqual('implied', cm.exception.old_origin)
+        helper = CommandLineHelper({}, ['cmd'])
+        helper.add('FOO=a,b,c', 'implied')
+        helper.add('--with-foo=a,b,c', 'implied')
+        value, option = helper.handle(foo)
+        self.assertEquals(PositiveOptionValue(('a', 'b', 'c')), value)
+        self.assertEquals('implied', value.origin)
+        self.assertEquals('--with-foo=a,b,c', option)
+
+        # Conflicts are also not allowed against what is in the
+        # environment/on the command line.
+        helper = CommandLineHelper({}, ['cmd', '--with-foo=a,b'])
+        helper.add('FOO=a,b,c', 'implied')
+        with self.assertRaises(ConflictingOption) as cm:
+            helper.handle(foo)
+        self.assertEqual('FOO=a,b,c', cm.exception.arg)
+        self.assertEqual('implied', cm.exception.origin)
+        self.assertEqual('--with-foo=a,b', cm.exception.old_arg)
+        self.assertEqual('command-line', cm.exception.old_origin)
+
+        helper = CommandLineHelper({}, ['cmd', '--with-foo=a,b'])
+        helper.add('--without-foo', 'implied')
+        with self.assertRaises(ConflictingOption) as cm:
+            helper.handle(foo)
+        self.assertEqual('--without-foo', cm.exception.arg)
+        self.assertEqual('implied', cm.exception.origin)
+        self.assertEqual('--with-foo=a,b', cm.exception.old_arg)
+        self.assertEqual('command-line', cm.exception.old_origin)
+
+
+if __name__ == '__main__':
+    main()