Repository: ipython/traitlets (python)

Issue:
consider allowing logging.LoggingAdapter to be a LoggingConfigurable.log
Sphinx uses `logging.LoggingAdapter` as its own logger, while nbconvert classes all inherit from traitlets `LoggingConfigurable`, and require the `log` trait to be a `logging.Logger`. This prevents passing sphinx logger to nbconvert classes. At the same time, docs [state](https://docs.python.org/3/library/logging.html#logging.LoggerAdapter):

> ...These methods have the same signatures as their counterparts in Logger, so you can use the two types of instances interchangeably.

Pull request description:
allow LoggerAdapter in Application.log
Relax Instance type check to a warning to allow for duck typing, and handle LoggerAdapter when setting `Application.log_format`.

Individual Applications may still make assumptions that Application.log is a Logger and not a LoggerAdapter, but this is probably rare.

closes #606

Patch:
diff --git a/traitlets/config/application.py b/traitlets/config/application.py
index 1daf9a1..c93172a 100644
--- a/traitlets/config/application.py
+++ b/traitlets/config/application.py
@@ -13,6 +13,7 @@ import os
 import pprint
 import re
 import sys
+import warnings
 
 from traitlets.config.configurable import Configurable, SingletonConfigurable
 from traitlets.config.loader import (
@@ -199,7 +200,13 @@ class Application(SingletonConfigurable):
     @observe_compat
     def _log_format_changed(self, change):
         """Change the log formatter when log_format is set."""
-        _log_handler = self.log.handlers[0]
+        _log_handler = self._get_log_handler()
+        if not _log_handler:
+            warnings.warn(
+                f"No Handler found on {self.log}, setting log_format will have no effect",
+                RuntimeWarning,
+            )
+            return
         _log_formatter = self._log_formatter_cls(fmt=self.log_format, datefmt=self.log_datefmt)
         _log_handler.setFormatter(_log_formatter)
 
diff --git a/traitlets/config/configurable.py b/traitlets/config/configurable.py
index 095f73e..ec04467 100644
--- a/traitlets/config/configurable.py
+++ b/traitlets/config/configurable.py
@@ -5,10 +5,12 @@
 
 
 from copy import deepcopy
+import logging
 import warnings
 
 from .loader import Config, LazyConfigValue, DeferredConfig, _is_section_key
 from traitlets.traitlets import (
+    Any,
     HasTraits,
     Instance,
     Container,
@@ -16,6 +18,7 @@ from traitlets.traitlets import (
     observe,
     observe_compat,
     default,
+    validate,
 )
 from ipython_genutils.text import indent, dedent, wrap_paragraphs
 
@@ -433,14 +436,38 @@ class LoggingConfigurable(Configurable):
     is to get the logger from the currently running Application.
     """
 
-    log = Instance('logging.Logger')
-    @default('log')
+    log = Any(help="Logger or LoggerAdapter instance")
+
+    @validate("log")
+    def _validate_log(self, proposal):
+        if not isinstance(proposal.value, (logging.Logger, logging.LoggerAdapter)):
+            # warn about unsupported type, but be lenient to allow for duck typing
+            warnings.warn(
+                f"{self.__class__.__name__}.log should be a Logger or LoggerAdapter,"
+                f" got {proposal.value}."
+            )
+        return proposal.value
+
+    @default("log")
     def _log_default(self):
         if isinstance(self.parent, LoggingConfigurable):
             return self.parent.log
         from traitlets import log
         return log.get_logger()
 
+    def _get_log_handler(self):
+        """Return the default Handler
+
+        Returns None if none can be found
+        """
+        logger = self.log
+        if isinstance(logger, logging.LoggerAdapter):
+            logger = logger.logger
+        if not getattr(logger, "handlers", None):
+            # no handlers attribute or empty handlers list
+            return None
+        return logger.handlers[0]
+
 
 class SingletonConfigurable(LoggingConfigurable):
     """A configurable that only allows one instance.


Test patch:
diff --git a/traitlets/config/tests/test_configurable.py b/traitlets/config/tests/test_configurable.py
index 1013ed1..da0d16d 100644
--- a/traitlets/config/tests/test_configurable.py
+++ b/traitlets/config/tests/test_configurable.py
@@ -8,6 +8,7 @@ from unittest import TestCase
 
 from pytest import mark
 
+from traitlets.config.application import Application
 from traitlets.config.configurable import (
     Configurable,
     LoggingConfigurable,
@@ -666,3 +667,14 @@ class TestLogger(TestCase):
         self.assertIn('Config option `totally_wrong` not recognized by `A`.', output)
         self.assertNotIn('Did you mean', output)
 
+    def test_logger_adapter(self):
+        logger = logging.getLogger("test_logger_adapter")
+        adapter = logging.LoggerAdapter(logger, {"key": "adapted"})
+
+        with self.assertLogs(logger, logging.INFO) as captured:
+            app = Application(log=adapter, log_level=logging.INFO)
+            app.log_format = "%(key)s %(message)s"
+            app.log.info("test message")
+
+        output = "\n".join(captured.output)
+        assert "adapted test message" in output