summaryrefslogtreecommitdiffstats
path: root/src/lib/Bcfg2/Options/Options.py
blob: 752e01b4e2425e15cf86d97481bf4a41e73fe6a8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
"""Base :class:`Bcfg2.Options.Option` object to represent an option.

Unlike options in :mod:`argparse`, an Option object does not need to
be associated with an option parser; it exists on its own.
"""

import argparse
import copy
import fnmatch
import os
import sys

from Bcfg2.Options import Types
from Bcfg2.Compat import ConfigParser


__all__ = ["Option", "BooleanOption", "RepositoryMacroOption", "PathOption",
           "PositionalArgument", "_debug"]

unit_test = False  # pylint: disable=C0103


def _debug(msg):
    """ Option parsing happens before verbose/debug have been set --
    they're options, after all -- so option parsing verbosity is
    enabled by changing this to True. The verbosity here is primarily
    of use to developers. """
    if unit_test:
        print("DEBUG: %s" % msg)
    elif os.environ.get('BCFG2_OPTIONS_DEBUG', '0').lower() in ["true", "yes",
                                                                "on", "1"]:
        sys.stderr.write("%s\n" % msg)


#: A dict that records a mapping of argparse action name (e.g.,
#: "store_true") to the argparse Action class for it.  See
#: :func:`_get_action_class`
_action_map = dict()  # pylint: disable=C0103


def _get_action_class(action_name):
    """ Given an argparse action name (e.g., "store_true"), get the
    related :class:`argparse.Action` class.  The mapping that stores
    this information in :mod:`argparse` itself is unfortunately
    private, so it's an implementation detail that we shouldn't depend
    on.  So we just instantiate a dummy parser, add a dummy argument,
    and determine the class that way. """
    if (isinstance(action_name, type) and
            issubclass(action_name, argparse.Action)):
        return action_name
    if action_name not in _action_map:
        action = argparse.ArgumentParser().add_argument(action_name,
                                                        action=action_name)
        _action_map[action_name] = action.__class__
    return _action_map[action_name]


class Option(object):
    """ Representation of an option that can be specified on the
    command line, as an environment variable, or in a config
    file. Precedence is in that order; that is, an option specified on
    the command line takes precendence over an option given by the
    environment, which takes precedence over an option specified in
    the config file. """

    #: Keyword arguments that should not be passed on to the
    #: :class:`argparse.ArgumentParser` constructor
    _local_args = ['cf', 'env', 'man']

    def __init__(self, *args, **kwargs):
        """ See :meth:`argparse.ArgumentParser.add_argument` for a
        full list of accepted parameters.

        In addition to supporting all arguments and keyword arguments
        from :meth:`argparse.ArgumentParser.add_argument`, several
        additional keyword arguments are allowed.

        :param cf: A tuple giving the section and option name that
                   this argument can be referenced as in the config
                   file.  The option name may contain the wildcard
                   '*', in which case the value will be a dict of all
                   options matching the glob.  (To use a wildcard in
                   the section, use a
                   :class:`Bcfg2.Options.WildcardSectionGroup`.)
        :type cf: tuple
        :param env: An environment variable that the value of this
                    option can be taken from.
        :type env: string
        :param man: A detailed description of the option that will be
                    used to populate automatically-generated manpages.
        :type man: string
        """
        #: The options by which this option can be called.
        #: (Coincidentally, this is also the list of arguments that
        #: will be passed to
        #: :meth:`argparse.ArgumentParser.add_argument` when this
        #: option is added to a parser.)  As a result, ``args`` can be
        #: tested to see if this argument can be given on the command
        #: line at all, or if it is purely a config file option.
        self.args = args
        self._kwargs = kwargs

        #: The tuple giving the section and option name for this
        #: option in the config file
        self.cf = None  # pylint: disable=C0103

        #: The environment variable that this option can take its
        #: value from
        self.env = None

        #: A detailed description of this option that will be used in
        #: man pages.
        self.man = None

        #: A list of :class:`Bcfg2.Options.Parser` objects to which
        #: this option has been added.  (There will be more than one
        #: parser if this option is added to a subparser, for
        #: instance.)
        self.parsers = []

        #: A dict of :class:`Bcfg2.Options.Parser` ->
        #: :class:`argparse.Action` that gives the actions that
        #: resulted from adding this option to each parser that it was
        #: added to.  If this option cannot be specified on the
        #: command line (i.e., it only takes its value from the config
        #: file), then this will be empty.
        self.actions = dict()

        self.type = self._kwargs.get("type")
        self.help = self._kwargs.get("help")
        self._default = self._kwargs.get("default")
        for kwarg in self._local_args:
            setattr(self, kwarg, self._kwargs.pop(kwarg, None))
        if self.args:
            # cli option
            self._dest = None
        else:
            action_cls = _get_action_class(self._kwargs.get('action', 'store'))
            # determine the name of this option.  use, in order, the
            # 'name' kwarg; the option name; the environment variable
            # name.
            self._dest = None
            if 'dest' in self._kwargs:
                self._dest = self._kwargs.pop('dest')
            elif self.env is not None:
                self._dest = self.env
            elif self.cf is not None:
                self._dest = self.cf[1]
            self._dest = self._dest.lower().replace("-", "_")
            kwargs = copy.copy(self._kwargs)
            kwargs.pop("action", None)
            self.actions[None] = action_cls(self._dest, self._dest, **kwargs)

    def __repr__(self):
        sources = []
        if self.args:
            sources.extend(self.args)
        if self.cf:
            sources.append("%s.%s" % self.cf)
        if self.env:
            sources.append("$" + self.env)
        spec = ["sources=%s" % sources, "default=%s" % self.default,
                "%d parsers" % len(self.parsers)]
        return '%s(%s: %s)' % (self.__class__.__name__,
                               self.dest, ", ".join(spec))

    def list_options(self):
        """ List options contained in this option.  This exists to
        provide a consistent interface with
        :class:`Bcfg2.Options.OptionGroup` """
        return [self]

    def finalize(self, namespace):
        """ Finalize the default value for this option.  This is used
        with actions (such as :class:`Bcfg2.Options.ComponentAction`)
        that allow you to specify a default in a different format than
        its final storage format; this can be called after it has been
        determined that the default will be used (i.e., the option is
        not given on the command line or in the config file) to store
        the appropriate default value in the appropriate format."""
        for parser, action in self.actions.items():
            if hasattr(action, "finalize"):
                if parser:
                    _debug("Finalizing %s for %s" % (self, parser))
                else:
                    _debug("Finalizing %s" % self)
                action.finalize(parser, namespace)

    @property
    def _type_func(self):
        """get a function for converting a value to the option type.

        this always returns a callable, even when ``type`` is None.
        """
        if self.type:
            return self.type
        else:
            return lambda x: x

    def from_config(self, cfp):
        """ Get the value of this option from the given
        :class:`ConfigParser.ConfigParser`.  If it is not found in the
        config file, the default is returned.  (If there is no
        default, None is returned.)

        :param cfp: The config parser to get the option value from
        :type cfp: ConfigParser.ConfigParser
        :returns: The default value
        """
        if not self.cf:
            return None
        if '*' in self.cf[1]:
            if cfp.has_section(self.cf[0]):
                # build a list of known options in this section, and
                # exclude them
                exclude = set()
                for parser in self.parsers:
                    exclude.update(o.cf[1]
                                   for o in parser.option_list
                                   if o.cf and o.cf[0] == self.cf[0])
                rv = dict([(o, cfp.get(self.cf[0], o))
                           for o in fnmatch.filter(cfp.options(self.cf[0]),
                                                   self.cf[1])
                           if o not in exclude])
            else:
                rv = {}
        else:
            try:
                rv = self._type_func(self.get_config_value(cfp))
            except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
                rv = None
        _debug("Getting value of %s from config file(s): %s" % (self, rv))
        return rv

    def get_config_value(self, cfp):
        """fetch a value from the config file.

        This is passed the config parser. Its result is passed to the
        type function for this option. It can be overridden to, e.g.,
        handle boolean options.
        """
        return cfp.get(*self.cf)

    def get_environ_value(self, value):
        """fetch a value from the environment.

        This is passed the raw value from the environment variable,
        and its result is passed to the type function for this
        option. It can be overridden to, e.g., handle boolean options.
        """
        return value

    def default_from_config(self, cfp):
        """ Set the default value of this option from the config file
        or from the environment.

        :param cfp: The config parser to get the option value from
        :type cfp: ConfigParser.ConfigParser
        """
        if self.env and self.env in os.environ:
            self.default = self._type_func(
                self.get_environ_value(os.environ[self.env]))
            _debug("Setting the default of %s from environment: %s" %
                   (self, self.default))
        else:
            val = self.from_config(cfp)
            if val is not None:
                _debug("Setting the default of %s from config: %s" %
                       (self, val))
                self.default = val

    def _get_default(self):
        """ Getter for the ``default`` property """
        return self._default

    def _set_default(self, value):
        """ Setter for the ``default`` property """
        self._default = value
        for action in self.actions.values():
            action.default = value

    #: The current default value of this option
    default = property(_get_default, _set_default)

    def _get_dest(self):
        """ Getter for the ``dest`` property """
        return self._dest

    def _set_dest(self, value):
        """ Setter for the ``dest`` property """
        self._dest = value
        for action in self.actions.values():
            action.dest = value

    def early_parsing_hook(self, early_opts):  # pylint: disable=C0111
        """Hook called at the end of early option parsing.

        This can be used to save option values for macro fixup.
        """
        pass

    #: The namespace destination of this option (see `dest
    #: <http://docs.python.org/dev/library/argparse.html#dest>`_)
    dest = property(_get_dest, _set_dest)

    def add_to_parser(self, parser):
        """ Add this option to the given parser.

        :param parser: The parser to add the option to.
        :type parser: Bcfg2.Options.Parser
        :returns: argparse.Action
        """
        self.parsers.append(parser)
        if self.args:
            # cli option
            _debug("Adding %s to %s as a CLI option" % (self, parser))
            action = parser.add_argument(*self.args, **self._kwargs)
            if not self._dest:
                self._dest = action.dest
            if self._default:
                action.default = self._default
            self.actions[parser] = action
        else:
            # else, config file-only option
            _debug("Adding %s to %s as a config file-only option" %
                   (self, parser))


class RepositoryMacroOption(Option):
    """Option that does translation of ``<repository>`` macros.

    Macro translation is done on the fly instead of just fixing up all
    values at the end of parsing because macro expansion needs to be
    done before path canonicalization for
    :class:`Bcfg2.Options.Options.PathOption`.
    """
    repository = None

    def __init__(self, *args, **kwargs):
        self._original_type = kwargs.pop('type', lambda x: x)
        kwargs['type'] = self._type
        kwargs.setdefault('metavar', '<path>')
        Option.__init__(self, *args, **kwargs)

    def early_parsing_hook(self, early_opts):
        if hasattr(early_opts, "repository"):
            if self.__class__.repository is None:
                _debug("Setting repository to %s for %s" %
                       (early_opts.repository, self.__class__.__name__))
                self.__class__.repository = early_opts.repository
            else:
                _debug("Repository is already set for %s" % self.__class__)

    def _get_default(self):
        """ Getter for the ``default`` property """
        if not hasattr(self._default, "replace"):
            return self._default
        else:
            return self._type(self._default)

    default = property(_get_default, Option._set_default)

    def transform_value(self, value):
        """transform the value after macro expansion.

         this can be overridden to further transform the value set by
        the user *after* macros are expanded, but before the user's
        ``type`` function is applied. principally exists for
        PathOption to canonicalize the path.
        """
        return value

    def _type(self, value):
        """Type function that fixes up <repository> macros."""
        if self.__class__.repository is None:
            return value
        else:
            return self._original_type(self.transform_value(
                value.replace("<repository>", self.__class__.repository)))


class PathOption(RepositoryMacroOption):
    """Shortcut for options that expect a path argument.

    Uses :meth:`Bcfg2.Options.Types.path` to transform the argument
    into a canonical path. The type of a path option can also be
    overridden to return a file-like object. For example:

    .. code-block:: python

        options = [
            Bcfg2.Options.PathOption(
                "--input", type=argparse.FileType('r'),
                help="The input file")]

    PathOptions also do translation of ``<repository>`` macros.
    """
    def transform_value(self, value):
        return Types.path(value)


class _BooleanOptionAction(argparse.Action):
    """BooleanOptionAction sets a boolean value.

    - if None is passed, store the default
    - if the option_string is not None, then the option was passed on the
      command line, thus store the opposite of the default (this is the
      argparse store_true and store_false behavior)
    - if a boolean value is passed, use that

    Makes a copy of the initial default, because otherwise the default
    can be changed by config file settings or environment
    variables. For instance, if a boolean option that defaults to True
    was set to False in the config file, specifying the option on the
    CLI would then set it back to True.

    Defined here instead of :mod:`Bcfg2.Options.Actions` because otherwise
    there is a circular import Options -> Actions -> Parser -> Options.
    """

    def __init__(self, *args, **kwargs):
        argparse.Action.__init__(self, *args, **kwargs)
        self.original = self.default

    def __call__(self, parser, namespace, values, option_string=None):
        if values is None:
            setattr(namespace, self.dest, self.default)
        elif option_string is not None:
            setattr(namespace, self.dest, not self.original)
        else:
            setattr(namespace, self.dest, bool(values))


class BooleanOption(Option):
    """ Shortcut for boolean options.  The default is False, but this
    can easily be overridden:

    .. code-block:: python

        options = [
            Bcfg2.Options.PathOption(
                "--dwim", default=True, help="Do What I Mean")]
    """
    def __init__(self, *args, **kwargs):
        kwargs.setdefault('action', _BooleanOptionAction)
        kwargs.setdefault('nargs', 0)
        kwargs.setdefault('default', False)
        Option.__init__(self, *args, **kwargs)

    def get_environ_value(self, value):
        if value.lower() in ["false", "no", "off", "0"]:
            return False
        elif value.lower() in ["true", "yes", "on", "1"]:
            return True
        else:
            raise ValueError("Invalid boolean value %s" % value)

    def get_config_value(self, cfp):
        """fetch a value from the config file.

        This is passed the config parser. Its result is passed to the
        type function for this option. It can be overridden to, e.g.,
        handle boolean options.
        """
        return cfp.getboolean(*self.cf)


class PositionalArgument(Option):
    """ Shortcut for positional arguments. """
    def __init__(self, *args, **kwargs):
        if 'metavar' not in kwargs:
            kwargs['metavar'] = '<%s>' % args[0]
        Option.__init__(self, *args, **kwargs)