diff --git a/Lib/logging/__init__.py b/Lib/logging/__init__.py index 15d5ac281791839..2675c487afab43f 100644 --- a/Lib/logging/__init__.py +++ b/Lib/logging/__init__.py @@ -1356,6 +1356,8 @@ def __init__(self, rootnode): self.disable = 0 self.emittedNoHandlerWarning = False self.loggerDict = {} + # Loggers whose isEnabledFor cache may be populated; see _clear_cache. + self._cached_loggers = weakref.WeakSet() self.loggerClass = None self.logRecordFactory = None @@ -1471,9 +1473,11 @@ def _clear_cache(self): """ with _lock: - for logger in self.loggerDict.values(): - if isinstance(logger, Logger): - logger._cache.clear() + # Only loggers that have populated a cache are visited, so + # setLevel() costs O(loggers with cached results), not O(loggers). + for logger in self._cached_loggers: + logger._cache.clear() + self._cached_loggers.clear() self.root._cache.clear() #--------------------------------------------------------------------------- @@ -1799,6 +1803,7 @@ def isEnabledFor(self, level): return self._cache[level] except KeyError: with _lock: + self.manager._cached_loggers.add(self) if self.manager.disable >= level: is_enabled = self._cache[level] = False else: diff --git a/Lib/test/test_logging.py b/Lib/test/test_logging.py index c45d8ea7901b80d..abd3b21ef5e9b58 100644 --- a/Lib/test/test_logging.py +++ b/Lib/test/test_logging.py @@ -6398,6 +6398,38 @@ def test_pickling(self): unpickled = pickle.loads(s) self.assertIs(unpickled, logger) + def test_caching_unregistered_logger(self): + # A logger that is not in loggerDict must not keep a stale cache. + self.addCleanup(logging.root.manager._clear_cache) + logger = logging.Logger("unregistered") + self.assertEqual(logger._cache, {}) + logger.setLevel(logging.DEBUG) + self.assertEqual(logger._cache, {}) + self.assertTrue(logger.isEnabledFor(logging.DEBUG)) + self.assertEqual(logger._cache, {logging.DEBUG: True}) + logger.setLevel(logging.ERROR) + self.assertEqual(logger._cache, {}) + self.assertFalse(logger.isEnabledFor(logging.DEBUG)) + self.assertEqual(logger._cache, {logging.DEBUG: False}) + + def test_clear_cache_empties_cached_loggers(self): + manager = logging.root.manager + self.addCleanup(manager._clear_cache) + registered = logging.getLogger("cached.registered") + unregistered = logging.Logger("cached.unregistered") + # Start from a known state: other tests may have cached results. + manager._clear_cache() + self.assertEqual(len(manager._cached_loggers), 0) + for logger in (registered, unregistered): + logger.isEnabledFor(logging.DEBUG) + self.assertIn(logger, manager._cached_loggers) + self.assertEqual(len(manager._cached_loggers), 2) + registered.setLevel(logging.INFO) + self.assertEqual(len(manager._cached_loggers), 0) + # A later cache miss starts tracking the logger again. + registered.isEnabledFor(logging.DEBUG) + self.assertEqual(list(manager._cached_loggers), [registered]) + def test_caching(self): root = self.root_logger logger1 = logging.getLogger("abc") diff --git a/Misc/NEWS.d/next/Library/2026-10-29-20-30-00.gh-issue-160936.lgCach.rst b/Misc/NEWS.d/next/Library/2026-10-29-20-30-00.gh-issue-160936.lgCach.rst new file mode 100644 index 000000000000000..888e9cfa88c5295 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-29-20-30-00.gh-issue-160936.lgCach.rst @@ -0,0 +1,4 @@ +Speed up :meth:`logging.Logger.setLevel` and :func:`logging.disable` when +there are many loggers: only loggers that have cached results are now cleared, +instead of every logger. This also clears the cache of loggers that are not +registered with the manager, which could previously keep a stale result.