diff options
Diffstat (limited to 'python/mozbuild/mozbuild/configure/options.py')
-rw-r--r-- | python/mozbuild/mozbuild/configure/options.py | 614 |
1 files changed, 614 insertions, 0 deletions
diff --git a/python/mozbuild/mozbuild/configure/options.py b/python/mozbuild/mozbuild/configure/options.py new file mode 100644 index 0000000000..cc3b4516ea --- /dev/null +++ b/python/mozbuild/mozbuild/configure/options.py @@ -0,0 +1,614 @@ +# 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/. + +import inspect +import os +import sys +from collections import OrderedDict + +import six + +HELP_OPTIONS_CATEGORY = "Help options" +# List of whitelisted option categories. If you want to add a new category, +# simply add it to this list; however, exercise discretion as +# "./configure --help" becomes less useful if there are an excessive number of +# categories. +_ALL_CATEGORIES = (HELP_OPTIONS_CATEGORY,) + + +def _infer_option_category(define_depth): + stack_frame = inspect.stack(0)[3 + define_depth] + try: + path = os.path.relpath(stack_frame[0].f_code.co_filename) + except ValueError: + # If this call fails, it means the relative path couldn't be determined + # (e.g. because this file is on a different drive than the cwd on a + # Windows machine). That's fine, just use the absolute filename. + path = stack_frame[0].f_code.co_filename + return "Options from " + path + + +def istupleofstrings(obj): + return ( + isinstance(obj, tuple) + and len(obj) + and all(isinstance(o, six.string_types) for o in obj) + ) + + +class OptionValue(tuple): + """Represents the value of a configure option. + + This class is not meant to be used directly. Use its subclasses instead. + + The `origin` attribute holds where the option comes from (e.g. environment, + command line, or default) + """ + + def __new__(cls, values=(), origin="unknown"): + return super(OptionValue, cls).__new__(cls, values) + + def __init__(self, values=(), origin="unknown"): + self.origin = origin + + def format(self, option): + if option.startswith("--"): + prefix, name, values = Option.split_option(option) + assert values == () + for prefix_set in ( + ("disable", "enable"), + ("without", "with"), + ): + if prefix in prefix_set: + prefix = prefix_set[int(bool(self))] + break + if prefix: + option = "--%s-%s" % (prefix, name) + elif self: + option = "--%s" % name + else: + return "" + if len(self): + return "%s=%s" % (option, ",".join(self)) + return option + elif self and not len(self): + return "%s=1" % option + return "%s=%s" % (option, ",".join(self)) + + def __eq__(self, other): + # This is to catch naive comparisons against strings and other + # types in moz.configure files, as it is really easy to write + # value == 'foo'. We only raise a TypeError for instances that + # have content, because value-less instances (like PositiveOptionValue + # and NegativeOptionValue) are common and it is trivial to + # compare these. + if not isinstance(other, tuple) and len(self): + raise TypeError( + "cannot compare a populated %s against an %s; " + "OptionValue instances are tuples - did you mean to " + "compare against member elements using [x]?" + % (type(other).__name__, type(self).__name__) + ) + + # Allow explicit tuples to be compared. + if type(other) == tuple: + return tuple.__eq__(self, other) + elif isinstance(other, bool): + return bool(self) == other + # Else we're likely an OptionValue class. + elif type(other) != type(self): + return False + else: + return super(OptionValue, self).__eq__(other) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s%s" % (self.__class__.__name__, super(OptionValue, self).__repr__()) + + @staticmethod + def from_(value): + if isinstance(value, OptionValue): + return value + elif value is True: + return PositiveOptionValue() + elif value is False or value == (): + return NegativeOptionValue() + elif isinstance(value, six.string_types): + return PositiveOptionValue((value,)) + elif isinstance(value, tuple): + return PositiveOptionValue(value) + else: + raise TypeError("Unexpected type: '%s'" % type(value).__name__) + + +class PositiveOptionValue(OptionValue): + """Represents the value for a positive option (--enable/--with/--foo) + in the form of a tuple for when values are given to the option (in the form + --option=value[,value2...]. + """ + + def __nonzero__(self): # py2 + return True + + def __bool__(self): # py3 + return True + + +class NegativeOptionValue(OptionValue): + """Represents the value for a negative option (--disable/--without) + + This is effectively an empty tuple with a `origin` attribute. + """ + + def __new__(cls, origin="unknown"): + return super(NegativeOptionValue, cls).__new__(cls, origin=origin) + + def __init__(self, origin="unknown"): + super(NegativeOptionValue, self).__init__(origin=origin) + + +class InvalidOptionError(Exception): + pass + + +class ConflictingOptionError(InvalidOptionError): + def __init__(self, message, **format_data): + if format_data: + message = message.format(**format_data) + super(ConflictingOptionError, self).__init__(message) + for k, v in six.iteritems(format_data): + setattr(self, k, v) + + +class Option(object): + """Represents a configure option + + A configure option can be a command line flag or an environment variable + or both. + + - `name` is the full command line flag (e.g. --enable-foo). + - `env` is the environment variable name (e.g. ENV) + - `nargs` is the number of arguments the option may take. It can be a + number or the special values '?' (0 or 1), '*' (0 or more), or '+' (1 or + more). + - `default` can be used to give a default value to the option. When the + `name` of the option starts with '--enable-' or '--with-', the implied + default is an empty PositiveOptionValue. When it starts with '--disable-' + or '--without-', the implied default is a NegativeOptionValue. + - `choices` restricts the set of values that can be given to the option. + - `help` is the option description for use in the --help output. + - `possible_origins` is a tuple of strings that are origins accepted for + this option. Example origins are 'mozconfig', 'implied', and 'environment'. + - `category` is a human-readable string used only for categorizing command- + line options when displaying the output of `configure --help`. If not + supplied, the script will attempt to infer an appropriate category based + on the name of the file where the option was defined. If supplied it must + be in the _ALL_CATEGORIES list above. + - `define_depth` should generally only be used by templates that are used + to instantiate an option indirectly. Set this to a positive integer to + force the script to look into a deeper stack frame when inferring the + `category`. + """ + + __slots__ = ( + "id", + "prefix", + "name", + "env", + "nargs", + "default", + "choices", + "help", + "possible_origins", + "category", + "define_depth", + ) + + def __init__( + self, + name=None, + env=None, + nargs=None, + default=None, + possible_origins=None, + choices=None, + category=None, + help=None, + define_depth=0, + ): + if not name and not env: + raise InvalidOptionError( + "At least an option name or an environment variable name must " + "be given" + ) + if name: + if not isinstance(name, six.string_types): + raise InvalidOptionError("Option must be a string") + if not name.startswith("--"): + raise InvalidOptionError("Option must start with `--`") + if "=" in name: + raise InvalidOptionError("Option must not contain an `=`") + if not name.islower(): + raise InvalidOptionError("Option must be all lowercase") + if env: + if not isinstance(env, six.string_types): + raise InvalidOptionError("Environment variable name must be a string") + if not env.isupper(): + raise InvalidOptionError( + "Environment variable name must be all uppercase" + ) + if nargs not in (None, "?", "*", "+") and not ( + isinstance(nargs, int) and nargs >= 0 + ): + raise InvalidOptionError( + "nargs must be a positive integer, '?', '*' or '+'" + ) + if ( + not isinstance(default, six.string_types) + and not isinstance(default, (bool, type(None))) + and not istupleofstrings(default) + ): + raise InvalidOptionError( + "default must be a bool, a string or a tuple of strings" + ) + if choices and not istupleofstrings(choices): + raise InvalidOptionError("choices must be a tuple of strings") + if category and not isinstance(category, six.string_types): + raise InvalidOptionError("Category must be a string") + if category and category not in _ALL_CATEGORIES: + raise InvalidOptionError( + "Category must either be inferred or in the _ALL_CATEGORIES " + "list in options.py: %s" % ", ".join(_ALL_CATEGORIES) + ) + if not isinstance(define_depth, int): + raise InvalidOptionError("DefineDepth must be an integer") + if not help: + raise InvalidOptionError("A help string must be provided") + if possible_origins and not istupleofstrings(possible_origins): + raise InvalidOptionError("possible_origins must be a tuple of strings") + self.possible_origins = possible_origins + + if name: + prefix, name, values = self.split_option(name) + assert values == () + + # --disable and --without options mean the default is enabled. + # --enable and --with options mean the default is disabled. + # However, we allow a default to be given so that the default + # can be affected by other factors. + if prefix: + if default is None: + default = prefix in ("disable", "without") + elif default is False: + prefix = { + "disable": "enable", + "without": "with", + }.get(prefix, prefix) + elif default is True: + prefix = { + "enable": "disable", + "with": "without", + }.get(prefix, prefix) + else: + prefix = "" + + self.prefix = prefix + self.name = name + self.env = env + if default in (None, False): + self.default = NegativeOptionValue(origin="default") + elif isinstance(default, tuple): + self.default = PositiveOptionValue(default, origin="default") + elif default is True: + self.default = PositiveOptionValue(origin="default") + else: + self.default = PositiveOptionValue((default,), origin="default") + if nargs is None: + nargs = 0 + if len(self.default) == 1: + nargs = "?" + elif len(self.default) > 1: + nargs = "*" + elif choices: + nargs = 1 + self.nargs = nargs + has_choices = choices is not None + if isinstance(self.default, PositiveOptionValue): + if has_choices and len(self.default) == 0: + raise InvalidOptionError( + "A `default` must be given along with `choices`" + ) + if not self._validate_nargs(len(self.default)): + raise InvalidOptionError("The given `default` doesn't satisfy `nargs`") + if has_choices and not all(d in choices for d in self.default): + raise InvalidOptionError( + "The `default` value must be one of %s" + % ", ".join("'%s'" % c for c in choices) + ) + elif has_choices: + maxargs = self.maxargs + if len(choices) < maxargs and maxargs != sys.maxsize: + raise InvalidOptionError("Not enough `choices` for `nargs`") + self.choices = choices + self.help = help + self.category = category or _infer_option_category(define_depth) + + @staticmethod + def split_option(option): + """Split a flag or variable into a prefix, a name and values + + Variables come in the form NAME=values (no prefix). + Flags come in the form --name=values or --prefix-name=values + where prefix is one of 'with', 'without', 'enable' or 'disable'. + The '=values' part is optional. Values are separated with commas. + """ + if not isinstance(option, six.string_types): + raise InvalidOptionError("Option must be a string") + + elements = option.split("=", 1) + name = elements[0] + values = tuple(elements[1].split(",")) if len(elements) == 2 else () + if name.startswith("--"): + name = name[2:] + if not name.islower(): + raise InvalidOptionError("Option must be all lowercase") + elements = name.split("-", 1) + prefix = elements[0] + if len(elements) == 2 and prefix in ( + "enable", + "disable", + "with", + "without", + ): + return prefix, elements[1], values + else: + if name.startswith("-"): + raise InvalidOptionError( + "Option must start with two dashes instead of one" + ) + if name.islower(): + raise InvalidOptionError( + 'Environment variable name "%s" must be all uppercase' % name + ) + return "", name, values + + @staticmethod + def _join_option(prefix, name): + # The constraints around name and env in __init__ make it so that + # we can distinguish between flags and environment variables with + # islower/isupper. + if name.isupper(): + assert not prefix + return name + elif prefix: + return "--%s-%s" % (prefix, name) + return "--%s" % name + + @property + def option(self): + if self.prefix or self.name: + return self._join_option(self.prefix, self.name) + else: + return self.env + + @property + def minargs(self): + if isinstance(self.nargs, int): + return self.nargs + return 1 if self.nargs == "+" else 0 + + @property + def maxargs(self): + if isinstance(self.nargs, int): + return self.nargs + return 1 if self.nargs == "?" else sys.maxsize + + def _validate_nargs(self, num): + minargs, maxargs = self.minargs, self.maxargs + return num >= minargs and num <= maxargs + + def get_value(self, option=None, origin="unknown"): + """Given a full command line option (e.g. --enable-foo=bar) or a + variable assignment (FOO=bar), returns the corresponding OptionValue. + + Note: variable assignments can come from either the environment or + from the command line (e.g. `../configure CFLAGS=-O2`) + """ + if not option: + return self.default + + if self.possible_origins and origin not in self.possible_origins: + raise InvalidOptionError( + "%s can not be set by %s. Values are accepted from: %s" + % (option, origin, ", ".join(self.possible_origins)) + ) + + prefix, name, values = self.split_option(option) + option = self._join_option(prefix, name) + + assert name in (self.name, self.env) + + if prefix in ("disable", "without"): + if values != (): + raise InvalidOptionError("Cannot pass a value to %s" % option) + return NegativeOptionValue(origin=origin) + + if name == self.env: + if values == ("",): + return NegativeOptionValue(origin=origin) + if self.nargs in (0, "?", "*") and values == ("1",): + return PositiveOptionValue(origin=origin) + + values = PositiveOptionValue(values, origin=origin) + + if not self._validate_nargs(len(values)): + raise InvalidOptionError( + "%s takes %s value%s" + % ( + option, + { + "?": "0 or 1", + "*": "0 or more", + "+": "1 or more", + }.get(self.nargs, str(self.nargs)), + "s" if (not isinstance(self.nargs, int) or self.nargs != 1) else "", + ) + ) + + if len(values) and self.choices: + relative_result = None + for val in values: + if self.nargs in ("+", "*"): + if val.startswith(("+", "-")): + if relative_result is None: + relative_result = list(self.default) + sign = val[0] + val = val[1:] + if sign == "+": + if val not in relative_result: + relative_result.append(val) + else: + try: + relative_result.remove(val) + except ValueError: + pass + + if val not in self.choices: + raise InvalidOptionError( + "'%s' is not one of %s" + % (val, ", ".join("'%s'" % c for c in self.choices)) + ) + + if relative_result is not None: + values = PositiveOptionValue(relative_result, origin=origin) + + return values + + def __repr__(self): + return "<%s [%s]>" % (self.__class__.__name__, self.option) + + +class CommandLineHelper(object): + """Helper class to handle the various ways options can be given either + on the command line of through the environment. + + For instance, an Option('--foo', env='FOO') can be passed as --foo on the + command line, or as FOO=1 in the environment *or* on the command line. + + If multiple variants are given, command line is prefered over the + environment, and if different values are given on the command line, the + last one wins. (This mimicks the behavior of autoconf, avoiding to break + existing mozconfigs using valid options in weird ways) + + Extra options can be added afterwards through API calls. For those, + conflicting values will raise an exception. + """ + + def __init__(self, environ=os.environ, argv=sys.argv): + self._environ = dict(environ) + self._args = OrderedDict() + self._extra_args = OrderedDict() + self._origins = {} + self._last = 0 + + assert argv and not argv[0].startswith("--") + for arg in argv[1:]: + self.add(arg, "command-line", self._args) + + def add(self, arg, origin="command-line", args=None): + assert origin != "default" + prefix, name, values = Option.split_option(arg) + if args is None: + args = self._extra_args + if args is self._extra_args and name in self._extra_args: + old_arg = self._extra_args[name][0] + old_prefix, _, old_values = Option.split_option(old_arg) + if prefix != old_prefix or values != old_values: + raise ConflictingOptionError( + "Cannot add '{arg}' to the {origin} set because it " + "conflicts with '{old_arg}' that was added earlier", + arg=arg, + origin=origin, + old_arg=old_arg, + old_origin=self._origins[old_arg], + ) + self._last += 1 + args[name] = arg, self._last + self._origins[arg] = origin + + def _prepare(self, option, args): + arg = None + origin = "command-line" + from_name = args.get(option.name) + from_env = args.get(option.env) + if from_name and from_env: + arg1, pos1 = from_name + arg2, pos2 = from_env + arg, pos = (arg1, pos1) if abs(pos1) > abs(pos2) else (arg2, pos2) + if args is self._extra_args and ( + option.get_value(arg1) != option.get_value(arg2) + ): + origin = self._origins[arg] + old_arg = arg2 if abs(pos1) > abs(pos2) else arg1 + raise ConflictingOptionError( + "Cannot add '{arg}' to the {origin} set because it " + "conflicts with '{old_arg}' that was added earlier", + arg=arg, + origin=origin, + old_arg=old_arg, + old_origin=self._origins[old_arg], + ) + elif from_name or from_env: + arg, pos = from_name if from_name else from_env + elif option.env and args is self._args: + env = self._environ.get(option.env) + if env is not None: + arg = "%s=%s" % (option.env, env) + origin = "environment" + + origin = self._origins.get(arg, origin) + + for k in (option.name, option.env): + try: + del args[k] + except KeyError: + pass + + return arg, origin + + def handle(self, option): + """Return the OptionValue corresponding to the given Option instance, + depending on the command line, environment, and extra arguments, and + the actual option or variable that set it. + Only works once for a given Option. + """ + assert isinstance(option, Option) + + arg, origin = self._prepare(option, self._args) + ret = option.get_value(arg, origin) + + extra_arg, extra_origin = self._prepare(option, self._extra_args) + extra_ret = option.get_value(extra_arg, extra_origin) + + if extra_ret.origin == "default": + return ret, arg + + if ret.origin != "default" and extra_ret != ret: + raise ConflictingOptionError( + "Cannot add '{arg}' to the {origin} set because it conflicts " + "with {old_arg} from the {old_origin} set", + arg=extra_arg, + origin=extra_ret.origin, + old_arg=arg, + old_origin=ret.origin, + ) + + return extra_ret, extra_arg + + def __iter__(self): + for d in (self._args, self._extra_args): + for arg, pos in six.itervalues(d): + yield arg |