Bug 1247836 - Building blocks for python configure draft
authorMike Hommey <mh+mozilla@glandium.org>
Thu, 03 Mar 2016 15:43:14 +0900
changeset 336467 d98cdb1916abb0e10416400b3d7adf0f19cb75c4
parent 336454 80d142fc4710661033c905301528bd810b00029d
child 515400 2139f25416a5a02a2b12fbe103ff16f432485635
push id12072
push userbmo:mh+mozilla@glandium.org
push dateThu, 03 Mar 2016 08:31:09 +0000
bugs1247836
milestone47.0a1
Bug 1247836 - Building blocks for python configure
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,428 @@
+# 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,
+    ConflictingOption,
+    InvalidOption,
+    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.
+
+    Additional primitives should be frowned upon to keep the sandbox itself as
+    simple as possible. Instead, helpers shall 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 = {}
+
+        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)
+        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 InvalidOption('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 referenced by any @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 (use 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 ConflictingOption as e:
+            func, reason = self._implied_options[e.arg]
+            raise InvalidOption(
+                "'%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.
+        '''
+        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 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.
+        '''
+        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 (
+    InvalidOption,
+    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(InvalidOption):
+            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(InvalidOption):
+            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(InvalidOption):
+            self.get_config(['--enable-with-env=value'])
+
+        with self.assertRaises(InvalidOption):
+            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(InvalidOption):
+            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(InvalidOption):
+            self.get_config(['--enable-values=numeric',
+                             '--returned-choices=a'])
+
+        with self.assertRaises(InvalidOption):
+            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(InvalidOption):
+            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()