changeset 8443:39a6825d10ca

feat: allow admin to set logging format from config.ini This is prep work for adding a per thread logging variable that can be used to tie all logs for a single request together. This uses the same default logging format as before, just moves it to config.ini. Also because of configparser, the logging format has to have doubled % signs. So use: %%(asctime)s not '%(asctime)s' as configparser tries to interpolate that string and asctime is not defined in the configparser's scope. Using %%(asctime)s is not interpolated by configparser and is passed into Roundup.
author John Rouillard <rouilj@ieee.org>
date Mon, 01 Sep 2025 21:54:48 -0400
parents 0bb29d0509c9
children e024fbb86f90
files CHANGES.txt doc/admin_guide.txt roundup/configuration.py test/test_config.py
diffstat 4 files changed, 107 insertions(+), 4 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES.txt	Mon Sep 01 20:35:54 2025 -0400
+++ b/CHANGES.txt	Mon Sep 01 21:54:48 2025 -0400
@@ -42,6 +42,8 @@
 - add readline command to roundup-admin to list history, control input
   mode etc. Also support bang (!) commands to rerun commands in history
   or put them in the input buffer for editing. (John Rouillard)
+- add format to logging section in config.ini. Used to set default
+  logging format. (John Rouillard)
 
 2025-07-13 2.5.0
 
--- a/doc/admin_guide.txt	Mon Sep 01 20:35:54 2025 -0400
+++ b/doc/admin_guide.txt	Mon Sep 01 21:54:48 2025 -0400
@@ -63,6 +63,8 @@
  - tracker configuration file lets you disable other loggers
    (e.g. when running under a wsgi framework) with
    ``logging`` -> ``disable_loggers``.
+ - tracker configuration file can set the log format using
+   ``logging`` -> ``format``. See :ref:`logFormat` for more info.
  - ``roundup-server`` specifies the location of a log file on the command
    line
  - ``roundup-server`` enable  using the standard python logger with
@@ -83,10 +85,26 @@
 In both cases, if no logfile is specified then logging will simply be sent
 to sys.stderr with only logging of ERROR messages.
 
+.. _logFormat:
+
+Defining the Log Format
+-----------------------
+
+Starting with Roundup 2.6 you can specify the logging format. In the
+``logging`` -> ``format`` setting of config.ini you can use any of the
+`standard logging LogRecord attributes
+<https://docs.python.org/3/library/logging.html#logrecord-attributes>`_.
+However you must double any ``%`` format markers. The default value
+is::
+
+  %%(asctime)s %%(levelname)s %%(message)s
+
 Standard Logging Setup
 ----------------------
 
-You can specify your log configs in one of two formats:
+If the settings in config.ini are not sufficient for your logging
+requirements, you can specify a full logging configuration in one of
+two formats:
 
   * `fileConfig format
     <https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig>`_
--- a/roundup/configuration.py	Mon Sep 01 20:35:54 2025 -0400
+++ b/roundup/configuration.py	Mon Sep 01 21:54:48 2025 -0400
@@ -273,7 +273,8 @@
         try:
             if config.has_option(self.section, self.setting):
                 self.set(config.get(self.section, self.setting))
-        except configparser.InterpolationSyntaxError as e:
+        except (configparser.InterpolationSyntaxError,
+                configparser.InterpolationMissingOptionError) as e:
             raise ParsingOptionError(
                 _("Error in %(filepath)s with section [%(section)s] at "
                   "option %(option)s: %(message)s") % {
@@ -575,6 +576,48 @@
             return None
 
 
+class LoggingFormatOption(Option):
+    """Replace escaped % (as %%) with single %.
+
+       Config file parsing allows variable interpolation using
+       %(keyname)s.  However this is exactly the format that we need
+       for creating a logging format string. So we tell the user to
+       quote the string using %%(...). Then we turn %%( -> %( when
+       retrieved.
+    """
+
+    class_description = ("Allowed value: Python logging module named "
+                         "attributes with % sign doubled.")
+
+    def str2value(self, value):
+        """Check format of unquoted string looking for missing specifiers.
+
+           This does a dirty check to see if a token is missing a
+           specifier. So "%(ascdate)s %(level) " would fail because of
+           the 's' missing after 'level)'. But "%(ascdate)s %(level)s"
+           would pass.
+
+           Note that %(foo)s generates a error from the ini parser
+           with a less than wonderful message.
+        """
+        unquoted_val = value.replace("%%(", "%(")
+
+        # regexp matches all current logging record object attribute names.
+        scanned_result = re.sub(r'%\([A-Za-z_]+\)\S','', unquoted_val )
+        if scanned_result.find('%(') != -1:
+            raise OptionValueError(
+                self, unquoted_val,
+                "Check that all substitution tokens have a format "
+                "specifier after the ). Unrecognized use of %%(...) in: "
+                "%s" % scanned_result)
+
+        return str(unquoted_val)
+
+    def _value2str(self, value):
+        """Replace %( with %%( to quote the format substitution param.
+        """
+        return value.replace("%(", "%%(")
+
 class OriginHeadersListOption(Option):
 
     """List of space seperated origin header values.
@@ -1614,6 +1657,10 @@
             "Minimal severity level of messages written to log file.\n"
             "If above 'config' option is set, this option has no effect.\n"
             "Allowed values: DEBUG, INFO, WARNING, ERROR"),
+        (LoggingFormatOption, "format",
+         "%(asctime)s %(levelname)s %(message)s",
+            "Format of the logging messages with all '%' signs\n"
+            "doubled so they are not interpreted by the config file."),
         (BooleanOption, "disable_loggers", "no",
             "If set to yes, only the loggers configured in this section will\n"
             "be used. Yes will disable gunicorn's --access-logfile.\n"),
@@ -2448,8 +2495,7 @@
         hdlr = logging.FileHandler(_file) if _file else \
             logging.StreamHandler(sys.stdout)
 
-        formatter = logging.Formatter(
-            '%(asctime)s %(levelname)s %(message)s')
+        formatter = logging.Formatter(self["LOGGING_FORMAT"])
         hdlr.setFormatter(formatter)
         # no logging API to remove all existing handlers!?!
         for h in logger.handlers:
--- a/test/test_config.py	Mon Sep 01 20:35:54 2025 -0400
+++ b/test/test_config.py	Mon Sep 01 21:54:48 2025 -0400
@@ -1112,6 +1112,43 @@
         self.assertIn("nati", string_rep)
         self.assertIn("'whoosh'", string_rep)
 
+    def testLoggerFormat(self):
+        config = configuration.CoreConfig()
+
+        # verify config is initalized to defaults
+        self.assertEqual(config['LOGGING_FORMAT'],
+                         '%(asctime)s %(levelname)s %(message)s')
+
+        # load config
+        config.load(self.dirname)
+        self.assertEqual(config['LOGGING_FORMAT'],
+                         '%(asctime)s %(levelname)s %(message)s')
+
+        # break config using an incomplete format specifier (no trailing 's')
+        self.munge_configini(mods=[ ("format = ", "%%(asctime)s %%(levelname) %%(message)s") ], section="[logging]")
+
+        # load config
+        with self.assertRaises(configuration.OptionValueError) as cm:
+            config.load(self.dirname)
+            
+        self.assertIn('Unrecognized use of %(...) in:  %(levelname)',
+                      cm.exception.args[2])
+
+        # break config by not dubling % sign to quote it from configparser
+        self.munge_configini(mods=[ ("format = ", "%(asctime)s %%(levelname) %%(message)s") ], section="[logging]")
+
+        with self.assertRaises(
+                configuration.ParsingOptionError) as cm:
+            config.load(self.dirname)
+
+        self.assertEqual(cm.exception.args[0],
+                         "Error in _test_instance/config.ini with section "
+                         "[logging] at option format: Bad value substitution: "
+                         "option 'format' in section 'logging' contains an "
+                         "interpolation key 'asctime' which is not a valid "
+                         "option name. Raw value: '%(asctime)s %%(levelname) "
+                         "%%(message)s'")
+
     def testDictLoggerConfigViaJson(self):
 
         # good base test case

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