diff roundup/configuration.py @ 6638:e1588ae185dc issue2550923_computed_property

merge from default branch. Fix travis.ci so CI builds don't error out
author John Rouillard <rouilj@ieee.org>
date Thu, 21 Apr 2022 16:54:17 -0400
parents 0d99ae7c8de6
children 408fd477761f
line wrap: on
line diff
--- a/roundup/configuration.py	Fri Oct 08 00:37:16 2021 -0400
+++ b/roundup/configuration.py	Thu Apr 21 16:54:17 2022 -0400
@@ -9,7 +9,9 @@
 # used here instead of try/except.
 import sys
 import getopt
-import logging, logging.config
+import errno
+import logging
+import logging.config
 import os
 import re
 import time
@@ -31,8 +33,6 @@
 
 from roundup.exceptions import RoundupException
 
-# XXX i don't think this module needs string translation, does it?
-
 ### Exceptions
 
 
@@ -40,6 +40,11 @@
     pass
 
 
+class ParsingOptionError(ConfigurationError):
+    def __str__(self):
+        return self.args[0]
+
+
 class NoConfigError(ConfigurationError):
 
     """Raised when configuration loading fails
@@ -261,8 +266,17 @@
 
     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))
+        try:
+            if config.has_option(self.section, self.setting):
+                self.set(config.get(self.section, self.setting))
+        except configparser.InterpolationSyntaxError as e:
+            raise ParsingOptionError(
+                _("Error in %(filepath)s with section [%(section)s] at "
+                  "option %(option)s: %(message)s") % {
+                      "filepath": self.config.filepath,
+                      "section": e.section,
+                      "option": e.option,
+                      "message": str(e)})
 
 
 class BooleanOption(Option):
@@ -408,19 +422,91 @@
             return _val
         raise OptionValueError(self, value, self.class_description)
 
+
 class IndexerOption(Option):
     """Valid options for indexer"""
 
-    allowed = ['', 'xapian', 'whoosh', 'native']
+    allowed = ['', 'xapian', 'whoosh', 'native', 'native-fts']
     class_description = "Allowed values: %s" % ', '.join("'%s'" % a
                                                          for a in allowed)
 
+    # FIXME this is the result of running:
+    #    SELECT cfgname FROM pg_ts_config;
+    # on a postgresql 14.1 server.
+    # So the best we can do is hardcode this.
+    valid_langs = [ "simple",
+                    "custom1",
+                    "custom2",
+                    "custom3",
+                    "custom4",
+                    "custom5",
+                    "arabic",
+                    "armenian",
+                    "basque",
+                    "catalan",
+                    "danish",
+                    "dutch",
+                    "english",
+                    "finnish",
+                    "french",
+                    "german",
+                    "greek",
+                    "hindi",
+                    "hungarian",
+                    "indonesian",
+                    "irish",
+                    "italian",
+                    "lithuanian",
+                    "nepali",
+                    "norwegian",
+                    "portuguese",
+                    "romanian",
+                    "russian",
+                    "serbian",
+                    "spanish",
+                    "swedish",
+                    "tamil",
+                    "turkish",
+                    "yiddish" ]
+                                                         
     def str2value(self, value):
         _val = value.lower()
         if _val in self.allowed:
             return _val
         raise OptionValueError(self, value, self.class_description)
 
+    def validate(self, options):
+
+        if self._value in ("", "xapian"):
+            try:
+                import xapian
+            except ImportError:
+                # indexer is probably '' and xapian isn't present
+                # so just return at end of method
+                pass
+            else:
+                try:
+                    lang = options["INDEXER_LANGUAGE"]._value
+                    xapian.Stem(lang)
+                except xapian.InvalidArgumentError:
+                    import textwrap
+                    lang_avail = b2s(xapian.Stem.get_available_languages())
+                    languages = textwrap.fill(_("Valid languages: ") +
+                                              lang_avail, 75,
+                                              subsequent_indent="   ")
+                    raise OptionValueError(options["INDEXER_LANGUAGE"],
+                                            lang, languages)
+
+        if self._value == "native-fts":
+            lang = options["INDEXER_LANGUAGE"]._value
+            if lang not in self.valid_langs:
+                import textwrap
+                languages = textwrap.fill(_("Expected languages: ") +
+                                            " ".join(self.valid_langs), 75,
+                                            subsequent_indent="   ")
+                raise OptionValueError(options["INDEXER_LANGUAGE"],
+                                       lang, languages)
+
 class MailAddressOption(Option):
 
     """Email address
@@ -552,6 +638,62 @@
             return value
 
 
+class SecretOption(Option):
+    """A string not beginning with file:// or a file starting with file://
+
+    Paths may be either absolute or relative to the HOME.
+    Value for option is the first line in the file.
+    It is mean to store secret information in the config file but
+    allow the config file to be stored in version control without
+    storing the secret there.
+
+    """
+
+    class_description = \
+      "A string that starts with 'file://' is interpreted as a file path \n" \
+      "relative to the tracker home. Using 'file:///' defines an absolute \n" \
+      "path. The first line of the file will be used as the value. Any \n" \
+      "string that does not start with 'file://' is used as is. It \n" \
+      "removes any whitespace at the end of the line, so a newline can \n" \
+      "be put in the file.\n"
+
+    def get(self):
+        _val = Option.get(self)
+        if isinstance(_val, str) and _val.startswith('file://'):
+            filepath = _val[7:]
+            if filepath and not os.path.isabs(filepath):
+                filepath = os.path.join(self.config["HOME"], filepath.strip())
+            try:
+                with open(filepath) as f:
+                    _val = f.readline().rstrip()
+            # except FileNotFoundError: py2/py3
+            # compatible version
+            except EnvironmentError as e:
+                if e.errno != errno.ENOENT:
+                    raise
+                else:
+                    raise OptionValueError(self, _val,
+                        "Unable to read value for %s. Error opening "
+                        "%s: %s." % (self.name, e.filename, e.args[1]))
+        return self.str2value(_val)
+
+    def validate(self, options):
+        if self.name == 'MAIL_PASSWORD':
+            if options['MAIL_USERNAME']._value:
+                # MAIL_PASSWORD is an exception. It is mandatory only
+                # if MAIL_USERNAME is set. So check only if username
+                # is set.
+                try:
+                    self.get()
+                except OptionUnsetError:
+                    # provide error message with link to MAIL_USERNAME
+                    raise OptionValueError(options["MAIL_PASSWORD"],
+                                           "not defined",
+                            "Mail username is set, so this must be defined.")
+        else:
+            self.get()
+
+
 class WebUrlOption(Option):
     """URL MUST start with http/https scheme and end with '/'"""
 
@@ -614,6 +756,18 @@
     # everything else taken from NullableOption (inheritance order)
 
 
+class SecretMandatoryOption(MandatoryOption, SecretOption):
+    # use get from SecretOption and rest from MandatoryOption
+    get = SecretOption.get
+    class_description = SecretOption.class_description
+
+
+class SecretNullableOption(NullableOption, SecretOption):
+    # use get from SecretOption and rest from NullableOption
+    get = SecretOption.get
+    class_description = SecretOption.class_description
+
+
 class TimezoneOption(Option):
 
     class_description = \
@@ -639,6 +793,17 @@
         return value
 
 
+class HttpVersionOption(Option):
+    """Used by roundup-server to verify http version is set to valid
+       string."""
+
+    def str2value(self, value):
+        if value not in ["HTTP/1.0", "HTTP/1.1"]:
+            raise OptionValueError(self, value,
+                "Valid vaues for -V or --http_version are: HTTP/1.0, HTTP/1.1")
+        return value
+
+
 class RegExpOption(Option):
 
     """Regular Expression option (value is Regular Expression Object)"""
@@ -758,17 +923,20 @@
             "Force Roundup to use a particular text indexer.\n"
             "If no indexer is supplied, the first available indexer\n"
             "will be used in the following order:\n"
-            "Possible values: xapian, whoosh, native (internal)."),
+            "Possible values: xapian, whoosh, native (internal), "
+            "native-fts.\nNote 'native-fts' will only be used if set."),
         (Option, "indexer_language", "english",
             "Used to determine what language should be used by the\n"
-            "indexer above. Currently only affects Xapian indexer. It\n"
-            "sets the language for the stemmer.\n"
+            "indexer above. Applies to Xapian and PostgreSQL native-fts\n"
+            "indexer. It sets the language for the stemmer, and PostgreSQL\n"
+            "native-fts stopwords and other dictionaries.\n"
             "Possible values: must be a valid language for the indexer,\n"
             "see indexer documentation for details."),
         (WordListOption, "indexer_stopwords", "",
             "Additional stop-words for the full-text indexer specific to\n"
             "your tracker. See the indexer source for the default list of\n"
-            "stop-words (eg. A,AND,ARE,AS,AT,BE,BUT,BY, ...)"),
+            "stop-words (eg. A,AND,ARE,AS,AT,BE,BUT,BY, ...). This is\n"
+            "not used by the native-fts indexer."),
         (OctalNumberOption, "umask", "0o002",
             "Defines the file creation mode mask."),
         (IntegerNumberGeqZeroOption, 'csv_field_size', '131072',
@@ -1038,7 +1206,7 @@
             "Setting this option makes Roundup migrate passwords with\n"
             "an insecure password-scheme to a more secure scheme\n"
             "when the user logs in via the web-interface."),
-        (MandatoryOption, "secret_key", create_token(),
+        (SecretMandatoryOption, "secret_key", create_token(),
             "A per tracker secret used in etag calculations for\n"
             "an object. It must not be empty.\n"
             "It prevents reverse engineering hidden data in an object\n"
@@ -1050,7 +1218,7 @@
             "(Note the default value changes every time\n"
             "     roundup-admin updateconfig\n"
             "is run, so it must be explicitly set to a non-empty string.\n"),
-        (MandatoryOption, "jwt_secret", "disabled",
+        (SecretNullableOption, "jwt_secret", "disabled",
             "This is used to generate/validate json web tokens (jwt).\n"
             "Even if you don't use jwts it must not be empty.\n"
             "If less than 256 bits (32 characters) in length it will\n"
@@ -1076,7 +1244,7 @@
         (NullableOption, 'user', 'roundup',
             "Database user name that Roundup should use.",
             ['MYSQL_DBUSER']),
-        (NullableOption, 'password', 'roundup',
+        (SecretNullableOption, 'password', 'roundup',
             "Database user password.",
             ['MYSQL_DBPASSWORD']),
         (NullableOption, 'read_default_file', '~/.my.cnf',
@@ -1157,7 +1325,7 @@
         (Option, "username", "", "SMTP login name.\n"
             "Set this if your mail host requires authenticated access.\n"
             "If username is not empty, password (below) MUST be set!"),
-        (Option, "password", NODEFAULT, "SMTP login password.\n"
+        (SecretMandatoryOption, "password", NODEFAULT, "SMTP login password.\n"
             "Set this if your mail host requires authenticated access."),
         (IntegerNumberGeqZeroOption, "port", smtplib.SMTP_PORT,
             "Default port to send SMTP on.\n"
@@ -1400,7 +1568,11 @@
     # actual name of the config file.  set on load.
     filepath = os.path.join(HOME, INI_FILE)
 
-    def __init__(self, config_path=None, layout=None, settings={}):
+    # List of option names that need additional validation after
+    # all options are loaded.
+    option_validators = []
+
+    def __init__(self, config_path=None, layout=None, settings=None):
         """Initialize confing instance
 
         Parameters:
@@ -1417,6 +1589,8 @@
                 The overrides are applied after loading config file.
 
         """
+        if settings is None:
+            settings = {}
         # initialize option containers:
         self.sections = []
         self.section_descriptions = {}
@@ -1471,6 +1645,9 @@
         for _name in option.aliases:
             self.options[_name] = option
 
+        if hasattr(option, 'validate'):
+            self.option_validators.append(option.name)
+
     def update_option(self, name, klass,
                       default=NODEFAULT, description=None):
         """Override behaviour of early created option.
@@ -1815,7 +1992,9 @@
     ext = None
     detectors = None
 
-    def __init__(self, home_dir=None, settings={}):
+    def __init__(self, home_dir=None, settings=None):
+        if settings is None:
+            settings = {}
         Config.__init__(self, home_dir, layout=SETTINGS, settings=settings)
         # load the config if home_dir given
         if home_dir is None:
@@ -1882,25 +2061,10 @@
             on each other. E.G. indexer_language can only be
             validated if xapian indexer is used.
         """
-        if options['INDEXER']._value in ("", "xapian"):
-            try:
-                import xapian
-            except ImportError:
-                # indexer is probably '' and xapian isn't present
-                # so just return at end of method
-                pass
-            else:
-                try:
-                    lang = options["INDEXER_LANGUAGE"]._value
-                    xapian.Stem(lang)
-                except xapian.InvalidArgumentError:
-                    import textwrap
-                    lang_avail = b2s(xapian.Stem.get_available_languages())
-                    languages = textwrap.fill(_("Valid languages: ") +
-                                              lang_avail, 75,
-                                              subsequent_indent="   ")
-                    raise OptionValueError( options["INDEXER_LANGUAGE"],
-                                            lang, languages)
+
+        for option in self.option_validators:
+            # validate() should throw an exception if there is an issue.
+            options[option].validate(options)
 
     def load(self, home_dir):
         """Load configuration from path designated by home_dir argument"""

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