Bug 1247836 - Building blocks for python configure. r=nalexander,r=chmanchester
authorMike Hommey <mh+mozilla@glandium.org>
Thu, 03 Mar 2016 15:43:14 +0900
changeset 327045 85ead00f0a39583ccb7fcce767017d1ec6877094
parent 327044 04b85307486049190b87746c7c7a0c5ab629a77a
child 327046 f39bfd0598db3e69b615671bf9e615e3e82b94b0
push id1146
push userCallek@gmail.com
push dateMon, 25 Jul 2016 16:35:44 +0000
treeherdermozilla-release@a55778f9cd5a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander, chmanchester
bugs1247836
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1247836 - Building blocks for python configure. r=nalexander,r=chmanchester
python/moz.build
python/mozbuild/mozbuild/configure/__init__.py
python/mozbuild/mozbuild/configure/help.py
python/mozbuild/mozbuild/test/configure/data/extra.configure
python/mozbuild/mozbuild/test/configure/data/included.configure
python/mozbuild/mozbuild/test/configure/data/moz.configure
python/mozbuild/mozbuild/test/configure/test_configure.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_configure.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',
--- a/python/mozbuild/mozbuild/configure/__init__.py
+++ b/python/mozbuild/mozbuild/configure/__init__.py
@@ -0,0 +1,448 @@
+# 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 inspect
+import os
+import sys
+import types
+from collections import OrderedDict
+from functools import wraps
+from mozbuild.configure.options import (
+    CommandLineHelper,
+    ConflictingOptionError,
+    InvalidOptionError,
+    Option,
+    OptionValue,
+)
+from mozbuild.configure.help import HelpFormatter
+from mozbuild.util import (
+    ReadOnlyDict,
+    ReadOnlyNamespace,
+)
+import mozpack.path as mozpath
+
+
+class ConfigureError(Exception):
+    pass
+
+
+class DummyFunction(object):
+    '''Sandbox-visible representation of @depends functions.'''
+    def __call__(self, *arg, **kwargs):
+        raise RuntimeError('The `%s` function may not be called'
+                           % self.__name__)
+
+
+class SandboxedGlobal(dict):
+    '''Identifiable dict type for use as function global'''
+
+
+class DependsOutput(dict):
+    '''Dict holding the results yielded by a @depends function.'''
+    __slots__ = ('implied_options',)
+
+    def __init__(self):
+        super(DependsOutput, self).__init__()
+        self.implied_options = []
+
+    def imply_option(self, option, reason=None):
+        if not isinstance(option, types.StringTypes):
+            raise TypeError('imply_option must be given a string')
+        self.implied_options.append((option, reason))
+
+
+def forbidden_import(*args, **kwargs):
+    raise ImportError('Importing modules is forbidden')
+
+
+class ConfigureSandbox(dict):
+    """Represents a sandbox for executing Python code for build configuration.
+    This is a different kind of sandboxing than the one used for moz.build
+    processing.
+
+    The sandbox has 5 primitives:
+    - option
+    - depends
+    - template
+    - advanced
+    - include
+
+    `option` and `include` are functions. `depends`, `template` and `advanced`
+    are decorators.
+
+    These primitives are declared as name_impl methods to this class and
+    the mapping name -> name_impl is done automatically in __getitem__.
+
+    Additional primitives should be frowned upon to keep the sandbox itself as
+    simple as possible. Instead, helpers should be created within the sandbox
+    with the existing primitives.
+
+    The sandbox is given, at creation, a dict where the yielded configuration
+    will be stored.
+
+        config = {}
+        sandbox = ConfigureSandbox(config)
+        sandbox.run(path)
+        do_stuff(config)
+    """
+
+    # The default set of builtins.
+    BUILTINS = ReadOnlyDict({
+        b: __builtins__[b]
+        for b in ('None', 'False', 'True', 'int', 'bool', 'any', 'all', 'len',
+                  'list', 'set', 'dict')
+    }, __import__=forbidden_import)
+
+    # Expose a limited set of functions from os.path
+    OS = ReadOnlyNamespace(path=ReadOnlyNamespace(
+        abspath=os.path.abspath,
+        basename=os.path.basename,
+        dirname=os.path.dirname,
+        exists=os.path.exists,
+        isabs=os.path.isabs,
+        isdir=os.path.isdir,
+        isfile=os.path.isfile,
+        join=os.path.join,
+        normpath=os.path.normpath,
+        realpath=os.path.realpath,
+        relpath=os.path.relpath,
+    ))
+
+    def __init__(self, config, environ=os.environ, argv=sys.argv,
+                 stdout=sys.stdout, stderr=sys.stderr):
+        dict.__setitem__(self, '__builtins__', self.BUILTINS)
+
+        self._paths = []
+        self._templates = set()
+        self._depends = {}
+        self._seen = set()
+
+        self._options = OrderedDict()
+        # Store the raw values returned by @depends functions
+        self._results = {}
+        # Store several kind of information:
+        # - value for each Option, as per returned by Option.get_value
+        # - raw option (as per command line or environment) for each value
+        # - config set by each @depends function
+        self._db = {}
+
+        # Store options added with `imply_option`, and the reason they were
+        # added (which can either have been given to `imply_option`, or
+        # infered.
+        self._implied_options = {}
+
+        self._helper = CommandLineHelper(environ, argv)
+
+        self._config, self._stdout, self._stderr = config, stdout, stderr
+
+        self._help = None
+        self._help_option = self.option_impl('--help',
+                                             help='print this message')
+        self._seen.add(self._help_option)
+        # self._option_impl('--help') will have set this if --help was on the
+        # command line.
+        if self._db[self._help_option]:
+            self._help = HelpFormatter(argv[0])
+            self._help.add(self._help_option)
+
+    def exec_file(self, path):
+        '''Execute one file within the sandbox. Users of this class probably
+        want to use `run` instead.'''
+
+        if self._paths:
+            path = mozpath.join(mozpath.dirname(self._paths[-1]), path)
+            if not mozpath.basedir(path, (mozpath.dirname(self._paths[0]),)):
+                raise ConfigureError(
+                    'Cannot include `%s` because it is not in a subdirectory '
+                    'of `%s`' % (path, mozpath.dirname(self._paths[0])))
+        else:
+            path = mozpath.abspath(path)
+        if path in self._paths:
+            raise ConfigureError(
+                'Cannot include `%s` because it was included already.' % path)
+        self._paths.append(path)
+
+        source = open(path, 'rb').read()
+
+        code = compile(source, path, 'exec')
+
+        exec(code, self)
+
+        self._paths.pop(-1)
+
+    def run(self, path):
+        '''Executes the given file within the sandbox, and ensure the overall
+        consistency of the executed script.'''
+        self.exec_file(path)
+
+        # All command line arguments should have been removed (handled) by now.
+        for arg in self._helper:
+            without_value = arg.split('=', 1)[0]
+            if arg in self._implied_options:
+                func, reason = self._implied_options[arg]
+                raise ConfigureError(
+                    '`%s`, emitted by `%s` in `%s`, was not handled.'
+                    % (without_value, func.__name__,
+                       func.func_code.co_filename))
+            raise InvalidOptionError('Unknown option: %s' % without_value)
+
+        # All options must be referenced by some @depends function
+        for option in self._options.itervalues():
+            if option not in self._seen:
+                raise ConfigureError(
+                    'Option `%s` is not handled ; reference it with a @depends'
+                    % option.option
+                )
+
+        if self._help:
+            self._help.usage(self._stdout)
+
+    def __getitem__(self, key):
+        impl = '%s_impl' % key
+        func = getattr(self, impl, None)
+        if func:
+            return func
+
+        return super(ConfigureSandbox, self).__getitem__(key)
+
+    def __setitem__(self, key, value):
+        if (key in self.BUILTINS or key == '__builtins__' or
+                hasattr(self, '%s_impl' % key)):
+            raise KeyError('Cannot reassign builtins')
+
+        if (not isinstance(value, DummyFunction) and
+                value not in self._templates):
+            raise KeyError('Cannot assign `%s` because it is neither a '
+                           '@depends nor a @template' % key)
+
+        return super(ConfigureSandbox, self).__setitem__(key, value)
+
+    def _resolve(self, arg):
+        if isinstance(arg, DummyFunction):
+            assert arg in self._depends
+            func = self._depends[arg]
+            assert not inspect.isgeneratorfunction(func)
+            assert func in self._results
+            if not func.with_help:
+                raise ConfigureError("Missing @depends for `%s`: '--help'" %
+                                     func.__name__)
+            self._seen.add(func)
+            result = self._results[func]
+            return result
+        return arg
+
+    def option_impl(self, *args, **kwargs):
+        '''Implementation of option()
+        This function creates and returns an Option() object, passing it the
+        resolved arguments (uses the result of functions when functions are
+        passed). In most cases, the result of this function is not expected to
+        be used.
+        Command line argument/environment variable parsing for this Option is
+        handled here.
+        '''
+        args = [self._resolve(arg) for arg in args]
+        kwargs = {k: self._resolve(v) for k, v in kwargs.iteritems()}
+        option = Option(*args, **kwargs)
+        if option.name in self._options:
+            raise ConfigureError('Option `%s` already defined'
+                                 % self._options[option.name].option)
+        if option.env in self._options:
+            raise ConfigureError('Option `%s` already defined'
+                                 % self._options[option.env].option)
+        if option.name:
+            self._options[option.name] = option
+        if option.env:
+            self._options[option.env] = option
+
+        try:
+            value, option_string = self._helper.handle(option)
+        except ConflictingOptionError as e:
+            func, reason = self._implied_options[e.arg]
+            raise InvalidOptionError(
+                "'%s' implied by '%s' conflicts with '%s' from the %s"
+                % (e.arg, reason, e.old_arg, e.old_origin))
+
+        if self._help:
+            self._help.add(option)
+
+        self._db[option] = value
+        self._db[value] = (option_string.split('=', 1)[0]
+                           if option_string else option_string)
+        return option
+
+    def depends_impl(self, *args):
+        '''Implementation of @depends()
+        This function is a decorator. It returns a function that subsequently
+        takes a function and returns a dummy function. The dummy function
+        identifies the actual function for the sandbox, while preventing
+        further function calls from within the sandbox.
+
+        @depends() takes a variable number of option strings or dummy function
+        references. The decorated function is called as soon as the decorator
+        is called, and the arguments it receives are the OptionValue or
+        function results corresponding to each of the arguments to @depends.
+        As an exception, when a HelpFormatter is attached, only functions that
+        have '--help' in their @depends argument list are called.
+
+        The decorated function is altered to use a different global namespace
+        for its execution. This different global namespace exposes a limited
+        set of functions from os.path, and two additional functions:
+        `imply_option` and `set_config`. The former allows to inject additional
+        options as if they had been passed on the command line. The latter
+        declares new configuration items for consumption by moz.build.
+        '''
+        if not args:
+            raise ConfigureError('@depends needs at least one argument')
+
+        with_help = False
+        resolved_args = []
+        for arg in args:
+            if isinstance(arg, types.StringTypes):
+                prefix, name, values = Option.split_option(arg)
+                if values != ():
+                    raise ConfigureError("Option must not contain an '='")
+                if name not in self._options:
+                    raise ConfigureError("'%s' is not a known option. "
+                                         "Maybe it's declared too late?"
+                                         % arg)
+                arg = self._options[name]
+                if arg == self._help_option:
+                    with_help = True
+            elif isinstance(arg, DummyFunction):
+                assert arg in self._depends
+                arg = self._depends[arg]
+            else:
+                raise TypeError(
+                    "Cannot use object of type '%s' as argument to @depends"
+                    % type(arg))
+            self._seen.add(arg)
+            resolved_arg = self._results.get(arg)
+            if resolved_arg is None:
+                assert arg in self._db or self._help
+                resolved_arg = self._db.get(arg)
+            resolved_args.append(resolved_arg)
+
+        def decorator(func):
+            if inspect.isgeneratorfunction(func):
+                raise ConfigureError(
+                    'Cannot decorate generator functions with @depends')
+            func, glob = self._prepare_function(func)
+            result = DependsOutput()
+            glob.update(
+                imply_option=result.imply_option,
+                set_config=result.__setitem__,
+            )
+            dummy = wraps(func)(DummyFunction())
+            self._depends[dummy] = func
+            func.with_help = with_help
+            if with_help:
+                for arg in args:
+                    if (isinstance(arg, DummyFunction) and
+                            not self._depends[arg].with_help):
+                        raise ConfigureError(
+                            "`%s` depends on '--help' and `%s`. "
+                            "`%s` must depend on '--help'"
+                            % (func.__name__, arg.__name__, arg.__name__))
+
+            if self._help and not with_help:
+                return dummy
+
+            self._results[func] = func(*resolved_args)
+            self._db[func] = ReadOnlyDict(result)
+
+            for option, reason in result.implied_options:
+                self._helper.add(option, 'implied')
+                if not reason:
+                    deps = []
+                    for name, value in zip(args, resolved_args):
+                        if not isinstance(value, OptionValue):
+                            raise ConfigureError(
+                                "Cannot infer what implied '%s'" % option)
+                        if name == '--help':
+                            continue
+                        deps.append(value.format(self._db.get(value) or name))
+                    if len(deps) != 1:
+                        raise ConfigureError(
+                            "Cannot infer what implied '%s'" % option)
+                    reason = deps[0]
+
+                self._implied_options[option] = func, reason
+
+            if not self._help:
+                for k, v in result.iteritems():
+                    if k in self._config:
+                        raise ConfigureError(
+                            "Cannot add '%s' to configuration: Key already "
+                            "exists" % k)
+                    self._config[k] = v
+
+            return dummy
+
+        return decorator
+
+    def include_impl(self, what):
+        '''Implementation of include().
+        Allows to include external files for execution in the sandbox.
+        It is possible to use a @depends function as argument, in which case
+        the result of the function is the file name to include. This latter
+        feature is only really meant for --enable-application/--enable-project.
+        '''
+        what = self._resolve(what)
+        if what:
+            if not isinstance(what, types.StringTypes):
+                raise TypeError("Unexpected type: '%s'" % type(what))
+            self.exec_file(what)
+
+    def template_impl(self, func):
+        '''Implementation of @template.
+        This function is a decorator. Template functions are called
+        immediately. They are altered so that their global namespace exposes
+        a limited set of functions from os.path, as well as `advanced`,
+        `depends` and `option`.
+        Templates allow to simplify repetitive constructs, or to implement
+        helper decorators and somesuch.
+        '''
+        template, glob = self._prepare_function(func)
+        glob.update(
+            advanced=self.advanced_impl,
+            depends=self.depends_impl,
+            option=self.option_impl,
+        )
+        self._templates.add(template)
+        return template
+
+    def advanced_impl(self, func):
+        '''Implementation of @advanced.
+        This function gives the decorated function access to the complete set
+        of builtins, allowing the import keyword as an expected side effect.
+        '''
+        func, glob = self._prepare_function(func)
+        glob.update(__builtins__=__builtins__)
+        return func
+
+    def _prepare_function(self, func):
+        '''Alter the given function global namespace with the common ground
+        for @depends, @template and @advanced.
+        '''
+        if not inspect.isfunction(func):
+            raise TypeError("Unexpected type: '%s'" % type(func))
+        if isinstance(func.func_globals, SandboxedGlobal):
+            return func, func.func_globals
+
+        glob = SandboxedGlobal(func.func_globals)
+        glob.update(
+            __builtins__=self.BUILTINS,
+            __file__=self._paths[-1],
+            os=self.OS,
+        )
+        func = wraps(func)(types.FunctionType(
+            func.func_code,
+            glob,
+            func.__name__,
+            func.func_defaults,
+            func.func_closure
+        ))
+        return func, glob
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/configure/help.py
@@ -0,0 +1,40 @@
+# 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
+from mozbuild.configure.options import Option
+
+
+class HelpFormatter(object):
+    def __init__(self, argv0):
+        self.intro = ['Usage: %s [options]' % os.path.basename(argv0)]
+        self.options = ['Options: [defaults in brackets after descriptions]']
+        self.env = ['Environment variables:']
+
+    def add(self, option):
+        assert isinstance(option, Option)
+        # TODO: improve formatting
+        target = self.options if option.name else self.env
+        opt = option.option
+        if option.choices:
+            opt += '={%s}' % ','.join(option.choices)
+        help = option.help or ''
+        if len(option.default):
+            if help:
+                help += ' '
+            help += '[%s]' % ','.join(option.default)
+
+        if len(opt) > 24 or not help:
+            target.append('  %s' % opt)
+            if help:
+                target.append('%s%s' % (' ' * 28, help))
+        else:
+            target.append('  %-24s  %s' % (opt, help))
+
+    def usage(self, out):
+        print('\n\n'.join('\n'.join(t)
+                          for t in (self.intro, self.options, self.env)),
+              file=out)
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/configure/data/extra.configure
@@ -0,0 +1,11 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+option('--extra', help='Extra')
+
+@depends('--extra')
+def extra(extra):
+    set_config('EXTRA', extra)
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/configure/data/included.configure
@@ -0,0 +1,47 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+# For more complex and repetitive things, we can create templates
+@template
+def check_compiler_flag(flag):
+    @depends(is_gcc)
+    def check(value):
+        if value:
+            set_config('CFLAGS', [flag])
+
+    return check
+
+check_compiler_flag('-Werror=foobar')
+
+# A template that doesn't return functions can be used in @depends functions.
+@template
+def fortytwo():
+    return 42
+
+@template
+def twentyone():
+    yield 21
+
+@depends(is_gcc)
+def check(value):
+    if value:
+        set_config('TEMPLATE_VALUE', fortytwo())
+        for val in twentyone():
+            set_config('TEMPLATE_VALUE_2', val)
+
+# Templates can use @advanced too to import modules and get the full set of
+# builtins.
+@template
+@advanced
+def platform():
+    import sys
+    return sys.platform
+
+option('--enable-advanced-template', help='Advanced template')
+@depends('--enable-advanced-template')
+def check(value):
+    if value:
+        set_config('PLATFORM', platform())
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/configure/data/moz.configure
@@ -0,0 +1,195 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+option('--enable-simple', help='Enable simple')
+
+# Setting MOZ_WITH_ENV in the environment has the same effect as passing
+# --enable-with-env.
+option('--enable-with-env', env='MOZ_WITH_ENV', help='Enable with env')
+
+# Optional values
+option('--enable-values', nargs='*', help='Enable values')
+
+# Everything supported in the Option class is supported in option(). Assume
+# the tests of the Option class are extensive about this.
+
+# Alternatively to --enable/--disable, there also is --with/--without. The
+# difference is semantic only. Behavior is the same as --enable/--disable.
+
+# When the option name starts with --disable/--without, the default is for
+# the option to be enabled.
+option('--without-thing', help='Build without thing')
+
+# A --enable/--with option with a default of False is equivalent to a
+# --disable/--without option. This can be used to change the defaults
+# depending on e.g. the target or the built application.
+option('--with-stuff', default=False, help='Build with stuff')
+
+# Other kinds of arbitrary options are also allowed. This is effectively
+# equivalent to --enable/--with, with no possibility of --disable/--without.
+option('--option', env='MOZ_OPTION', help='Option')
+
+# It is also possible to pass options through the environment only.
+option(env='CC', nargs=1, help='C Compiler')
+
+# Call the function when the --enable-simple option is processed, with its
+# OptionValue as argument.
+@depends('--enable-simple')
+def simple(simple):
+    if simple:
+        set_config('ENABLED_SIMPLE', simple)
+
+# There can be multiple functions depending on the same option.
+@depends('--enable-simple')
+def simple(simple):
+    set_config('SIMPLE', simple)
+
+@depends('--enable-with-env')
+def with_env(with_env):
+    set_config('WITH_ENV', with_env)
+
+# It doesn't matter if the dependency is on --enable or --disable
+@depends('--disable-values')
+def with_env2(values):
+    set_config('VALUES', values)
+
+# It is possible to @depends on environment-only options.
+@depends('CC')
+def is_gcc(cc):
+    return cc and 'gcc' in cc[0]
+
+@depends(is_gcc)
+def is_gcc_check(is_gcc):
+    set_config('IS_GCC', is_gcc)
+
+# It is possible to depend on the result from another function.
+# The input argument is a dict fed with the elements the function implied.
+@depends(with_env2)
+def with_env3(values):
+    set_config('VALUES2', values['VALUES'])
+
+# @depends functions can also return results for use as input to another
+# @depends.
+@depends(with_env3)
+def with_env4(values):
+    return values['VALUES2']
+
+@depends(with_env4)
+def with_env5(values):
+    set_config('VALUES3', values)
+
+# The result from @depends functions can also be used as input to options.
+# The result must be returned, not implied. The function must also depend
+# on --help.
+@depends('--enable-simple', '--help')
+def simple(simple, help):
+    return 'simple' if simple else 'not-simple'
+
+option('--with-returned-default', default=simple, help='Returned default')
+
+@depends('--with-returned-default')
+def default(value):
+    set_config('DEFAULTED', value)
+
+# @depends functions can also declare that some extra options are implied.
+# Those options need to be defined _after_ the function, and they mustn't
+# appear on the command line or the environment with conflicting values.
+@depends('--enable-values')
+def values(values):
+    if values:
+        imply_option('--enable-implied')
+        imply_option(values.format('--with-implied-values'))
+        imply_option(values.format('WITH_IMPLIED_ENV'))
+
+option('--enable-implied', help='Implied')
+
+option('--with-implied-values', nargs='*', help='Implied values')
+
+option(env='WITH_IMPLIED_ENV', nargs='*', help='Implied env')
+
+@depends('--enable-implied')
+def implied(value):
+    set_config('IMPLIED', value)
+
+@depends('--with-implied-values')
+def implied(values):
+    set_config('IMPLIED_VALUES', values)
+
+@depends('WITH_IMPLIED_ENV')
+def implied(values):
+    set_config('IMPLIED_ENV', values)
+
+@depends('--enable-values', '--help')
+def choices(values, help):
+    if len(values):
+        return {
+            'alpha': ('a', 'b', 'c'),
+            'numeric': ('0', '1', '2'),
+        }.get(values[0])
+
+option('--returned-choices', choices=choices, help='Choices')
+
+@depends('--returned-choices')
+def returned_choices(values):
+    set_config('CHOICES', values)
+
+# All options must be referenced by some @depends function.
+# It is possible to depend on multiple options/functions
+@depends('--without-thing', '--with-stuff', with_env4, '--option')
+def remainder(*args):
+    set_config('REMAINDER', args)
+
+# It is possible to include other files to extend the configuration script.
+include('included.configure')
+
+# It is also possible for the include file path to come from the result of a
+# @depends function. That function needs to depend on '--help' like for option
+# defaults and choices.
+option('--enable-include', nargs=1, help='Include')
+@depends('--enable-include', '--help')
+def include_path(path, help):
+    return path[0] if path else None
+
+include(include_path)
+
+# @advanced functions are allowed to import modules and have access to
+# the standard builtins instead of restricted ones. The order of the decorators
+# matter: @advanced needs to appear last.
+option('--with-advanced', nargs='?', help='Advanced')
+@depends('--with-advanced')
+@advanced
+def with_advanced(value):
+    if value:
+        from mozbuild.configure.options import OptionValue
+        set_config('ADVANCED', isinstance(value, OptionValue))
+
+# Trying to import without @advanced will fail at runtime.
+@depends('--with-advanced')
+def with_advanced(value):
+    if len(value) and value[0] == 'break':
+        from mozbuild.configure.options import OptionValue
+        set_config('ADVANCED', isinstance(value, OptionValue))
+
+# A limited set of functions from os.path are exposed to non @advanced
+# functions.
+@depends('--with-advanced')
+def with_advanced(value):
+    if len(value):
+        set_config('IS_FILE', os.path.isfile(value[0]))
+
+# An @advanced function can still import the full set.
+@depends('--with-advanced')
+@advanced
+def with_advanced(value):
+    if len(value):
+        import os.path
+        set_config('HAS_GETATIME', hasattr(os.path, 'getatime'))
+
+@depends('--with-advanced')
+@advanced
+def with_advanced(value):
+    if len(value):
+        set_config('HAS_GETATIME2', hasattr(os.path, 'getatime'))
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/configure/test_configure.py
@@ -0,0 +1,307 @@
+# 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
+
+from StringIO import StringIO
+import sys
+import unittest
+
+from mozunit import main
+
+from mozbuild.configure.options import (
+    InvalidOptionError,
+    NegativeOptionValue,
+    PositiveOptionValue,
+)
+from mozbuild.configure import ConfigureSandbox
+
+import mozpack.path as mozpath
+
+test_data_path = mozpath.abspath(mozpath.dirname(__file__))
+test_data_path = mozpath.join(test_data_path, 'data')
+
+
+class TestConfigure(unittest.TestCase):
+    def get_result(self, args=[], environ={}, prog='/bin/configure'):
+        config = {}
+        out = StringIO()
+        sandbox = ConfigureSandbox(config, environ, [prog] + args, out, out)
+
+        sandbox.run(mozpath.join(test_data_path, 'moz.configure'))
+
+        return config, out.getvalue()
+
+    def get_config(self, options=[], env={}):
+        config, out = self.get_result(options, environ=env)
+        self.assertEquals('', out)
+        return config
+
+    def test_defaults(self):
+        config = self.get_config()
+        self.maxDiff = None
+        self.assertEquals({
+            'CHOICES': NegativeOptionValue(),
+            'DEFAULTED': PositiveOptionValue(('not-simple',)),
+            'IS_GCC': NegativeOptionValue(),
+            'REMAINDER': (PositiveOptionValue(), NegativeOptionValue(),
+                          NegativeOptionValue(), NegativeOptionValue()),
+            'SIMPLE': NegativeOptionValue(),
+            'VALUES': NegativeOptionValue(),
+            'VALUES2': NegativeOptionValue(),
+            'VALUES3': NegativeOptionValue(),
+            'WITH_ENV': NegativeOptionValue(),
+            'IMPLIED': NegativeOptionValue(),
+            'IMPLIED_ENV': NegativeOptionValue(),
+            'IMPLIED_VALUES': NegativeOptionValue(),
+        }, config)
+
+    def test_help(self):
+        config, out = self.get_result(['--help'])
+        self.assertEquals({}, config)
+        self.maxDiff = None
+        self.assertEquals(
+            'Usage: configure [options]\n'
+            '\n'
+            'Options: [defaults in brackets after descriptions]\n'
+            '  --help                    print this message\n'
+            '  --enable-simple           Enable simple\n'
+            '  --enable-with-env         Enable with env\n'
+            '  --enable-values           Enable values\n'
+            '  --without-thing           Build without thing\n'
+            '  --with-stuff              Build with stuff\n'
+            '  --option                  Option\n'
+            '  --with-returned-default   Returned default [not-simple]\n'
+            '  --enable-implied          Implied\n'
+            '  --with-implied-values     Implied values\n'
+            '  --returned-choices        Choices\n'
+            '  --enable-advanced-template\n'
+            '                            Advanced template\n'
+            '  --enable-include          Include\n'
+            '  --with-advanced           Advanced\n'
+            '\n'
+            'Environment variables:\n'
+            '  CC                        C Compiler\n'
+            '  WITH_IMPLIED_ENV          Implied env\n',
+            out
+        )
+
+    def test_unknown(self):
+        with self.assertRaises(InvalidOptionError):
+            self.get_config(['--unknown'])
+
+    def test_simple(self):
+        for config in (
+                self.get_config(),
+                self.get_config(['--disable-simple']),
+                # Last option wins.
+                self.get_config(['--enable-simple', '--disable-simple']),
+        ):
+            self.assertNotIn('ENABLED_SIMPLE', config)
+            self.assertIn('SIMPLE', config)
+            self.assertEquals(NegativeOptionValue(), config['SIMPLE'])
+
+        for config in (
+                self.get_config(['--enable-simple']),
+                self.get_config(['--disable-simple', '--enable-simple']),
+        ):
+            self.assertIn('ENABLED_SIMPLE', config)
+            self.assertIn('SIMPLE', config)
+            self.assertEquals(PositiveOptionValue(), config['SIMPLE'])
+            self.assertIs(config['SIMPLE'], config['ENABLED_SIMPLE'])
+
+        # --enable-simple doesn't take values.
+        with self.assertRaises(InvalidOptionError):
+            self.get_config(['--enable-simple=value'])
+
+    def test_with_env(self):
+        for config in (
+                self.get_config(),
+                self.get_config(['--disable-with-env']),
+                self.get_config(['--enable-with-env', '--disable-with-env']),
+                self.get_config(env={'MOZ_WITH_ENV': ''}),
+                # Options win over environment
+                self.get_config(['--disable-with-env'],
+                                env={'MOZ_WITH_ENV': '1'}),
+        ):
+            self.assertIn('WITH_ENV', config)
+            self.assertEquals(NegativeOptionValue(), config['WITH_ENV'])
+
+        for config in (
+                self.get_config(['--enable-with-env']),
+                self.get_config(['--disable-with-env', '--enable-with-env']),
+                self.get_config(env={'MOZ_WITH_ENV': '1'}),
+                self.get_config(['--enable-with-env'],
+                                env={'MOZ_WITH_ENV': ''}),
+        ):
+            self.assertIn('WITH_ENV', config)
+            self.assertEquals(PositiveOptionValue(), config['WITH_ENV'])
+
+        with self.assertRaises(InvalidOptionError):
+            self.get_config(['--enable-with-env=value'])
+
+        with self.assertRaises(InvalidOptionError):
+            self.get_config(env={'MOZ_WITH_ENV': 'value'})
+
+    def test_values(self, name='VALUES'):
+        for config in (
+            self.get_config(),
+            self.get_config(['--disable-values']),
+            self.get_config(['--enable-values', '--disable-values']),
+        ):
+            self.assertIn(name, config)
+            self.assertEquals(NegativeOptionValue(), config[name])
+
+        for config in (
+            self.get_config(['--enable-values']),
+            self.get_config(['--disable-values', '--enable-values']),
+        ):
+            self.assertIn(name, config)
+            self.assertEquals(PositiveOptionValue(), config[name])
+
+        config = self.get_config(['--enable-values=foo'])
+        self.assertIn(name, config)
+        self.assertEquals(PositiveOptionValue(('foo',)), config[name])
+
+        config = self.get_config(['--enable-values=foo,bar'])
+        self.assertIn(name, config)
+        self.assertTrue(config[name])
+        self.assertEquals(PositiveOptionValue(('foo', 'bar')), config[name])
+
+    def test_values2(self):
+        self.test_values('VALUES2')
+
+    def test_values3(self):
+        self.test_values('VALUES3')
+
+    def test_returned_default(self):
+        config = self.get_config(['--enable-simple'])
+        self.assertIn('DEFAULTED', config)
+        self.assertEquals(
+            PositiveOptionValue(('simple',)), config['DEFAULTED'])
+
+        config = self.get_config(['--disable-simple'])
+        self.assertIn('DEFAULTED', config)
+        self.assertEquals(
+            PositiveOptionValue(('not-simple',)), config['DEFAULTED'])
+
+    def test_implied_options(self):
+        config = self.get_config(['--enable-values'])
+        self.assertIn('IMPLIED', config)
+        self.assertIn('IMPLIED_VALUES', config)
+        self.assertIn('IMPLIED_ENV', config)
+        self.assertEquals(PositiveOptionValue(), config['IMPLIED'])
+        self.assertEquals(PositiveOptionValue(), config['IMPLIED_VALUES'])
+        self.assertEquals(PositiveOptionValue(), config['IMPLIED_ENV'])
+
+        config = self.get_config(['--enable-values=a'])
+        self.assertIn('IMPLIED', config)
+        self.assertIn('IMPLIED_VALUES', config)
+        self.assertIn('IMPLIED_ENV', config)
+        self.assertEquals(PositiveOptionValue(), config['IMPLIED'])
+        self.assertEquals(
+            PositiveOptionValue(('a',)), config['IMPLIED_VALUES'])
+        self.assertEquals(PositiveOptionValue(('a',)), config['IMPLIED_ENV'])
+
+        config = self.get_config(['--enable-values=a,b'])
+        self.assertIn('IMPLIED', config)
+        self.assertIn('IMPLIED_VALUES', config)
+        self.assertIn('IMPLIED_ENV', config)
+        self.assertEquals(PositiveOptionValue(), config['IMPLIED'])
+        self.assertEquals(
+            PositiveOptionValue(('a', 'b')), config['IMPLIED_VALUES'])
+        self.assertEquals(
+            PositiveOptionValue(('a', 'b')), config['IMPLIED_ENV'])
+
+        config = self.get_config(['--disable-values'])
+        self.assertIn('IMPLIED', config)
+        self.assertIn('IMPLIED_VALUES', config)
+        self.assertIn('IMPLIED_ENV', config)
+        self.assertEquals(NegativeOptionValue(), config['IMPLIED'])
+        self.assertEquals(NegativeOptionValue(), config['IMPLIED_VALUES'])
+        self.assertEquals(NegativeOptionValue(), config['IMPLIED_ENV'])
+
+        # --enable-values implies --enable-implied, which conflicts with
+        # --disable-implied
+        with self.assertRaises(InvalidOptionError):
+            self.get_config(['--enable-values', '--disable-implied'])
+
+    def test_returned_choices(self):
+        for val in ('a', 'b', 'c'):
+            config = self.get_config(
+                ['--enable-values=alpha', '--returned-choices=%s' % val])
+            self.assertIn('CHOICES', config)
+            self.assertEquals(PositiveOptionValue((val,)), config['CHOICES'])
+
+        for val in ('0', '1', '2'):
+            config = self.get_config(
+                ['--enable-values=numeric', '--returned-choices=%s' % val])
+            self.assertIn('CHOICES', config)
+            self.assertEquals(PositiveOptionValue((val,)), config['CHOICES'])
+
+        with self.assertRaises(InvalidOptionError):
+            self.get_config(['--enable-values=numeric',
+                             '--returned-choices=a'])
+
+        with self.assertRaises(InvalidOptionError):
+            self.get_config(['--enable-values=alpha', '--returned-choices=0'])
+
+    def test_included(self):
+        config = self.get_config(env={'CC': 'gcc'})
+        self.assertIn('IS_GCC', config)
+        self.assertEquals(config['IS_GCC'], True)
+
+        config = self.get_config(
+            ['--enable-include=extra.configure', '--extra'])
+        self.assertIn('EXTRA', config)
+        self.assertEquals(PositiveOptionValue(), config['EXTRA'])
+
+        with self.assertRaises(InvalidOptionError):
+            self.get_config(['--extra'])
+
+    def test_template(self):
+        config = self.get_config(env={'CC': 'gcc'})
+        self.assertIn('CFLAGS', config)
+        self.assertEquals(config['CFLAGS'], ['-Werror=foobar'])
+
+        config = self.get_config(env={'CC': 'clang'})
+        self.assertNotIn('CFLAGS', config)
+
+    def test_advanced(self):
+        config = self.get_config(['--with-advanced'])
+        self.assertIn('ADVANCED', config)
+        self.assertEquals(config['ADVANCED'], True)
+
+        with self.assertRaises(ImportError):
+            self.get_config(['--with-advanced=break'])
+
+    def test_os_path(self):
+        config = self.get_config(['--with-advanced=%s' % __file__])
+        self.assertIn('IS_FILE', config)
+        self.assertEquals(config['IS_FILE'], True)
+
+        config = self.get_config(['--with-advanced=%s.no-exist' % __file__])
+        self.assertIn('IS_FILE', config)
+        self.assertEquals(config['IS_FILE'], False)
+
+        self.assertIn('HAS_GETATIME', config)
+        self.assertEquals(config['HAS_GETATIME'], True)
+        self.assertIn('HAS_GETATIME2', config)
+        self.assertEquals(config['HAS_GETATIME2'], False)
+
+    def test_template_call(self):
+        config = self.get_config(env={'CC': 'gcc'})
+        self.assertIn('TEMPLATE_VALUE', config)
+        self.assertEquals(config['TEMPLATE_VALUE'], 42)
+        self.assertIn('TEMPLATE_VALUE_2', config)
+        self.assertEquals(config['TEMPLATE_VALUE_2'], 21)
+
+    def test_template_advanced(self):
+        config = self.get_config(['--enable-advanced-template'])
+        self.assertIn('PLATFORM', config)
+        self.assertEquals(config['PLATFORM'], sys.platform)
+
+
+if __name__ == '__main__':
+    main()