comparison roundup/configuration.py @ 2618:8b08558e30a0

Roundup Issue Tracker configuration support
author Alexander Smishlajev <a1s@users.sourceforge.net>
date Sun, 25 Jul 2004 11:29:40 +0000
parents
children 4b4ca3bd086b
comparison
equal deleted inserted replaced
2617:33fffbf7ae68 2618:8b08558e30a0
1 # Roundup Issue Tracker configuration support
2 #
3 # $Id: configuration.py,v 1.1 2004-07-25 11:29:40 a1s Exp $
4 #
5 __docformat__ = "restructuredtext"
6
7 import imp
8 import os
9 import time
10 import ConfigParser
11
12 # XXX i don't think this module needs string translation, does it?
13
14 ### Exceptions
15
16 class ConfigurationError(Exception):
17
18 # without this, pychecker complains about missing class attribute...
19 args = ()
20
21 class NoConfigError(ConfigurationError):
22
23 """Raised when configuration loading fails
24
25 Constructor parameters: path to the directory that was used as TRACKER_HOME
26
27 """
28
29 def __str__(self):
30 return "No valid configuration files found in directory %s" \
31 % self.args[0]
32
33 class InvalidOptionError(ConfigurationError, KeyError, AttributeError):
34
35 """Attempted access to non-existing configuration option
36
37 Configuration options may be accessed as configuration object
38 attributes or items. So this exception instances also are
39 instances of KeyError (invalid item access) and AttrributeError
40 (invalid attribute access).
41
42 Constructor parameter: option name
43
44 """
45
46 def __str__(self):
47 return "Unsupported configuration option: %s" % self.args[0]
48
49 class OptionValueError(ConfigurationError, ValueError):
50
51 """Raised upon attempt to assign an invalid value to config option
52
53 Constructor parameters: Option instance, offending value
54 and optional info string.
55
56 """
57
58 def __str__(self):
59 _args = self.args
60 _rv = "Invalid value for %(option)s: %(value)r" % {
61 "option": _args[0].name, "value": _args[1]}
62 if len(_args) > 2:
63 _rv += "\n".join(("",) + _args[2:])
64 return _rv
65
66 class OptionUnsetError(ConfigurationError):
67
68 """Raised when no Option value is available - neither set, nor default
69
70 Constructor parameters: Option instance.
71
72 """
73
74 def __str__(self):
75 return "%s is not set and has no default" % self.args[0].name
76
77 class UnsetDefaultValue:
78
79 """Special object meaning that default value for Option is not specified"""
80
81 def __str__(self):
82 return "NO DEFAULT"
83
84 NODEFAULT = UnsetDefaultValue()
85
86 ### Option classes
87
88 class Option:
89
90 """Single configuration option.
91
92 Options have following attributes:
93
94 config
95 reference to the containing Config object
96 section
97 name of the section in the tracker .ini file
98 setting
99 option name in the tracker .ini file
100 default
101 default option value
102 description
103 option description. Makes a comment in the tracker .ini file
104 name
105 "canonical name" of the configuration option.
106 For items in the 'main' section this is uppercased
107 'setting' name. For other sections, the name is
108 composed of the section name and the setting name,
109 joined with underscore.
110 aliases
111 list of "also known as" names. Used to access the settings
112 by old names used in previous Roundup versions.
113 "Canonical name" is also included.
114
115 The name and aliases are forced to be uppercase.
116 The setting name is forced to lowercase.
117
118 """
119
120 class_description = None
121
122 def __init__(self, config, section, setting,
123 default=NODEFAULT, description=None, aliases=None
124 ):
125 self.config = config
126 self.section = section
127 self.setting = setting.lower()
128 self.default = default
129 self.description = description
130 self.name = setting.upper()
131 if section != "main":
132 self.name = "_".join((section.upper(), self.name))
133 if aliases:
134 self.aliases = [alias.upper() for alias in list(aliases)]
135 else:
136 self.aliases = []
137 self.aliases.insert(0, self.name)
138 # value is private. use get() and set() to access
139 self._value = default
140
141 def get(self):
142 """Return current option value"""
143 if self._value is NODEFAULT:
144 raise OptionUnsetError(self)
145 return self._value
146
147 def set(self, value):
148 """Update the value"""
149 self._value = value
150
151 def reset(self):
152 """Reset the value to default"""
153 self._value = self.default
154
155 def isdefault(self):
156 """Return True if current value is the default one"""
157 return self._value == self.default
158
159 def isset(self):
160 """Return True if the value is avaliable (either set or default)"""
161 return self._value != NODEFAULT
162
163 def str(self, default=0):
164 """Return string representation of the value
165
166 If 'default' argument is set, format the default value.
167 Otherwise format current value.
168
169 """
170 if default:
171 return str(self.default)
172 else:
173 return str(self._value)
174
175 def __str__(self):
176 return self.str()
177
178 def __repr__(self):
179 if self.isdefault():
180 _format = "<%(class)s %(name)s (default): %(value)s>"
181 else:
182 _format = "<%(class)s %(name)s (default: %(default)s): %(value)s>"
183 return _format % {
184 "class": self.__class__.__name__,
185 "name": self.name,
186 "default": self.str(default=1),
187 "value": str(self),
188 }
189
190 def format(self):
191 """Return .ini file fragment for this option"""
192 _desc_lines = []
193 for _description in (self.description, self.class_description):
194 if _description:
195 _desc_lines.extend(_description.split("\n"))
196 # comment out the setting line if there is no value
197 if self.isset():
198 _is_set = ""
199 else:
200 _is_set = "#"
201 _rv = "# %(description)s\n# Default: %(default)s\n" \
202 "%(is_set)s%(name)s = %(value)s\n" % {
203 "description": "\n# ".join(_desc_lines),
204 "default": self.str(default=1),
205 "name": self.setting,
206 "value": str(self),
207 "is_set": _is_set
208 }
209 return _rv
210
211 def load_ini(self, config):
212 """Load value from ConfigParser object"""
213 if config.has_option(self.section, self.setting):
214 self.set(config.get(self.section, self.setting))
215
216 def load_pyconfig(self, config):
217 """Load value from old-style config (python module)"""
218 for _name in self.aliases:
219 if hasattr(config, _name):
220 self.set(getattr(config, _name))
221 break
222
223 class BooleanOption(Option):
224
225 """Boolean option: yes or no"""
226
227 class_description = "Allowed values: yes, no"
228
229 def str(self, default=0):
230 if default:
231 _val = self.default
232 else:
233 _val = self._value
234 if _val:
235 return "yes"
236 else:
237 return "no"
238
239 def set(self, value):
240 if type(value) == type(""):
241 _val = value.lower()
242 if _val in ("yes", "true", "on", "1"):
243 _val = 1
244 elif _val in ("no", "false", "off", "0"):
245 _val = 0
246 else:
247 raise OptionValueError(self, value, self.class_description)
248 else:
249 _val = value and 1 or 0
250 Option.set(self, _val)
251
252 class RunDetectorOption(Option):
253
254 """When a detector is run: always, never or for new items only"""
255
256 class_description = "Allowed values: yes, no, new"
257
258 def set(self, value):
259 _val = value.lower()
260 if _val in ("yes", "no", "new"):
261 Option.set(self, _val)
262 else:
263 raise OptionValueError(self, value, self.class_description)
264
265 class MailAddressOption(Option):
266
267 """Email address
268
269 Email addresses may be either fully qualified or local.
270 In the latter case MAIL_DOMAIN is automatically added.
271
272 """
273
274 def get(self):
275 _val = Option.get(self)
276 if "@" not in _val:
277 _val = "@".join((_val, self.config["MAIL_DOMAIN"]))
278 return _val
279
280 class FilePathOption(Option):
281
282 """File or directory path name
283
284 Paths may be either absolute or relative to the TRACKER_HOME.
285
286 """
287
288 def get(self):
289 _val = Option.get(self)
290 if not os.path.isabs(_val):
291 _val = os.path.join(self.config["TRACKER_HOME"], _val)
292 return _val
293
294 class FloatNumberOption(Option):
295
296 """Floating point numbers"""
297
298 def set(self, value):
299 try:
300 _val = float(value)
301 except ValueError:
302 raise OptionValueError(self, value,
303 "Floating point number required")
304 else:
305 Option.set(self, _val)
306
307 class IntegerNumberOption(Option):
308
309 """Integer numbers"""
310
311 def set(self, value):
312 try:
313 _val = int(value)
314 except ValueError:
315 raise OptionValueError(self, value, "Integer number required")
316 else:
317 Option.set(self, _val)
318
319 ### Main configuration layout.
320 # Config is described as a sequence of sections,
321 # where each section name is followed by a sequence
322 # of Option definitions. Each Option definition
323 # is a sequence containing class name and constructor
324 # parameters, starting from the setting name:
325 # setting, default, [description, [aliases]]
326 SETTINGS = (
327 ("main", (
328 (FilePathOption, "database", "db", "Database directory path"),
329 (FilePathOption, "templates", "html",
330 "Path to the HTML templates directory"),
331 (MailAddressOption, "admin_email", "roundup-admin",
332 "Email address that roundup will complain to"
333 " if it runs into trouble"),
334 (MailAddressOption, "dispatcher_email", "roundup-admin",
335 "The 'dispatcher' is a role that can get notified\n"
336 "of new items to the database.\n"
337 "It is used by the ERROR_MESSAGES_TO config setting."),
338 (Option, "email_from_tag", "",
339 "Additional text to include in the \"name\" part\n"
340 "of the From: address used in nosy messages.\n"
341 "If the sending user is \"Foo Bar\", the From: line\n"
342 "is usually: \"Foo Bar\" <issue_tracker@tracker.example>\n"
343 "the EMAIL_FROM_TAG goes inside the \"Foo Bar\" quotes like so:\n"
344 "\"Foo Bar EMAIL_FROM_TAG\" <issue_tracker@tracker.example>"),
345 (Option, "new_web_user_roles", "User",
346 "Roles that a user gets when they register"
347 " with Web User Interface\n"
348 "This is a comma-separated string of role names"
349 " (e.g. 'Admin,User')"),
350 (Option, "new_email_user_roles", "User",
351 "Roles that a user gets when they register"
352 " with Email Gateway\n"
353 "This is a comma-separated string of role names"
354 " (e.g. 'Admin,User')"),
355 (Option, "error_messages_to", "user",
356 # XXX This description needs better wording,
357 # with explicit allowed values list.
358 "Send error message emails to the dispatcher, user, or both?\n"
359 "The dispatcher is configured using the DISPATCHER_EMAIL"
360 " setting."),
361 (Option, "html_version", "html4",
362 "HTML version to generate. The templates are html4 by default.\n"
363 "If you wish to make them xhtml, then you'll need to change this\n"
364 "var to 'xhtml' too so all auto-generated HTML is compliant.\n"
365 "Allowed values: html4, xhtml"),
366 # It seems to me that all timezone offsets in the modern world
367 # are integral hours. However, there were fractional hour offsets
368 # in the past. Use float number for sure.
369 (FloatNumberOption, "timezone", "0",
370 "Numeric timezone offset used when users do not choose their own\n"
371 "in their settings.",
372 ["DEFAULT_TIMEZONE"]),
373 )),
374 ("tracker", (
375 (Option, "name", "Roundup issue tracker",
376 "A descriptive name for your roundup instance"),
377 (Option, "web", NODEFAULT,
378 "The web address that the tracker is viewable at.\n"
379 "This will be included in information"
380 " sent to users of the tracker.\n"
381 "The URL MUST include the cgi-bin part or anything else\n"
382 "that is required to get to the home page of the tracker.\n"
383 "You MUST include a trailing '/' in the URL."),
384 (MailAddressOption, "email", "issue_tracker",
385 "Email address that mail to roundup should go to"),
386 )),
387 # XXX This section covers two service areas:
388 # outgoing mail (domain, smtp parameters)
389 # and creation of issues from incoming mail.
390 # These things should be separated.
391 # In addition, 'charset' option is used in nosy messages only,
392 # so this option actually belongs to the 'nosy' section.
393 ("mail", (
394 (Option, "domain", NODEFAULT, "Domain name used for email addresses"),
395 (Option, "host", NODEFAULT,
396 "SMTP mail host that roundup will use to send mail"),
397 (Option, "username", NODEFAULT, "SMTP login name\n"
398 "Set this if your mail host requires authenticated access"),
399 (Option, "password", NODEFAULT, "SMTP login password\n"
400 "Set this if your mail host requires authenticated access"),
401 (BooleanOption, "tls", "no",
402 "If your SMTP mail host provides or requires TLS\n"
403 "(Transport Layer Security) then set this option to 'yes'"),
404 (FilePathOption, "tls_keyfile", NODEFAULT,
405 "If TLS is used, you may set this option to the name\n"
406 "of a PEM formatted file that contains your private key"),
407 (FilePathOption, "tls_certfile", NODEFAULT,
408 "If TLS is used, you may set this option to the name\n"
409 "of a PEM formatted certificate chain file"),
410 (BooleanOption, "keep_quoted_text", "yes",
411 "Keep email citations when accepting messages.\n"
412 "Setting this to \"no\" strips out \"quoted\" text"
413 " from the message.\n"
414 "Signatures are also stripped.",
415 ["EMAIL_KEEP_QUOTED_TEXT"]),
416 (BooleanOption, "leave_body_unchanged", "no",
417 "Preserve the email body as is - that is,\n"
418 "keep the citations _and_ signatures.",
419 ["EMAIL_LEAVE_BODY_UNCHANGED"]),
420 (Option, "default_class", "issue",
421 "Default class to use in the mailgw\n"
422 "if one isn't supplied in email subjects.\n"
423 "To disable, leave the value blank."),
424 (Option, "charset", "utf-8",
425 "Character set to encode email headers with.\n"
426 "We use utf-8 by default, as it's the most flexible.\n"
427 "Some mail readers (eg. Eudora) can't cope with that,\n"
428 "so you might need to specify a more limited character set\n"
429 "(eg. iso-8859-1)",
430 ["EMAIL_CHARSET"]),
431 )),
432 ("nosy", (
433 (BooleanOption, "messages_to_author", "no",
434 "Send nosy messages to the author of the message",
435 ["MESSAGES_TO_AUTHOR"]),
436 (Option, "signature_position", "bottom",
437 "Where to place the email signature\n"
438 "Allowed values: top, bottom, none",
439 ["EMAIL_SIGNATURE_POSITION"]),
440 (RunDetectorOption, "add_author", "new",
441 "Does the author of a message get placed on the nosy list\n"
442 "automatically? If 'new' is used, then the author will\n"
443 "only be added when a message creates a new issue.\n"
444 "If 'yes', then the author will be added on followups too.\n"
445 "If 'no', they're never added to the nosy.\n",
446 ["ADD_AUTHOR_TO_NOSY"]),
447 (RunDetectorOption, "add_recipients", "new",
448 "Do the recipients (To:, Cc:) of a message get placed on the\n"
449 "nosy list? If 'new' is used, then the recipients will\n"
450 "only be added when a message creates a new issue.\n"
451 "If 'yes', then the recipients will be added on followups too.\n"
452 "If 'no', they're never added to the nosy.\n",
453 ["ADD_RECIPIENTS_TO_NOSY"]),
454 )),
455 )
456
457 ### Main class
458
459 class Config:
460
461 """Roundup instance configuration.
462
463 Configuration options may be accessed as attributes or items
464 of instances of this class. All option names are uppercased.
465
466 """
467
468 # Config file names (in the TRACKER_HOME directory):
469 INI_FILE = "config.ini" # new style config file name
470 PYCONFIG = "config" # module name for old style configuration
471
472 # Object attributes that should not be taken as common configuration
473 # options in __setattr__ (most of them are initialized in constructor):
474 # builtin pseudo-option - tracker home directory
475 TRACKER_HOME = "."
476 # names of .ini file sections, in order
477 sections = None
478 # lists of option names for each section, in order
479 section_options = None
480 # mapping from option names and aliases to Option instances
481 options = None
482
483 def __init__(self, tracker_home=None):
484 # initialize option containers:
485 self.sections = []
486 self.section_options = {}
487 self.options = {}
488 # add options from the SETTINGS structure
489 for (_section, _options) in SETTINGS:
490 for _option_def in _options:
491 _class = _option_def[0]
492 _args = _option_def[1:]
493 _option = _class(self, _section, *_args)
494 self.add_option(_option)
495 # load the config if tracker_home given
496 if tracker_home is not None:
497 self.load(tracker_home)
498
499 def add_option(self, option):
500 """Adopt a new Option object"""
501 _section = option.section
502 _name = option.setting
503 if _section not in self.sections:
504 self.sections.append(_section)
505 _options = self._get_section_options(_section)
506 if _name not in _options:
507 _options.append(_name)
508 # (section, name) key is used for writing .ini file
509 self.options[(_section, _name)] = option
510 # make the option known under all of it's A.K.A.s
511 for _name in option.aliases:
512 self.options[_name] = option
513
514 def reset(self):
515 """Set all options to their default values"""
516 for _option in self.items():
517 _option.reset()
518
519 # option and section locators (used in option access methods)
520
521 def _get_option(self, name):
522 try:
523 return self.options[name]
524 except KeyError:
525 raise InvalidOptionError(name)
526
527 def _get_section_options(self, name):
528 return self.section_options.setdefault(name, [])
529
530 # file operations
531
532 def load(self, tracker_home):
533 """Load configuration from path designated by tracker_home argument"""
534 if os.path.isfile(os.path.join(tracker_home, self.INI_FILE)):
535 self.load_ini(tracker_home)
536 else:
537 self.load_pyconfig(tracker_home)
538
539 def load_ini(self, tracker_home):
540 """Set options from config.ini file in given tracker_home directory"""
541 # parse the file
542 _config = ConfigParser.ConfigParser()
543 _config.read([os.path.join(tracker_home, self.INI_FILE)])
544 # .ini file loaded ok. set the options, starting from TRACKER_HOME
545 self.reset()
546 self.TRACKER_HOME = tracker_home
547 for _option in self.items():
548 _option.load_ini(_config)
549
550 def load_pyconfig(self, tracker_home):
551 """Set options from config.py file in given tracker_home directory"""
552 # try to locate and import the module
553 _mod_fp = None
554 try:
555 try:
556 _module = imp.find_module(self.PYCONFIG, [tracker_home])
557 _mod_fp = _module[0]
558 _config = imp.load_module(self.PYCONFIG, *_module)
559 except ImportError:
560 raise NoConfigError(tracker_home)
561 finally:
562 if _mod_fp is not None:
563 _mod_fp.close()
564 # module loaded ok. set the options, starting from TRACKER_HOME
565 self.reset()
566 self.TRACKER_HOME = tracker_home
567 for _option in self.items():
568 _option.load_pyconfig(_config)
569 # backward compatibility:
570 # SMTP login parameters were specified as a tuple in old style configs
571 # convert them to new plain string options
572 _mailuser = getattr(_config, "MAILUSER", ())
573 if len(_mailuser) > 0:
574 self.MAIL_USERNAME = _mailuser[0]
575 if len(_mailuser) > 1:
576 self.MAIL_PASSWORD = _mailuser[1]
577
578 def save(self, ini_file=None):
579 """Write current configuration to .ini file
580
581 'ini_file' argument, if passed, must be valid full path
582 to the file to write. If omitted, default file in current
583 TRACKER_HOME is created.
584
585 If the file to write already exists, it is saved with '.bak'
586 extension.
587
588 """
589 if ini_file is None:
590 ini_file = os.path.join(self.TRACKER_HOME, self.INI_FILE)
591 _tmp_file = os.path.splitext(ini_file)[0]
592 _bak_file = _tmp_file + ".bak"
593 _tmp_file = _tmp_file + ".tmp"
594 _fp = file(_tmp_file, "wt")
595 _fp.write("# %s configuration file\n" % self["TRACKER_NAME"])
596 _fp.write("# Autogenerated at %s\n" % time.asctime())
597 for _section in self.sections:
598 _fp.write("\n[%s]\n" % _section)
599 for _option in self._get_section_options(_section):
600 _fp.write("\n" + self.options[(_section, _option)].format())
601 _fp.close()
602 if os.access(ini_file, os.F_OK):
603 if os.access(_bak_file, os.F_OK):
604 os.remove(_bak_file)
605 os.rename(ini_file, _bak_file)
606 os.rename(_tmp_file, ini_file)
607
608 # container emulation
609
610 def __len__(self):
611 return len(self.items())
612
613 def __getitem__(self, name):
614 if name == "TRACKER_HOME":
615 return self.TRACKER_HOME
616 else:
617 return self._get_option(name).get()
618
619 def __setitem__(self, name, value):
620 if name == "TRACKER_HOME":
621 self.TRACKER_HOME = value
622 else:
623 self._get_option(name).set(value)
624
625 def __delitem__(self, name):
626 _option = self._get_option(name)
627 _section = _option.section
628 _name = _option.setting
629 self._get_section_options(_section).remove(_name)
630 del self.options[(_section, _name)]
631 for _alias in _option.aliases:
632 del self.options[_alias]
633
634 def items(self):
635 """Return the list of Option objects, in .ini file order
636
637 Note that TRACKER_HOME is not included in this list
638 because it is builtin pseudo-option, not a real Option
639 object loaded from or saved to .ini file.
640
641 """
642 return [self.options[(_section, _name)]
643 for _section in self.sections
644 for _name in self._get_section_options(_section)
645 ]
646
647 def keys(self):
648 """Return the list of "canonical" names of the options
649
650 Unlike .items(), this list also includes TRACKER_HOME
651
652 """
653 return ["TRACKER_HOME"] + [_option.name for _option in self.items()]
654
655 # .values() is not implemented because i am not sure what should be
656 # the values returned from this method: Option instances or config values?
657
658 # attribute emulation
659
660 def __setattr__(self, name, value):
661 if self.__dict__.has_key(name) \
662 or self.__class__.__dict__.has_key(name):
663 self.__dict__[name] = value
664 else:
665 self._get_option(name).set(value)
666
667 # Note: __getattr__ is not symmetric to __setattr__:
668 # self.__dict__ lookup is done before calling this method
669 __getattr__ = __getitem__
670
671 # vim: set et sts=4 sw=4 :

Roundup Issue Tracker: http://roundup-tracker.org/