1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5from __future__ import absolute_import, print_function, unicode_literals
6
7from collections import OrderedDict
8import inspect
9import os
10import six
11import sys
12
13
14HELP_OPTIONS_CATEGORY = 'Help options'
15# List of whitelisted option categories. If you want to add a new category,
16# simply add it to this list; however, exercise discretion as
17# "./configure --help" becomes less useful if there are an excessive number of
18# categories.
19_ALL_CATEGORIES = (
20    HELP_OPTIONS_CATEGORY,
21)
22
23
24def _infer_option_category(define_depth):
25    stack_frame = inspect.stack(0)[3 + define_depth]
26    try:
27        path = os.path.relpath(stack_frame[0].f_code.co_filename)
28    except ValueError:
29        # If this call fails, it means the relative path couldn't be determined
30        # (e.g. because this file is on a different drive than the cwd on a
31        # Windows machine). That's fine, just use the absolute filename.
32        path = stack_frame[0].f_code.co_filename
33    return 'Options from ' + path
34
35
36def istupleofstrings(obj):
37    return isinstance(obj, tuple) and len(obj) and all(
38        isinstance(o, six.string_types) for o in obj)
39
40
41class OptionValue(tuple):
42    '''Represents the value of a configure option.
43
44    This class is not meant to be used directly. Use its subclasses instead.
45
46    The `origin` attribute holds where the option comes from (e.g. environment,
47    command line, or default)
48    '''
49    def __new__(cls, values=(), origin='unknown'):
50        return super(OptionValue, cls).__new__(cls, values)
51
52    def __init__(self, values=(), origin='unknown'):
53        self.origin = origin
54
55    def format(self, option):
56        if option.startswith('--'):
57            prefix, name, values = Option.split_option(option)
58            assert values == ()
59            for prefix_set in (
60                    ('disable', 'enable'),
61                    ('without', 'with'),
62            ):
63                if prefix in prefix_set:
64                    prefix = prefix_set[int(bool(self))]
65                    break
66            if prefix:
67                option = '--%s-%s' % (prefix, name)
68            elif self:
69                option = '--%s' % name
70            else:
71                return ''
72            if len(self):
73                return '%s=%s' % (option, ','.join(self))
74            return option
75        elif self and not len(self):
76            return '%s=1' % option
77        return '%s=%s' % (option, ','.join(self))
78
79    def __eq__(self, other):
80        # This is to catch naive comparisons against strings and other
81        # types in moz.configure files, as it is really easy to write
82        # value == 'foo'. We only raise a TypeError for instances that
83        # have content, because value-less instances (like PositiveOptionValue
84        # and NegativeOptionValue) are common and it is trivial to
85        # compare these.
86        if not isinstance(other, tuple) and len(self):
87            raise TypeError('cannot compare a populated %s against an %s; '
88                            'OptionValue instances are tuples - did you mean to '
89                            'compare against member elements using [x]?' % (
90                                type(other).__name__, type(self).__name__))
91
92        # Allow explicit tuples to be compared.
93        if type(other) == tuple:
94            return tuple.__eq__(self, other)
95        elif isinstance(other, bool):
96            return bool(self) == other
97        # Else we're likely an OptionValue class.
98        elif type(other) != type(self):
99            return False
100        else:
101            return super(OptionValue, self).__eq__(other)
102
103    def __ne__(self, other):
104        return not self.__eq__(other)
105
106    def __repr__(self):
107        return '%s%s' % (self.__class__.__name__,
108                         super(OptionValue, self).__repr__())
109
110    @staticmethod
111    def from_(value):
112        if isinstance(value, OptionValue):
113            return value
114        elif value is True:
115            return PositiveOptionValue()
116        elif value is False or value == ():
117            return NegativeOptionValue()
118        elif isinstance(value, six.string_types):
119            return PositiveOptionValue((value,))
120        elif isinstance(value, tuple):
121            return PositiveOptionValue(value)
122        else:
123            raise TypeError("Unexpected type: '%s'"
124                            % type(value).__name__)
125
126
127class PositiveOptionValue(OptionValue):
128    '''Represents the value for a positive option (--enable/--with/--foo)
129    in the form of a tuple for when values are given to the option (in the form
130    --option=value[,value2...].
131    '''
132
133    def __nonzero__(self):  # py2
134        return True
135
136    def __bool__(self):  # py3
137        return True
138
139
140class NegativeOptionValue(OptionValue):
141    '''Represents the value for a negative option (--disable/--without)
142
143    This is effectively an empty tuple with a `origin` attribute.
144    '''
145    def __new__(cls, origin='unknown'):
146        return super(NegativeOptionValue, cls).__new__(cls, origin=origin)
147
148    def __init__(self, origin='unknown'):
149        return super(NegativeOptionValue, self).__init__(origin=origin)
150
151
152class InvalidOptionError(Exception):
153    pass
154
155
156class ConflictingOptionError(InvalidOptionError):
157    def __init__(self, message, **format_data):
158        if format_data:
159            message = message.format(**format_data)
160        super(ConflictingOptionError, self).__init__(message)
161        for k, v in six.iteritems(format_data):
162            setattr(self, k, v)
163
164
165class Option(object):
166    '''Represents a configure option
167
168    A configure option can be a command line flag or an environment variable
169    or both.
170
171    - `name` is the full command line flag (e.g. --enable-foo).
172    - `env` is the environment variable name (e.g. ENV)
173    - `nargs` is the number of arguments the option may take. It can be a
174      number or the special values '?' (0 or 1), '*' (0 or more), or '+' (1 or
175      more).
176    - `default` can be used to give a default value to the option. When the
177      `name` of the option starts with '--enable-' or '--with-', the implied
178      default is an empty PositiveOptionValue. When it starts with '--disable-'
179      or '--without-', the implied default is a NegativeOptionValue.
180    - `choices` restricts the set of values that can be given to the option.
181    - `help` is the option description for use in the --help output.
182    - `possible_origins` is a tuple of strings that are origins accepted for
183      this option. Example origins are 'mozconfig', 'implied', and 'environment'.
184    - `category` is a human-readable string used only for categorizing command-
185      line options when displaying the output of `configure --help`. If not
186      supplied, the script will attempt to infer an appropriate category based
187      on the name of the file where the option was defined. If supplied it must
188      be in the _ALL_CATEGORIES list above.
189    - `define_depth` should generally only be used by templates that are used
190      to instantiate an option indirectly. Set this to a positive integer to
191      force the script to look into a deeper stack frame when inferring the
192      `category`.
193    '''
194    __slots__ = (
195        'id', 'prefix', 'name', 'env', 'nargs', 'default', 'choices', 'help',
196        'possible_origins', 'category', 'define_depth',
197    )
198
199    def __init__(self, name=None, env=None, nargs=None, default=None,
200                 possible_origins=None, choices=None, category=None, help=None,
201                 define_depth=0):
202        if not name and not env:
203            raise InvalidOptionError(
204                'At least an option name or an environment variable name must '
205                'be given')
206        if name:
207            if not isinstance(name, six.string_types):
208                raise InvalidOptionError('Option must be a string')
209            if not name.startswith('--'):
210                raise InvalidOptionError('Option must start with `--`')
211            if '=' in name:
212                raise InvalidOptionError('Option must not contain an `=`')
213            if not name.islower():
214                raise InvalidOptionError('Option must be all lowercase')
215        if env:
216            if not isinstance(env, six.string_types):
217                raise InvalidOptionError(
218                    'Environment variable name must be a string')
219            if not env.isupper():
220                raise InvalidOptionError(
221                    'Environment variable name must be all uppercase')
222        if nargs not in (None, '?', '*', '+') and not (
223                isinstance(nargs, int) and nargs >= 0):
224            raise InvalidOptionError(
225                "nargs must be a positive integer, '?', '*' or '+'")
226        if (not isinstance(default, six.string_types) and
227                not isinstance(default, (bool, type(None))) and
228                not istupleofstrings(default)):
229            raise InvalidOptionError(
230                'default must be a bool, a string or a tuple of strings')
231        if choices and not istupleofstrings(choices):
232            raise InvalidOptionError(
233                'choices must be a tuple of strings')
234        if category and not isinstance(category, six.string_types):
235            raise InvalidOptionError('Category must be a string')
236        if category and category not in _ALL_CATEGORIES:
237            raise InvalidOptionError(
238                'Category must either be inferred or in the _ALL_CATEGORIES '
239                'list in options.py: %s' % ', '.join(_ALL_CATEGORIES))
240        if not isinstance(define_depth, int):
241            raise InvalidOptionError('DefineDepth must be an integer')
242        if not help:
243            raise InvalidOptionError('A help string must be provided')
244        if possible_origins and not istupleofstrings(possible_origins):
245            raise InvalidOptionError(
246                'possible_origins must be a tuple of strings')
247        self.possible_origins = possible_origins
248
249        if name:
250            prefix, name, values = self.split_option(name)
251            assert values == ()
252
253            # --disable and --without options mean the default is enabled.
254            # --enable and --with options mean the default is disabled.
255            # However, we allow a default to be given so that the default
256            # can be affected by other factors.
257            if prefix:
258                if default is None:
259                    default = prefix in ('disable', 'without')
260                elif default is False:
261                    prefix = {
262                        'disable': 'enable',
263                        'without': 'with',
264                    }.get(prefix, prefix)
265                elif default is True:
266                    prefix = {
267                        'enable': 'disable',
268                        'with': 'without',
269                    }.get(prefix, prefix)
270        else:
271            prefix = ''
272
273        self.prefix = prefix
274        self.name = name
275        self.env = env
276        if default in (None, False):
277            self.default = NegativeOptionValue(origin='default')
278        elif isinstance(default, tuple):
279            self.default = PositiveOptionValue(default, origin='default')
280        elif default is True:
281            self.default = PositiveOptionValue(origin='default')
282        else:
283            self.default = PositiveOptionValue((default,), origin='default')
284        if nargs is None:
285            nargs = 0
286            if len(self.default) == 1:
287                nargs = '?'
288            elif len(self.default) > 1:
289                nargs = '*'
290            elif choices:
291                nargs = 1
292        self.nargs = nargs
293        has_choices = choices is not None
294        if isinstance(self.default, PositiveOptionValue):
295            if has_choices and len(self.default) == 0:
296                raise InvalidOptionError(
297                    'A `default` must be given along with `choices`')
298            if not self._validate_nargs(len(self.default)):
299                raise InvalidOptionError(
300                    "The given `default` doesn't satisfy `nargs`")
301            if has_choices and not all(d in choices for d in self.default):
302                raise InvalidOptionError(
303                    'The `default` value must be one of %s' %
304                    ', '.join("'%s'" % c for c in choices))
305        elif has_choices:
306            maxargs = self.maxargs
307            if len(choices) < maxargs and maxargs != sys.maxsize:
308                raise InvalidOptionError('Not enough `choices` for `nargs`')
309        self.choices = choices
310        self.help = help
311        self.category = category or _infer_option_category(define_depth)
312
313    @staticmethod
314    def split_option(option):
315        '''Split a flag or variable into a prefix, a name and values
316
317        Variables come in the form NAME=values (no prefix).
318        Flags come in the form --name=values or --prefix-name=values
319        where prefix is one of 'with', 'without', 'enable' or 'disable'.
320        The '=values' part is optional. Values are separated with commas.
321        '''
322        if not isinstance(option, six.string_types):
323            raise InvalidOptionError('Option must be a string')
324
325        elements = option.split('=', 1)
326        name = elements[0]
327        values = tuple(elements[1].split(',')) if len(elements) == 2 else ()
328        if name.startswith('--'):
329            name = name[2:]
330            if not name.islower():
331                raise InvalidOptionError('Option must be all lowercase')
332            elements = name.split('-', 1)
333            prefix = elements[0]
334            if len(elements) == 2 and prefix in ('enable', 'disable',
335                                                 'with', 'without'):
336                return prefix, elements[1], values
337        else:
338            if name.startswith('-'):
339                raise InvalidOptionError(
340                    'Option must start with two dashes instead of one')
341            if name.islower():
342                raise InvalidOptionError(
343                    'Environment variable name "%s" must be all uppercase' % name)
344        return '', name, values
345
346    @staticmethod
347    def _join_option(prefix, name):
348        # The constraints around name and env in __init__ make it so that
349        # we can distinguish between flags and environment variables with
350        # islower/isupper.
351        if name.isupper():
352            assert not prefix
353            return name
354        elif prefix:
355            return '--%s-%s' % (prefix, name)
356        return '--%s' % name
357
358    @property
359    def option(self):
360        if self.prefix or self.name:
361            return self._join_option(self.prefix, self.name)
362        else:
363            return self.env
364
365    @property
366    def minargs(self):
367        if isinstance(self.nargs, int):
368            return self.nargs
369        return 1 if self.nargs == '+' else 0
370
371    @property
372    def maxargs(self):
373        if isinstance(self.nargs, int):
374            return self.nargs
375        return 1 if self.nargs == '?' else sys.maxsize
376
377    def _validate_nargs(self, num):
378        minargs, maxargs = self.minargs, self.maxargs
379        return num >= minargs and num <= maxargs
380
381    def get_value(self, option=None, origin='unknown'):
382        '''Given a full command line option (e.g. --enable-foo=bar) or a
383        variable assignment (FOO=bar), returns the corresponding OptionValue.
384
385        Note: variable assignments can come from either the environment or
386        from the command line (e.g. `../configure CFLAGS=-O2`)
387        '''
388        if not option:
389            return self.default
390
391        if self.possible_origins and origin not in self.possible_origins:
392            raise InvalidOptionError(
393                '%s can not be set by %s. Values are accepted from: %s' %
394                (option, origin, ', '.join(self.possible_origins)))
395
396        prefix, name, values = self.split_option(option)
397        option = self._join_option(prefix, name)
398
399        assert name in (self.name, self.env)
400
401        if prefix in ('disable', 'without'):
402            if values != ():
403                raise InvalidOptionError('Cannot pass a value to %s' % option)
404            return NegativeOptionValue(origin=origin)
405
406        if name == self.env:
407            if values == ('',):
408                return NegativeOptionValue(origin=origin)
409            if self.nargs in (0, '?', '*') and values == ('1',):
410                return PositiveOptionValue(origin=origin)
411
412        values = PositiveOptionValue(values, origin=origin)
413
414        if not self._validate_nargs(len(values)):
415            raise InvalidOptionError('%s takes %s value%s' % (
416                option,
417                {
418                    '?': '0 or 1',
419                    '*': '0 or more',
420                    '+': '1 or more',
421                }.get(self.nargs, str(self.nargs)),
422                's' if (not isinstance(self.nargs, int) or
423                        self.nargs != 1) else ''
424            ))
425
426        if len(values) and self.choices:
427            relative_result = None
428            for val in values:
429                if self.nargs in ('+', '*'):
430                    if val.startswith(('+', '-')):
431                        if relative_result is None:
432                            relative_result = list(self.default)
433                        sign = val[0]
434                        val = val[1:]
435                        if sign == '+':
436                            if val not in relative_result:
437                                relative_result.append(val)
438                        else:
439                            try:
440                                relative_result.remove(val)
441                            except ValueError:
442                                pass
443
444                if val not in self.choices:
445                    raise InvalidOptionError(
446                        "'%s' is not one of %s"
447                        % (val, ', '.join("'%s'" % c for c in self.choices)))
448
449            if relative_result is not None:
450                values = PositiveOptionValue(relative_result, origin=origin)
451
452        return values
453
454    def __repr__(self):
455        return '<%s [%s]>' % (self.__class__.__name__, self.option)
456
457
458class CommandLineHelper(object):
459    '''Helper class to handle the various ways options can be given either
460    on the command line of through the environment.
461
462    For instance, an Option('--foo', env='FOO') can be passed as --foo on the
463    command line, or as FOO=1 in the environment *or* on the command line.
464
465    If multiple variants are given, command line is prefered over the
466    environment, and if different values are given on the command line, the
467    last one wins. (This mimicks the behavior of autoconf, avoiding to break
468    existing mozconfigs using valid options in weird ways)
469
470    Extra options can be added afterwards through API calls. For those,
471    conflicting values will raise an exception.
472    '''
473
474    def __init__(self, environ=os.environ, argv=sys.argv):
475        self._environ = dict(environ)
476        self._args = OrderedDict()
477        self._extra_args = OrderedDict()
478        self._origins = {}
479        self._last = 0
480
481        assert(argv and not argv[0].startswith('--'))
482        for arg in argv[1:]:
483            self.add(arg, 'command-line', self._args)
484
485    def add(self, arg, origin='command-line', args=None):
486        assert origin != 'default'
487        prefix, name, values = Option.split_option(arg)
488        if args is None:
489            args = self._extra_args
490        if args is self._extra_args and name in self._extra_args:
491            old_arg = self._extra_args[name][0]
492            old_prefix, _, old_values = Option.split_option(old_arg)
493            if prefix != old_prefix or values != old_values:
494                raise ConflictingOptionError(
495                    "Cannot add '{arg}' to the {origin} set because it "
496                    "conflicts with '{old_arg}' that was added earlier",
497                    arg=arg, origin=origin, old_arg=old_arg,
498                    old_origin=self._origins[old_arg])
499        self._last += 1
500        args[name] = arg, self._last
501        self._origins[arg] = origin
502
503    def _prepare(self, option, args):
504        arg = None
505        origin = 'command-line'
506        from_name = args.get(option.name)
507        from_env = args.get(option.env)
508        if from_name and from_env:
509            arg1, pos1 = from_name
510            arg2, pos2 = from_env
511            arg, pos = (arg1, pos1) if abs(pos1) > abs(pos2) else (arg2, pos2)
512            if args is self._extra_args and (option.get_value(arg1) !=
513                                             option.get_value(arg2)):
514                origin = self._origins[arg]
515                old_arg = arg2 if abs(pos1) > abs(pos2) else arg1
516                raise ConflictingOptionError(
517                    "Cannot add '{arg}' to the {origin} set because it "
518                    "conflicts with '{old_arg}' that was added earlier",
519                    arg=arg, origin=origin, old_arg=old_arg,
520                    old_origin=self._origins[old_arg])
521        elif from_name or from_env:
522            arg, pos = from_name if from_name else from_env
523        elif option.env and args is self._args:
524            env = self._environ.get(option.env)
525            if env is not None:
526                arg = '%s=%s' % (option.env, env)
527                origin = 'environment'
528
529        origin = self._origins.get(arg, origin)
530
531        for k in (option.name, option.env):
532            try:
533                del args[k]
534            except KeyError:
535                pass
536
537        return arg, origin
538
539    def handle(self, option):
540        '''Return the OptionValue corresponding to the given Option instance,
541        depending on the command line, environment, and extra arguments, and
542        the actual option or variable that set it.
543        Only works once for a given Option.
544        '''
545        assert isinstance(option, Option)
546
547        arg, origin = self._prepare(option, self._args)
548        ret = option.get_value(arg, origin)
549
550        extra_arg, extra_origin = self._prepare(option, self._extra_args)
551        extra_ret = option.get_value(extra_arg, extra_origin)
552
553        if extra_ret.origin == 'default':
554            return ret, arg
555
556        if ret.origin != 'default' and extra_ret != ret:
557            raise ConflictingOptionError(
558                "Cannot add '{arg}' to the {origin} set because it conflicts "
559                "with {old_arg} from the {old_origin} set", arg=extra_arg,
560                origin=extra_ret.origin, old_arg=arg, old_origin=ret.origin)
561
562        return extra_ret, extra_arg
563
564    def __iter__(self):
565        for d in (self._args, self._extra_args):
566            for arg, pos in six.itervalues(d):
567                yield arg
568