1 # Roundup Issue Tracker configuration support
2 #
3 # $Id: configuration.py,v 1.51 2008-09-01 02:30:06 richard Exp $
4 #
5 __docformat__ = "restructuredtext"
7 import ConfigParser
8 import getopt
9 import imp
10 import logging, logging.config
11 import os
12 import re
13 import sys
14 import time
15 import smtplib
17 import roundup.date
19 # XXX i don't think this module needs string translation, does it?
21 ### Exceptions
23 class ConfigurationError(Exception):
24 pass
26 class NoConfigError(ConfigurationError):
28 """Raised when configuration loading fails
30 Constructor parameters: path to the directory that was used as HOME
32 """
34 def __str__(self):
35 return "No valid configuration files found in directory %s" \
36 % self.args[0]
38 class InvalidOptionError(ConfigurationError, KeyError, AttributeError):
40 """Attempted access to non-existing configuration option
42 Configuration options may be accessed as configuration object
43 attributes or items. So this exception instances also are
44 instances of KeyError (invalid item access) and AttributeError
45 (invalid attribute access).
47 Constructor parameter: option name
49 """
51 def __str__(self):
52 return "Unsupported configuration option: %s" % self.args[0]
54 class OptionValueError(ConfigurationError, ValueError):
56 """Raised upon attempt to assign an invalid value to config option
58 Constructor parameters: Option instance, offending value
59 and optional info string.
61 """
63 def __str__(self):
64 _args = self.args
65 _rv = "Invalid value for %(option)s: %(value)r" % {
66 "option": _args[0].name, "value": _args[1]}
67 if len(_args) > 2:
68 _rv += "\n".join(("",) + _args[2:])
69 return _rv
71 class OptionUnsetError(ConfigurationError):
73 """Raised when no Option value is available - neither set, nor default
75 Constructor parameters: Option instance.
77 """
79 def __str__(self):
80 return "%s is not set and has no default" % self.args[0].name
82 class UnsetDefaultValue:
84 """Special object meaning that default value for Option is not specified"""
86 def __str__(self):
87 return "NO DEFAULT"
89 NODEFAULT = UnsetDefaultValue()
91 ### Option classes
93 class Option:
95 """Single configuration option.
97 Options have following attributes:
99 config
100 reference to the containing Config object
101 section
102 name of the section in the tracker .ini file
103 setting
104 option name in the tracker .ini file
105 default
106 default option value
107 description
108 option description. Makes a comment in the tracker .ini file
109 name
110 "canonical name" of the configuration option.
111 For items in the 'main' section this is uppercased
112 'setting' name. For other sections, the name is
113 composed of the section name and the setting name,
114 joined with underscore.
115 aliases
116 list of "also known as" names. Used to access the settings
117 by old names used in previous Roundup versions.
118 "Canonical name" is also included.
120 The name and aliases are forced to be uppercase.
121 The setting name is forced to lowercase.
123 """
125 class_description = None
127 def __init__(self, config, section, setting,
128 default=NODEFAULT, description=None, aliases=None
129 ):
130 self.config = config
131 self.section = section
132 self.setting = setting.lower()
133 self.default = default
134 self.description = description
135 self.name = setting.upper()
136 if section != "main":
137 self.name = "_".join((section.upper(), self.name))
138 if aliases:
139 self.aliases = [alias.upper() for alias in list(aliases)]
140 else:
141 self.aliases = []
142 self.aliases.insert(0, self.name)
143 # convert default to internal representation
144 if default is NODEFAULT:
145 _value = default
146 else:
147 _value = self.str2value(default)
148 # value is private. use get() and set() to access
149 self._value = self._default_value = _value
151 def str2value(self, value):
152 """Return 'value' argument converted to internal representation"""
153 return value
155 def _value2str(self, value):
156 """Return 'value' argument converted to external representation
158 This is actual conversion method called only when value
159 is not NODEFAULT. Heirs with different conversion rules
160 override this method, not the public .value2str().
162 """
163 return str(value)
165 def value2str(self, value=NODEFAULT, current=0):
166 """Return 'value' argument converted to external representation
168 If 'current' is True, use current option value.
170 """
171 if current:
172 value = self._value
173 if value is NODEFAULT:
174 return str(value)
175 else:
176 return self._value2str(value)
178 def get(self):
179 """Return current option value"""
180 if self._value is NODEFAULT:
181 raise OptionUnsetError(self)
182 return self._value
184 def set(self, value):
185 """Update the value"""
186 self._value = self.str2value(value)
188 def reset(self):
189 """Reset the value to default"""
190 self._value = self._default_value
192 def isdefault(self):
193 """Return True if current value is the default one"""
194 return self._value == self._default_value
196 def isset(self):
197 """Return True if the value is available (either set or default)"""
198 return self._value != NODEFAULT
200 def __str__(self):
201 return self.value2str(self._value)
203 def __repr__(self):
204 if self.isdefault():
205 _format = "<%(class)s %(name)s (default): %(value)s>"
206 else:
207 _format = "<%(class)s %(name)s (default: %(default)s): %(value)s>"
208 return _format % {
209 "class": self.__class__.__name__,
210 "name": self.name,
211 "default": self.value2str(self._default_value),
212 "value": self.value2str(self._value),
213 }
215 def format(self):
216 """Return .ini file fragment for this option"""
217 _desc_lines = []
218 for _description in (self.description, self.class_description):
219 if _description:
220 _desc_lines.extend(_description.split("\n"))
221 # comment out the setting line if there is no value
222 if self.isset():
223 _is_set = ""
224 else:
225 _is_set = "#"
226 _rv = "# %(description)s\n# Default: %(default)s\n" \
227 "%(is_set)s%(name)s = %(value)s\n" % {
228 "description": "\n# ".join(_desc_lines),
229 "default": self.value2str(self._default_value),
230 "name": self.setting,
231 "value": self.value2str(self._value),
232 "is_set": _is_set
233 }
234 return _rv
236 def load_ini(self, config):
237 """Load value from ConfigParser object"""
238 if config.has_option(self.section, self.setting):
239 self.set(config.get(self.section, self.setting))
241 def load_pyconfig(self, config):
242 """Load value from old-style config (python module)"""
243 for _name in self.aliases:
244 if hasattr(config, _name):
245 self.set(getattr(config, _name))
246 break
248 class BooleanOption(Option):
250 """Boolean option: yes or no"""
252 class_description = "Allowed values: yes, no"
254 def _value2str(self, value):
255 if value:
256 return "yes"
257 else:
258 return "no"
260 def str2value(self, value):
261 if type(value) == type(""):
262 _val = value.lower()
263 if _val in ("yes", "true", "on", "1"):
264 _val = 1
265 elif _val in ("no", "false", "off", "0"):
266 _val = 0
267 else:
268 raise OptionValueError(self, value, self.class_description)
269 else:
270 _val = value and 1 or 0
271 return _val
273 class WordListOption(Option):
275 """List of strings"""
277 class_description = "Allowed values: comma-separated list of words"
279 def _value2str(self, value):
280 return ','.join(value)
282 def str2value(self, value):
283 return value.split(',')
285 class RunDetectorOption(Option):
287 """When a detector is run: always, never or for new items only"""
289 class_description = "Allowed values: yes, no, new"
291 def str2value(self, value):
292 _val = value.lower()
293 if _val in ("yes", "no", "new"):
294 return _val
295 else:
296 raise OptionValueError(self, value, self.class_description)
298 class MailAddressOption(Option):
300 """Email address
302 Email addresses may be either fully qualified or local.
303 In the latter case MAIL_DOMAIN is automatically added.
305 """
307 def get(self):
308 _val = Option.get(self)
309 if "@" not in _val:
310 _val = "@".join((_val, self.config["MAIL_DOMAIN"]))
311 return _val
313 class FilePathOption(Option):
315 """File or directory path name
317 Paths may be either absolute or relative to the HOME.
319 """
321 class_description = "The path may be either absolute or relative\n" \
322 "to the directory containig this config file."
324 def get(self):
325 _val = Option.get(self)
326 if _val and not os.path.isabs(_val):
327 _val = os.path.join(self.config["HOME"], _val)
328 return _val
330 class FloatNumberOption(Option):
332 """Floating point numbers"""
334 def str2value(self, value):
335 try:
336 return float(value)
337 except ValueError:
338 raise OptionValueError(self, value,
339 "Floating point number required")
341 def _value2str(self, value):
342 _val = str(value)
343 # strip fraction part from integer numbers
344 if _val.endswith(".0"):
345 _val = _val[:-2]
346 return _val
348 class IntegerNumberOption(Option):
350 """Integer numbers"""
352 def str2value(self, value):
353 try:
354 return int(value)
355 except ValueError:
356 raise OptionValueError(self, value, "Integer number required")
358 class OctalNumberOption(Option):
360 """Octal Integer numbers"""
362 def str2value(self, value):
363 try:
364 return int(value, 8)
365 except ValueError:
366 raise OptionValueError(self, value, "Octal Integer number required")
368 def _value2str(self, value):
369 return oct(value)
371 class NullableOption(Option):
373 """Option that is set to None if its string value is one of NULL strings
375 Default nullable strings list contains empty string only.
376 There is constructor parameter allowing to specify different nullables.
378 Conversion to external representation returns the first of the NULL
379 strings list when the value is None.
381 """
383 NULL_STRINGS = ("",)
385 def __init__(self, config, section, setting,
386 default=NODEFAULT, description=None, aliases=None,
387 null_strings=NULL_STRINGS
388 ):
389 self.null_strings = list(null_strings)
390 Option.__init__(self, config, section, setting, default,
391 description, aliases)
393 def str2value(self, value):
394 if value in self.null_strings:
395 return None
396 else:
397 return value
399 def _value2str(self, value):
400 if value is None:
401 return self.null_strings[0]
402 else:
403 return value
405 class NullableFilePathOption(NullableOption, FilePathOption):
407 # .get() and class_description are from FilePathOption,
408 get = FilePathOption.get
409 class_description = FilePathOption.class_description
410 # everything else taken from NullableOption (inheritance order)
412 class TimezoneOption(Option):
414 class_description = \
415 "If pytz module is installed, value may be any valid\n" \
416 "timezone specification (e.g. EET or Europe/Warsaw).\n" \
417 "If pytz is not installed, value must be integer number\n" \
418 "giving local timezone offset from UTC in hours."
420 def str2value(self, value):
421 try:
422 roundup.date.get_timezone(value)
423 except KeyError:
424 raise OptionValueError(self, value,
425 "Timezone name or numeric hour offset required")
426 return value
428 class RegExpOption(Option):
430 """Regular Expression option (value is Regular Expression Object)"""
432 class_description = "Value is Python Regular Expression (UTF8-encoded)."
434 RE_TYPE = type(re.compile(""))
436 def __init__(self, config, section, setting,
437 default=NODEFAULT, description=None, aliases=None,
438 flags=0,
439 ):
440 self.flags = flags
441 Option.__init__(self, config, section, setting, default,
442 description, aliases)
444 def _value2str(self, value):
445 assert isinstance(value, self.RE_TYPE)
446 return value.pattern
448 def str2value(self, value):
449 if not isinstance(value, unicode):
450 value = str(value)
451 # if it is 7-bit ascii, use it as string,
452 # otherwise convert to unicode.
453 try:
454 value.decode("ascii")
455 except UnicodeError:
456 value = value.decode("utf-8")
457 return re.compile(value, self.flags)
459 ### Main configuration layout.
460 # Config is described as a sequence of sections,
461 # where each section name is followed by a sequence
462 # of Option definitions. Each Option definition
463 # is a sequence containing class name and constructor
464 # parameters, starting from the setting name:
465 # setting, default, [description, [aliases]]
466 # Note: aliases should only exist in historical options for backwards
467 # compatibility - new options should *not* have aliases!
468 SETTINGS = (
469 ("main", (
470 (FilePathOption, "database", "db", "Database directory path."),
471 (FilePathOption, "templates", "html",
472 "Path to the HTML templates directory."),
473 (NullableFilePathOption, "static_files", "",
474 "Path to directory holding additional static files\n"
475 "available via Web UI. This directory may contain\n"
476 "sitewide images, CSS stylesheets etc. and is searched\n"
477 "for these files prior to the TEMPLATES directory\n"
478 "specified above. If this option is not set, all static\n"
479 "files are taken from the TEMPLATES directory"),
480 (MailAddressOption, "admin_email", "roundup-admin",
481 "Email address that roundup will complain to if it runs\n"
482 "into trouble.\n"
483 "If no domain is specified then the config item\n"
484 "mail -> domain is added."),
485 (MailAddressOption, "dispatcher_email", "roundup-admin",
486 "The 'dispatcher' is a role that can get notified\n"
487 "of new items to the database.\n"
488 "It is used by the ERROR_MESSAGES_TO config setting.\n"
489 "If no domain is specified then the config item\n"
490 "mail -> domain is added."),
491 (Option, "email_from_tag", "",
492 "Additional text to include in the \"name\" part\n"
493 "of the From: address used in nosy messages.\n"
494 "If the sending user is \"Foo Bar\", the From: line\n"
495 "is usually: \"Foo Bar\" <issue_tracker@tracker.example>\n"
496 "the EMAIL_FROM_TAG goes inside the \"Foo Bar\" quotes like so:\n"
497 "\"Foo Bar EMAIL_FROM_TAG\" <issue_tracker@tracker.example>"),
498 (Option, "new_web_user_roles", "User",
499 "Roles that a user gets when they register"
500 " with Web User Interface.\n"
501 "This is a comma-separated string of role names"
502 " (e.g. 'Admin,User')."),
503 (Option, "new_email_user_roles", "User",
504 "Roles that a user gets when they register"
505 " with Email Gateway.\n"
506 "This is a comma-separated string of role names"
507 " (e.g. 'Admin,User')."),
508 (Option, "error_messages_to", "user",
509 # XXX This description needs better wording,
510 # with explicit allowed values list.
511 "Send error message emails to the dispatcher, user, or both?\n"
512 "The dispatcher is configured using the DISPATCHER_EMAIL"
513 " setting."),
514 (Option, "html_version", "html4",
515 "HTML version to generate. The templates are html4 by default.\n"
516 "If you wish to make them xhtml, then you'll need to change this\n"
517 "var to 'xhtml' too so all auto-generated HTML is compliant.\n"
518 "Allowed values: html4, xhtml"),
519 (TimezoneOption, "timezone", "UTC", "Default timezone offset,"
520 " applied when user's timezone is not set.",
521 ["DEFAULT_TIMEZONE"]),
522 (BooleanOption, "instant_registration", "no",
523 "Register new users instantly, or require confirmation via\n"
524 "email?"),
525 (BooleanOption, "email_registration_confirmation", "yes",
526 "Offer registration confirmation by email or only through the web?"),
527 (WordListOption, "indexer_stopwords", "",
528 "Additional stop-words for the full-text indexer specific to\n"
529 "your tracker. See the indexer source for the default list of\n"
530 "stop-words (eg. A,AND,ARE,AS,AT,BE,BUT,BY, ...)"),
531 (OctalNumberOption, "umask", "02",
532 "Defines the file creation mode mask."),
533 (IntegerNumberOption, 'csv_field_size', '131072',
534 "Maximum size of a csv-field during import. Roundups export\n"
535 "format is a csv (comma separated values) variant. The csv\n"
536 "reader has a limit on the size of individual fields\n"
537 "starting with python 2.5. Set this to a higher value if you\n"
538 "get the error 'Error: field larger than field limit' during\n"
539 "import."),
540 )),
541 ("tracker", (
542 (Option, "name", "Roundup issue tracker",
543 "A descriptive name for your roundup instance."),
544 (Option, "web", NODEFAULT,
545 "The web address that the tracker is viewable at.\n"
546 "This will be included in information"
547 " sent to users of the tracker.\n"
548 "The URL MUST include the cgi-bin part or anything else\n"
549 "that is required to get to the home page of the tracker.\n"
550 "You MUST include a trailing '/' in the URL."),
551 (MailAddressOption, "email", "issue_tracker",
552 "Email address that mail to roundup should go to.\n"
553 "If no domain is specified then mail_domain is added."),
554 (NullableOption, "language", "",
555 "Default locale name for this tracker.\n"
556 "If this option is not set, the language is determined\n"
557 "by OS environment variable LANGUAGE, LC_ALL, LC_MESSAGES,\n"
558 "or LANG, in that order of preference."),
559 )),
560 ("web", (
561 (BooleanOption, "allow_html_file", "no",
562 "Setting this option enables Roundup to serve uploaded HTML\n"
563 "file content *as HTML*. This is a potential security risk\n"
564 "and is therefore disabled by default. Set to 'yes' if you\n"
565 "trust *all* users uploading content to your tracker."),
566 (BooleanOption, 'http_auth', "yes",
567 "Whether to use HTTP Basic Authentication, if present.\n"
568 "Roundup will use either the REMOTE_USER or HTTP_AUTHORIZATION\n"
569 "variables supplied by your web server (in that order).\n"
570 "Set this option to 'no' if you do not wish to use HTTP Basic\n"
571 "Authentication in your web interface."),
572 (BooleanOption, 'use_browser_language', "yes",
573 "Whether to use HTTP Accept-Language, if present.\n"
574 "Browsers send a language-region preference list.\n"
575 "It's usually set in the client's browser or in their\n"
576 "Operating System.\n"
577 "Set this option to 'no' if you want to ignore it."),
578 (BooleanOption, "debug", "no",
579 "Setting this option makes Roundup display error tracebacks\n"
580 "in the user's browser rather than emailing them to the\n"
581 "tracker admin."),
582 (BooleanOption, "migrate_passwords", "yes",
583 "Setting this option makes Roundup migrate passwords with\n"
584 "an insecure password-scheme to a more secure scheme\n"
585 "when the user logs in via the web-interface."),
586 )),
587 ("rdbms", (
588 (Option, 'name', 'roundup',
589 "Name of the database to use.",
590 ['MYSQL_DBNAME']),
591 (NullableOption, 'host', 'localhost',
592 "Database server host.",
593 ['MYSQL_DBHOST']),
594 (NullableOption, 'port', '',
595 "TCP port number of the database server.\n"
596 "Postgresql usually resides on port 5432 (if any),\n"
597 "for MySQL default port number is 3306.\n"
598 "Leave this option empty to use backend default"),
599 (NullableOption, 'user', 'roundup',
600 "Database user name that Roundup should use.",
601 ['MYSQL_DBUSER']),
602 (NullableOption, 'password', 'roundup',
603 "Database user password.",
604 ['MYSQL_DBPASSWORD']),
605 (NullableOption, 'read_default_file', '~/.my.cnf',
606 "Name of the MySQL defaults file.\n"
607 "Only used in MySQL connections."),
608 (NullableOption, 'read_default_group', 'roundup',
609 "Name of the group to use in the MySQL defaults file (.my.cnf).\n"
610 "Only used in MySQL connections."),
611 (IntegerNumberOption, 'sqlite_timeout', '30',
612 "Number of seconds to wait when the SQLite database is locked\n"
613 "Default: use a 30 second timeout (extraordinarily generous)\n"
614 "Only used in SQLite connections."),
615 (IntegerNumberOption, 'cache_size', '100',
616 "Size of the node cache (in elements)"),
617 (BooleanOption, "allow_create", "yes",
618 "Setting this option to 'no' protects the database against table creations."),
619 (BooleanOption, "allow_alter", "yes",
620 "Setting this option to 'no' protects the database against table alterations."),
621 (BooleanOption, "allow_drop", "yes",
622 "Setting this option to 'no' protects the database against table drops."),
623 (NullableOption, 'template', '',
624 "Name of the PostgreSQL template for database creation.\n"
625 "For database creation the template used has to match\n"
626 "the character encoding used (UTF8), there are different\n"
627 "PostgreSQL installations using different templates with\n"
628 "different encodings. If you get an error:\n"
629 " new encoding (UTF8) is incompatible with the encoding of\n"
630 " the template database (SQL_ASCII)\n"
631 " HINT: Use the same encoding as in the template database,\n"
632 " or use template0 as template.\n"
633 "then set this option to the template name given in the\n"
634 "error message."),
635 ), "Settings in this section are used"
636 " by RDBMS backends only"
637 ),
638 ("logging", (
639 (FilePathOption, "config", "",
640 "Path to configuration file for standard Python logging module.\n"
641 "If this option is set, logging configuration is loaded\n"
642 "from specified file; options 'filename' and 'level'\n"
643 "in this section are ignored."),
644 (FilePathOption, "filename", "",
645 "Log file name for minimal logging facility built into Roundup.\n"
646 "If no file name specified, log messages are written on stderr.\n"
647 "If above 'config' option is set, this option has no effect."),
648 (Option, "level", "ERROR",
649 "Minimal severity level of messages written to log file.\n"
650 "If above 'config' option is set, this option has no effect.\n"
651 "Allowed values: DEBUG, INFO, WARNING, ERROR"),
652 )),
653 ("mail", (
654 (Option, "domain", NODEFAULT,
655 "The email domain that admin_email, issue_tracker and\n"
656 "dispatcher_email belong to.\n"
657 "This domain is added to those config items if they don't\n"
658 "explicitly include a domain.\n"
659 "Do not include the '@' symbol."),
660 (Option, "host", NODEFAULT,
661 "SMTP mail host that roundup will use to send mail",
662 ["MAILHOST"],),
663 (Option, "username", "", "SMTP login name.\n"
664 "Set this if your mail host requires authenticated access.\n"
665 "If username is not empty, password (below) MUST be set!"),
666 (Option, "password", NODEFAULT, "SMTP login password.\n"
667 "Set this if your mail host requires authenticated access."),
668 (IntegerNumberOption, "port", smtplib.SMTP_PORT,
669 "Default port to send SMTP on.\n"
670 "Set this if your mail server runs on a different port."),
671 (NullableOption, "local_hostname", '',
672 "The local hostname to use during SMTP transmission.\n"
673 "Set this if your mail server requires something specific."),
674 (BooleanOption, "tls", "no",
675 "If your SMTP mail host provides or requires TLS\n"
676 "(Transport Layer Security) then set this option to 'yes'."),
677 (NullableFilePathOption, "tls_keyfile", "",
678 "If TLS is used, you may set this option to the name\n"
679 "of a PEM formatted file that contains your private key."),
680 (NullableFilePathOption, "tls_certfile", "",
681 "If TLS is used, you may set this option to the name\n"
682 "of a PEM formatted certificate chain file."),
683 (Option, "charset", "utf-8",
684 "Character set to encode email headers with.\n"
685 "We use utf-8 by default, as it's the most flexible.\n"
686 "Some mail readers (eg. Eudora) can't cope with that,\n"
687 "so you might need to specify a more limited character set\n"
688 "(eg. iso-8859-1).",
689 ["EMAIL_CHARSET"]),
690 (FilePathOption, "debug", "",
691 "Setting this option makes Roundup to write all outgoing email\n"
692 "messages to this file *instead* of sending them.\n"
693 "This option has the same effect as environment variable"
694 " SENDMAILDEBUG.\nEnvironment variable takes precedence."),
695 (BooleanOption, "add_authorinfo", "yes",
696 "Add a line with author information at top of all messages\n"
697 "sent by roundup"),
698 (BooleanOption, "add_authoremail", "yes",
699 "Add the mail address of the author to the author information at\n"
700 "the top of all messages.\n"
701 "If this is false but add_authorinfo is true, only the name\n"
702 "of the actor is added which protects the mail address of the\n"
703 "actor from being exposed at mail archives, etc."),
704 ), "Outgoing email options.\nUsed for nozy messages and approval requests"),
705 ("mailgw", (
706 (BooleanOption, "keep_quoted_text", "yes",
707 "Keep email citations when accepting messages.\n"
708 "Setting this to \"no\" strips out \"quoted\" text"
709 " from the message.\n"
710 "Signatures are also stripped.",
711 ["EMAIL_KEEP_QUOTED_TEXT"]),
712 (BooleanOption, "leave_body_unchanged", "no",
713 "Preserve the email body as is - that is,\n"
714 "keep the citations _and_ signatures.",
715 ["EMAIL_LEAVE_BODY_UNCHANGED"]),
716 (Option, "default_class", "issue",
717 "Default class to use in the mailgw\n"
718 "if one isn't supplied in email subjects.\n"
719 "To disable, leave the value blank.",
720 ["MAIL_DEFAULT_CLASS"]),
721 (NullableOption, "language", "",
722 "Default locale name for the tracker mail gateway.\n"
723 "If this option is not set, mail gateway will use\n"
724 "the language of the tracker instance."),
725 (Option, "subject_prefix_parsing", "strict",
726 "Controls the parsing of the [prefix] on subject\n"
727 "lines in incoming emails. \"strict\" will return an\n"
728 "error to the sender if the [prefix] is not recognised.\n"
729 "\"loose\" will attempt to parse the [prefix] but just\n"
730 "pass it through as part of the issue title if not\n"
731 "recognised. \"none\" will always pass any [prefix]\n"
732 "through as part of the issue title."),
733 (Option, "subject_suffix_parsing", "strict",
734 "Controls the parsing of the [suffix] on subject\n"
735 "lines in incoming emails. \"strict\" will return an\n"
736 "error to the sender if the [suffix] is not recognised.\n"
737 "\"loose\" will attempt to parse the [suffix] but just\n"
738 "pass it through as part of the issue title if not\n"
739 "recognised. \"none\" will always pass any [suffix]\n"
740 "through as part of the issue title."),
741 (Option, "subject_suffix_delimiters", "[]",
742 "Defines the brackets used for delimiting the prefix and \n"
743 'suffix in a subject line. The presence of "suffix" in\n'
744 "the config option name is a historical artifact and may\n"
745 "be ignored."),
746 (Option, "subject_content_match", "always",
747 "Controls matching of the incoming email subject line\n"
748 "against issue titles in the case where there is no\n"
749 "designator [prefix]. \"never\" turns off matching.\n"
750 "\"creation + interval\" or \"activity + interval\"\n"
751 "will match an issue for the interval after the issue's\n"
752 "creation or last activity. The interval is a standard\n"
753 "Roundup interval."),
754 (BooleanOption, "subject_updates_title", "yes",
755 "Update issue title if incoming subject of email is different.\n"
756 "Setting this to \"no\" will ignore the title part of"
757 " the subject\nof incoming email messages.\n"),
758 (RegExpOption, "refwd_re", "(\s*\W?\s*(fw|fwd|re|aw|sv|ang)\W)+",
759 "Regular expression matching a single reply or forward\n"
760 "prefix prepended by the mailer. This is explicitly\n"
761 "stripped from the subject during parsing."),
762 (RegExpOption, "origmsg_re",
763 "^[>|\s]*-----\s?Original Message\s?-----$",
764 "Regular expression matching start of an original message\n"
765 "if quoted the in body."),
766 (RegExpOption, "sign_re", "^[>|\s]*-- ?$",
767 "Regular expression matching the start of a signature\n"
768 "in the message body."),
769 (RegExpOption, "eol_re", r"[\r\n]+",
770 "Regular expression matching end of line."),
771 (RegExpOption, "blankline_re", r"[\r\n]+\s*[\r\n]+",
772 "Regular expression matching a blank line."),
773 (BooleanOption, "unpack_rfc822", "no",
774 "Unpack attached messages (encoded as message/rfc822 in MIME)\n"
775 "as multiple parts attached as files to the issue, if not\n"
776 "set we handle message/rfc822 attachments as a single file."),
777 (BooleanOption, "ignore_alternatives", "no",
778 "When parsing incoming mails, roundup uses the first\n"
779 "text/plain part it finds. If this part is inside a\n"
780 "multipart/alternative, and this option is set, all other\n"
781 "parts of the multipart/alternative are ignored. The default\n"
782 "is to keep all parts and attach them to the issue."),
783 ), "Roundup Mail Gateway options"),
784 ("pgp", (
785 (BooleanOption, "enable", "no",
786 "Enable PGP processing. Requires pyme."),
787 (NullableOption, "roles", "",
788 "If specified, a comma-separated list of roles to perform\n"
789 "PGP processing on. If not specified, it happens for all\n"
790 "users."),
791 (NullableOption, "homedir", "",
792 "Location of PGP directory. Defaults to $HOME/.gnupg if\n"
793 "not specified."),
794 ), "OpenPGP mail processing options"),
795 ("nosy", (
796 (RunDetectorOption, "messages_to_author", "no",
797 "Send nosy messages to the author of the message.",
798 ["MESSAGES_TO_AUTHOR"]),
799 (Option, "signature_position", "bottom",
800 "Where to place the email signature.\n"
801 "Allowed values: top, bottom, none",
802 ["EMAIL_SIGNATURE_POSITION"]),
803 (RunDetectorOption, "add_author", "new",
804 "Does the author of a message get placed on the nosy list\n"
805 "automatically? If 'new' is used, then the author will\n"
806 "only be added when a message creates a new issue.\n"
807 "If 'yes', then the author will be added on followups too.\n"
808 "If 'no', they're never added to the nosy.\n",
809 ["ADD_AUTHOR_TO_NOSY"]),
810 (RunDetectorOption, "add_recipients", "new",
811 "Do the recipients (To:, Cc:) of a message get placed on the\n"
812 "nosy list? If 'new' is used, then the recipients will\n"
813 "only be added when a message creates a new issue.\n"
814 "If 'yes', then the recipients will be added on followups too.\n"
815 "If 'no', they're never added to the nosy.\n",
816 ["ADD_RECIPIENTS_TO_NOSY"]),
817 (Option, "email_sending", "single",
818 "Controls the email sending from the nosy reactor. If\n"
819 "\"multiple\" then a separate email is sent to each\n"
820 "recipient. If \"single\" then a single email is sent with\n"
821 "each recipient as a CC address."),
822 (IntegerNumberOption, "max_attachment_size", sys.maxint,
823 "Attachments larger than the given number of bytes\n"
824 "won't be attached to nosy mails. They will be replaced by\n"
825 "a link to the tracker's download page for the file.")
826 ), "Nosy messages sending"),
827 )
829 ### Configuration classes
831 class Config:
833 """Base class for configuration objects.
835 Configuration options may be accessed as attributes or items
836 of instances of this class. All option names are uppercased.
838 """
840 # Config file name
841 INI_FILE = "config.ini"
843 # Object attributes that should not be taken as common configuration
844 # options in __setattr__ (most of them are initialized in constructor):
845 # builtin pseudo-option - package home directory
846 HOME = "."
847 # names of .ini file sections, in order
848 sections = None
849 # section comments
850 section_descriptions = None
851 # lists of option names for each section, in order
852 section_options = None
853 # mapping from option names and aliases to Option instances
854 options = None
855 # actual name of the config file. set on load.
856 filepath = os.path.join(HOME, INI_FILE)
858 def __init__(self, config_path=None, layout=None, settings={}):
859 """Initialize confing instance
861 Parameters:
862 config_path:
863 optional directory or file name of the config file.
864 If passed, load the config after processing layout (if any).
865 If config_path is a directory name, use default base name
866 of the config file.
867 layout:
868 optional configuration layout, a sequence of
869 section definitions suitable for .add_section()
870 settings:
871 optional setting overrides (dictionary).
872 The overrides are applied after loading config file.
874 """
875 # initialize option containers:
876 self.sections = []
877 self.section_descriptions = {}
878 self.section_options = {}
879 self.options = {}
880 # add options from the layout structure
881 if layout:
882 for section in layout:
883 self.add_section(*section)
884 if config_path is not None:
885 self.load(config_path)
886 for (name, value) in settings.items():
887 self[name.upper()] = value
889 def add_section(self, section, options, description=None):
890 """Define new config section
892 Parameters:
893 section - name of the config.ini section
894 options - a sequence of Option definitions.
895 Each Option definition is a sequence
896 containing class object and constructor
897 parameters, starting from the setting name:
898 setting, default, [description, [aliases]]
899 description - optional section comment
901 Note: aliases should only exist in historical options
902 for backwards compatibility - new options should
903 *not* have aliases!
905 """
906 if description or not self.section_descriptions.has_key(section):
907 self.section_descriptions[section] = description
908 for option_def in options:
909 klass = option_def[0]
910 args = option_def[1:]
911 option = klass(self, section, *args)
912 self.add_option(option)
914 def add_option(self, option):
915 """Adopt a new Option object"""
916 _section = option.section
917 _name = option.setting
918 if _section not in self.sections:
919 self.sections.append(_section)
920 _options = self._get_section_options(_section)
921 if _name not in _options:
922 _options.append(_name)
923 # (section, name) key is used for writing .ini file
924 self.options[(_section, _name)] = option
925 # make the option known under all of its A.K.A.s
926 for _name in option.aliases:
927 self.options[_name] = option
929 def update_option(self, name, klass,
930 default=NODEFAULT, description=None
931 ):
932 """Override behaviour of early created option.
934 Parameters:
935 name:
936 option name
937 klass:
938 one of the Option classes
939 default:
940 optional default value for the option
941 description:
942 optional new description for the option
944 Conversion from current option value to new class value
945 is done via string representation.
947 This method may be used to attach some brains
948 to options autocreated by UserConfig.
950 """
951 # fetch current option
952 option = self._get_option(name)
953 # compute constructor parameters
954 if default is NODEFAULT:
955 default = option.default
956 if description is None:
957 description = option.description
958 value = option.value2str(current=1)
959 # resurrect the option
960 option = klass(self, option.section, option.setting,
961 default=default, description=description)
962 # apply the value
963 option.set(value)
964 # incorporate new option
965 del self[name]
966 self.add_option(option)
968 def reset(self):
969 """Set all options to their default values"""
970 for _option in self.items():
971 _option.reset()
973 # Meant for commandline tools.
974 # Allows automatic creation of configuration files like this:
975 # roundup-server -p 8017 -u roundup --save-config
976 def getopt(self, args, short_options="", long_options=(),
977 config_load_options=("C", "config"), **options
978 ):
979 """Apply options specified in command line arguments.
981 Parameters:
982 args:
983 command line to parse (sys.argv[1:])
984 short_options:
985 optional string of letters for command line options
986 that are not config options
987 long_options:
988 optional list of names for long options
989 that are not config options
990 config_load_options:
991 two-element sequence (letter, long_option) defining
992 the options for config file. If unset, don't load
993 config file; otherwise config file is read prior
994 to applying other options. Short option letter
995 must not have a colon and long_option name must
996 not have an equal sign or '--' prefix.
997 options:
998 mapping from option names to command line option specs.
999 e.g. server_port="p:", server_user="u:"
1000 Names are forced to lower case for commandline parsing
1001 (long options) and to upper case to find config options.
1002 Command line options accepting no value are assumed
1003 to be binary and receive value 'yes'.
1005 Return value: same as for python standard getopt(), except that
1006 processed options are removed from returned option list.
1008 """
1009 # take a copy of long_options
1010 long_options = list(long_options)
1011 # build option lists
1012 cfg_names = {}
1013 booleans = []
1014 for (name, letter) in options.items():
1015 cfg_name = name.upper()
1016 short_opt = "-" + letter[0]
1017 name = name.lower().replace("_", "-")
1018 cfg_names.update({short_opt: cfg_name, "--" + name: cfg_name})
1020 short_options += letter
1021 if letter[-1] == ":":
1022 long_options.append(name + "=")
1023 else:
1024 booleans.append(short_opt)
1025 long_options.append(name)
1027 if config_load_options:
1028 short_options += config_load_options[0] + ":"
1029 long_options.append(config_load_options[1] + "=")
1030 # compute names that will be searched in getopt return value
1031 config_load_options = (
1032 "-" + config_load_options[0],
1033 "--" + config_load_options[1],
1034 )
1035 # parse command line arguments
1036 optlist, args = getopt.getopt(args, short_options, long_options)
1037 # load config file if requested
1038 if config_load_options:
1039 for option in optlist:
1040 if option[0] in config_load_options:
1041 self.load_ini(option[1])
1042 optlist.remove(option)
1043 break
1044 # apply options
1045 extra_options = []
1046 for (opt, arg) in optlist:
1047 if (opt in booleans): # and not arg
1048 arg = "yes"
1049 try:
1050 name = cfg_names[opt]
1051 except KeyError:
1052 extra_options.append((opt, arg))
1053 else:
1054 self[name] = arg
1055 return (extra_options, args)
1057 # option and section locators (used in option access methods)
1059 def _get_option(self, name):
1060 try:
1061 return self.options[name]
1062 except KeyError:
1063 raise InvalidOptionError(name)
1065 def _get_section_options(self, name):
1066 return self.section_options.setdefault(name, [])
1068 def _get_unset_options(self):
1069 """Return options that need manual adjustments
1071 Return value is a dictionary where keys are section
1072 names and values are lists of option names as they
1073 appear in the config file.
1075 """
1076 need_set = {}
1077 for option in self.items():
1078 if not option.isset():
1079 need_set.setdefault(option.section, []).append(option.setting)
1080 return need_set
1082 def _adjust_options(self, config):
1083 """Load ad-hoc option definitions from ConfigParser instance."""
1084 pass
1086 def _get_name(self):
1087 """Return the service name for config file heading"""
1088 return ""
1090 # file operations
1092 def load_ini(self, config_path, defaults=None):
1093 """Set options from config.ini file in given home_dir
1095 Parameters:
1096 config_path:
1097 directory or file name of the config file.
1098 If config_path is a directory name, use default
1099 base name of the config file
1100 defaults:
1101 optional dictionary of defaults for ConfigParser
1103 Note: if home_dir does not contain config.ini file,
1104 no error is raised. Config will be reset to defaults.
1106 """
1107 if os.path.isdir(config_path):
1108 home_dir = config_path
1109 config_path = os.path.join(config_path, self.INI_FILE)
1110 else:
1111 home_dir = os.path.dirname(config_path)
1112 # parse the file
1113 config_defaults = {"HOME": home_dir}
1114 if defaults:
1115 config_defaults.update(defaults)
1116 config = ConfigParser.ConfigParser(config_defaults)
1117 config.read([config_path])
1118 # .ini file loaded ok.
1119 self.HOME = home_dir
1120 self.filepath = config_path
1121 self._adjust_options(config)
1122 # set the options, starting from HOME
1123 self.reset()
1124 for option in self.items():
1125 option.load_ini(config)
1127 def load(self, home_dir):
1128 """Load configuration settings from home_dir"""
1129 self.load_ini(home_dir)
1131 def save(self, ini_file=None):
1132 """Write current configuration to .ini file
1134 'ini_file' argument, if passed, must be valid full path
1135 to the file to write. If omitted, default file in current
1136 HOME is created.
1138 If the file to write already exists, it is saved with '.bak'
1139 extension.
1141 """
1142 if ini_file is None:
1143 ini_file = self.filepath
1144 _tmp_file = os.path.splitext(ini_file)[0]
1145 _bak_file = _tmp_file + ".bak"
1146 _tmp_file = _tmp_file + ".tmp"
1147 _fp = file(_tmp_file, "wt")
1148 _fp.write("# %s configuration file\n" % self._get_name())
1149 _fp.write("# Autogenerated at %s\n" % time.asctime())
1150 need_set = self._get_unset_options()
1151 if need_set:
1152 _fp.write("\n# WARNING! Following options need adjustments:\n")
1153 for section, options in need_set.items():
1154 _fp.write("# [%s]: %s\n" % (section, ", ".join(options)))
1155 for section in self.sections:
1156 comment = self.section_descriptions.get(section, None)
1157 if comment:
1158 _fp.write("\n# ".join([""] + comment.split("\n")) +"\n")
1159 else:
1160 # no section comment - just leave a blank line between sections
1161 _fp.write("\n")
1162 _fp.write("[%s]\n" % section)
1163 for option in self._get_section_options(section):
1164 _fp.write("\n" + self.options[(section, option)].format())
1165 _fp.close()
1166 if os.access(ini_file, os.F_OK):
1167 if os.access(_bak_file, os.F_OK):
1168 os.remove(_bak_file)
1169 os.rename(ini_file, _bak_file)
1170 os.rename(_tmp_file, ini_file)
1172 # container emulation
1174 def __len__(self):
1175 return len(self.items())
1177 def __getitem__(self, name):
1178 if name == "HOME":
1179 return self.HOME
1180 else:
1181 return self._get_option(name).get()
1183 def __setitem__(self, name, value):
1184 if name == "HOME":
1185 self.HOME = value
1186 else:
1187 self._get_option(name).set(value)
1189 def __delitem__(self, name):
1190 _option = self._get_option(name)
1191 _section = _option.section
1192 _name = _option.setting
1193 self._get_section_options(_section).remove(_name)
1194 del self.options[(_section, _name)]
1195 for _alias in _option.aliases:
1196 del self.options[_alias]
1198 def items(self):
1199 """Return the list of Option objects, in .ini file order
1201 Note that HOME is not included in this list
1202 because it is builtin pseudo-option, not a real Option
1203 object loaded from or saved to .ini file.
1205 """
1206 return [self.options[(_section, _name)]
1207 for _section in self.sections
1208 for _name in self._get_section_options(_section)
1209 ]
1211 def keys(self):
1212 """Return the list of "canonical" names of the options
1214 Unlike .items(), this list also includes HOME
1216 """
1217 return ["HOME"] + [_option.name for _option in self.items()]
1219 # .values() is not implemented because i am not sure what should be
1220 # the values returned from this method: Option instances or config values?
1222 # attribute emulation
1224 def __setattr__(self, name, value):
1225 if self.__dict__.has_key(name) or hasattr(self.__class__, name):
1226 self.__dict__[name] = value
1227 else:
1228 self._get_option(name).set(value)
1230 # Note: __getattr__ is not symmetric to __setattr__:
1231 # self.__dict__ lookup is done before calling this method
1232 def __getattr__(self, name):
1233 return self[name]
1235 class UserConfig(Config):
1237 """Configuration for user extensions.
1239 Instances of this class have no predefined configuration layout.
1240 Options are created on the fly for each setting present in the
1241 config file.
1243 """
1245 def _adjust_options(self, config):
1246 # config defaults appear in all sections.
1247 # we'll need to filter them out.
1248 defaults = config.defaults().keys()
1249 # see what options are already defined and add missing ones
1250 preset = [(option.section, option.setting) for option in self.items()]
1251 for section in config.sections():
1252 for name in config.options(section):
1253 if ((section, name) not in preset) \
1254 and (name not in defaults):
1255 self.add_option(Option(self, section, name))
1257 class CoreConfig(Config):
1259 """Roundup instance configuration.
1261 Core config has a predefined layout (see the SETTINGS structure),
1262 supports loading of old-style pythonic configurations and holds
1263 three additional attributes:
1264 logging:
1265 instance logging engine, from standard python logging module
1266 or minimalistic logger implemented in Roundup
1267 detectors:
1268 user-defined configuration for detectors
1269 ext:
1270 user-defined configuration for extensions
1272 """
1274 # module name for old style configuration
1275 PYCONFIG = "config"
1276 # user configs
1277 ext = None
1278 detectors = None
1280 def __init__(self, home_dir=None, settings={}):
1281 Config.__init__(self, home_dir, layout=SETTINGS, settings=settings)
1282 # load the config if home_dir given
1283 if home_dir is None:
1284 self.init_logging()
1286 def copy(self):
1287 new = CoreConfig()
1288 new.sections = list(self.sections)
1289 new.section_descriptions = dict(self.section_descriptions)
1290 new.section_options = dict(self.section_options)
1291 new.options = dict(self.options)
1292 return new
1294 def _get_unset_options(self):
1295 need_set = Config._get_unset_options(self)
1296 # remove MAIL_PASSWORD if MAIL_USER is empty
1297 if "password" in need_set.get("mail", []):
1298 if not self["MAIL_USERNAME"]:
1299 settings = need_set["mail"]
1300 settings.remove("password")
1301 if not settings:
1302 del need_set["mail"]
1303 return need_set
1305 def _get_name(self):
1306 return self["TRACKER_NAME"]
1308 def reset(self):
1309 Config.reset(self)
1310 if self.ext:
1311 self.ext.reset()
1312 if self.detectors:
1313 self.detectors.reset()
1314 self.init_logging()
1316 def init_logging(self):
1317 _file = self["LOGGING_CONFIG"]
1318 if _file and os.path.isfile(_file):
1319 logging.config.fileConfig(_file)
1320 return
1322 _file = self["LOGGING_FILENAME"]
1323 # set file & level on the roundup logger
1324 logger = logging.getLogger('roundup')
1325 if _file:
1326 hdlr = logging.FileHandler(_file)
1327 else:
1328 hdlr = logging.StreamHandler(sys.stdout)
1329 formatter = logging.Formatter(
1330 '%(asctime)s %(levelname)s %(message)s')
1331 hdlr.setFormatter(formatter)
1332 # no logging API to remove all existing handlers!?!
1333 for h in logger.handlers:
1334 h.close()
1335 logger.removeHandler(hdlr)
1336 logger.handlers = [hdlr]
1337 logger.setLevel(logging._levelNames[self["LOGGING_LEVEL"] or "ERROR"])
1339 def load(self, home_dir):
1340 """Load configuration from path designated by home_dir argument"""
1341 if os.path.isfile(os.path.join(home_dir, self.INI_FILE)):
1342 self.load_ini(home_dir)
1343 else:
1344 self.load_pyconfig(home_dir)
1345 self.init_logging()
1346 self.ext = UserConfig(os.path.join(home_dir, "extensions"))
1347 self.detectors = UserConfig(os.path.join(home_dir, "detectors"))
1349 def load_ini(self, home_dir, defaults=None):
1350 """Set options from config.ini file in given home_dir directory"""
1351 config_defaults = {"TRACKER_HOME": home_dir}
1352 if defaults:
1353 config_defaults.update(defaults)
1354 Config.load_ini(self, home_dir, config_defaults)
1356 def load_pyconfig(self, home_dir):
1357 """Set options from config.py file in given home_dir directory"""
1358 # try to locate and import the module
1359 _mod_fp = None
1360 try:
1361 try:
1362 _module = imp.find_module(self.PYCONFIG, [home_dir])
1363 _mod_fp = _module[0]
1364 _config = imp.load_module(self.PYCONFIG, *_module)
1365 except ImportError:
1366 raise NoConfigError(home_dir)
1367 finally:
1368 if _mod_fp is not None:
1369 _mod_fp.close()
1370 # module loaded ok. set the options, starting from HOME
1371 self.reset()
1372 self.HOME = home_dir
1373 for _option in self.items():
1374 _option.load_pyconfig(_config)
1375 # backward compatibility:
1376 # SMTP login parameters were specified as a tuple in old style configs
1377 # convert them to new plain string options
1378 _mailuser = getattr(_config, "MAILUSER", ())
1379 if len(_mailuser) > 0:
1380 self.MAIL_USERNAME = _mailuser[0]
1381 if len(_mailuser) > 1:
1382 self.MAIL_PASSWORD = _mailuser[1]
1384 # in this config, HOME is also known as TRACKER_HOME
1385 def __getitem__(self, name):
1386 if name == "TRACKER_HOME":
1387 return self.HOME
1388 else:
1389 return Config.__getitem__(self, name)
1391 def __setitem__(self, name, value):
1392 if name == "TRACKER_HOME":
1393 self.HOME = value
1394 else:
1395 self._get_option(name).set(value)
1397 def __setattr__(self, name, value):
1398 if name == "TRACKER_HOME":
1399 self.__dict__["HOME"] = value
1400 else:
1401 Config.__setattr__(self, name, value)
1403 # vim: set et sts=4 sw=4 :