"Fossies" - the Fresh Open Source Software Archive

Member "roundup-2.0.0/roundup/configuration.py" (29 Jun 2020, 78023 Bytes) of package /linux/www/roundup-2.0.0.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. See also the latest Fossies "Diffs" side-by-side code changes report for "configuration.py": 1.6.1_vs_2.0.0.

    1 # Roundup Issue Tracker configuration support
    2 #
    3 __docformat__ = "restructuredtext"
    4 
    5 # Some systems have a backport of the Python 3 configparser module to
    6 # Python 2: <https://pypi.org/project/configparser/>.  That breaks
    7 # Roundup if used with Python 2 because it generates unicode objects
    8 # where not expected by the Python code.  Thus, a version check is
    9 # used here instead of try/except.
   10 import sys
   11 import getopt
   12 import imp
   13 import logging, logging.config
   14 import os
   15 import re
   16 import time
   17 import smtplib
   18 
   19 import roundup.date
   20 
   21 from roundup.anypy.strings import b2s
   22 import roundup.anypy.random_ as random_
   23 import binascii
   24 
   25 from roundup.backends import list_backends
   26 
   27 if sys.version_info[0] > 2:
   28     import configparser          # Python 3
   29 else:
   30     import ConfigParser as configparser  # Python 2
   31 
   32 from roundup.exceptions import RoundupException
   33 
   34 # XXX i don't think this module needs string translation, does it?
   35 
   36 ### Exceptions
   37 
   38 
   39 class ConfigurationError(RoundupException):
   40     pass
   41 
   42 
   43 class NoConfigError(ConfigurationError):
   44 
   45     """Raised when configuration loading fails
   46 
   47     Constructor parameters: path to the directory that was used as HOME
   48 
   49     """
   50 
   51     def __str__(self):
   52         return "No valid configuration files found in directory %s" \
   53             % self.args[0]
   54 
   55 
   56 class InvalidOptionError(ConfigurationError, KeyError, AttributeError):
   57 
   58     """Attempted access to non-existing configuration option
   59 
   60     Configuration options may be accessed as configuration object
   61     attributes or items.  So this exception instances also are
   62     instances of KeyError (invalid item access) and AttributeError
   63     (invalid attribute access).
   64 
   65     Constructor parameter: option name
   66 
   67     """
   68 
   69     def __str__(self):
   70         return "Unsupported configuration option: %s" % self.args[0]
   71 
   72 
   73 class OptionValueError(ConfigurationError, ValueError):
   74 
   75     """Raised upon attempt to assign an invalid value to config option
   76 
   77     Constructor parameters: Option instance, offending value
   78     and optional info string.
   79 
   80     """
   81 
   82     def __str__(self):
   83         _args = self.args
   84         _rv = "Invalid value for %(option)s: %(value)r" % {
   85             "option": _args[0].name, "value": _args[1]}
   86         if len(_args) > 2:
   87             _rv += "\n".join(("",) + _args[2:])
   88         return _rv
   89 
   90 
   91 class OptionUnsetError(ConfigurationError):
   92 
   93     """Raised when no Option value is available - neither set, nor default
   94 
   95     Constructor parameters: Option instance.
   96 
   97     """
   98 
   99     def __str__(self):
  100         return "%s is not set and has no default" % self.args[0].name
  101 
  102 
  103 class UnsetDefaultValue:
  104 
  105     """Special object meaning that default value for Option is not specified"""
  106 
  107     def __str__(self):
  108         return "NO DEFAULT"
  109 
  110 
  111 NODEFAULT = UnsetDefaultValue()
  112 
  113 
  114 def create_token(size=32):
  115     return b2s(binascii.b2a_base64(random_.token_bytes(size)).strip())
  116 
  117 ### Option classes
  118 
  119 
  120 class Option:
  121 
  122     """Single configuration option.
  123 
  124     Options have following attributes:
  125 
  126         config
  127             reference to the containing Config object
  128         section
  129             name of the section in the tracker .ini file
  130         setting
  131             option name in the tracker .ini file
  132         default
  133             default option value
  134         description
  135             option description.  Makes a comment in the tracker .ini file
  136         name
  137             "canonical name" of the configuration option.
  138             For items in the 'main' section this is uppercased
  139             'setting' name.  For other sections, the name is
  140             composed of the section name and the setting name,
  141             joined with underscore.
  142         aliases
  143             list of "also known as" names.  Used to access the settings
  144             by old names used in previous Roundup versions.
  145             "Canonical name" is also included.
  146 
  147     The name and aliases are forced to be uppercase.
  148     The setting name is forced to lowercase.
  149 
  150     """
  151 
  152     class_description = None
  153 
  154     def __init__(self, config, section, setting,
  155                  default=NODEFAULT, description=None, aliases=None):
  156         self.config = config
  157         self.section = section
  158         self.setting = setting.lower()
  159         self.default = default
  160         self.description = description
  161         self.name = setting.upper()
  162         if section != "main":
  163             self.name = "_".join((section.upper(), self.name))
  164         if aliases:
  165             self.aliases = [alias.upper() for alias in list(aliases)]
  166         else:
  167             self.aliases = []
  168         self.aliases.insert(0, self.name)
  169         # convert default to internal representation
  170         if default is NODEFAULT:
  171             _value = default
  172         else:
  173             _value = self.str2value(default)
  174         # value is private.  use get() and set() to access
  175         self._value = self._default_value = _value
  176 
  177     def str2value(self, value):
  178         """Return 'value' argument converted to internal representation"""
  179         return value
  180 
  181     def _value2str(self, value):
  182         """Return 'value' argument converted to external representation
  183 
  184         This is actual conversion method called only when value
  185         is not NODEFAULT.  Heirs with different conversion rules
  186         override this method, not the public .value2str().
  187 
  188         """
  189         return str(value)
  190 
  191     def value2str(self, value=NODEFAULT, current=0):
  192         """Return 'value' argument converted to external representation
  193 
  194         If 'current' is True, use current option value.
  195 
  196         """
  197         if current:
  198             value = self._value
  199         if value is NODEFAULT:
  200             return str(value)
  201         else:
  202             return self._value2str(value)
  203 
  204     def get(self):
  205         """Return current option value"""
  206         if self._value is NODEFAULT:
  207             raise OptionUnsetError(self)
  208         return self._value
  209 
  210     def set(self, value):
  211         """Update the value"""
  212         self._value = self.str2value(value)
  213 
  214     def reset(self):
  215         """Reset the value to default"""
  216         self._value = self._default_value
  217 
  218     def isdefault(self):
  219         """Return True if current value is the default one"""
  220         return self._value == self._default_value
  221 
  222     def isset(self):
  223         """Return True if the value is available (either set or default)"""
  224         return self._value != NODEFAULT
  225 
  226     def __str__(self):
  227         return self.value2str(self._value)
  228 
  229     def __repr__(self):
  230         if self.isdefault():
  231             _format = "<%(class)s %(name)s (default): %(value)s>"
  232         else:
  233             _format = "<%(class)s %(name)s (default: %(default)s): %(value)s>"
  234         return _format % {
  235             "class": self.__class__.__name__,
  236             "name": self.name,
  237             "default": self.value2str(self._default_value),
  238             "value": self.value2str(self._value),
  239         }
  240 
  241     def format(self):
  242         """Return .ini file fragment for this option"""
  243         _desc_lines = []
  244         for _description in (self.description, self.class_description):
  245             if _description:
  246                 _desc_lines.extend(_description.split("\n"))
  247         # comment out the setting line if there is no value
  248         if self.isset():
  249             _is_set = ""
  250         else:
  251             _is_set = "#"
  252         _rv = "# %(description)s\n# Default: %(default)s\n" \
  253             "%(is_set)s%(name)s = %(value)s\n" % {
  254                 "description": "\n# ".join(_desc_lines),
  255                 "default": self.value2str(self._default_value),
  256                 "name": self.setting,
  257                 "value": self.value2str(self._value),
  258                 "is_set": _is_set
  259             }
  260         return _rv
  261 
  262     def load_ini(self, config):
  263         """Load value from ConfigParser object"""
  264         if config.has_option(self.section, self.setting):
  265             self.set(config.get(self.section, self.setting))
  266 
  267     def load_pyconfig(self, config):
  268         """Load value from old-style config (python module)"""
  269         for _name in self.aliases:
  270             if hasattr(config, _name):
  271                 self.set(getattr(config, _name))
  272                 break
  273 
  274 
  275 class BooleanOption(Option):
  276 
  277     """Boolean option: yes or no"""
  278 
  279     class_description = "Allowed values: yes, no"
  280 
  281     def _value2str(self, value):
  282         if value:
  283             return "yes"
  284         else:
  285             return "no"
  286 
  287     def str2value(self, value):
  288         if isinstance(value, type("")):
  289             _val = value.lower()
  290             if _val in ("yes", "true", "on", "1"):
  291                 _val = 1
  292             elif _val in ("no", "false", "off", "0"):
  293                 _val = 0
  294             else:
  295                 raise OptionValueError(self, value, self.class_description)
  296         else:
  297             _val = value and 1 or 0
  298         return _val
  299 
  300 
  301 class WordListOption(Option):
  302 
  303     """List of strings"""
  304 
  305     class_description = "Allowed values: comma-separated list of words"
  306 
  307     def _value2str(self, value):
  308         return ','.join(value)
  309 
  310     def str2value(self, value):
  311         return value.split(',')
  312 
  313 
  314 class RunDetectorOption(Option):
  315 
  316     """When a detector is run: always, never or for new items only"""
  317 
  318     class_description = "Allowed values: yes, no, new"
  319 
  320     def str2value(self, value):
  321         _val = value.lower()
  322         if _val in ("yes", "no", "new"):
  323             return _val
  324         else:
  325             raise OptionValueError(self, value, self.class_description)
  326 
  327 
  328 class CsrfSettingOption(Option):
  329 
  330     """How should a csrf measure be enforced: required, yes, logfailure, no"""
  331 
  332     class_description = "Allowed values: required, yes, logfailure, no"
  333 
  334     def str2value(self, value):
  335         _val = value.lower()
  336         if _val in ("required", "yes", "logfailure", "no"):
  337             return _val
  338         else:
  339             raise OptionValueError(self, value, self.class_description)
  340 
  341 
  342 class SameSiteSettingOption(Option):
  343 
  344     """How should the SameSite cookie setting be set: strict, lax
  345 or should it not be added (none)"""
  346 
  347     class_description = "Allowed values: Strict, Lax, None"
  348 
  349     def str2value(self, value):
  350         _val = value.lower()
  351         if _val in ("strict", "lax", "none"):
  352             return _val.capitalize()
  353         else:
  354             raise OptionValueError(self, value, self.class_description)
  355 
  356 
  357 class DatabaseBackend(Option):
  358     """handle exact text of backend and make sure it's available"""
  359     class_description = "Available backends: %s" % ", ".join(list_backends())
  360 
  361     def str2value(self, value):
  362         _val = value.lower()
  363         if _val in list_backends():
  364             return _val
  365         else:
  366             raise OptionValueError(self, value, self.class_description)
  367 
  368 
  369 class HtmlToTextOption(Option):
  370 
  371     """What module should be used to convert emails with only text/html
  372     parts into text for display in roundup. Choose from beautifulsoup
  373     4, dehtml - the internal code or none to disable html to text
  374     conversion. If beautifulsoup chosen but not available, dehtml will
  375     be used.
  376 
  377     """
  378 
  379     class_description = "Allowed values: beautifulsoup, dehtml, none"
  380 
  381     def str2value(self, value):
  382         _val = value.lower()
  383         if _val in ("beautifulsoup", "dehtml", "none"):
  384             return _val
  385         else:
  386             raise OptionValueError(self, value, self.class_description)
  387 
  388 
  389 class EmailBodyOption(Option):
  390 
  391     """When to replace message body or strip quoting: always, never
  392     or for new items only"""
  393 
  394     class_description = "Allowed values: yes, no, new"
  395 
  396     def str2value(self, value):
  397         _val = value.lower()
  398         if _val in ("yes", "no", "new"):
  399             return _val
  400         else:
  401             raise OptionValueError(self, value, self.class_description)
  402 
  403 
  404 class IsolationOption(Option):
  405     """Database isolation levels"""
  406 
  407     allowed = ['read uncommitted', 'read committed', 'repeatable read',
  408                'serializable']
  409     class_description = "Allowed values: %s" % ', '.join("'%s'" % a
  410                                                          for a in allowed)
  411 
  412     def str2value(self, value):
  413         _val = value.lower()
  414         if _val in self.allowed:
  415             return _val
  416         raise OptionValueError(self, value, self.class_description)
  417 
  418 
  419 class MailAddressOption(Option):
  420 
  421     """Email address
  422 
  423     Email addresses may be either fully qualified or local.
  424     In the latter case MAIL_DOMAIN is automatically added.
  425 
  426     """
  427 
  428     def get(self):
  429         _val = Option.get(self)
  430         if "@" not in _val:
  431             _val = "@".join((_val, self.config["MAIL_DOMAIN"]))
  432         return _val
  433 
  434 
  435 class FilePathOption(Option):
  436 
  437     """File or directory path name
  438 
  439     Paths may be either absolute or relative to the HOME.
  440 
  441     """
  442 
  443     class_description = "The path may be either absolute or relative\n" \
  444         "to the directory containing this config file."
  445 
  446     def get(self):
  447         _val = Option.get(self)
  448         if _val and not os.path.isabs(_val):
  449             _val = os.path.join(self.config["HOME"], _val)
  450         return _val
  451 
  452 
  453 class MultiFilePathOption(Option):
  454 
  455     """List of space seperated File or directory path name
  456 
  457     Paths may be either absolute or relative to the HOME. None
  458     is returned if there are no elements.
  459 
  460     """
  461 
  462     class_description = "The space separated paths may be either absolute\n" \
  463         "or relative to the directory containing this config file."
  464 
  465     def get(self):
  466         pathlist = []
  467         _val = Option.get(self)
  468         for elem in _val.split():
  469             if elem and not os.path.isabs(elem):
  470                 pathlist.append(os.path.join(self.config["HOME"], elem))
  471             else:
  472                 pathlist.append(elem)
  473         if pathlist:
  474             return pathlist
  475         else:
  476             return None
  477 
  478 
  479 class FloatNumberOption(Option):
  480 
  481     """Floating point numbers"""
  482 
  483     def str2value(self, value):
  484         try:
  485             return float(value)
  486         except ValueError:
  487             raise OptionValueError(self, value,
  488                                    "Floating point number required")
  489 
  490     def _value2str(self, value):
  491         _val = str(value)
  492         # strip fraction part from integer numbers
  493         if _val.endswith(".0"):
  494             _val = _val[:-2]
  495         return _val
  496 
  497 
  498 class IntegerNumberOption(Option):
  499 
  500     """Integer numbers"""
  501 
  502     def str2value(self, value):
  503         try:
  504             return int(value)
  505         except ValueError:
  506             raise OptionValueError(self, value, "Integer number required")
  507 
  508 
  509 class IntegerNumberGeqZeroOption(Option):
  510 
  511     """Integer numbers greater than or equal to zero."""
  512 
  513     def str2value(self, value):
  514         try:
  515             v = int(value)
  516             if v < 0:
  517                 raise OptionValueError(self, value,
  518                       "Integer number greater than or equal to zero required")
  519             return v
  520         except OptionValueError:
  521             raise  # pass through subclass
  522         except ValueError:
  523             raise OptionValueError(self, value, "Integer number required")
  524 
  525 
  526 class OctalNumberOption(Option):
  527 
  528     """Octal Integer numbers"""
  529 
  530     def str2value(self, value):
  531         try:
  532             return int(value, 8)
  533         except ValueError:
  534             raise OptionValueError(self, value,
  535                                    "Octal Integer number required")
  536 
  537     def _value2str(self, value):
  538         return oct(value)
  539 
  540 
  541 class MandatoryOption(Option):
  542     """Option must not be empty"""
  543     def str2value(self, value):
  544         if not value:
  545             raise OptionValueError(self, value, "Value must not be empty.")
  546         else:
  547             return value
  548 
  549 
  550 class WebUrlOption(Option):
  551     """URL MUST start with http/https scheme and end with '/'"""
  552 
  553     def str2value(self, value):
  554         if not value:
  555             raise OptionValueError(self, value, "Value must not be empty.")
  556 
  557         error_msg = ''
  558         if not value.startswith(('http://', 'https://')):
  559             error_msg = "Value must start with http:// or https://.\n"
  560 
  561         if not value.endswith('/'):
  562             error_msg += "Value must end with /."
  563 
  564         if error_msg:
  565             raise OptionValueError(self, value, error_msg)
  566         else:
  567             return value
  568 
  569 
  570 class NullableOption(Option):
  571 
  572     """Option that is set to None if its string value is one of NULL strings
  573 
  574     Default nullable strings list contains empty string only.
  575     There is constructor parameter allowing to specify different nullables.
  576 
  577     Conversion to external representation returns the first of the NULL
  578     strings list when the value is None.
  579 
  580     """
  581 
  582     NULL_STRINGS = ("",)
  583 
  584     def __init__(self, config, section, setting,
  585                  default=NODEFAULT, description=None, aliases=None,
  586                  null_strings=NULL_STRINGS):
  587         self.null_strings = list(null_strings)
  588         Option.__init__(self, config, section, setting, default,
  589                         description, aliases)
  590 
  591     def str2value(self, value):
  592         if value in self.null_strings:
  593             return None
  594         else:
  595             return value
  596 
  597     def _value2str(self, value):
  598         if value is None:
  599             return self.null_strings[0]
  600         else:
  601             return value
  602 
  603 
  604 class NullableFilePathOption(NullableOption, FilePathOption):
  605 
  606     # .get() and class_description are from FilePathOption,
  607     get = FilePathOption.get
  608     class_description = FilePathOption.class_description
  609     # everything else taken from NullableOption (inheritance order)
  610 
  611 
  612 class TimezoneOption(Option):
  613 
  614     class_description = \
  615         "If pytz module is installed, value may be any valid\n" \
  616         "timezone specification (e.g. EET or Europe/Warsaw).\n" \
  617         "If pytz is not installed, value must be integer number\n" \
  618         "giving local timezone offset from UTC in hours."
  619 
  620     # fix issue2551030, default value for timezone
  621     # Must be 0 if no pytz can be UTC if pytz.
  622     try:
  623         import pytz
  624         defaulttz = "UTC"
  625     except ImportError:
  626         defaulttz = "0"
  627 
  628     def str2value(self, value):
  629         try:
  630             roundup.date.get_timezone(value)
  631         except KeyError:
  632             raise OptionValueError(self, value,
  633                     "Timezone name or numeric hour offset required")
  634         return value
  635 
  636 
  637 class RegExpOption(Option):
  638 
  639     """Regular Expression option (value is Regular Expression Object)"""
  640 
  641     class_description = "Value is Python Regular Expression (UTF8-encoded)."
  642 
  643     RE_TYPE = type(re.compile(""))
  644 
  645     def __init__(self, config, section, setting,
  646                  default=NODEFAULT, description=None, aliases=None,
  647                  flags=0):
  648         self.flags = flags
  649         Option.__init__(self, config, section, setting, default,
  650                         description, aliases)
  651 
  652     def _value2str(self, value):
  653         assert isinstance(value, self.RE_TYPE)
  654         return value.pattern
  655 
  656     def str2value(self, value):
  657         if not isinstance(value, type(u'')):
  658             value = str(value)
  659         if not isinstance(value, type(u'')):
  660             # if it is 7-bit ascii, use it as string,
  661             # otherwise convert to unicode.
  662             try:
  663                 value.decode("ascii")
  664             except UnicodeError:
  665                 value = value.decode("utf-8")
  666         return re.compile(value, self.flags)
  667 
  668 ### Main configuration layout.
  669 # Config is described as a sequence of sections,
  670 # where each section name is followed by a sequence
  671 # of Option definitions.  Each Option definition
  672 # is a sequence containing class name and constructor
  673 # parameters, starting from the setting name:
  674 # setting, default, [description, [aliases]]
  675 # Note: aliases should only exist in historical options for backwards
  676 # compatibility - new options should *not* have aliases!
  677 
  678 
  679 SETTINGS = (
  680     ("main", (
  681         (FilePathOption, "database", "db", "Database directory path."),
  682         (Option, "template_engine", "zopetal",
  683             "Templating engine to use.\n"
  684             "Possible values are 'zopetal' for the old TAL engine\n"
  685             "ported from Zope, or 'chameleon' for Chameleon."),
  686         (FilePathOption, "templates", "html",
  687             "Path to the HTML templates directory."),
  688         (MultiFilePathOption, "static_files", "",
  689             "A list of space separated directory paths (or a single\n"
  690             "directory).  These directories hold additional static\n"
  691             "files available via Web UI.  These directories may\n"
  692             "contain sitewide images, CSS stylesheets etc. If a '-'\n"
  693             "is included, the list processing ends and the TEMPLATES\n"
  694             "directory is not searched after the specified\n"
  695             "directories.  If this option is not set, all static\n"
  696             "files are taken from the TEMPLATES directory."),
  697         (MailAddressOption, "admin_email", "roundup-admin",
  698             "Email address that roundup will complain to if it runs\n"
  699             "into trouble.\n"
  700             "If no domain is specified then the config item\n"
  701             "mail -> domain is added."),
  702         (MailAddressOption, "dispatcher_email", "roundup-admin",
  703             "The 'dispatcher' is a role that can get notified\n"
  704             "of new items to the database.\n"
  705             "It is used by the ERROR_MESSAGES_TO config setting.\n"
  706             "If no domain is specified then the config item\n"
  707             "mail -> domain is added."),
  708         (Option, "email_from_tag", "",
  709             "Additional text to include in the \"name\" part\n"
  710             "of the From: address used in nosy messages.\n"
  711             "If the sending user is \"Foo Bar\", the From: line\n"
  712             "is usually: \"Foo Bar\" <issue_tracker@tracker.example>\n"
  713             "the EMAIL_FROM_TAG goes inside the \"Foo Bar\" quotes like so:\n"
  714             "\"Foo Bar EMAIL_FROM_TAG\" <issue_tracker@tracker.example>"),
  715         (Option, "new_web_user_roles", "User",
  716             "Roles that a user gets when they register"
  717             " with Web User Interface.\n"
  718             "This is a comma-separated string of role names"
  719             " (e.g. 'Admin,User')."),
  720         (Option, "new_email_user_roles", "User",
  721             "Roles that a user gets when they register"
  722             " with Email Gateway.\n"
  723             "This is a comma-separated string of role names"
  724             " (e.g. 'Admin,User')."),
  725         (Option, "obsolete_history_roles", "Admin",
  726             "On schema changes, properties or classes in the history may\n"
  727             "become obsolete.  Since normal access permissions do not apply\n"
  728             "(we don't know if a user should see such a property or class)\n"
  729             "a list of roles is specified here that are allowed to see\n"
  730             "these obsolete properties in the history. By default only the\n"
  731             "admin role may see these history entries, you can make them\n"
  732             "visible to all users by adding, e.g., the 'User' role here."),
  733         (Option, "error_messages_to", "user",
  734             'Send error message emails to the "dispatcher", "user", '
  735             'or "both" (these are the allowed values)?\n'
  736             'The dispatcher is configured using the DISPATCHER_EMAIL'
  737             ' setting.'),
  738         (Option, "html_version", "html4",
  739             "HTML version to generate. The templates are html4 by default.\n"
  740             "If you wish to make them xhtml, then you'll need to change this\n"
  741             "var to 'xhtml' too so all auto-generated HTML is compliant.\n"
  742             "Allowed values: html4, xhtml"),
  743         (TimezoneOption, "timezone", TimezoneOption.defaulttz,
  744             "Default timezone offset,"
  745             " applied when user's timezone is not set.",
  746             ["DEFAULT_TIMEZONE"]),
  747         (BooleanOption, "instant_registration", "no",
  748             "Register new users instantly, or require confirmation via\n"
  749             "email?"),
  750         (BooleanOption, "email_registration_confirmation", "yes",
  751             "Offer registration confirmation by email or only through the web?"),
  752         (Option, "indexer", "",
  753             "Force Roundup to use a particular text indexer.\n"
  754             "If no indexer is supplied, the first available indexer\n"
  755             "will be used in the following order:\n"
  756             "Possible values: xapian, whoosh, native (internal)."),
  757         (WordListOption, "indexer_stopwords", "",
  758             "Additional stop-words for the full-text indexer specific to\n"
  759             "your tracker. See the indexer source for the default list of\n"
  760             "stop-words (eg. A,AND,ARE,AS,AT,BE,BUT,BY, ...)"),
  761         (OctalNumberOption, "umask", "0o002",
  762             "Defines the file creation mode mask."),
  763         (IntegerNumberGeqZeroOption, 'csv_field_size', '131072',
  764             "Maximum size of a csv-field during import. Roundups export\n"
  765             "format is a csv (comma separated values) variant. The csv\n"
  766             "reader has a limit on the size of individual fields\n"
  767             "starting with python 2.5. Set this to a higher value if you\n"
  768             "get the error 'Error: field larger than field limit' during\n"
  769             "import."),
  770         (IntegerNumberGeqZeroOption, 'password_pbkdf2_default_rounds', '10000',
  771             "Sets the default number of rounds used when encoding passwords\n"
  772             "using the PBKDF2 scheme. Set this to a higher value on faster\n"
  773             "systems which want more security.\n"
  774             "PBKDF2 (Password-Based Key Derivation Function) is a\n"
  775             "password hashing mechanism that derives hash from the\n"
  776             "password and a random salt. For authentication this process\n"
  777             "is repeated with the same salt as in the stored hash.\n"
  778             "If both hashes match, the authentication succeeds.\n"
  779             "PBKDF2 supports a variable 'rounds' parameter which varies\n"
  780             "the time-cost of calculating the hash - doubling the number\n"
  781             "of rounds doubles the cpu time required to calculate it. The\n"
  782             "purpose of this is to periodically adjust the rounds as CPUs\n"
  783             "become faster. The currently enforced minimum number of\n"
  784             "rounds is 1000.\n"
  785             "See: http://en.wikipedia.org/wiki/PBKDF2 and RFC2898"),
  786     )),
  787     ("tracker", (
  788         (Option, "name", "Roundup issue tracker",
  789             "A descriptive name for your roundup instance."),
  790         (WebUrlOption, "web", NODEFAULT,
  791             "The web address that the tracker is viewable at.\n"
  792             "This will be included in information"
  793             " sent to users of the tracker.\n"
  794             "The URL MUST include the cgi-bin part or anything else\n"
  795             "that is required to get to the home page of the tracker.\n"
  796             "URL MUST start with http/https scheme and end with '/'"),
  797         (MailAddressOption, "email", "issue_tracker",
  798             "Email address that mail to roundup should go to.\n"
  799             "If no domain is specified then mail_domain is added."),
  800         (Option, "replyto_address", "",
  801             "Controls the reply-to header address used when sending\n"
  802             "nosy messages.\n"
  803             "If the value is unset (default) the roundup tracker's\n"
  804             "email address (above) is used.\n"
  805             "If set to \"AUTHOR\" then the primary email address of the\n"
  806             "author of the change will be used as the reply-to\n"
  807             "address. This allows email exchanges to occur outside of\n"
  808             "the view of roundup and exposes the address of the person\n"
  809             "who updated the issue, but it could be useful in some\n"
  810             "unusual circumstances.\n"
  811             "If set to some other value, the value is used as the reply-to\n"
  812             "address. It must be a valid RFC2822 address or people will not\n"
  813             "be able to reply."),
  814         (NullableOption, "language", "",
  815             "Default locale name for this tracker.\n"
  816             "If this option is not set, the language is determined\n"
  817             "by OS environment variable LANGUAGE, LC_ALL, LC_MESSAGES,\n"
  818             "or LANG, in that order of preference."),
  819     )),
  820     ("web", (
  821         (BooleanOption, "allow_html_file", "no",
  822             "Setting this option enables Roundup to serve uploaded HTML\n"
  823             "file content *as HTML*. This is a potential security risk\n"
  824             "and is therefore disabled by default. Set to 'yes' if you\n"
  825             "trust *all* users uploading content to your tracker."),
  826         (BooleanOption, 'http_auth', "yes",
  827             "Whether to use HTTP Basic Authentication, if present.\n"
  828             "Roundup will use either the REMOTE_USER or HTTP_AUTHORIZATION\n"
  829             "variables supplied by your web server (in that order).\n"
  830             "Set this option to 'no' if you do not wish to use HTTP Basic\n"
  831             "Authentication in your web interface."),
  832         (BooleanOption, 'http_auth_convert_realm_to_lowercase', "no",
  833             "If usernames consist of a name and a domain/realm part of\n"
  834             "the form user@realm and we're using REMOTE_USER for\n"
  835             "authentication (e.g. via Kerberos), convert the realm part\n"
  836             "of the incoming REMOTE_USER to lowercase before matching\n"
  837             "against the roundup username. This allows roundup usernames\n"
  838             "to be lowercase (including the realm) and still follow the\n"
  839             "Kerberos convention of using an uppercase realm. In\n"
  840             "addition this is compatible with Active Directory which\n"
  841             "stores the username with realm as UserPrincipalName in\n"
  842             "lowercase."),
  843         (BooleanOption, 'cookie_takes_precedence', "no",
  844             "If the http_auth option is in effect (see above)\n"
  845             "we're accepting a REMOTE_USER variable resulting from\n"
  846             "an authentication mechanism implemented in the web-server,\n"
  847             "e.g., Kerberos login or similar. To override the mechanism\n"
  848             "provided by the web-server (e.g. for enabling sub-login as\n"
  849             "another user) we tell roundup that the cookie takes\n"
  850             "precedence over a REMOTE_USER or HTTP_AUTHORIZATION\n"
  851             "variable. So if both, a cookie and a REMOTE_USER is\n"
  852             "present, the cookie wins.\n"),
  853         (IntegerNumberGeqZeroOption, 'login_attempts_min', "3",
  854             "Limit login attempts per user per minute to this number.\n"
  855             "By default the 4th login attempt in a minute will notify\n"
  856             "the user that they need to wait 20 seconds before trying to\n"
  857             "log in again. This limits password guessing attacks and\n"
  858             "shouldn't need to be changed. Rate limiting on login can\n"
  859             "be disabled by setting the value to 0."),
  860         (IntegerNumberGeqZeroOption, 'registration_delay', "4",
  861             "The number of seconds needed to complete the new user\n"
  862             "registration form. This limits the rate at which bots\n"
  863             "can attempt to sign up. Limit can be disabled by setting\n"
  864             "the value to 0."),
  865         (BooleanOption, 'registration_prevalidate_username', "no",
  866             "When registering a user, check that the username\n"
  867             "is available before sending confirmation email.\n"
  868             "Usually a username conflict is detected when\n"
  869             "confirming the registration. Disabled by default as\n"
  870             "it can be used for guessing existing usernames.\n"),
  871         (SameSiteSettingOption, 'samesite_cookie_setting', "Lax",
  872             """Set the mode of the SameSite cookie option for
  873 the session cookie. Choices are 'Lax' or
  874 'Strict'. 'None' can be used to suppress the
  875 option. Strict mode provides additional security
  876 against CSRF attacks, but may confuse users who
  877 are logged into roundup and open a roundup link
  878 from a source other than roundup (e.g. link in
  879 email)."""),
  880         (BooleanOption, 'enable_xmlrpc', "yes",
  881             """Whether to enable the XMLRPC API in the roundup web
  882 interface. By default the XMLRPC endpoint is the string 'xmlrpc'
  883 after the roundup web url configured in the 'tracker' section.
  884 If this variable is set to 'no', the xmlrpc path has no special meaning
  885 and will yield an error message."""),
  886         (BooleanOption, 'enable_rest', "yes",
  887             """Whether to enable the REST API in the roundup web
  888 interface. By default the REST endpoint is the string 'rest' plus any
  889 additional REST-API parameters after the roundup web url configured in
  890 the tracker section. If this variable is set to 'no', the rest path has
  891 no special meaning and will yield an error message."""),
  892         (IntegerNumberGeqZeroOption, 'api_calls_per_interval', "0",
  893          "Limit API calls per api_interval_in_sec seconds to\n"
  894          "this number.\n"
  895          "Determines the burst rate and the rate that new api\n"
  896          "calls will be made available. If set to 360 and\n"
  897          "api_intervals_in_sec is set to 3600, the 361st call in\n"
  898          "10 seconds results in a 429 error to the caller. It\n"
  899          "tells them to wait 10 seconds (360/3600) before making\n"
  900          "another api request. A value of 0 turns off rate\n"
  901          "limiting in the API. Tune this as needed. See rest\n"
  902          "documentation for more info.\n"),
  903         (IntegerNumberGeqZeroOption, 'api_interval_in_sec', "3600",
  904          "Defines the interval in seconds over which an api client can\n"
  905          "make api_calls_per_interval api calls. Tune this as needed.\n"),
  906         (CsrfSettingOption, 'csrf_enforce_token', "yes",
  907             """How do we deal with @csrf fields in posted forms.
  908 Set this to 'required' to block the post and notify
  909     the user if the field is missing or invalid.
  910 Set this to 'yes' to block the post and notify the user
  911     if the token is invalid, but accept the form if
  912     the field is missing.
  913 Set this to 'logfailure' to log a notice to the roundup
  914     log if the field is invalid or missing, but accept
  915     the post.
  916 Set this to 'no' to ignore the field and accept the post.
  917             """),
  918         (IntegerNumberGeqZeroOption, 'csrf_token_lifetime', "20160",
  919             """csrf_tokens have a limited lifetime. If they are not
  920 used they are purged from the database after this
  921 number of minutes. Default (20160) is 2 weeks."""),
  922         (CsrfSettingOption, 'csrf_enforce_token', "yes",
  923             """How do we deal with @csrf fields in posted forms.
  924 Set this to 'required' to block the post and notify
  925     the user if the field is missing or invalid.
  926 Set this to 'yes' to block the post and notify the user
  927     if the token is invalid, but accept the form if
  928     the field is missing.
  929 Set this to 'logfailure' to log a notice to the roundup
  930     log if the field is invalid or missing, but accept
  931     the post.
  932 Set this to 'no' to ignore the field and accept the post.
  933             """),
  934         (CsrfSettingOption, 'csrf_enforce_header_X-REQUESTED-WITH', "yes",
  935             """This is only used for xmlrpc and rest requests. This test is
  936 done after Origin and Referer headers are checked. It only
  937 verifies that the X-Requested-With header exists. The value
  938 is ignored.
  939 Set this to 'required' to block the post and notify
  940     the user if the header is missing or invalid.
  941 Set this to 'yes' is the same as required.
  942 Set this to 'logfailure' is the same as 'no'.
  943 Set this to 'no' to ignore the header and accept the post."""),
  944         (CsrfSettingOption, 'csrf_enforce_header_referer', "yes",
  945             """Verify that the Referer http header matches the
  946 tracker.web setting in config.ini.
  947 Set this to 'required' to block the post and notify
  948     the user if the header is missing or invalid.
  949 Set this to 'yes' to block the post and notify the user
  950     if the header is invalid, but accept the form if
  951     the field is missing.
  952 Set this to 'logfailure' to log a notice to the roundup
  953     log if the header is invalid or missing, but accept
  954     the post.
  955 Set this to 'no' to ignore the header and accept the post."""),
  956         (CsrfSettingOption, 'csrf_enforce_header_origin', "yes",
  957             """Verify that the Origin http header matches the
  958 tracker.web setting in config.ini.
  959 Set this to 'required' to block the post and notify
  960     the user if the header is missing or invalid.
  961 Set this to 'yes' to block the post and notify the user
  962     if the header is invalid, but accept the form if
  963     the field is missing.
  964 Set this to 'logfailure' to log a notice to the roundup
  965     log if the header is invalid or missing, but accept
  966     the post.
  967 Set this to 'no' to ignore the header and accept the post."""),
  968         (CsrfSettingOption, 'csrf_enforce_header_x-forwarded-host', "yes",
  969             """Verify that the X-Forwarded-Host http header matches
  970 the host part of the tracker.web setting in config.ini.
  971 Set this to 'required' to block the post and notify
  972     the user if the header is missing or invalid.
  973 Set this to 'yes' to block the post and notify the user
  974     if the header is invalid, but accept the form if
  975     the field is missing.
  976 Set this to 'logfailure' to log a notice to the roundup
  977     log if the header is invalid or missing, but accept
  978     the post.
  979 Set this to 'no' to ignore the header and accept the post."""),
  980         (CsrfSettingOption, 'csrf_enforce_header_host', "yes",
  981             """"If there is no X-Forward-Host header, verify that
  982 the Host http header matches the host part of the
  983 tracker.web setting in config.ini.
  984 Set this to 'required' to block the post and notify
  985     the user if the header is missing or invalid.
  986 Set this to 'yes' to block the post and notify the user
  987     if the header is invalid, but accept the form if
  988     the field is missing.
  989 Set this to 'logfailure' to log a notice to the roundup
  990     log if the header is invalid or missing, but accept
  991     the post.
  992 Set this to 'no' to ignore the header and accept the post."""),
  993         (IntegerNumberGeqZeroOption, 'csrf_header_min_count', "1",
  994             """Minimum number of header checks that must pass
  995 to accept the request. Set to 0 to accept post
  996 even if no header checks pass. Usually the Host header check
  997 always passes, so setting it less than 1 is not recommended."""),
  998         (BooleanOption, 'use_browser_language', "yes",
  999             "Whether to use HTTP Accept-Language, if present.\n"
 1000             "Browsers send a language-region preference list.\n"
 1001             "It's usually set in the client's browser or in their\n"
 1002             "Operating System.\n"
 1003             "Set this option to 'no' if you want to ignore it."),
 1004         (BooleanOption, "debug", "no",
 1005             "Setting this option makes Roundup display error tracebacks\n"
 1006             "in the user's browser rather than emailing them to the\n"
 1007             "tracker admin."),
 1008         (BooleanOption, "migrate_passwords", "yes",
 1009             "Setting this option makes Roundup migrate passwords with\n"
 1010             "an insecure password-scheme to a more secure scheme\n"
 1011             "when the user logs in via the web-interface."),
 1012         (MandatoryOption, "secret_key", create_token(),
 1013             "A per tracker secret used in etag calculations for\n"
 1014             "an object. It must not be empty.\n"
 1015             "It prevents reverse engineering hidden data in an object\n"
 1016             "by calculating the etag for a sample object. Then modifying\n"
 1017             "hidden properties until the sample object's etag matches\n"
 1018             "the one returned by roundup.\n"
 1019             "Changing this changes the etag and invalidates updates by\n"
 1020             "clients. It must be persistent across application restarts.\n"
 1021             "(Note the default value changes every time\n"
 1022             "     roundup-admin updateconfig\n"
 1023             "is run, so it must be explicitly set to a non-empty string.\n"),
 1024         (MandatoryOption, "jwt_secret", "disabled",
 1025             "This is used to generate/validate json web tokens (jwt).\n"
 1026             "Even if you don't use jwts it must not be empty.\n"
 1027             "If less than 256 bits (32 characters) in length it will\n"
 1028             "disable use of jwt. Changing this invalidates all jwts\n"
 1029             "issued by the roundup instance requiring *all* users to\n"
 1030             "generate new jwts. This is experimental and disabled by\n"
 1031             "default. It must be persistent across application restarts.\n"),
 1032     )),
 1033     ("rdbms", (
 1034         (DatabaseBackend, 'backend', NODEFAULT,
 1035             "Database backend."),
 1036         (Option, 'name', 'roundup',
 1037             "Name of the database to use.",
 1038             ['MYSQL_DBNAME']),
 1039         (NullableOption, 'host', 'localhost',
 1040             "Database server host.",
 1041             ['MYSQL_DBHOST']),
 1042         (NullableOption, 'port', '',
 1043             "TCP port number of the database server.\n"
 1044             "Postgresql usually resides on port 5432 (if any),\n"
 1045             "for MySQL default port number is 3306.\n"
 1046             "Leave this option empty to use backend default"),
 1047         (NullableOption, 'user', 'roundup',
 1048             "Database user name that Roundup should use.",
 1049             ['MYSQL_DBUSER']),
 1050         (NullableOption, 'password', 'roundup',
 1051             "Database user password.",
 1052             ['MYSQL_DBPASSWORD']),
 1053         (NullableOption, 'read_default_file', '~/.my.cnf',
 1054             "Name of the MySQL defaults file.\n"
 1055             "Only used in MySQL connections."),
 1056         (NullableOption, 'read_default_group', 'roundup',
 1057             "Name of the group to use in the MySQL defaults file (.my.cnf).\n"
 1058             "Only used in MySQL connections."),
 1059         (Option, 'mysql_charset', 'utf8',
 1060             "Charset to use for mysql connection,\n"
 1061             "use 'default' for the mysql default, no charset option\n"
 1062             "is used when creating the connection in that case.\n"
 1063             "Otherwise any permissible mysql charset is allowed here.\n"
 1064             "Only used in MySQL connections."),
 1065         (IntegerNumberGeqZeroOption, 'sqlite_timeout', '30',
 1066             "Number of seconds to wait when the SQLite database is locked\n"
 1067             "Default: use a 30 second timeout (extraordinarily generous)\n"
 1068             "Only used in SQLite connections."),
 1069         (IntegerNumberGeqZeroOption, 'cache_size', '100',
 1070             "Size of the node cache (in elements)"),
 1071         (BooleanOption, "allow_create", "yes",
 1072             "Setting this option to 'no' protects the database against table creations."),
 1073         (BooleanOption, "allow_alter", "yes",
 1074             "Setting this option to 'no' protects the database against table alterations."),
 1075         (BooleanOption, "allow_drop", "yes",
 1076             "Setting this option to 'no' protects the database against table drops."),
 1077         (NullableOption, 'template', '',
 1078             "Name of the PostgreSQL template for database creation.\n"
 1079             "For database creation the template used has to match\n"
 1080             "the character encoding used (UTF8), there are different\n"
 1081             "PostgreSQL installations using different templates with\n"
 1082             "different encodings. If you get an error:\n"
 1083             "  new encoding (UTF8) is incompatible with the encoding of\n"
 1084             "  the template database (SQL_ASCII)\n"
 1085             "  HINT:  Use the same encoding as in the template database,\n"
 1086             "  or use template0 as template.\n"
 1087             "then set this option to the template name given in the\n"
 1088             "error message."),
 1089         (IsolationOption, 'isolation_level', 'read committed',
 1090             "Database isolation level, currently supported for\n"
 1091             "PostgreSQL and mysql. See, e.g.,\n"
 1092             "http://www.postgresql.org/docs/9.1/static/transaction-iso.html"),
 1093     ), "Settings in this section (except for backend) are used"
 1094         " by RDBMS backends only."
 1095     ),
 1096     ("logging", (
 1097         (FilePathOption, "config", "",
 1098             "Path to configuration file for standard Python logging module.\n"
 1099             "If this option is set, logging configuration is loaded\n"
 1100             "from specified file; options 'filename' and 'level'\n"
 1101             "in this section are ignored."),
 1102         (FilePathOption, "filename", "",
 1103             "Log file name for minimal logging facility built into Roundup.\n"
 1104             "If no file name specified, log messages are written on stderr.\n"
 1105             "If above 'config' option is set, this option has no effect."),
 1106         (Option, "level", "ERROR",
 1107             "Minimal severity level of messages written to log file.\n"
 1108             "If above 'config' option is set, this option has no effect.\n"
 1109             "Allowed values: DEBUG, INFO, WARNING, ERROR"),
 1110         (BooleanOption, "disable_loggers", "no",
 1111             "If set to yes, only the loggers configured in this section will\n"
 1112             "be used. Yes will disable gunicorn's --access-logfile.\n"),
 1113     )),
 1114     ("mail", (
 1115         (Option, "domain", NODEFAULT,
 1116             "The email domain that admin_email, issue_tracker and\n"
 1117             "dispatcher_email belong to.\n"
 1118             "This domain is added to those config items if they don't\n"
 1119             "explicitly include a domain.\n"
 1120             "Do not include the '@' symbol."),
 1121         (Option, "host", NODEFAULT,
 1122             "SMTP mail host that roundup will use to send mail",
 1123             ["MAILHOST"],),
 1124         (Option, "username", "", "SMTP login name.\n"
 1125             "Set this if your mail host requires authenticated access.\n"
 1126             "If username is not empty, password (below) MUST be set!"),
 1127         (Option, "password", NODEFAULT, "SMTP login password.\n"
 1128             "Set this if your mail host requires authenticated access."),
 1129         (IntegerNumberGeqZeroOption, "port", smtplib.SMTP_PORT,
 1130             "Default port to send SMTP on.\n"
 1131             "Set this if your mail server runs on a different port."),
 1132         (NullableOption, "local_hostname", '',
 1133             "The local hostname to use during SMTP transmission.\n"
 1134             "Set this if your mail server requires something specific."),
 1135         (BooleanOption, "tls", "no",
 1136             "If your SMTP mail host provides or requires TLS\n"
 1137             "(Transport Layer Security) then set this option to 'yes'."),
 1138         (NullableFilePathOption, "tls_keyfile", "",
 1139             "If TLS is used, you may set this option to the name\n"
 1140             "of a PEM formatted file that contains your private key."),
 1141         (NullableFilePathOption, "tls_certfile", "",
 1142             "If TLS is used, you may set this option to the name\n"
 1143             "of a PEM formatted certificate chain file."),
 1144         (Option, "charset", "utf-8",
 1145             "Character set to encode email headers with.\n"
 1146             "We use utf-8 by default, as it's the most flexible.\n"
 1147             "Some mail readers (eg. Eudora) can't cope with that,\n"
 1148             "so you might need to specify a more limited character set\n"
 1149             "(eg. iso-8859-1).",
 1150             ["EMAIL_CHARSET"]),
 1151         (FilePathOption, "debug", "",
 1152             "Setting this option makes Roundup write all outgoing email\n"
 1153             "messages to this file *instead* of sending them.\n"
 1154             "This option has the same effect as environment variable"
 1155             " SENDMAILDEBUG.\nEnvironment variable takes precedence."),
 1156         (BooleanOption, "add_authorinfo", "yes",
 1157             "Add a line with author information at top of all messages\n"
 1158             "sent by roundup"),
 1159         (BooleanOption, "add_authoremail", "yes",
 1160             "Add the mail address of the author to the author information at\n"
 1161             "the top of all messages.\n"
 1162             "If this is false but add_authorinfo is true, only the name\n"
 1163             "of the actor is added which protects the mail address of the\n"
 1164             "actor from being exposed at mail archives, etc."),
 1165     ), "Outgoing email options.\n"
 1166      "Used for nosy messages and approval requests"),
 1167     ("mailgw", (
 1168         (EmailBodyOption, "keep_quoted_text", "yes",
 1169             "Keep email citations when accepting messages.\n"
 1170             "Setting this to \"no\" strips out \"quoted\" text\n"
 1171             "from the message. Setting this to \"new\" keeps quoted\n"
 1172             "text only if a new issue is being created.\n"
 1173             "Signatures are also stripped.",
 1174             ["EMAIL_KEEP_QUOTED_TEXT"]),
 1175         (EmailBodyOption, "leave_body_unchanged", "no",
 1176             "Setting this to \"yes\" preserves the email body\n"
 1177             "as is - that is, keep the citations _and_ signatures.\n"
 1178             "Setting this to \"new\" keeps the body only if we are\n"
 1179             "creating a new issue.",
 1180             ["EMAIL_LEAVE_BODY_UNCHANGED"]),
 1181         (Option, "default_class", "issue",
 1182             "Default class to use in the mailgw\n"
 1183             "if one isn't supplied in email subjects.\n"
 1184             "To disable, leave the value blank.",
 1185             ["MAIL_DEFAULT_CLASS"]),
 1186         (NullableOption, "language", "",
 1187             "Default locale name for the tracker mail gateway.\n"
 1188             "If this option is not set, mail gateway will use\n"
 1189             "the language of the tracker instance."),
 1190         (Option, "subject_prefix_parsing", "strict",
 1191             "Controls the parsing of the [prefix] on subject\n"
 1192             "lines in incoming emails. \"strict\" will return an\n"
 1193             "error to the sender if the [prefix] is not recognised.\n"
 1194             "\"loose\" will attempt to parse the [prefix] but just\n"
 1195             "pass it through as part of the issue title if not\n"
 1196             "recognised. \"none\" will always pass any [prefix]\n"
 1197             "through as part of the issue title."),
 1198         (Option, "subject_suffix_parsing", "strict",
 1199             "Controls the parsing of the [suffix] on subject\n"
 1200             "lines in incoming emails. \"strict\" will return an\n"
 1201             "error to the sender if the [suffix] is not recognised.\n"
 1202             "\"loose\" will attempt to parse the [suffix] but just\n"
 1203             "pass it through as part of the issue title if not\n"
 1204             "recognised. \"none\" will always pass any [suffix]\n"
 1205             "through as part of the issue title."),
 1206         (Option, "subject_suffix_delimiters", "[]",
 1207             "Defines the brackets used for delimiting the prefix and \n"
 1208             'suffix in a subject line. The presence of "suffix" in\n'
 1209             "the config option name is a historical artifact and may\n"
 1210             "be ignored."),
 1211         (Option, "subject_content_match", "always",
 1212             "Controls matching of the incoming email subject line\n"
 1213             "against issue titles in the case where there is no\n"
 1214             "designator [prefix]. \"never\" turns off matching.\n"
 1215             "\"creation + interval\" or \"activity + interval\"\n"
 1216             "will match an issue for the interval after the issue's\n"
 1217             "creation or last activity. The interval is a standard\n"
 1218             "Roundup interval."),
 1219         (BooleanOption, "subject_updates_title", "yes",
 1220             "Update issue title if incoming subject of email is different.\n"
 1221             "Setting this to \"no\" will ignore the title part of"
 1222             " the subject\nof incoming email messages.\n"),
 1223         (RegExpOption, "refwd_re", r"(\s*\W?\s*(fw|fwd|re|aw|sv|ang)\W)+",
 1224             "Regular expression matching a single reply or forward\n"
 1225             "prefix prepended by the mailer. This is explicitly\n"
 1226             "stripped from the subject during parsing."),
 1227         (RegExpOption, "origmsg_re",
 1228             r"^[>|\s]*-----\s?Original Message\s?-----$",
 1229             "Regular expression matching start of an original message\n"
 1230             "if quoted the in body."),
 1231         (RegExpOption, "sign_re", r"^[>|\s]*-- ?$",
 1232             "Regular expression matching the start of a signature\n"
 1233             "in the message body."),
 1234         (RegExpOption, "eol_re", r"[\r\n]+",
 1235             "Regular expression matching end of line."),
 1236         (RegExpOption, "blankline_re", r"[\r\n]+\s*[\r\n]+",
 1237             "Regular expression matching a blank line."),
 1238         (BooleanOption, "unpack_rfc822", "no",
 1239             "Unpack attached messages (encoded as message/rfc822 in MIME)\n"
 1240             "as multiple parts attached as files to the issue, if not\n"
 1241             "set we handle message/rfc822 attachments as a single file."),
 1242         (BooleanOption, "ignore_alternatives", "no",
 1243             "When parsing incoming mails, roundup uses the first\n"
 1244             "text/plain part it finds. If this part is inside a\n"
 1245             "multipart/alternative, and this option is set, all other\n"
 1246             "parts of the multipart/alternative are ignored. The default\n"
 1247             "is to keep all parts and attach them to the issue."),
 1248         (HtmlToTextOption, "convert_htmltotext", "none",
 1249             "If an email has only text/html parts, use this module\n"
 1250             "to convert the html to text. Choose from beautifulsoup 4,\n"
 1251             "dehtml - (internal code), or none to disable conversion.\n"
 1252             "If 'none' is selected, email without a text/plain part\n"
 1253             "will be returned to the user with a message. If\n"
 1254             "beautifulsoup is selected but not installed dehtml will\n"
 1255             "be used instead."),
 1256         (BooleanOption, "keep_real_from", "no",
 1257             "When handling emails ignore the Resent-From:-header\n"
 1258             "and use the original senders From:-header instead.\n"
 1259             "(This might be desirable in some situations where a moderator\n"
 1260             "reads incoming messages first before bouncing them to Roundup)",
 1261             ["EMAIL_KEEP_REAL_FROM"]),
 1262      ), "Roundup Mail Gateway options"),
 1263     ("pgp", (
 1264         (BooleanOption, "enable", "no",
 1265             "Enable PGP processing. Requires gpg. If you're planning\n"
 1266             "to send encrypted PGP mail to the tracker, you should also\n"
 1267             "enable the encrypt-option below, otherwise mail received\n"
 1268             "encrypted might be sent unencrypted to another user."),
 1269         (NullableOption, "roles", "",
 1270             "If specified, a comma-separated list of roles to perform\n"
 1271             "PGP processing on. If not specified, it happens for all\n"
 1272             "users. Note that received PGP messages (signed and/or\n"
 1273             "encrypted) will be processed with PGP even if the user\n"
 1274             "doesn't have one of the PGP roles, you can use this to make\n"
 1275             "PGP processing completely optional by defining a role here\n"
 1276             "and not assigning any users to that role."),
 1277         (NullableOption, "homedir", "",
 1278             "Location of PGP directory. Defaults to $HOME/.gnupg if\n"
 1279             "not specified."),
 1280         (BooleanOption, "encrypt", "no",
 1281             "Enable PGP encryption. All outgoing mails are encrypted.\n"
 1282             "This requires that keys for all users (with one of the gpg\n"
 1283             "roles above or all users if empty) are available. Note that\n"
 1284             "it makes sense to educate users to also send mails encrypted\n"
 1285             "to the tracker, to enforce this, set 'require_incoming'\n"
 1286             "option below (but see the note)."),
 1287         (Option, "require_incoming", "signed",
 1288             "Require that pgp messages received by roundup are either\n"
 1289             "'signed', 'encrypted' or 'both'. If encryption is required\n"
 1290             "we do not return the message (in clear) to the user but just\n"
 1291             "send an informational message that the message was rejected.\n"
 1292             "Note that this still presents known-plaintext to an attacker\n"
 1293             "when the users sends the mail a second time with encryption\n"
 1294             "turned on."),
 1295     ), "OpenPGP mail processing options"),
 1296     ("nosy", (
 1297         (Option, "messages_to_author", "no",
 1298             "Send nosy messages to the author of the message.\n"
 1299             "Allowed values: yes, no, new, nosy -- if yes, messages\n"
 1300             "are sent to the author even if not on the nosy list, same\n"
 1301             "for new (but only for new messages). When set to nosy,\n"
 1302             "the nosy list controls sending messages to the author.",
 1303             ["MESSAGES_TO_AUTHOR"]),
 1304         (Option, "signature_position", "bottom",
 1305             "Where to place the email signature.\n"
 1306             "Allowed values: top, bottom, none",
 1307             ["EMAIL_SIGNATURE_POSITION"]),
 1308         (RunDetectorOption, "add_author", "new",
 1309             "Does the author of a message get placed on the nosy list\n"
 1310             "automatically?  If 'new' is used, then the author will\n"
 1311             "only be added when a message creates a new issue.\n"
 1312             "If 'yes', then the author will be added on followups too.\n"
 1313             "If 'no', they're never added to the nosy.\n",
 1314             ["ADD_AUTHOR_TO_NOSY"]),
 1315         (RunDetectorOption, "add_recipients", "new",
 1316             "Do the recipients (To:, Cc:) of a message get placed on the\n"
 1317             "nosy list?  If 'new' is used, then the recipients will\n"
 1318             "only be added when a message creates a new issue.\n"
 1319             "If 'yes', then the recipients will be added on followups too.\n"
 1320             "If 'no', they're never added to the nosy.\n",
 1321             ["ADD_RECIPIENTS_TO_NOSY"]),
 1322         (Option, "email_sending", "single",
 1323             "Controls the email sending from the nosy reactor. If\n"
 1324             "\"multiple\" then a separate email is sent to each\n"
 1325             "recipient. If \"single\" then a single email is sent with\n"
 1326             "each recipient as a CC address."),
 1327         (IntegerNumberGeqZeroOption, "max_attachment_size", sys.maxsize,
 1328             "Attachments larger than the given number of bytes\n"
 1329             "won't be attached to nosy mails. They will be replaced by\n"
 1330             "a link to the tracker's download page for the file.")
 1331     ), "Nosy messages sending"),
 1332 )
 1333 
 1334 ### Configuration classes
 1335 
 1336 
 1337 class Config:
 1338 
 1339     """Base class for configuration objects.
 1340 
 1341     Configuration options may be accessed as attributes or items
 1342     of instances of this class.  All option names are uppercased.
 1343 
 1344     """
 1345 
 1346     # Config file name
 1347     INI_FILE = "config.ini"
 1348 
 1349     # Object attributes that should not be taken as common configuration
 1350     # options in __setattr__ (most of them are initialized in constructor):
 1351     # builtin pseudo-option - package home directory
 1352     HOME = "."
 1353     # names of .ini file sections, in order
 1354     sections = None
 1355     # section comments
 1356     section_descriptions = None
 1357     # lists of option names for each section, in order
 1358     section_options = None
 1359     # mapping from option names and aliases to Option instances
 1360     options = None
 1361     # actual name of the config file.  set on load.
 1362     filepath = os.path.join(HOME, INI_FILE)
 1363 
 1364     def __init__(self, config_path=None, layout=None, settings={}):
 1365         """Initialize confing instance
 1366 
 1367         Parameters:
 1368             config_path:
 1369                 optional directory or file name of the config file.
 1370                 If passed, load the config after processing layout (if any).
 1371                 If config_path is a directory name, use default base name
 1372                 of the config file.
 1373             layout:
 1374                 optional configuration layout, a sequence of
 1375                 section definitions suitable for .add_section()
 1376             settings:
 1377                 optional setting overrides (dictionary).
 1378                 The overrides are applied after loading config file.
 1379 
 1380         """
 1381         # initialize option containers:
 1382         self.sections = []
 1383         self.section_descriptions = {}
 1384         self.section_options = {}
 1385         self.options = {}
 1386         # add options from the layout structure
 1387         if layout:
 1388             for section in layout:
 1389                 self.add_section(*section)
 1390         if config_path is not None:
 1391             self.load(config_path)
 1392         for (name, value) in settings.items():
 1393             self[name.upper()] = value
 1394 
 1395     def add_section(self, section, options, description=None):
 1396         """Define new config section
 1397 
 1398         Parameters:
 1399             section - name of the config.ini section
 1400             options - a sequence of Option definitions.
 1401                 Each Option definition is a sequence
 1402                 containing class object and constructor
 1403                 parameters, starting from the setting name:
 1404                 setting, default, [description, [aliases]]
 1405             description - optional section comment
 1406 
 1407         Note: aliases should only exist in historical options
 1408         for backwards compatibility - new options should
 1409         *not* have aliases!
 1410 
 1411         """
 1412         if description or not (section in self.section_descriptions):
 1413             self.section_descriptions[section] = description
 1414         for option_def in options:
 1415             klass = option_def[0]
 1416             args = option_def[1:]
 1417             option = klass(self, section, *args)
 1418             self.add_option(option)
 1419 
 1420     def add_option(self, option):
 1421         """Adopt a new Option object"""
 1422         _section = option.section
 1423         _name = option.setting
 1424         if _section not in self.sections:
 1425             self.sections.append(_section)
 1426         _options = self._get_section_options(_section)
 1427         if _name not in _options:
 1428             _options.append(_name)
 1429         # (section, name) key is used for writing .ini file
 1430         self.options[(_section, _name)] = option
 1431         # make the option known under all of its A.K.A.s
 1432         for _name in option.aliases:
 1433             self.options[_name] = option
 1434 
 1435     def update_option(self, name, klass,
 1436                       default=NODEFAULT, description=None):
 1437         """Override behaviour of early created option.
 1438 
 1439         Parameters:
 1440             name:
 1441                 option name
 1442             klass:
 1443                 one of the Option classes
 1444             default:
 1445                 optional default value for the option
 1446             description:
 1447                 optional new description for the option
 1448 
 1449         Conversion from current option value to new class value
 1450         is done via string representation.
 1451 
 1452         This method may be used to attach some brains
 1453         to options autocreated by UserConfig.
 1454 
 1455         """
 1456         # fetch current option
 1457         option = self._get_option(name)
 1458         # compute constructor parameters
 1459         if default is NODEFAULT:
 1460             default = option.default
 1461         if description is None:
 1462             description = option.description
 1463         value = option.value2str(current=1)
 1464         # resurrect the option
 1465         option = klass(self, option.section, option.setting,
 1466                        default=default, description=description)
 1467         # apply the value
 1468         option.set(value)
 1469         # incorporate new option
 1470         del self[name]
 1471         self.add_option(option)
 1472 
 1473     def reset(self):
 1474         """Set all options to their default values"""
 1475         for _option in self.items():
 1476             _option.reset()
 1477 
 1478     # Meant for commandline tools.
 1479     # Allows automatic creation of configuration files like this:
 1480     #  roundup-server -p 8017 -u roundup --save-config
 1481     def getopt(self, args, short_options="", long_options=(),
 1482                config_load_options=("C", "config"), **options):
 1483         """Apply options specified in command line arguments.
 1484 
 1485         Parameters:
 1486             args:
 1487                 command line to parse (sys.argv[1:])
 1488             short_options:
 1489                 optional string of letters for command line options
 1490                 that are not config options
 1491             long_options:
 1492                 optional list of names for long options
 1493                 that are not config options
 1494             config_load_options:
 1495                 two-element sequence (letter, long_option) defining
 1496                 the options for config file.  If unset, don't load
 1497                 config file; otherwise config file is read prior
 1498                 to applying other options.  Short option letter
 1499                 must not have a colon and long_option name must
 1500                 not have an equal sign or '--' prefix.
 1501             options:
 1502                 mapping from option names to command line option specs.
 1503                 e.g. server_port="p:", server_user="u:"
 1504                 Names are forced to lower case for commandline parsing
 1505                 (long options) and to upper case to find config options.
 1506                 Command line options accepting no value are assumed
 1507                 to be binary and receive value 'yes'.
 1508 
 1509         Return value: same as for python standard getopt(), except that
 1510         processed options are removed from returned option list.
 1511 
 1512         """
 1513         # take a copy of long_options
 1514         long_options = list(long_options)
 1515         # build option lists
 1516         cfg_names = {}
 1517         booleans = []
 1518         for (name, letter) in options.items():
 1519             cfg_name = name.upper()
 1520             short_opt = "-" + letter[0]
 1521             name = name.lower().replace("_", "-")
 1522             cfg_names.update({short_opt: cfg_name, "--" + name: cfg_name})
 1523 
 1524             short_options += letter
 1525             if letter[-1] == ":":
 1526                 long_options.append(name + "=")
 1527             else:
 1528                 booleans.append(short_opt)
 1529                 long_options.append(name)
 1530 
 1531         if config_load_options:
 1532             short_options += config_load_options[0] + ":"
 1533             long_options.append(config_load_options[1] + "=")
 1534             # compute names that will be searched in getopt return value
 1535             config_load_options = (
 1536                 "-" + config_load_options[0],
 1537                 "--" + config_load_options[1],
 1538             )
 1539         # parse command line arguments
 1540         optlist, args = getopt.getopt(args, short_options, long_options)
 1541         # load config file if requested
 1542         if config_load_options:
 1543             for option in optlist:
 1544                 if option[0] in config_load_options:
 1545                     self.load_ini(option[1])
 1546                     optlist.remove(option)
 1547                     break
 1548         # apply options
 1549         extra_options = []
 1550         for (opt, arg) in optlist:
 1551             if (opt in booleans):  # and not arg
 1552                 arg = "yes"
 1553             try:
 1554                 name = cfg_names[opt]
 1555             except KeyError:
 1556                 extra_options.append((opt, arg))
 1557             else:
 1558                 self[name] = arg
 1559         return (extra_options, args)
 1560 
 1561     # option and section locators (used in option access methods)
 1562 
 1563     def _get_option(self, name):
 1564         try:
 1565             return self.options[name]
 1566         except KeyError:
 1567             raise InvalidOptionError(name)
 1568 
 1569     def _get_section_options(self, name):
 1570         return self.section_options.setdefault(name, [])
 1571 
 1572     def _get_unset_options(self):
 1573         """Return options that need manual adjustments
 1574 
 1575         Return value is a dictionary where keys are section
 1576         names and values are lists of option names as they
 1577         appear in the config file.
 1578 
 1579         """
 1580         need_set = {}
 1581         for option in self.items():
 1582             if not option.isset():
 1583                 need_set.setdefault(option.section, []).append(option.setting)
 1584         return need_set
 1585 
 1586     def _adjust_options(self, config):
 1587         """Load ad-hoc option definitions from ConfigParser instance."""
 1588         pass
 1589 
 1590     def _get_name(self):
 1591         """Return the service name for config file heading"""
 1592         return ""
 1593 
 1594     # file operations
 1595 
 1596     def load_ini(self, config_path, defaults=None):
 1597         """Set options from config.ini file in given home_dir
 1598 
 1599         Parameters:
 1600             config_path:
 1601                 directory or file name of the config file.
 1602                 If config_path is a directory name, use default
 1603                 base name of the config file
 1604             defaults:
 1605                 optional dictionary of defaults for ConfigParser
 1606 
 1607         Note: if home_dir does not contain config.ini file,
 1608         no error is raised.  Config will be reset to defaults.
 1609 
 1610         """
 1611         if os.path.isdir(config_path):
 1612             home_dir = config_path
 1613             config_path = os.path.join(config_path, self.INI_FILE)
 1614         else:
 1615             home_dir = os.path.dirname(config_path)
 1616         # parse the file
 1617         config_defaults = {"HOME": home_dir}
 1618         if defaults:
 1619             config_defaults.update(defaults)
 1620         config = configparser.ConfigParser(config_defaults)
 1621         config.read([config_path])
 1622         # .ini file loaded ok.
 1623         self.HOME = home_dir
 1624         self.filepath = config_path
 1625         self._adjust_options(config)
 1626         # set the options, starting from HOME
 1627         self.reset()
 1628         for option in self.items():
 1629             option.load_ini(config)
 1630 
 1631     def load(self, home_dir):
 1632         """Load configuration settings from home_dir"""
 1633         self.load_ini(home_dir)
 1634 
 1635     def save(self, ini_file=None):
 1636         """Write current configuration to .ini file
 1637 
 1638         'ini_file' argument, if passed, must be valid full path
 1639         to the file to write.  If omitted, default file in current
 1640         HOME is created.
 1641 
 1642         If the file to write already exists, it is saved with '.bak'
 1643         extension.
 1644 
 1645         """
 1646         if ini_file is None:
 1647             ini_file = self.filepath
 1648         _tmp_file = os.path.splitext(ini_file)[0]
 1649         _bak_file = _tmp_file + ".bak"
 1650         _tmp_file = _tmp_file + ".tmp"
 1651         _fp = open(_tmp_file, "wt")
 1652         _fp.write("# %s configuration file\n" % self._get_name())
 1653         _fp.write("# Autogenerated at %s\n" % time.asctime())
 1654         need_set = self._get_unset_options()
 1655         if need_set:
 1656             _fp.write("\n# WARNING! Following options need adjustments:\n")
 1657             for section, options in need_set.items():
 1658                 _fp.write("#  [%s]: %s\n" % (section, ", ".join(options)))
 1659         for section in self.sections:
 1660             comment = self.section_descriptions.get(section, None)
 1661             if comment:
 1662                 _fp.write("\n# ".join([""] + comment.split("\n")) + "\n")
 1663             else:
 1664                 # no section comment - just leave a blank line between sections
 1665                 _fp.write("\n")
 1666             _fp.write("[%s]\n" % section)
 1667             for option in self._get_section_options(section):
 1668                 _fp.write("\n" + self.options[(section, option)].format())
 1669         _fp.close()
 1670         if os.access(ini_file, os.F_OK):
 1671             if os.access(_bak_file, os.F_OK):
 1672                 os.remove(_bak_file)
 1673             os.rename(ini_file, _bak_file)
 1674         os.rename(_tmp_file, ini_file)
 1675 
 1676     # container emulation
 1677 
 1678     def __len__(self):
 1679         return len(self.items())
 1680 
 1681     def __getitem__(self, name):
 1682         if name == "HOME":
 1683             return self.HOME
 1684         else:
 1685             return self._get_option(name).get()
 1686 
 1687     def __setitem__(self, name, value):
 1688         if name == "HOME":
 1689             self.HOME = value
 1690         else:
 1691             self._get_option(name).set(value)
 1692 
 1693     def __delitem__(self, name):
 1694         _option = self._get_option(name)
 1695         _section = _option.section
 1696         _name = _option.setting
 1697         self._get_section_options(_section).remove(_name)
 1698         del self.options[(_section, _name)]
 1699         for _alias in _option.aliases:
 1700             del self.options[_alias]
 1701 
 1702     def items(self):
 1703         """Return the list of Option objects, in .ini file order
 1704 
 1705         Note that HOME is not included in this list
 1706         because it is builtin pseudo-option, not a real Option
 1707         object loaded from or saved to .ini file.
 1708 
 1709         """
 1710         return [self.options[(_section, _name)]
 1711                 for _section in self.sections
 1712                 for _name in self._get_section_options(_section)]
 1713 
 1714     def keys(self):
 1715         """Return the list of "canonical" names of the options
 1716 
 1717         Unlike .items(), this list also includes HOME
 1718 
 1719         """
 1720         return ["HOME"] + [_option.name for _option in self.items()]
 1721 
 1722     # .values() is not implemented because i am not sure what should be
 1723     # the values returned from this method: Option instances or config values?
 1724 
 1725     # attribute emulation
 1726 
 1727     def __setattr__(self, name, value):
 1728         if (name in self.__dict__) or hasattr(self.__class__, name):
 1729             self.__dict__[name] = value
 1730         else:
 1731             self._get_option(name).set(value)
 1732 
 1733     # Note: __getattr__ is not symmetric to __setattr__:
 1734     #   self.__dict__ lookup is done before calling this method
 1735     def __getattr__(self, name):
 1736         return self[name]
 1737 
 1738 
 1739 class UserConfig(Config):
 1740 
 1741     """Configuration for user extensions.
 1742 
 1743     Instances of this class have no predefined configuration layout.
 1744     Options are created on the fly for each setting present in the
 1745     config file.
 1746 
 1747     """
 1748 
 1749     def _adjust_options(self, config):
 1750         # config defaults appear in all sections.
 1751         # we'll need to filter them out.
 1752         defaults = list(config.defaults().keys())
 1753         # see what options are already defined and add missing ones
 1754         preset = [(option.section, option.setting) for option in self.items()]
 1755         for section in config.sections():
 1756             for name in config.options(section):
 1757                 if ((section, name) not in preset) \
 1758                    and (name not in defaults):
 1759                     self.add_option(Option(self, section, name))
 1760 
 1761 
 1762 class CoreConfig(Config):
 1763 
 1764     """Roundup instance configuration.
 1765 
 1766     Core config has a predefined layout (see the SETTINGS structure),
 1767     supports loading of old-style pythonic configurations and holds
 1768     three additional attributes:
 1769         detectors:
 1770             user-defined configuration for detectors
 1771         ext:
 1772             user-defined configuration for extensions
 1773 
 1774     """
 1775 
 1776     # module name for old style configuration
 1777     PYCONFIG = "config"
 1778     # user configs
 1779     ext = None
 1780     detectors = None
 1781 
 1782     def __init__(self, home_dir=None, settings={}):
 1783         Config.__init__(self, home_dir, layout=SETTINGS, settings=settings)
 1784         # load the config if home_dir given
 1785         if home_dir is None:
 1786             self.init_logging()
 1787 
 1788     def copy(self):
 1789         new = CoreConfig()
 1790         new.sections = list(self.sections)
 1791         new.section_descriptions = dict(self.section_descriptions)
 1792         new.section_options = dict(self.section_options)
 1793         new.options = dict(self.options)
 1794         return new
 1795 
 1796     def _get_unset_options(self):
 1797         need_set = Config._get_unset_options(self)
 1798         # remove MAIL_PASSWORD if MAIL_USER is empty
 1799         if "password" in need_set.get("mail", []):
 1800             if not self["MAIL_USERNAME"]:
 1801                 settings = need_set["mail"]
 1802                 settings.remove("password")
 1803                 if not settings:
 1804                     del need_set["mail"]
 1805         return need_set
 1806 
 1807     def _get_name(self):
 1808         return self["TRACKER_NAME"]
 1809 
 1810     def reset(self):
 1811         Config.reset(self)
 1812         if self.ext:
 1813             self.ext.reset()
 1814         if self.detectors:
 1815             self.detectors.reset()
 1816         self.init_logging()
 1817 
 1818     def init_logging(self):
 1819         _file = self["LOGGING_CONFIG"]
 1820         if _file and os.path.isfile(_file):
 1821             logging.config.fileConfig(_file,
 1822                    disable_existing_loggers=self["LOGGING_DISABLE_LOGGERS"])
 1823             return
 1824 
 1825         _file = self["LOGGING_FILENAME"]
 1826         # set file & level on the roundup logger
 1827         logger = logging.getLogger('roundup')
 1828         if _file:
 1829             hdlr = logging.FileHandler(_file)
 1830         else:
 1831             hdlr = logging.StreamHandler(sys.stdout)
 1832         formatter = logging.Formatter(
 1833             '%(asctime)s %(levelname)s %(message)s')
 1834         hdlr.setFormatter(formatter)
 1835         # no logging API to remove all existing handlers!?!
 1836         for h in logger.handlers:
 1837             h.close()
 1838             logger.removeHandler(hdlr)
 1839         logger.handlers = [hdlr]
 1840         logger.setLevel(self["LOGGING_LEVEL"] or "ERROR")
 1841 
 1842     def load(self, home_dir):
 1843         """Load configuration from path designated by home_dir argument"""
 1844         if os.path.isfile(os.path.join(home_dir, self.INI_FILE)):
 1845             self.load_ini(home_dir)
 1846         else:
 1847             self.load_pyconfig(home_dir)
 1848         self.init_logging()
 1849         self.ext = UserConfig(os.path.join(home_dir, "extensions"))
 1850         self.detectors = UserConfig(os.path.join(home_dir, "detectors"))
 1851 
 1852     def load_ini(self, home_dir, defaults=None):
 1853         """Set options from config.ini file in given home_dir directory"""
 1854         config_defaults = {"TRACKER_HOME": home_dir}
 1855         if defaults:
 1856             config_defaults.update(defaults)
 1857         Config.load_ini(self, home_dir, config_defaults)
 1858 
 1859     def load_pyconfig(self, home_dir):
 1860         """Set options from config.py file in given home_dir directory"""
 1861         # try to locate and import the module
 1862         _mod_fp = None
 1863         try:
 1864             try:
 1865                 _module = imp.find_module(self.PYCONFIG, [home_dir])
 1866                 _mod_fp = _module[0]
 1867                 _config = imp.load_module(self.PYCONFIG, *_module)
 1868             except ImportError:
 1869                 raise NoConfigError(home_dir)
 1870         finally:
 1871             if _mod_fp is not None:
 1872                 _mod_fp.close()
 1873         # module loaded ok.  set the options, starting from HOME
 1874         self.reset()
 1875         self.HOME = home_dir
 1876         for _option in self.items():
 1877             _option.load_pyconfig(_config)
 1878         # backward compatibility:
 1879         # SMTP login parameters were specified as a tuple in old style configs
 1880         # convert them to new plain string options
 1881         _mailuser = getattr(_config, "MAILUSER", ())
 1882         if len(_mailuser) > 0:
 1883             self.MAIL_USERNAME = _mailuser[0]
 1884         if len(_mailuser) > 1:
 1885             self.MAIL_PASSWORD = _mailuser[1]
 1886 
 1887     # in this config, HOME is also known as TRACKER_HOME
 1888     def __getitem__(self, name):
 1889         if name == "TRACKER_HOME":
 1890             return self.HOME
 1891         else:
 1892             return Config.__getitem__(self, name)
 1893 
 1894     def __setitem__(self, name, value):
 1895         if name == "TRACKER_HOME":
 1896             self.HOME = value
 1897         else:
 1898             self._get_option(name).set(value)
 1899 
 1900     def __setattr__(self, name, value):
 1901         if name == "TRACKER_HOME":
 1902             self.__dict__["HOME"] = value
 1903         else:
 1904             Config.__setattr__(self, name, value)
 1905 
 1906 # vim: set et sts=4 sw=4 :