view roundup/configuration.py @ 2618:8b08558e30a0

Roundup Issue Tracker configuration support
author Alexander Smishlajev <a1s@users.sourceforge.net>
date Sun, 25 Jul 2004 11:29:40 +0000
parents
children 4b4ca3bd086b
line wrap: on
line source

# Roundup Issue Tracker configuration support
#
# $Id: configuration.py,v 1.1 2004-07-25 11:29:40 a1s Exp $
#
__docformat__ = "restructuredtext"

import imp
import os
import time
import ConfigParser

# XXX i don't think this module needs string translation, does it?

### Exceptions

class ConfigurationError(Exception):

    # without this, pychecker complains about missing class attribute...
    args = ()

class NoConfigError(ConfigurationError):

    """Raised when configuration loading fails

    Constructor parameters: path to the directory that was used as TRACKER_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 AttrributeError
    (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)
        # value is private.  use get() and set() to access
        self._value = default

    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 = value

    def reset(self):
        """Reset the value to default"""
        self._value = self.default

    def isdefault(self):
        """Return True if current value is the default one"""
        return self._value == self.default

    def isset(self):
        """Return True if the value is avaliable (either set or default)"""
        return self._value != NODEFAULT

    def str(self, default=0):
        """Return string representation of the value

        If 'default' argument is set, format the default value.
        Otherwise format current value.

        """
        if default:
            return str(self.default)
        else:
            return str(self._value)

    def __str__(self):
        return self.str()

    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.str(default=1),
            "value": str(self),
        }

    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.str(default=1),
                "name": self.setting,
                "value": str(self),
                "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 str(self, default=0):
        if default:
            _val = self.default
        else:
            _val = self._value
        if _val:
            return "yes"
        else:
            return "no"

    def set(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
        Option.set(self, _val)

class RunDetectorOption(Option):

    """When a detector is run: always, never or for new items only"""

    class_description = "Allowed values: yes, no, new"

    def set(self, value):
        _val = value.lower()
        if _val in ("yes", "no", "new"):
            Option.set(self, _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 TRACKER_HOME.

    """

    def get(self):
        _val = Option.get(self)
        if not os.path.isabs(_val):
            _val = os.path.join(self.config["TRACKER_HOME"], _val)
        return _val

class FloatNumberOption(Option):

    """Floating point numbers"""

    def set(self, value):
        try:
            _val = float(value)
        except ValueError:
            raise OptionValueError(self, value,
                "Floating point number required")
        else:
            Option.set(self, _val)

class IntegerNumberOption(Option):

    """Integer numbers"""

    def set(self, value):
        try:
            _val = int(value)
        except ValueError:
            raise OptionValueError(self, value, "Integer number required")
        else:
            Option.set(self, _val)

### 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]]
SETTINGS = (
    ("main", (
        (FilePathOption, "database", "db", "Database directory path"),
        (FilePathOption, "templates", "html",
            "Path to the HTML templates directory"),
        (MailAddressOption, "admin_email", "roundup-admin",
            "Email address that roundup will complain to"
            " if it runs into trouble"),
        (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."),
        (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"),
        # It seems to me that all timezone offsets in the modern world
        # are integral hours.  However, there were fractional hour offsets
        # in the past.  Use float number for sure.
        (FloatNumberOption, "timezone", "0",
            "Numeric timezone offset used when users do not choose their own\n"
            "in their settings.",
            ["DEFAULT_TIMEZONE"]),
    )),
    ("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"),
    )),
    # XXX This section covers two service areas:
    #   outgoing mail (domain, smtp parameters)
    #   and creation of issues from incoming mail.
    #   These things should be separated.
    #   In addition, 'charset' option is used in nosy messages only,
    #   so this option actually belongs to the 'nosy' section.
    ("mail", (
        (Option, "domain", NODEFAULT, "Domain name used for email addresses"),
        (Option, "host", NODEFAULT,
            "SMTP mail host that roundup will use to send mail"),
        (Option, "username", NODEFAULT, "SMTP login name\n"
            "Set this if your mail host requires authenticated access"),
        (Option, "password", NODEFAULT, "SMTP login password\n"
            "Set this if your mail host requires authenticated access"),
        (BooleanOption, "tls", "no",
            "If your SMTP mail host provides or requires TLS\n"
            "(Transport Layer Security) then set this option to 'yes'"),
        (FilePathOption, "tls_keyfile", NODEFAULT,
            "If TLS is used, you may set this option to the name\n"
            "of a PEM formatted file that contains your private key"),
        (FilePathOption, "tls_certfile", NODEFAULT,
            "If TLS is used, you may set this option to the name\n"
            "of a PEM formatted certificate chain file"),
        (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."),
        (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"]),
    )),
    ("nosy", (
        (BooleanOption, "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"]),
    )),
)

### Main class

class Config:

    """Roundup instance configuration.

    Configuration options may be accessed as attributes or items
    of instances of this class.  All option names are uppercased.

    """

    # Config file names (in the TRACKER_HOME directory):
    INI_FILE = "config.ini" # new style config file name
    PYCONFIG = "config"     # module name for old style configuration

    # Object attributes that should not be taken as common configuration
    # options in __setattr__ (most of them are initialized in constructor):
    #   builtin pseudo-option - tracker home directory
    TRACKER_HOME = "."
    # names of .ini file sections, in order
    sections = None
    # lists of option names for each section, in order
    section_options = None
    # mapping from option names and aliases to Option instances
    options = None

    def __init__(self, tracker_home=None):
        # initialize option containers:
        self.sections = []
        self.section_options = {}
        self.options = {}
        # add options from the SETTINGS structure
        for (_section, _options) in SETTINGS:
            for _option_def in _options:
                _class = _option_def[0]
                _args = _option_def[1:]
                _option = _class(self, _section, *_args)
                self.add_option(_option)
        # load the config if tracker_home given
        if tracker_home is not None:
            self.load(tracker_home)

    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 reset(self):
        """Set all options to their default values"""
        for _option in self.items():
            _option.reset()

    # 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, [])

    # file operations

    def load(self, tracker_home):
        """Load configuration from path designated by tracker_home argument"""
        if os.path.isfile(os.path.join(tracker_home, self.INI_FILE)):
            self.load_ini(tracker_home)
        else:
            self.load_pyconfig(tracker_home)

    def load_ini(self, tracker_home):
        """Set options from config.ini file in given tracker_home directory"""
        # parse the file
        _config = ConfigParser.ConfigParser()
        _config.read([os.path.join(tracker_home, self.INI_FILE)])
        # .ini file loaded ok.  set the options, starting from TRACKER_HOME
        self.reset()
        self.TRACKER_HOME = tracker_home
        for _option in self.items():
            _option.load_ini(_config)

    def load_pyconfig(self, tracker_home):
        """Set options from config.py file in given tracker_home directory"""
        # try to locate and import the module
        _mod_fp = None
        try:
            try:
                _module = imp.find_module(self.PYCONFIG, [tracker_home])
                _mod_fp = _module[0]
                _config = imp.load_module(self.PYCONFIG, *_module)
            except ImportError:
                raise NoConfigError(tracker_home)
        finally:
            if _mod_fp is not None:
                _mod_fp.close()
        # module loaded ok.  set the options, starting from TRACKER_HOME
        self.reset()
        self.TRACKER_HOME = tracker_home
        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]

    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
        TRACKER_HOME is created.

        If the file to write already exists, it is saved with '.bak'
        extension.

        """
        if ini_file is None:
            ini_file = os.path.join(self.TRACKER_HOME, self.INI_FILE)
        _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["TRACKER_NAME"])
        _fp.write("# Autogenerated at %s\n" % time.asctime())
        for _section in self.sections:
            _fp.write("\n[%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 == "TRACKER_HOME":
            return self.TRACKER_HOME
        else:
            return self._get_option(name).get()

    def __setitem__(self, name, value):
        if name == "TRACKER_HOME":
            self.TRACKER_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 TRACKER_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 TRACKER_HOME

        """
        return ["TRACKER_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 self.__class__.__dict__.has_key(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
    __getattr__ = __getitem__

# vim: set et sts=4 sw=4 :

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