Bug 1296530 - Add an only_when context manager, and a `when` argument to include(). r?chmanchester draft
authorMike Hommey <mh+mozilla@glandium.org>
Wed, 12 Oct 2016 16:56:11 +0900
changeset 425040 753c1e3022c343e4988e6b773d457c5709451fef
parent 425039 161b19d821c066b805ec2ba5284cdd2a3cd481d3
child 425041 1572d1ca4787888a74175dc135ff45935d10575d
push id32321
push userbmo:mh+mozilla@glandium.org
push dateFri, 14 Oct 2016 02:53:47 +0000
reviewerschmanchester
bugs1296530
milestone52.0a1
Bug 1296530 - Add an only_when context manager, and a `when` argument to include(). r?chmanchester
python/mozbuild/mozbuild/configure/__init__.py
python/mozbuild/mozbuild/test/configure/test_configure.py
--- a/python/mozbuild/mozbuild/configure/__init__.py
+++ b/python/mozbuild/mozbuild/configure/__init__.py
@@ -26,16 +26,17 @@ from mozbuild.configure.help import Help
 from mozbuild.configure.util import (
     ConfigureOutputHandler,
     getpreferredencoding,
     LineIO,
 )
 from mozbuild.util import (
     exec_,
     memoize,
+    memoized_property,
     ReadOnlyDict,
     ReadOnlyNamespace,
 )
 
 import mozpack.path as mozpath
 
 
 class ConfigureError(Exception):
@@ -45,69 +46,134 @@ class ConfigureError(Exception):
 class SandboxDependsFunction(object):
     '''Sandbox-visible representation of @depends functions.'''
     def __call__(self, *arg, **kwargs):
         raise ConfigureError('The `%s` function may not be called'
                              % self.__name__)
 
 
 class DependsFunction(object):
-    __slots__ = ('func', 'dependencies', 'sandboxed')
-    def __init__(self, sandbox, func, dependencies):
+    __slots__ = (
+        'func', 'dependencies', 'when', 'sandboxed', 'sandbox', '_result')
+
+    def __init__(self, sandbox, func, dependencies, when=None):
         assert isinstance(sandbox, ConfigureSandbox)
         self.func = func
         self.dependencies = dependencies
         self.sandboxed = wraps(func)(SandboxDependsFunction())
+        self.sandbox = sandbox
+        self.when = when
         sandbox._depends[self.sandboxed] = self
 
+        # Only @depends functions with a dependency on '--help' are executed
+        # immediately. Everything else is queued for later execution.
+        if sandbox._help_option in dependencies:
+            sandbox._value_for(self)
+        elif not sandbox._help:
+            sandbox._execution_queue.append((sandbox._value_for, (self,)))
+
     @property
     def name(self):
         return self.func.__name__
 
     @property
     def sandboxed_dependencies(self):
         return [
             d.sandboxed if isinstance(d, DependsFunction) else d
             for d in self.dependencies
         ]
 
+    @memoized_property
+    def result(self):
+        if self.when and not self.sandbox._value_for(self.when):
+            return None
+
+        resolved_args = [self.sandbox._value_for(d) for d in self.dependencies]
+        return self.func(*resolved_args)
+
     def __repr__(self):
         return '<%s.%s %s(%s)>' % (
             self.__class__.__module__,
             self.__class__.__name__,
             self.name,
             ', '.join(repr(d) for d in self.dependencies),
         )
 
 
+class CombinedDependsFunction(DependsFunction):
+    def __init__(self, sandbox, func, dependencies):
+        @memoize
+        @wraps(func)
+        def wrapper(*args):
+            return func(args)
+
+        flatten_deps = []
+        for d in dependencies:
+            if isinstance(d, CombinedDependsFunction) and d.func == wrapper:
+                for d2 in d.dependencies:
+                    if d2 not in flatten_deps:
+                        flatten_deps.append(d2)
+            elif d not in flatten_deps:
+                flatten_deps.append(d)
+
+        # Automatically add a --help dependency if one of the dependencies
+        # depends on it.
+        for d in flatten_deps:
+            if (isinstance(d, DependsFunction) and
+                sandbox._help_option in d.dependencies):
+                flatten_deps.insert(0, sandbox._help_option)
+                break
+
+        super(CombinedDependsFunction, self).__init__(
+            sandbox, wrapper, flatten_deps)
+
+    @memoized_property
+    def result(self):
+        # Ignore --help for the combined result
+        deps = self.dependencies
+        if deps[0] == self.sandbox._help_option:
+            deps = deps[1:]
+        resolved_args = [self.sandbox._value_for(d) for d in deps]
+        return self.func(*resolved_args)
+
+    def __eq__(self, other):
+        return (isinstance(other, self.__class__) and
+                self.func == other.func and
+                set(self.dependencies) == set(other.dependencies))
+
+    def __ne__(self, other):
+        return not self == other
+
 class SandboxedGlobal(dict):
     '''Identifiable dict type for use as function global'''
 
 
 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 8 primitives:
+    The sandbox has 9 primitives:
     - option
     - depends
     - template
     - imports
     - include
     - set_config
     - set_define
     - imply_option
+    - only_when
 
     `option`, `include`, `set_config`, `set_define` and `imply_option` are
-    functions. `depends`, `template`, and `imports` are decorators.
+    functions. `depends`, `template`, and `imports` are decorators. `only_when`
+    is a context_manager.
 
     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.
 
@@ -160,19 +226,22 @@ class ConfigureSandbox(dict):
         self._implied_options = []
 
         # Store all results from _prepare_function
         self._prepared_functions = set()
 
         # Queue of functions to execute, with their arguments
         self._execution_queue = []
 
-        # Store the `when`s associated to some options/depends.
+        # Store the `when`s associated to some options.
         self._conditions = {}
 
+        # A list of conditions to apply as a default `when` for every *_impl()
+        self._default_conditions = []
+
         self._helper = CommandLineHelper(environ, argv)
 
         assert isinstance(config, dict)
         self._config = config
 
         if logger is None:
             logger = moz_logger = logging.getLogger('moz.configure')
             logger.setLevel(logging.DEBUG)
@@ -348,26 +417,17 @@ class ConfigureSandbox(dict):
                     if self._help_option not in arg.dependencies:
                         raise ConfigureError(
                             "`%s` depends on '--help' and `%s`. "
                             "`%s` must depend on '--help'"
                             % (obj.name, arg.name, arg.name))
         elif self._help or need_help_dependency:
             raise ConfigureError("Missing @depends for `%s`: '--help'" %
                                  obj.name)
-        return self._value_for_depends_real(obj)
-
-    @memoize
-    def _value_for_depends_real(self, obj):
-        when = self._conditions.get(obj)
-        if when and not self._value_for(when):
-            return None
-
-        resolved_args = [self._value_for(d) for d in obj.dependencies]
-        return obj.func(*resolved_args)
+        return obj.result
 
     @memoize
     def _value_for_option(self, option):
         implied = {}
         for implied_option in self._implied_options[:]:
             if implied_option.name not in (option.name, option.env):
                 continue
             self._implied_options.remove(implied_option)
@@ -436,28 +496,55 @@ class ConfigureSandbox(dict):
             arg = self._depends[arg]
         else:
             raise TypeError(
                 "Cannot use object of type '%s' as %sargument to %s"
                 % (type(arg).__name__, '`%s` ' % arg_name if arg_name else '',
                    callee_name))
         return arg
 
+    def _normalize_when(self, when, callee_name):
+        if when is not None:
+            when = self._dependency(when, callee_name, 'when')
+
+        if self._default_conditions:
+            # Create a pseudo @depends function for the combination of all
+            # default conditions and `when`.
+            dependencies = [when] if when else []
+            dependencies.extend(self._default_conditions)
+            if len(dependencies) == 1:
+                return dependencies[0]
+            return CombinedDependsFunction(self, all, dependencies)
+        return when
+
+    @contextmanager
+    def only_when_impl(self, when):
+        '''Implementation of only_when()
+
+        `only_when` is a context manager that essentially makes calls to
+        other sandbox functions within the context block ignored.
+        '''
+        when = self._normalize_when(when, 'only_when')
+        if when and self._default_conditions[-1:] != [when]:
+            self._default_conditions.append(when)
+            yield
+            self._default_conditions.pop()
+        else:
+            yield
+
     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.
         '''
-        when = kwargs.get('when')
-        if when is not None:
-            when = self._dependency(when, 'option', 'when')
+        when = self._normalize_when(kwargs.get('when'), 'option')
         args = [self._resolve(arg) for arg in args]
         kwargs = {k: self._resolve(v) for k, v in kwargs.iteritems()
                                       if k != 'when'}
         option = Option(*args, **kwargs)
         if when:
             self._conditions[option] = when
         if option.name in self._options:
             raise ConfigureError('Option `%s` already defined' % option.option)
@@ -496,19 +583,17 @@ class ConfigureSandbox(dict):
             raise ConfigureError('@depends needs at least one argument')
 
         for k in kwargs:
             if k != 'when':
                 raise TypeError(
                     "depends_impl() got an unexpected keyword argument '%s'"
                     % k)
 
-        when = kwargs.get('when')
-        if when is not None:
-            when = self._dependency(when, '@depends', 'when')
+        when = self._normalize_when(kwargs.get('when'), '@depends')
 
         dependencies = tuple(self._dependency(arg, '@depends') for arg in args)
 
         conditions = [
             self._conditions[d]
             for d in dependencies
             if d in self._conditions and isinstance(d, Option)
         ]
@@ -517,44 +602,34 @@ class ConfigureSandbox(dict):
                 raise ConfigureError('@depends function needs the same `when` '
                                      'as options it depends on')
 
         def decorator(func):
             if inspect.isgeneratorfunction(func):
                 raise ConfigureError(
                     'Cannot decorate generator functions with @depends')
             func, glob = self._prepare_function(func)
-            depends = DependsFunction(self, func, dependencies)
-            if when:
-                self._conditions[depends] = when
-
-            # Only @depends functions with a dependency on '--help' are
-            # executed immediately. Everything else is queued for later
-            # execution.
-            if self._help_option in dependencies:
-                self._value_for(depends)
-            elif not self._help:
-                self._execution_queue.append((self._value_for, (depends,)))
-
+            depends = DependsFunction(self, func, dependencies, when=when)
             return depends.sandboxed
 
         return decorator
 
-    def include_impl(self, what):
+    def include_impl(self, what, when=None):
         '''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).__name__)
-            self.include_file(what)
+        with self.only_when_impl(when):
+            what = self._resolve(what)
+            if what:
+                if not isinstance(what, types.StringTypes):
+                    raise TypeError("Unexpected type: '%s'" % type(what).__name__)
+                self.include_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 `depends` and
         `option`.
         Templates allow to simplify repetitive constructs, or to implement
@@ -696,32 +771,30 @@ class ConfigureSandbox(dict):
 
     def set_config_impl(self, name, value, when=None):
         '''Implementation of set_config().
         Set the configuration items with the given name to the given value.
         Both `name` and `value` can be references to @depends functions,
         in which case the result from these functions is used. If the result
         of either function is None, the configuration item is not set.
         '''
-        if when is not None:
-            when = self._dependency(when, 'set_config', 'when')
+        when = self._normalize_when(when, 'set_config')
 
         self._execution_queue.append((
             self._resolve_and_set, (self._config, name, value, when)))
 
     def set_define_impl(self, name, value, when=None):
         '''Implementation of set_define().
         Set the define with the given name to the given value. Both `name` and
         `value` can be references to @depends functions, in which case the
         result from these functions is used. If the result of either function
         is None, the define is not set. If the result is False, the define is
         explicitly undefined (-U).
         '''
-        if when is not None:
-            when = self._dependency(when, 'set_define', 'when')
+        when = self._normalize_when(when, 'set_define')
 
         defines = self._config.setdefault('DEFINES', {})
         self._execution_queue.append((
             self._resolve_and_set, (defines, name, value, when)))
 
     def imply_option_impl(self, option, value, reason=None, when=None):
         '''Implementation of imply_option().
         Injects additional options as if they had been passed on the command
@@ -783,18 +856,17 @@ class ConfigureSandbox(dict):
             reason = "imply_option at %s:%s" % (filename, line)
 
         if not reason:
             raise ConfigureError(
                 "Cannot infer what implies '%s'. Please add a `reason` to "
                 "the `imply_option` call."
                 % option)
 
-        if when is not None:
-            when = self._dependency(when, 'imply_option', 'when')
+        when = self._normalize_when(when, 'imply_option')
 
         prefix, name, values = Option.split_option(option)
         if values != ():
             raise ConfigureError("Implied option must not contain an '='")
 
         self._implied_options.append(ReadOnlyNamespace(
             option=option,
             prefix=prefix,
--- a/python/mozbuild/mozbuild/test/configure/test_configure.py
+++ b/python/mozbuild/mozbuild/test/configure/test_configure.py
@@ -897,16 +897,88 @@ class TestConfigure(unittest.TestCase):
         with self.assertRaises(TypeError) as e:
             with self.moz_configure('''
                 include(42)
             '''):
                 self.get_config()
 
         self.assertEquals(e.exception.message, "Unexpected type: 'int'")
 
+    def test_include_when(self):
+        with MockedOpen({
+            os.path.join(test_data_path, 'moz.configure'): textwrap.dedent('''
+                @depends('--help')
+                def always(_):
+                    return True
+                @depends('--help')
+                def never(_):
+                    return False
+
+                option('--with-foo', help='foo')
+
+                include('always.configure', when=always)
+                include('never.configure', when=never)
+                include('foo.configure', when='--with-foo')
+
+                set_config('FOO', foo)
+                set_config('BAR', bar)
+                set_config('QUX', qux)
+            '''),
+            os.path.join(test_data_path, 'always.configure'): textwrap.dedent('''
+                option('--with-bar', help='bar')
+                @depends('--with-bar')
+                def bar(x):
+                    if x:
+                        return 'bar'
+            '''),
+            os.path.join(test_data_path, 'never.configure'): textwrap.dedent('''
+                option('--with-qux', help='qux')
+                @depends('--with-qux')
+                def qux(x):
+                    if x:
+                        return 'qux'
+            '''),
+            os.path.join(test_data_path, 'foo.configure'): textwrap.dedent('''
+                option('--with-foo-really', help='really foo')
+                @depends('--with-foo-really')
+                def foo(x):
+                    if x:
+                        return 'foo'
+
+                include('foo2.configure', when='--with-foo-really')
+            '''),
+            os.path.join(test_data_path, 'foo2.configure'): textwrap.dedent('''
+                set_config('FOO2', True)
+            '''),
+        }):
+            config = self.get_config()
+            self.assertEquals(config, {})
+
+            config = self.get_config(['--with-foo'])
+            self.assertEquals(config, {})
+
+            config = self.get_config(['--with-bar'])
+            self.assertEquals(config, {
+                'BAR': 'bar',
+            })
+
+            with self.assertRaises(InvalidOptionError) as e:
+                self.get_config(['--with-qux'])
+
+            self.assertEquals(
+                e.exception.message,
+                '--with-qux is not available in this configuration'
+            )
+
+            config = self.get_config(['--with-foo', '--with-foo-really'])
+            self.assertEquals(config, {
+                'FOO': 'foo',
+                'FOO2': True,
+            })
+
     def test_sandbox_failures(self):
         with self.assertRaises(KeyError) as e:
             with self.moz_configure('''
                 include = 42
             '''):
                 self.get_config()
 
         self.assertEquals(e.exception.message, 'Cannot reassign builtins')
@@ -1142,11 +1214,100 @@ class TestConfigure(unittest.TestCase):
                 def foo(value):
                     return value
             '''):
                 self.get_config()
 
         self.assertEquals(e.exception.message,
                           "Invalid argument to @imports: 'os*'")
 
+    def test_only_when(self):
+        moz_configure = '''
+            option('--enable-when', help='when')
+            @depends('--enable-when', '--help')
+            def when(value, _):
+                return bool(value)
+
+            with only_when(when):
+                option('--foo', nargs='*', help='foo')
+                @depends('--foo')
+                def foo(value):
+                    return value
+
+                set_config('FOO', foo)
+                set_define('FOO', foo)
+
+            # It is possible to depend on a function defined in a only_when
+            # block. It then resolves to `None`.
+            set_config('BAR', depends(foo)(lambda x: x))
+            set_define('BAR', depends(foo)(lambda x: x))
+        '''
+
+        with self.moz_configure(moz_configure):
+            config = self.get_config()
+            self.assertEqual(config, {
+                'DEFINES': {},
+            })
+
+            config = self.get_config(['--enable-when'])
+            self.assertEqual(config, {
+                'BAR': NegativeOptionValue(),
+                'FOO': NegativeOptionValue(),
+                'DEFINES': {
+                    'BAR': NegativeOptionValue(),
+                    'FOO': NegativeOptionValue(),
+                },
+            })
+
+            config = self.get_config(['--enable-when', '--foo=bar'])
+            self.assertEqual(config, {
+                'BAR': PositiveOptionValue(['bar']),
+                'FOO': PositiveOptionValue(['bar']),
+                'DEFINES': {
+                    'BAR': PositiveOptionValue(['bar']),
+                    'FOO': PositiveOptionValue(['bar']),
+                },
+            })
+
+            # The --foo option doesn't exist when --enable-when is not given.
+            with self.assertRaises(InvalidOptionError) as e:
+                self.get_config(['--foo'])
+
+            self.assertEquals(e.exception.message,
+                              '--foo is not available in this configuration')
+
+        # Cannot depend on an option defined in a only_when block, because we
+        # don't know what OptionValue would make sense.
+        with self.moz_configure(moz_configure + '''
+            set_config('QUX', depends('--foo')(lambda x: x))
+        '''):
+            with self.assertRaises(ConfigureError) as e:
+                self.get_config()
+
+            self.assertEquals(e.exception.message,
+                              '@depends function needs the same `when` as '
+                              'options it depends on')
+
+        with self.moz_configure(moz_configure + '''
+            set_config('QUX', depends('--foo', when=when)(lambda x: x))
+        '''):
+            self.get_config(['--enable-when'])
+
+        # Using imply_option for an option defined in a only_when block fails
+        # similarly if the imply_option happens outside the block.
+        with self.moz_configure('''
+            imply_option('--foo', True)
+        ''' + moz_configure):
+            with self.assertRaises(InvalidOptionError) as e:
+                self.get_config()
+
+            self.assertEquals(e.exception.message,
+                              '--foo is not available in this configuration')
+
+        # And similarly doesn't fail when the condition is true.
+        with self.moz_configure('''
+            imply_option('--foo', True)
+        ''' + moz_configure):
+            self.get_config(['--enable-when'])
+
 
 if __name__ == '__main__':
     main()