Code

svn repository setup
[roundup.git] / roundup / configuration.py
diff --git a/roundup/configuration.py b/roundup/configuration.py
new file mode 100644 (file)
index 0000000..4fa4c94
--- /dev/null
@@ -0,0 +1,1344 @@
+# Roundup Issue Tracker configuration support
+#
+# $Id: configuration.py,v 1.51 2008-09-01 02:30:06 richard Exp $
+#
+__docformat__ = "restructuredtext"
+
+import ConfigParser
+import getopt
+import imp
+import logging, logging.config
+import os
+import re
+import sys
+import time
+import smtplib
+
+import roundup.date
+
+# XXX i don't think this module needs string translation, does it?
+
+### Exceptions
+
+class ConfigurationError(Exception):
+    pass
+
+class NoConfigError(ConfigurationError):
+
+    """Raised when configuration loading fails
+
+    Constructor parameters: path to the directory that was used as HOME
+
+    """
+
+    def __str__(self):
+        return "No valid configuration files found in directory %s" \
+            % self.args[0]
+
+class InvalidOptionError(ConfigurationError, KeyError, AttributeError):
+
+    """Attempted access to non-existing configuration option
+
+    Configuration options may be accessed as configuration object
+    attributes or items.  So this exception instances also are
+    instances of KeyError (invalid item access) and AttributeError
+    (invalid attribute access).
+
+    Constructor parameter: option name
+
+    """
+
+    def __str__(self):
+        return "Unsupported configuration option: %s" % self.args[0]
+
+class OptionValueError(ConfigurationError, ValueError):
+
+    """Raised upon attempt to assign an invalid value to config option
+
+    Constructor parameters: Option instance, offending value
+    and optional info string.
+
+    """
+
+    def __str__(self):
+        _args = self.args
+        _rv = "Invalid value for %(option)s: %(value)r" % {
+            "option": _args[0].name, "value": _args[1]}
+        if len(_args) > 2:
+            _rv += "\n".join(("",) + _args[2:])
+        return _rv
+
+class OptionUnsetError(ConfigurationError):
+
+    """Raised when no Option value is available - neither set, nor default
+
+    Constructor parameters: Option instance.
+
+    """
+
+    def __str__(self):
+        return "%s is not set and has no default" % self.args[0].name
+
+class UnsetDefaultValue:
+
+    """Special object meaning that default value for Option is not specified"""
+
+    def __str__(self):
+        return "NO DEFAULT"
+
+NODEFAULT = UnsetDefaultValue()
+
+### Option classes
+
+class Option:
+
+    """Single configuration option.
+
+    Options have following attributes:
+
+        config
+            reference to the containing Config object
+        section
+            name of the section in the tracker .ini file
+        setting
+            option name in the tracker .ini file
+        default
+            default option value
+        description
+            option description.  Makes a comment in the tracker .ini file
+        name
+            "canonical name" of the configuration option.
+            For items in the 'main' section this is uppercased
+            'setting' name.  For other sections, the name is
+            composed of the section name and the setting name,
+            joined with underscore.
+        aliases
+            list of "also known as" names.  Used to access the settings
+            by old names used in previous Roundup versions.
+            "Canonical name" is also included.
+
+    The name and aliases are forced to be uppercase.
+    The setting name is forced to lowercase.
+
+    """
+
+    class_description = None
+
+    def __init__(self, config, section, setting,
+        default=NODEFAULT, description=None, aliases=None
+    ):
+        self.config = config
+        self.section = section
+        self.setting = setting.lower()
+        self.default = default
+        self.description = description
+        self.name = setting.upper()
+        if section != "main":
+            self.name = "_".join((section.upper(), self.name))
+        if aliases:
+            self.aliases = [alias.upper() for alias in list(aliases)]
+        else:
+            self.aliases = []
+        self.aliases.insert(0, self.name)
+        # convert default to internal representation
+        if default is NODEFAULT:
+            _value = default
+        else:
+            _value = self.str2value(default)
+        # value is private.  use get() and set() to access
+        self._value = self._default_value = _value
+
+    def str2value(self, value):
+        """Return 'value' argument converted to internal representation"""
+        return value
+
+    def _value2str(self, value):
+        """Return 'value' argument converted to external representation
+
+        This is actual conversion method called only when value
+        is not NODEFAULT.  Heirs with different conversion rules
+        override this method, not the public .value2str().
+
+        """
+        return str(value)
+
+    def value2str(self, value=NODEFAULT, current=0):
+        """Return 'value' argument converted to external representation
+
+        If 'current' is True, use current option value.
+
+        """
+        if current:
+            value = self._value
+        if value is NODEFAULT:
+            return str(value)
+        else:
+            return self._value2str(value)
+
+    def get(self):
+        """Return current option value"""
+        if self._value is NODEFAULT:
+            raise OptionUnsetError(self)
+        return self._value
+
+    def set(self, value):
+        """Update the value"""
+        self._value = self.str2value(value)
+
+    def reset(self):
+        """Reset the value to default"""
+        self._value = self._default_value
+
+    def isdefault(self):
+        """Return True if current value is the default one"""
+        return self._value == self._default_value
+
+    def isset(self):
+        """Return True if the value is available (either set or default)"""
+        return self._value != NODEFAULT
+
+    def __str__(self):
+        return self.value2str(self._value)
+
+    def __repr__(self):
+        if self.isdefault():
+            _format = "<%(class)s %(name)s (default): %(value)s>"
+        else:
+            _format = "<%(class)s %(name)s (default: %(default)s): %(value)s>"
+        return _format % {
+            "class": self.__class__.__name__,
+            "name": self.name,
+            "default": self.value2str(self._default_value),
+            "value": self.value2str(self._value),
+        }
+
+    def format(self):
+        """Return .ini file fragment for this option"""
+        _desc_lines = []
+        for _description in (self.description, self.class_description):
+            if _description:
+                _desc_lines.extend(_description.split("\n"))
+        # comment out the setting line if there is no value
+        if self.isset():
+            _is_set = ""
+        else:
+            _is_set = "#"
+        _rv = "# %(description)s\n# Default: %(default)s\n" \
+            "%(is_set)s%(name)s = %(value)s\n" % {
+                "description": "\n# ".join(_desc_lines),
+                "default": self.value2str(self._default_value),
+                "name": self.setting,
+                "value": self.value2str(self._value),
+                "is_set": _is_set
+            }
+        return _rv
+
+    def load_ini(self, config):
+        """Load value from ConfigParser object"""
+        if config.has_option(self.section, self.setting):
+            self.set(config.get(self.section, self.setting))
+
+    def load_pyconfig(self, config):
+        """Load value from old-style config (python module)"""
+        for _name in self.aliases:
+            if hasattr(config, _name):
+                self.set(getattr(config, _name))
+                break
+
+class BooleanOption(Option):
+
+    """Boolean option: yes or no"""
+
+    class_description = "Allowed values: yes, no"
+
+    def _value2str(self, value):
+        if value:
+            return "yes"
+        else:
+            return "no"
+
+    def str2value(self, value):
+        if type(value) == type(""):
+            _val = value.lower()
+            if _val in ("yes", "true", "on", "1"):
+                _val = 1
+            elif _val in ("no", "false", "off", "0"):
+                _val = 0
+            else:
+                raise OptionValueError(self, value, self.class_description)
+        else:
+            _val = value and 1 or 0
+        return _val
+
+class WordListOption(Option):
+
+    """List of strings"""
+
+    class_description = "Allowed values: comma-separated list of words"
+
+    def _value2str(self, value):
+        return ','.join(value)
+
+    def str2value(self, value):
+        return value.split(',')
+
+class RunDetectorOption(Option):
+
+    """When a detector is run: always, never or for new items only"""
+
+    class_description = "Allowed values: yes, no, new"
+
+    def str2value(self, value):
+        _val = value.lower()
+        if _val in ("yes", "no", "new"):
+            return _val
+        else:
+            raise OptionValueError(self, value, self.class_description)
+
+class MailAddressOption(Option):
+
+    """Email address
+
+    Email addresses may be either fully qualified or local.
+    In the latter case MAIL_DOMAIN is automatically added.
+
+    """
+
+    def get(self):
+        _val = Option.get(self)
+        if "@" not in _val:
+            _val = "@".join((_val, self.config["MAIL_DOMAIN"]))
+        return _val
+
+class FilePathOption(Option):
+
+    """File or directory path name
+
+    Paths may be either absolute or relative to the HOME.
+
+    """
+
+    class_description = "The path may be either absolute or relative\n" \
+        "to the directory containig this config file."
+
+    def get(self):
+        _val = Option.get(self)
+        if _val and not os.path.isabs(_val):
+            _val = os.path.join(self.config["HOME"], _val)
+        return _val
+
+class FloatNumberOption(Option):
+
+    """Floating point numbers"""
+
+    def str2value(self, value):
+        try:
+            return float(value)
+        except ValueError:
+            raise OptionValueError(self, value,
+                "Floating point number required")
+
+    def _value2str(self, value):
+        _val = str(value)
+        # strip fraction part from integer numbers
+        if _val.endswith(".0"):
+            _val = _val[:-2]
+        return _val
+
+class IntegerNumberOption(Option):
+
+    """Integer numbers"""
+
+    def str2value(self, value):
+        try:
+            return int(value)
+        except ValueError:
+            raise OptionValueError(self, value, "Integer number required")
+
+class OctalNumberOption(Option):
+
+    """Octal Integer numbers"""
+
+    def str2value(self, value):
+        try:
+            return int(value, 8)
+        except ValueError:
+            raise OptionValueError(self, value, "Octal Integer number required")
+
+    def _value2str(self, value):
+        return oct(value)
+
+class NullableOption(Option):
+
+    """Option that is set to None if it's string value is one of NULL strings
+
+    Default nullable strings list contains empty string only.
+    There is constructor parameter allowing to specify different nullables.
+
+    Conversion to external representation returns the first of the NULL
+    strings list when the value is None.
+
+    """
+
+    NULL_STRINGS = ("",)
+
+    def __init__(self, config, section, setting,
+        default=NODEFAULT, description=None, aliases=None,
+        null_strings=NULL_STRINGS
+    ):
+        self.null_strings = list(null_strings)
+        Option.__init__(self, config, section, setting, default,
+            description, aliases)
+
+    def str2value(self, value):
+        if value in self.null_strings:
+            return None
+        else:
+            return value
+
+    def _value2str(self, value):
+        if value is None:
+            return self.null_strings[0]
+        else:
+            return value
+
+class NullableFilePathOption(NullableOption, FilePathOption):
+
+    # .get() and class_description are from FilePathOption,
+    get = FilePathOption.get
+    class_description = FilePathOption.class_description
+    # everything else taken from NullableOption (inheritance order)
+
+class TimezoneOption(Option):
+
+    class_description = \
+        "If pytz module is installed, value may be any valid\n" \
+        "timezone specification (e.g. EET or Europe/Warsaw).\n" \
+        "If pytz is not installed, value must be integer number\n" \
+        "giving local timezone offset from UTC in hours."
+
+    def str2value(self, value):
+        try:
+            roundup.date.get_timezone(value)
+        except KeyError:
+            raise OptionValueError(self, value,
+                    "Timezone name or numeric hour offset required")
+        return value
+
+class RegExpOption(Option):
+
+    """Regular Expression option (value is Regular Expression Object)"""
+
+    class_description = "Value is Python Regular Expression (UTF8-encoded)."
+
+    RE_TYPE = type(re.compile(""))
+
+    def __init__(self, config, section, setting,
+        default=NODEFAULT, description=None, aliases=None,
+        flags=0,
+    ):
+        self.flags = flags
+        Option.__init__(self, config, section, setting, default,
+            description, aliases)
+
+    def _value2str(self, value):
+        assert isinstance(value, self.RE_TYPE)
+        return value.pattern
+
+    def str2value(self, value):
+        if not isinstance(value, unicode):
+            value = str(value)
+            # if it is 7-bit ascii, use it as string,
+            # otherwise convert to unicode.
+            try:
+                value.decode("ascii")
+            except UnicodeError:
+                value = value.decode("utf-8")
+        return re.compile(value, self.flags)
+
+### Main configuration layout.
+# Config is described as a sequence of sections,
+# where each section name is followed by a sequence
+# of Option definitions.  Each Option definition
+# is a sequence containing class name and constructor
+# parameters, starting from the setting name:
+# setting, default, [description, [aliases]]
+# Note: aliases should only exist in historical options for backwards
+# compatibility - new options should *not* have aliases!
+SETTINGS = (
+    ("main", (
+        (FilePathOption, "database", "db", "Database directory path."),
+        (FilePathOption, "templates", "html",
+            "Path to the HTML templates directory."),
+        (NullableFilePathOption, "static_files", "",
+            "Path to directory holding additional static files\n"
+            "available via Web UI.  This directory may contain\n"
+            "sitewide images, CSS stylesheets etc. and is searched\n"
+            "for these files prior to the TEMPLATES directory\n"
+            "specified above.  If this option is not set, all static\n"
+            "files are taken from the TEMPLATES directory"),
+        (MailAddressOption, "admin_email", "roundup-admin",
+            "Email address that roundup will complain to if it runs\n"
+            "into trouble.\n"
+            "If no domain is specified then the config item\n"
+            "mail -> domain is added."),
+        (MailAddressOption, "dispatcher_email", "roundup-admin",
+            "The 'dispatcher' is a role that can get notified\n"
+            "of new items to the database.\n"
+            "It is used by the ERROR_MESSAGES_TO config setting.\n"
+            "If no domain is specified then the config item\n"
+            "mail -> domain is added."),
+        (Option, "email_from_tag", "",
+            "Additional text to include in the \"name\" part\n"
+            "of the From: address used in nosy messages.\n"
+            "If the sending user is \"Foo Bar\", the From: line\n"
+            "is usually: \"Foo Bar\" <issue_tracker@tracker.example>\n"
+            "the EMAIL_FROM_TAG goes inside the \"Foo Bar\" quotes like so:\n"
+            "\"Foo Bar EMAIL_FROM_TAG\" <issue_tracker@tracker.example>"),
+        (Option, "new_web_user_roles", "User",
+            "Roles that a user gets when they register"
+            " with Web User Interface.\n"
+            "This is a comma-separated string of role names"
+            " (e.g. 'Admin,User')."),
+        (Option, "new_email_user_roles", "User",
+            "Roles that a user gets when they register"
+            " with Email Gateway.\n"
+            "This is a comma-separated string of role names"
+            " (e.g. 'Admin,User')."),
+        (Option, "error_messages_to", "user",
+            # XXX This description needs better wording,
+            #   with explicit allowed values list.
+            "Send error message emails to the dispatcher, user, or both?\n"
+            "The dispatcher is configured using the DISPATCHER_EMAIL"
+            " setting."),
+        (Option, "html_version", "html4",
+            "HTML version to generate. The templates are html4 by default.\n"
+            "If you wish to make them xhtml, then you'll need to change this\n"
+            "var to 'xhtml' too so all auto-generated HTML is compliant.\n"
+            "Allowed values: html4, xhtml"),
+        (TimezoneOption, "timezone", "UTC", "Default timezone offset,"
+            " applied when user's timezone is not set.",
+            ["DEFAULT_TIMEZONE"]),
+        (BooleanOption, "instant_registration", "no",
+            "Register new users instantly, or require confirmation via\n"
+            "email?"),
+        (BooleanOption, "email_registration_confirmation", "yes",
+            "Offer registration confirmation by email or only through the web?"),
+        (WordListOption, "indexer_stopwords", "",
+            "Additional stop-words for the full-text indexer specific to\n"
+            "your tracker. See the indexer source for the default list of\n"
+            "stop-words (eg. A,AND,ARE,AS,AT,BE,BUT,BY, ...)"),
+        (OctalNumberOption, "umask", "02",
+            "Defines the file creation mode mask."),
+    )),
+    ("tracker", (
+        (Option, "name", "Roundup issue tracker",
+            "A descriptive name for your roundup instance."),
+        (Option, "web", NODEFAULT,
+            "The web address that the tracker is viewable at.\n"
+            "This will be included in information"
+            " sent to users of the tracker.\n"
+            "The URL MUST include the cgi-bin part or anything else\n"
+            "that is required to get to the home page of the tracker.\n"
+            "You MUST include a trailing '/' in the URL."),
+        (MailAddressOption, "email", "issue_tracker",
+            "Email address that mail to roundup should go to.\n"
+            "If no domain is specified then mail_domain is added."),
+        (NullableOption, "language", "",
+            "Default locale name for this tracker.\n"
+            "If this option is not set, the language is determined\n"
+            "by OS environment variable LANGUAGE, LC_ALL, LC_MESSAGES,\n"
+            "or LANG, in that order of preference."),
+    )),
+    ("web", (
+        (BooleanOption, 'http_auth', "yes",
+            "Whether to use HTTP Basic Authentication, if present.\n"
+            "Roundup will use either the REMOTE_USER or HTTP_AUTHORIZATION\n"
+            "variables supplied by your web server (in that order).\n"
+            "Set this option to 'no' if you do not wish to use HTTP Basic\n"
+            "Authentication in your web interface."),
+        (BooleanOption, 'use_browser_language', "yes",
+            "Whether to use HTTP Accept-Language, if present.\n"
+            "Browsers send a language-region preference list.\n"
+            "It's usually set in the client's browser or in their\n"
+            "Operating System.\n"
+            "Set this option to 'no' if you want to ignore it."),
+        (BooleanOption, "debug", "no",
+            "Setting this option makes Roundup display error tracebacks\n"
+            "in the user's browser rather than emailing them to the\n"
+            "tracker admin."),
+    )),
+    ("rdbms", (
+        (Option, 'name', 'roundup',
+            "Name of the database to use.",
+            ['MYSQL_DBNAME']),
+        (NullableOption, 'host', 'localhost',
+            "Database server host.",
+            ['MYSQL_DBHOST']),
+        (NullableOption, 'port', '',
+            "TCP port number of the database server.\n"
+            "Postgresql usually resides on port 5432 (if any),\n"
+            "for MySQL default port number is 3306.\n"
+            "Leave this option empty to use backend default"),
+        (NullableOption, 'user', 'roundup',
+            "Database user name that Roundup should use.",
+            ['MYSQL_DBUSER']),
+        (NullableOption, 'password', 'roundup',
+            "Database user password.",
+            ['MYSQL_DBPASSWORD']),
+        (NullableOption, 'read_default_file', '~/.my.cnf',
+            "Name of the MySQL defaults file.\n"
+            "Only used in MySQL connections."),
+        (NullableOption, 'read_default_group', 'roundup',
+            "Name of the group to use in the MySQL defaults file (.my.cnf).\n"
+            "Only used in MySQL connections."),
+    ), "Settings in this section are used"
+        " by Postgresql and MySQL backends only"
+    ),
+    ("logging", (
+        (FilePathOption, "config", "",
+            "Path to configuration file for standard Python logging module.\n"
+            "If this option is set, logging configuration is loaded\n"
+            "from specified file; options 'filename' and 'level'\n"
+            "in this section are ignored."),
+        (FilePathOption, "filename", "",
+            "Log file name for minimal logging facility built into Roundup.\n"
+            "If no file name specified, log messages are written on stderr.\n"
+            "If above 'config' option is set, this option has no effect."),
+        (Option, "level", "ERROR",
+            "Minimal severity level of messages written to log file.\n"
+            "If above 'config' option is set, this option has no effect.\n"
+            "Allowed values: DEBUG, INFO, WARNING, ERROR"),
+    )),
+    ("mail", (
+        (Option, "domain", NODEFAULT,
+            "The email domain that admin_email, issue_tracker and\n"
+            "dispatcher_email belong to.\n"
+            "This domain is added to those config items if they don't\n"
+            "explicitly include a domain.\n"
+            "Do not include the '@' symbol."),
+        (Option, "host", NODEFAULT,
+            "SMTP mail host that roundup will use to send mail",
+            ["MAILHOST"],),
+        (Option, "username", "", "SMTP login name.\n"
+            "Set this if your mail host requires authenticated access.\n"
+            "If username is not empty, password (below) MUST be set!"),
+        (Option, "password", NODEFAULT, "SMTP login password.\n"
+            "Set this if your mail host requires authenticated access."),
+        (IntegerNumberOption, "port", smtplib.SMTP_PORT,
+            "Default port to send SMTP on.\n"
+            "Set this if your mail server runs on a different port."),
+        (NullableOption, "local_hostname", '',
+            "The local hostname to use during SMTP transmission.\n"
+            "Set this if your mail server requires something specific."),
+        (BooleanOption, "tls", "no",
+            "If your SMTP mail host provides or requires TLS\n"
+            "(Transport Layer Security) then set this option to 'yes'."),
+        (NullableFilePathOption, "tls_keyfile", "",
+            "If TLS is used, you may set this option to the name\n"
+            "of a PEM formatted file that contains your private key."),
+        (NullableFilePathOption, "tls_certfile", "",
+            "If TLS is used, you may set this option to the name\n"
+            "of a PEM formatted certificate chain file."),
+        (Option, "charset", "utf-8",
+            "Character set to encode email headers with.\n"
+            "We use utf-8 by default, as it's the most flexible.\n"
+            "Some mail readers (eg. Eudora) can't cope with that,\n"
+            "so you might need to specify a more limited character set\n"
+            "(eg. iso-8859-1).",
+            ["EMAIL_CHARSET"]),
+        (FilePathOption, "debug", "",
+            "Setting this option makes Roundup to write all outgoing email\n"
+            "messages to this file *instead* of sending them.\n"
+            "This option has the same effect as environment variable"
+            " SENDMAILDEBUG.\nEnvironment variable takes precedence."),
+        (BooleanOption, "add_authorinfo", "yes",
+            "Add a line with author information at top of all messages\n"
+            "sent by roundup"),
+        (BooleanOption, "add_authoremail", "yes",
+            "Add the mail address of the author to the author information at\n"
+            "the top of all messages.\n"
+            "If this is false but add_authorinfo is true, only the name\n"
+            "of the actor is added which protects the mail address of the\n"
+            "actor from being exposed at mail archives, etc."),
+    ), "Outgoing email options.\nUsed for nozy messages and approval requests"),
+    ("mailgw", (
+        (BooleanOption, "keep_quoted_text", "yes",
+            "Keep email citations when accepting messages.\n"
+            "Setting this to \"no\" strips out \"quoted\" text"
+            " from the message.\n"
+            "Signatures are also stripped.",
+            ["EMAIL_KEEP_QUOTED_TEXT"]),
+        (BooleanOption, "leave_body_unchanged", "no",
+            "Preserve the email body as is - that is,\n"
+            "keep the citations _and_ signatures.",
+            ["EMAIL_LEAVE_BODY_UNCHANGED"]),
+        (Option, "default_class", "issue",
+            "Default class to use in the mailgw\n"
+            "if one isn't supplied in email subjects.\n"
+            "To disable, leave the value blank.",
+            ["MAIL_DEFAULT_CLASS"]),
+        (NullableOption, "language", "",
+            "Default locale name for the tracker mail gateway.\n"
+            "If this option is not set, mail gateway will use\n"
+            "the language of the tracker instance."),
+        (Option, "subject_prefix_parsing", "strict",
+            "Controls the parsing of the [prefix] on subject\n"
+            "lines in incoming emails. \"strict\" will return an\n"
+            "error to the sender if the [prefix] is not recognised.\n"
+            "\"loose\" will attempt to parse the [prefix] but just\n"
+            "pass it through as part of the issue title if not\n"
+            "recognised. \"none\" will always pass any [prefix]\n"
+            "through as part of the issue title."),
+        (Option, "subject_suffix_parsing", "strict",
+            "Controls the parsing of the [suffix] on subject\n"
+            "lines in incoming emails. \"strict\" will return an\n"
+            "error to the sender if the [suffix] is not recognised.\n"
+            "\"loose\" will attempt to parse the [suffix] but just\n"
+            "pass it through as part of the issue title if not\n"
+            "recognised. \"none\" will always pass any [suffix]\n"
+            "through as part of the issue title."),
+        (Option, "subject_suffix_delimiters", "[]",
+            "Defines the brackets used for delimiting the prefix and \n"
+            'suffix in a subject line. The presence of "suffix" in\n'
+            "the config option name is a historical artifact and may\n"
+            "be ignored."),
+        (Option, "subject_content_match", "always",
+            "Controls matching of the incoming email subject line\n"
+            "against issue titles in the case where there is no\n"
+            "designator [prefix]. \"never\" turns off matching.\n"
+            "\"creation + interval\" or \"activity + interval\"\n"
+            "will match an issue for the interval after the issue's\n"
+            "creation or last activity. The interval is a standard\n"
+            "Roundup interval."),
+        (RegExpOption, "refwd_re", "(\s*\W?\s*(fw|fwd|re|aw|sv|ang)\W)+",
+            "Regular expression matching a single reply or forward\n"
+            "prefix prepended by the mailer. This is explicitly\n"
+            "stripped from the subject during parsing."),
+        (RegExpOption, "origmsg_re",
+            "^[>|\s]*-----\s?Original Message\s?-----$",
+            "Regular expression matching start of an original message\n"
+            "if quoted the in body."),
+        (RegExpOption, "sign_re", "^[>|\s]*-- ?$",
+            "Regular expression matching the start of a signature\n"
+            "in the message body."),
+        (RegExpOption, "eol_re", r"[\r\n]+",
+            "Regular expression matching end of line."),
+        (RegExpOption, "blankline_re", r"[\r\n]+\s*[\r\n]+",
+            "Regular expression matching a blank line."),
+        (BooleanOption, "ignore_alternatives", "no",
+            "When parsing incoming mails, roundup uses the first\n"
+            "text/plain part it finds. If this part is inside a\n"
+            "multipart/alternative, and this option is set, all other\n"
+            "parts of the multipart/alternative are ignored. The default\n"
+            "is to keep all parts and attach them to the issue."),
+    ), "Roundup Mail Gateway options"),
+    ("pgp", (
+        (BooleanOption, "enable", "no",
+            "Enable PGP processing. Requires pyme."),
+        (NullableOption, "roles", "",
+            "If specified, a comma-separated list of roles to perform\n"
+            "PGP processing on. If not specified, it happens for all\n"
+            "users."),
+        (NullableOption, "homedir", "",
+            "Location of PGP directory. Defaults to $HOME/.gnupg if\n"
+            "not specified."),
+    ), "OpenPGP mail processing options"),
+    ("nosy", (
+        (RunDetectorOption, "messages_to_author", "no",
+            "Send nosy messages to the author of the message.",
+            ["MESSAGES_TO_AUTHOR"]),
+        (Option, "signature_position", "bottom",
+            "Where to place the email signature.\n"
+            "Allowed values: top, bottom, none",
+            ["EMAIL_SIGNATURE_POSITION"]),
+        (RunDetectorOption, "add_author", "new",
+            "Does the author of a message get placed on the nosy list\n"
+            "automatically?  If 'new' is used, then the author will\n"
+            "only be added when a message creates a new issue.\n"
+            "If 'yes', then the author will be added on followups too.\n"
+            "If 'no', they're never added to the nosy.\n",
+            ["ADD_AUTHOR_TO_NOSY"]),
+        (RunDetectorOption, "add_recipients", "new",
+            "Do the recipients (To:, Cc:) of a message get placed on the\n"
+            "nosy list?  If 'new' is used, then the recipients will\n"
+            "only be added when a message creates a new issue.\n"
+            "If 'yes', then the recipients will be added on followups too.\n"
+            "If 'no', they're never added to the nosy.\n",
+            ["ADD_RECIPIENTS_TO_NOSY"]),
+        (Option, "email_sending", "single",
+            "Controls the email sending from the nosy reactor. If\n"
+            "\"multiple\" then a separate email is sent to each\n"
+            "recipient. If \"single\" then a single email is sent with\n"
+            "each recipient as a CC address."),
+        (IntegerNumberOption, "max_attachment_size", sys.maxint,
+            "Attachments larger than the given number of bytes\n"
+            "won't be attached to nosy mails. They will be replaced by\n"
+            "a link to the tracker's download page for the file.")
+    ), "Nosy messages sending"),
+)
+
+### Configuration classes
+
+class Config:
+
+    """Base class for configuration objects.
+
+    Configuration options may be accessed as attributes or items
+    of instances of this class.  All option names are uppercased.
+
+    """
+
+    # Config file name
+    INI_FILE = "config.ini"
+
+    # Object attributes that should not be taken as common configuration
+    # options in __setattr__ (most of them are initialized in constructor):
+    # builtin pseudo-option - package home directory
+    HOME = "."
+    # names of .ini file sections, in order
+    sections = None
+    # section comments
+    section_descriptions = None
+    # lists of option names for each section, in order
+    section_options = None
+    # mapping from option names and aliases to Option instances
+    options = None
+    # actual name of the config file.  set on load.
+    filepath = os.path.join(HOME, INI_FILE)
+
+    def __init__(self, config_path=None, layout=None, settings={}):
+        """Initialize confing instance
+
+        Parameters:
+            config_path:
+                optional directory or file name of the config file.
+                If passed, load the config after processing layout (if any).
+                If config_path is a directory name, use default base name
+                of the config file.
+            layout:
+                optional configuration layout, a sequence of
+                section definitions suitable for .add_section()
+            settings:
+                optional setting overrides (dictionary).
+                The overrides are applied after loading config file.
+
+        """
+        # initialize option containers:
+        self.sections = []
+        self.section_descriptions = {}
+        self.section_options = {}
+        self.options = {}
+        # add options from the layout structure
+        if layout:
+            for section in layout:
+                self.add_section(*section)
+        if config_path is not None:
+            self.load(config_path)
+        for (name, value) in settings.items():
+            self[name.upper()] = value
+
+    def add_section(self, section, options, description=None):
+        """Define new config section
+
+        Parameters:
+            section - name of the config.ini section
+            options - a sequence of Option definitions.
+                Each Option definition is a sequence
+                containing class object and constructor
+                parameters, starting from the setting name:
+                setting, default, [description, [aliases]]
+            description - optional section comment
+
+        Note: aliases should only exist in historical options
+        for backwards compatibility - new options should
+        *not* have aliases!
+
+        """
+        if description or not self.section_descriptions.has_key(section):
+            self.section_descriptions[section] = description
+        for option_def in options:
+            klass = option_def[0]
+            args = option_def[1:]
+            option = klass(self, section, *args)
+            self.add_option(option)
+
+    def add_option(self, option):
+        """Adopt a new Option object"""
+        _section = option.section
+        _name = option.setting
+        if _section not in self.sections:
+            self.sections.append(_section)
+        _options = self._get_section_options(_section)
+        if _name not in _options:
+            _options.append(_name)
+        # (section, name) key is used for writing .ini file
+        self.options[(_section, _name)] = option
+        # make the option known under all of it's A.K.A.s
+        for _name in option.aliases:
+            self.options[_name] = option
+
+    def update_option(self, name, klass,
+        default=NODEFAULT, description=None
+    ):
+        """Override behaviour of early created option.
+
+        Parameters:
+            name:
+                option name
+            klass:
+                one of the Option classes
+            default:
+                optional default value for the option
+            description:
+                optional new description for the option
+
+        Conversion from current option value to new class value
+        is done via string representation.
+
+        This method may be used to attach some brains
+        to options autocreated by UserConfig.
+
+        """
+        # fetch current option
+        option = self._get_option(name)
+        # compute constructor parameters
+        if default is NODEFAULT:
+            default = option.default
+        if description is None:
+            description = option.description
+        value = option.value2str(current=1)
+        # resurrect the option
+        option = klass(self, option.section, option.setting,
+            default=default, description=description)
+        # apply the value
+        option.set(value)
+        # incorporate new option
+        del self[name]
+        self.add_option(option)
+
+    def reset(self):
+        """Set all options to their default values"""
+        for _option in self.items():
+            _option.reset()
+
+    # Meant for commandline tools.
+    # Allows automatic creation of configuration files like this:
+    #  roundup-server -p 8017 -u roundup --save-config
+    def getopt(self, args, short_options="", long_options=(),
+        config_load_options=("C", "config"), **options
+    ):
+        """Apply options specified in command line arguments.
+
+        Parameters:
+            args:
+                command line to parse (sys.argv[1:])
+            short_options:
+                optional string of letters for command line options
+                that are not config options
+            long_options:
+                optional list of names for long options
+                that are not config options
+            config_load_options:
+                two-element sequence (letter, long_option) defining
+                the options for config file.  If unset, don't load
+                config file; otherwise config file is read prior
+                to applying other options.  Short option letter
+                must not have a colon and long_option name must
+                not have an equal sign or '--' prefix.
+            options:
+                mapping from option names to command line option specs.
+                e.g. server_port="p:", server_user="u:"
+                Names are forced to lower case for commandline parsing
+                (long options) and to upper case to find config options.
+                Command line options accepting no value are assumed
+                to be binary and receive value 'yes'.
+
+        Return value: same as for python standard getopt(), except that
+        processed options are removed from returned option list.
+
+        """
+        # take a copy of long_options
+        long_options = list(long_options)
+        # build option lists
+        cfg_names = {}
+        booleans = []
+        for (name, letter) in options.items():
+            cfg_name = name.upper()
+            short_opt = "-" + letter[0]
+            name = name.lower().replace("_", "-")
+            cfg_names.update({short_opt: cfg_name, "--" + name: cfg_name})
+
+            short_options += letter
+            if letter[-1] == ":":
+                long_options.append(name + "=")
+            else:
+                booleans.append(short_opt)
+                long_options.append(name)
+
+        if config_load_options:
+            short_options += config_load_options[0] + ":"
+            long_options.append(config_load_options[1] + "=")
+            # compute names that will be searched in getopt return value
+            config_load_options = (
+                "-" + config_load_options[0],
+                "--" + config_load_options[1],
+            )
+        # parse command line arguments
+        optlist, args = getopt.getopt(args, short_options, long_options)
+        # load config file if requested
+        if config_load_options:
+            for option in optlist:
+                if option[0] in config_load_options:
+                    self.load_ini(option[1])
+                    optlist.remove(option)
+                    break
+        # apply options
+        extra_options = []
+        for (opt, arg) in optlist:
+            if (opt in booleans): # and not arg
+                arg = "yes"
+            try:
+                name = cfg_names[opt]
+            except KeyError:
+                extra_options.append((opt, arg))
+            else:
+                self[name] = arg
+        return (extra_options, args)
+
+    # option and section locators (used in option access methods)
+
+    def _get_option(self, name):
+        try:
+            return self.options[name]
+        except KeyError:
+            raise InvalidOptionError(name)
+
+    def _get_section_options(self, name):
+        return self.section_options.setdefault(name, [])
+
+    def _get_unset_options(self):
+        """Return options that need manual adjustments
+
+        Return value is a dictionary where keys are section
+        names and values are lists of option names as they
+        appear in the config file.
+
+        """
+        need_set = {}
+        for option in self.items():
+            if not option.isset():
+                need_set.setdefault(option.section, []).append(option.setting)
+        return need_set
+
+    def _adjust_options(self, config):
+        """Load ad-hoc option definitions from ConfigParser instance."""
+        pass
+
+    def _get_name(self):
+        """Return the service name for config file heading"""
+        return ""
+
+    # file operations
+
+    def load_ini(self, config_path, defaults=None):
+        """Set options from config.ini file in given home_dir
+
+        Parameters:
+            config_path:
+                directory or file name of the config file.
+                If config_path is a directory name, use default
+                base name of the config file
+            defaults:
+                optional dictionary of defaults for ConfigParser
+
+        Note: if home_dir does not contain config.ini file,
+        no error is raised.  Config will be reset to defaults.
+
+        """
+        if os.path.isdir(config_path):
+            home_dir = config_path
+            config_path = os.path.join(config_path, self.INI_FILE)
+        else:
+            home_dir = os.path.dirname(config_path)
+        # parse the file
+        config_defaults = {"HOME": home_dir}
+        if defaults:
+            config_defaults.update(defaults)
+        config = ConfigParser.ConfigParser(config_defaults)
+        config.read([config_path])
+        # .ini file loaded ok.
+        self.HOME = home_dir
+        self.filepath = config_path
+        self._adjust_options(config)
+        # set the options, starting from HOME
+        self.reset()
+        for option in self.items():
+            option.load_ini(config)
+
+    def load(self, home_dir):
+        """Load configuration settings from home_dir"""
+        self.load_ini(home_dir)
+
+    def save(self, ini_file=None):
+        """Write current configuration to .ini file
+
+        'ini_file' argument, if passed, must be valid full path
+        to the file to write.  If omitted, default file in current
+        HOME is created.
+
+        If the file to write already exists, it is saved with '.bak'
+        extension.
+
+        """
+        if ini_file is None:
+            ini_file = self.filepath
+        _tmp_file = os.path.splitext(ini_file)[0]
+        _bak_file = _tmp_file + ".bak"
+        _tmp_file = _tmp_file + ".tmp"
+        _fp = file(_tmp_file, "wt")
+        _fp.write("# %s configuration file\n" % self._get_name())
+        _fp.write("# Autogenerated at %s\n" % time.asctime())
+        need_set = self._get_unset_options()
+        if need_set:
+            _fp.write("\n# WARNING! Following options need adjustments:\n")
+            for section, options in need_set.items():
+                _fp.write("#  [%s]: %s\n" % (section, ", ".join(options)))
+        for section in self.sections:
+            comment = self.section_descriptions.get(section, None)
+            if comment:
+                _fp.write("\n# ".join([""] + comment.split("\n")) +"\n")
+            else:
+                # no section comment - just leave a blank line between sections
+                _fp.write("\n")
+            _fp.write("[%s]\n" % section)
+            for option in self._get_section_options(section):
+                _fp.write("\n" + self.options[(section, option)].format())
+        _fp.close()
+        if os.access(ini_file, os.F_OK):
+            if os.access(_bak_file, os.F_OK):
+                os.remove(_bak_file)
+            os.rename(ini_file, _bak_file)
+        os.rename(_tmp_file, ini_file)
+
+    # container emulation
+
+    def __len__(self):
+        return len(self.items())
+
+    def __getitem__(self, name):
+        if name == "HOME":
+            return self.HOME
+        else:
+            return self._get_option(name).get()
+
+    def __setitem__(self, name, value):
+        if name == "HOME":
+            self.HOME = value
+        else:
+            self._get_option(name).set(value)
+
+    def __delitem__(self, name):
+        _option = self._get_option(name)
+        _section = _option.section
+        _name = _option.setting
+        self._get_section_options(_section).remove(_name)
+        del self.options[(_section, _name)]
+        for _alias in _option.aliases:
+            del self.options[_alias]
+
+    def items(self):
+        """Return the list of Option objects, in .ini file order
+
+        Note that HOME is not included in this list
+        because it is builtin pseudo-option, not a real Option
+        object loaded from or saved to .ini file.
+
+        """
+        return [self.options[(_section, _name)]
+            for _section in self.sections
+            for _name in self._get_section_options(_section)
+        ]
+
+    def keys(self):
+        """Return the list of "canonical" names of the options
+
+        Unlike .items(), this list also includes HOME
+
+        """
+        return ["HOME"] + [_option.name for _option in self.items()]
+
+    # .values() is not implemented because i am not sure what should be
+    # the values returned from this method: Option instances or config values?
+
+    # attribute emulation
+
+    def __setattr__(self, name, value):
+        if self.__dict__.has_key(name) or hasattr(self.__class__, name):
+            self.__dict__[name] = value
+        else:
+            self._get_option(name).set(value)
+
+    # Note: __getattr__ is not symmetric to __setattr__:
+    #   self.__dict__ lookup is done before calling this method
+    def __getattr__(self, name):
+        return self[name]
+
+class UserConfig(Config):
+
+    """Configuration for user extensions.
+
+    Instances of this class have no predefined configuration layout.
+    Options are created on the fly for each setting present in the
+    config file.
+
+    """
+
+    def _adjust_options(self, config):
+        # config defaults appear in all sections.
+        # we'll need to filter them out.
+        defaults = config.defaults().keys()
+        # see what options are already defined and add missing ones
+        preset = [(option.section, option.setting) for option in self.items()]
+        for section in config.sections():
+            for name in config.options(section):
+                if ((section, name) not in preset) \
+                and (name not in defaults):
+                    self.add_option(Option(self, section, name))
+
+class CoreConfig(Config):
+
+    """Roundup instance configuration.
+
+    Core config has a predefined layout (see the SETTINGS structure),
+    supports loading of old-style pythonic configurations and holds
+    three additional attributes:
+        logging:
+            instance logging engine, from standard python logging module
+            or minimalistic logger implemented in Roundup
+        detectors:
+            user-defined configuration for detectors
+        ext:
+            user-defined configuration for extensions
+
+    """
+
+    # module name for old style configuration
+    PYCONFIG = "config"
+    # user configs
+    ext = None
+    detectors = None
+
+    def __init__(self, home_dir=None, settings={}):
+        Config.__init__(self, home_dir, layout=SETTINGS, settings=settings)
+        # load the config if home_dir given
+        if home_dir is None:
+            self.init_logging()
+
+    def _get_unset_options(self):
+        need_set = Config._get_unset_options(self)
+        # remove MAIL_PASSWORD if MAIL_USER is empty
+        if "password" in need_set.get("mail", []):
+            if not self["MAIL_USERNAME"]:
+                settings = need_set["mail"]
+                settings.remove("password")
+                if not settings:
+                    del need_set["mail"]
+        return need_set
+
+    def _get_name(self):
+        return self["TRACKER_NAME"]
+
+    def reset(self):
+        Config.reset(self)
+        if self.ext:
+            self.ext.reset()
+        if self.detectors:
+            self.detectors.reset()
+        self.init_logging()
+
+    def init_logging(self):
+        _file = self["LOGGING_CONFIG"]
+        if _file and os.path.isfile(_file):
+            logging.config.fileConfig(_file)
+            return
+
+        _file = self["LOGGING_FILENAME"]
+        # set file & level on the root logger
+        logger = logging.getLogger()
+        if _file:
+            hdlr = logging.FileHandler(_file)
+        else:
+            hdlr = logging.StreamHandler(sys.stdout)
+        formatter = logging.Formatter(
+            '%(asctime)s %(levelname)s %(message)s')
+        hdlr.setFormatter(formatter)
+        # no logging API to remove all existing handlers!?!
+        logger.handlers = [hdlr]
+        logger.setLevel(logging._levelNames[self["LOGGING_LEVEL"] or "ERROR"])
+
+    def load(self, home_dir):
+        """Load configuration from path designated by home_dir argument"""
+        if os.path.isfile(os.path.join(home_dir, self.INI_FILE)):
+            self.load_ini(home_dir)
+        else:
+            self.load_pyconfig(home_dir)
+        self.init_logging()
+        self.ext = UserConfig(os.path.join(home_dir, "extensions"))
+        self.detectors = UserConfig(os.path.join(home_dir, "detectors"))
+
+    def load_ini(self, home_dir, defaults=None):
+        """Set options from config.ini file in given home_dir directory"""
+        config_defaults = {"TRACKER_HOME": home_dir}
+        if defaults:
+            config_defaults.update(defaults)
+        Config.load_ini(self, home_dir, config_defaults)
+
+    def load_pyconfig(self, home_dir):
+        """Set options from config.py file in given home_dir directory"""
+        # try to locate and import the module
+        _mod_fp = None
+        try:
+            try:
+                _module = imp.find_module(self.PYCONFIG, [home_dir])
+                _mod_fp = _module[0]
+                _config = imp.load_module(self.PYCONFIG, *_module)
+            except ImportError:
+                raise NoConfigError(home_dir)
+        finally:
+            if _mod_fp is not None:
+                _mod_fp.close()
+        # module loaded ok.  set the options, starting from HOME
+        self.reset()
+        self.HOME = home_dir
+        for _option in self.items():
+            _option.load_pyconfig(_config)
+        # backward compatibility:
+        # SMTP login parameters were specified as a tuple in old style configs
+        # convert them to new plain string options
+        _mailuser = getattr(_config, "MAILUSER", ())
+        if len(_mailuser) > 0:
+            self.MAIL_USERNAME = _mailuser[0]
+        if len(_mailuser) > 1:
+            self.MAIL_PASSWORD = _mailuser[1]
+
+    # in this config, HOME is also known as TRACKER_HOME
+    def __getitem__(self, name):
+        if name == "TRACKER_HOME":
+            return self.HOME
+        else:
+            return Config.__getitem__(self, name)
+
+    def __setitem__(self, name, value):
+        if name == "TRACKER_HOME":
+            self.HOME = value
+        else:
+            self._get_option(name).set(value)
+
+    def __setattr__(self, name, value):
+        if name == "TRACKER_HOME":
+            self.__dict__["HOME"] = value
+        else:
+            Config.__setattr__(self, name, value)
+
+# vim: set et sts=4 sw=4 :