]> git.llucax.com Git - software/pymin.git/blob - pymin/config.py
Add an example of regular attributes usage with a ValidatedClass
[software/pymin.git] / pymin / config.py
1 # vim: set encoding=utf-8 et sw=4 sts=4 :
2
3 # TODO
4 #
5 # * add 'append' mode to ListOption (like ConfigOption).
6 #
7 # * update/write documentation
8
9 import os
10 import re
11 import sys
12 import shlex
13 import optparse
14 from formencode import Schema, ForEach, Invalid, validators
15 from ConfigParser import SafeConfigParser, InterpolationMissingOptionError, \
16                          InterpolationSyntaxError, InterpolationDepthError, \
17                          MissingSectionHeaderError, ParsingError
18 import logging ; log = logging.getLogger('pymin.config')
19
20
21 __all__ = ('ConfigError', 'MissingSectionHeaderError', 'ParsingError',
22            'Options', 'OptionGroup', 'Option',
23            'VOID', 'load_config', 'config', 'options')
24
25
26 # regular expression to check if a name is a valid python identifier
27 identifier_re = re.compile(r'^[a-zA-Z_]\w*$')
28
29
30 class ConfigError(RuntimeError):
31     """
32     Raised when the problem is due to an user error.
33     """
34     pass
35
36
37 class VoidClass:
38     def __repr__(self):
39         return "<void>"
40     def __len__(self):
41         "For boolean expression evaluation"
42         return 0
43 VOID = VoidClass()
44
45
46 class Options:
47     """
48     Represent a set of options a program can have.
49
50     Both command-line and configuration file options are handled.
51
52     .. attr:: default_group
53
54         The name of the default :class:`OptionGroup`. Options that
55         don't belong to any group are looked in the section with this
56         name in the config file.
57
58     .. attr:: default_group
59
60         Default :class:`OptionGroup` description.
61
62     .. attr:: options
63
64         A list of :class:`Option` or :class:`OptionGroup`
65         instances. Groups are translated to a section when parsing the
66         config file, and to prefixes in the long options when parsing
67         the command-line.
68     """
69
70     def __init__(self, schemacls=None):
71         self.default_group = ''
72         self.default_group_desc = ''
73         self.options = []
74         if schemacls is None:
75             schemacls = Schema
76         self.schemacls = schemacls
77         self.schema = None # filled in process()
78
79     def add(self, options):
80         log.debug('Options.add(%r)', options)
81         if isinstance(options, (list, tuple)):
82             self.options.extend(options)
83         else:
84             self.options.append(options)
85
86     def add_group(self, *args, **kwargs):
87         g = OptionGroup(*args, **kwargs)
88         log.debug('Options.add_group(%r)', g)
89         self.add(g)
90
91     def get_group(self, group_name):
92         for g in self.options:
93             if isinstance(g, OptionGroup) and g.name == group_name:
94                 return g
95
96     def init(self, default_group=None, default_group_desc=None,
97              options=None):
98         """
99         Initialize the class. Since the class is instantiated by the
100         config framework, you should use this method to set it's values.
101
102         Arguments are directly mapped to the class attributes, with
103         the particularity of options being appended to existing options
104         (instead of replaced).
105         """
106         log.debug('Options.init(default_group=%r, default_group_desc=%r, '
107                   'options=%r)', default_group, default_group_desc, options)
108         if not identifier_re.match(default_group):
109             raise ValueError("Invalid default group name '%s' (group names "
110                              "must be valid python identifiers" % name)
111         if default_group is not None:
112             self.default_group = default_group
113         if default_group_desc is not None:
114             self.default_group_desc = default_group_desc
115         if options is not None:
116             self.options.extend(options)
117
118     def process(self, parser):
119         """
120         Process the command-line options.
121
122         ``parser`` should be a :class:`optparse.OptionParser` instance.
123         """
124         log.debug('Options.process()')
125         self.schema = self.schemacls()
126         config_opts = []
127         for o in self.options:
128             o.process(parser, self.schema, config_opts, self.default_group)
129         return config_opts
130
131     def values(self, confparser, parseropts, ignore_errors):
132         """
133         Post process the command-line options.
134
135         ``config`` should be an object where options are stored. If
136         ``prefix`` is specified, the prefix will be used to look for
137         the option.
138         """
139         log.debug('Options.values()')
140         # first populate the confparser with the values from the command-line
141         # options to make the variable interpolation works as expected.
142         for o in self.options:
143             o.set_value(confparser, parseropts, self.default_group)
144         # then, get the actual values
145         v = dict()
146         for o in self.options:
147             v[o.name] = o.value(confparser, parseropts, self.default_group,
148                                 ignore_errors)
149         log.debug('Options.values() -> %r', v)
150         return v
151
152     @property
153     def defaults(self):
154         log.debug('Options.defaults()')
155         defaults = dict()
156         for o in self.options:
157             defaults.update(o.defaults)
158         log.debug('Options.defaults() -> %r', defaults)
159         return defaults
160
161
162 class OptionGroup:
163
164     def __init__(self, name, description=None, options=None):
165         log.debug('OptionGroup(name=%r, description=%r, options=%r)', name,
166                   description, options)
167         if not identifier_re.match(name):
168             raise ValueError("Invalid group name '%s' (group names must be "
169                              "valid python identifiers" % name)
170         self.name = name
171         self.oname = name.replace('_', '-')
172         self.description = description
173         self.options = []
174         if options is not None:
175             self.add(options)
176
177     def add(self, options):
178         log.debug('OptionGroup.add(%r)', options)
179         if not isinstance(options, (list, tuple)):
180             options = [options]
181         for o in options:
182             if isinstance(o, OptionGroup):
183                 raise ConfigError("Groups can't be nested (group '%s' found "
184                                   "inside group '%s')" % (o.name, self.name))
185         self.options.extend(options)
186
187     def process(self, parser, schema, config_opts, default_group):
188         """
189         Process the command-line options.
190
191         ``parser`` should be a :class:`optparse.OptionParser` instance.
192         """
193         subschema = schema.__class__()
194         group = optparse.OptionGroup(parser, self.description)
195         for o in self.options:
196             if o.short:
197                 raise ValueError("Grouped options can't have short name "
198                                   "(option '%s' in grupo '%s' have this short "
199                                   "names: %s)" % (o.name, self.name,
200                                             ','.join('-' + o for o in o.short)))
201             o.process(group, subschema, config_opts, self)
202         log.debug('Adding group %s to optparse')
203         parser.add_option_group(group)
204         schema.add_field(self.name, subschema)
205
206     def value(self, confparser, parseropts, default_group, ignore_errors):
207         """
208         """
209         v = dict()
210         for o in self.options:
211             v[o.name] = o.value(confparser, parseropts, self, ignore_errors)
212         return v
213
214     def set_value(self, confparser, parseropts, default_group):
215         """
216         """
217         v = dict()
218         for o in self.options:
219             o.set_value(confparser, parseropts, self)
220         return v
221
222     @property
223     def defaults(self):
224         defaults = dict()
225         for o in self.options:
226             defaults.update(o.defaults)
227         defaults = { self.oname: defaults }
228         return defaults
229
230     def __repr__(self):
231         return 'OptionGroup<%s>%r' % (self.name, self.options)
232
233
234 class Option:
235     """
236     A program's option.
237
238     .. attr:: name
239
240         The name of the option. The config object will have an attribute
241         with this name. When parsing configuration and command-line
242         options, the ``_`` in this name are replace with ``-``.
243
244     .. attr:: validator
245
246         See :mod:`formencode.validators` for available validators.
247
248     .. attr:: short
249
250         Short aliases (only for command-line options).
251
252     .. attr:: long
253
254         Long aliases.
255
256     .. attr:: default
257
258         Default value.
259
260     .. attr:: help
261
262         Help message for the option.
263
264     .. attr:: metavar
265
266         Name of the option argument used in the help message.
267
268     """
269
270     def __init__(self, name, validator=None, short=(), long=(), default=VOID,
271                  help=VOID, metavar=VOID):
272         log.debug('Option(name=%r, validator=%r, short=%r, long=%r, '
273                   'default=%r, help=%r, metavar=%r)', name, validator, short,
274                   long, default, help, metavar)
275         if not identifier_re.match(name):
276             raise ValueError("Invalid option name '%s' (option names must be "
277                              "valid python identifiers" % name)
278         self.name = name
279         self.oname = name.replace('_', '-')
280         self.validator = validator
281         if isinstance(short, basestring):
282             short = [short]
283         self.short = list(short)
284         if isinstance(long, basestring):
285             long = [long]
286         self.long = [self.oname] + list(long)
287         self.default = default
288         self.help = help
289         self.metavar = metavar
290
291     def process(self, parser, schema, config_opts, group):
292         """
293         Process the command-line options.
294
295         ``parser`` should be a :class:`optparse.OptionParser` instance. If
296         ``prefix`` is specified, all long command-line options are
297         prefixed with that. For example: ``some_option`` will become
298         ``--prefix-some-option``.
299         """
300         if isinstance(group, basestring):
301             group = None
302         parser.add_option(type='string', dest=self.dest(group), default=VOID,
303                           metavar=self.metavar, help=self.help,
304                           *self.optparser_args(group))
305         log.debug('Option<%s>.process() -> add_option(%s, type=%r, dest=%r, '
306                   'default=%r, metavar=%r, help=%r)', self.name,
307                   ', '.join(repr(i) for i in self.optparser_args(group)),
308                   'string', self.dest(group), VOID, self.metavar, self.help)
309         schema.add_field(self.name, self.validator)
310
311     def value(self, confparser, parseropts, group, ignore_errors):
312         """
313         Post process the command-line options.
314
315         ``config`` should be an object where options are stored. If
316         ``prefix`` is specified, the prefix will be used to look for
317         the option.
318         """
319         section = group
320         if isinstance(group, OptionGroup):
321             section = group.oname
322         if (confparser.has_section(section)
323                     and confparser.has_option(section, self.oname)):
324             return confparser.get(section, self.oname)
325         if self.default is VOID:
326             if ignore_errors:
327                 return None
328             raise ConfigError('mandatory option "%s" not present' % self.name)
329         return self.default
330
331     def set_value(self, confparser, parseropts, group):
332         val = getattr(parseropts, self.dest(group))
333         if val is not VOID:
334             section = group
335             if isinstance(group, OptionGroup):
336                 section = group.oname
337             if not confparser.has_section(section):
338                 confparser.add_section(section)
339             confparser.set(section, self.oname, val)
340
341     def optparser_args(self, group=None):
342         args = []
343         args.extend('-' + s for s in self.short)
344         prefix = ''
345         if group:
346             prefix = group.oname + '-'
347         args.extend('--' + prefix + l for l in self.long)
348         return args
349
350     def dest(self, group=None):
351         prefix = ''
352         if group and not isinstance(group, basestring):
353             prefix = group.name + '.'
354         return prefix + self.name
355
356     @property
357     def defaults(self):
358         default = {}
359         if self.default is not VOID:
360             default[self.oname] = str(self.default)
361         return default
362
363     def __repr__(self):
364         return 'Option<%s>' % (self.name)
365
366
367 class ListOption(Option):
368
369     def process(self, parser, schema, config_opts, group):
370         """
371         """
372         if isinstance(group, basestring):
373             group = None
374         parser.add_option(type='string', dest=self.dest(group), default=[],
375                           metavar=self.metavar, help=self.help,
376                           action='append', *self.optparser_args(group))
377         log.debug('Option<%s>.process() -> add_option(%s, type=%r, dest=%r, '
378                   'action=%r, default=%r, metavar=%r, help=%r)', self.name,
379                   ', '.join(repr(i) for i in self.optparser_args(group)),
380                   'string', self.dest(group), 'append', [], self.metavar,
381                   self.help)
382         schema.add_field(self.name, ForEach(self.validator, if_empty=[]))
383
384     def value(self, confparser, parseropts, group, ignore_errors):
385         """
386         """
387         value = Option.value(self, confparser, parseropts, group, ignore_errors)
388         if not isinstance(value, (list, tuple)):
389             value = shlex.split(value)
390         return value
391
392     def set_value(self, confparser, parseropts, group):
393         val = getattr(parseropts, self.dest(group))
394         if val:
395             val = ' '.join([repr(i) for i in val])
396             setattr(parseropts, self.dest(group), val)
397             Option.set_value(self, confparser, parseropts, group)
398
399     def __repr__(self):
400         return 'ListOption<%s>' % (self.name)
401
402
403 class ConfigOption(ListOption):
404
405     def __init__(self, name, short=(), long=(), default=VOID, help=VOID,
406                  metavar=VOID, override=False):
407         ListOption.__init__(self, name, validators.String, short, long, default,
408                             help, metavar)
409         self.override = override
410
411     def process(self, parser, schema, config_opts, group):
412         ListOption.process(self, parser, schema, config_opts, group)
413         config_opts.append(self)
414
415     def value(self, confparser, parseropts, group, ignore_errors):
416         pass
417
418     def set_value(self, confparser, parseropts, group):
419         pass
420
421
422 class Config:
423     """
424     Dummy object that stores all the configuration data.
425     """
426
427     def __repr__(self):
428         return 'Config(%r)' % self.__dict__
429
430
431 config = None
432
433 args = []
434
435 options = Options()
436
437
438 class LazyOptionParser(optparse.OptionParser):
439
440     ignore_errors = False
441
442     exit_status = 1
443
444     def exit(self, status=0, msg=None):
445         if self.ignore_errors:
446             return
447         optparse.OptionParser.exit(self, status, msg)
448
449     def error(self, msg):
450         if self.ignore_errors:
451             return
452         self.print_usage(sys.stderr)
453         self.exit(self.exit_status, "%s: error: %s\n"
454                   % (self.get_prog_name(), msg))
455
456     def print_help(self, file=None):
457         if self.ignore_errors:
458             return
459         optparse.OptionParser.print_help(self, file)
460
461     def _process_short_opts(self, rargs, values):
462         try:
463             return optparse.OptionParser._process_short_opts(self, rargs,
464                                                              values)
465         except Exception, e:
466             if not self.ignore_errors:
467                 raise
468
469     def _process_long_opt(self, rargs, values):
470         try:
471             return optparse.OptionParser._process_long_opt(self, rargs, values)
472         except Exception, e:
473             if not self.ignore_errors:
474                 raise
475
476     def _process_short_opts(self, rargs, values):
477         try:
478             return optparse.OptionParser._process_short_opts(self, rargs, values)
479         except Exception, e:
480             if not self.ignore_errors:
481                 raise
482
483
484 def load_options(version=None, description=None, ignore_errors=False):
485     # load command-line options
486     optparser = LazyOptionParser(version=version, description=description)
487     optparser.ignore_errors = ignore_errors
488     config_opts = options.process(optparser)
489     (opts, args) = optparser.parse_args()
490     log.debug('load_options() -> %r %r', opts, args)
491     # help the GC
492     optparser.destroy()
493     return (opts, config_opts, args)
494
495
496 def make_config(values):
497     log.debug('make_config()')
498     config = Config()
499     for (name, value) in values.items():
500         if isinstance(value, dict):
501             log.debug('make_config() -> processing group %s: %r', name, value)
502             setattr(config, name, make_config(value))
503         else:
504             log.debug('make_config() -> processing value %s: %r', name, value)
505             setattr(config, name, value)
506     log.debug('make_config() -> config = %r', config)
507     return config
508
509
510 def load_conf(config_file_paths, version, description, defaults, ignore_errors):
511     log.debug('load_conf(%r, version=%r, description=%r, ignore_errors=%r)',
512               config_file_paths, version, description, ignore_errors)
513     global options
514
515     # load command-line options
516     (opts, config_opts, args) = load_options(version, description,
517                                              ignore_errors=ignore_errors)
518
519     # process config file options to see what config files to load
520     for opt in config_opts:
521         files = getattr(opts, opt.name)
522         log.debug('load_conf() -> processing configuration file option '
523                   '"%s": %r', opt.name, files)
524         if not files:
525             log.debug('load_conf() -> option not set! looking for the next')
526             continue
527         if opt.override:
528             log.debug('load_conf() -> overriding default config files')
529             config_file_paths = files
530         else:
531             log.debug('load_conf() -> appending to default config files')
532             config_file_paths.extend(files)
533
534     confparser = SafeConfigParser(defaults)
535     readed = confparser.read(config_file_paths)
536     log.debug('load_conf() -> readed config files: %r', readed)
537
538     try:
539         log.debug('load_conf() -> sections: %r', confparser.sections())
540         log.debug('load_conf() -> readed values from config files: %r',
541                   [confparser.items(s) for s in confparser.sections()])
542         values = options.values(confparser, opts, ignore_errors)
543     except InterpolationMissingOptionError, e:
544         raise ConfigError('bad value substitution for option "%s" in '
545                               'section [%s] (references an unexistent option '
546                               '"%s")' % (e.option, e.section, e.reference))
547     except InterpolationDepthError, e:
548         raise ConfigError('value interpolation too deeply recursive in '
549                           'option "%s", section [%s]' % (e.option, e.section))
550     except InterpolationSyntaxError, e:
551         raise ConfigError('bad syntax for interpolation variable in option '
552                           '"%s", section [%s]' % (e.option, e.section))
553
554     values = options.schema.to_python(values)
555     log.debug('load_conf() -> validated values: %r', values)
556
557     config = make_config(values)
558
559     # TODO options.check_orphans(confparser, config)
560
561     return (config, args)
562
563
564 def load_config(config_file_paths, version=None, description=None,
565                 add_plugin_opts=None, defaults=None):
566     "callback signature: add_plugin_opts(config, args)"
567     log.debug('load_config(%r, version=%r, description=%r, add_plugin_opts=%r)',
568               config_file_paths, version, description, add_plugin_opts)
569     global args
570     global config
571
572     (config, args) = load_conf(config_file_paths, version, description,
573                                defaults, add_plugin_opts is not None)
574     while add_plugin_opts:
575         log.debug('load_config() -> calling %r', add_plugin_opts)
576         add_plugin_opts = add_plugin_opts(config, args)
577         log.debug('load_config() -> got config=%r / args=%r', config, args)
578         (config, args) = load_conf(config_file_paths, version, description,
579                                    defaults, add_plugin_opts is not None)
580
581     return (config, args)
582
583
584 if __name__ == '__main__':
585
586     import os
587     import tempfile
588     from formencode import validators as V
589
590     logging.basicConfig(
591         level   = logging.DEBUG,
592         format  = '%(levelname)-8s %(message)s',
593     )
594
595     def print_config(config, prefix=''):
596         for attr in dir(config):
597             if not attr.startswith('__'):
598                 val = getattr(config, attr)
599                 if isinstance(val, Config):
600                     print prefix, 'Group %s:' % attr
601                     print_config(val, '\t')
602                 else:
603                     print prefix, attr, '=', val
604
605     options.init('pymind', 'Default group description', [
606         Option('dry_run', V.StringBool, 'd', default=True,
607                 help="pretend, don't execute commands"),
608         Option('bind_addr', V.CIDR, 'a', default='127.0.0.1', metavar='ADDR',
609                 help='IP address to bind to'),
610         Option('bind_port', V.Int, 'p', default=9999, metavar='PORT',
611                 help="port to bind to"),
612         Option('mandatory', V.Int, 'm', metavar='N',
613                 help="mandatory parameter"),
614         ConfigOption('config_file', 'c', metavar="FILE",
615                 help="configuration file"),
616         ConfigOption('override_config_file', 'o', metavar="FILE",
617                 help="configuration file", override=True),
618         ListOption('plugins', V.String, 's', metavar="SERV", default=[],
619                 help="plugins pymin should use"),
620         OptionGroup('test_grp', 'A test group', [
621             Option('test', V.StringBool, default=False,
622                     help="test group option"),
623             Option('oneof', V.OneOf(['tcp', 'udp']), default='tcp',
624                     help="test option for OneOf validator"),
625             ListOption('list', V.StringBool, default=[True, True, False],
626                     help="test ListOption"),
627         ]),
628     ])
629
630     def add_more_plugin_opts(config, args):
631         print 'add_more_plugin_opts'
632         print '---------------'
633         print config, args
634         print '---------------'
635         print
636         g = options.get_group('plugin')
637         g.add(ListOption('other', V.Int, default=[1,2], metavar='OTHER',
638                          help="a list of numbers"))
639
640     def add_plugin_opts(config, args):
641         print 'add_plugin_opts'
642         print '---------------'
643         print config, args
644         print '---------------'
645         print
646         if 'plugin' in config.plugins:
647             options.add(
648                 OptionGroup('plugin', 'some plug-in options', [
649                     Option('active', V.StringBool, default=True, metavar='ACT',
650                             help="if ACT is true, the plug-in is active"),
651                 ])
652             )
653             return add_more_plugin_opts
654
655
656     f = tempfile.NamedTemporaryFile()
657     f.write("""
658 [pymind]
659 dry-run:         yes
660 bind-addr:       0.0.0.0
661 bind-port:       2000
662 log-config-file: /etc/pymin/log.conf
663
664 [test-grp]
665 test = yes
666 """)
667     f.flush()
668     f.seek(0)
669     print '-'*78
670     print f.read()
671     print '-'*78
672
673     try:
674         (c, a) = load_config([f.name], '%prog 0.1', 'this is a program test',
675                              add_plugin_opts)
676         print "Config:"
677         print_config(c)
678         print "Args:", a
679         print
680         print "Globals:"
681         print "Config:"
682         print_config(config)
683         print "Args:", args
684     except ConfigError, e:
685         print e
686     except MissingSectionHeaderError, e:
687         print "%s:%s: missing section header near: %s" \
688                 % (e.filename, e.lineno, e.line)
689     except ParsingError, e:
690         for (lineno, line) in e.errors:
691             print "%s:%s: invalid syntax near: %s" % (e.filename, lineno, line)
692         print e.errors
693     except Invalid, e:
694         print e.error_dict
695     f.close()
696