From 7805580ede5e4811e10b91545e09bf9aed87e36d Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Mon, 31 Aug 2026 08:28:54 -0700 Subject: [PATCH 1/8] gh-999999: Defer rarely-used imports in the logging package Lift the function-level imports in logging/handlers.py and logging/config.py to module scope as lazy imports, and mark other cold-path imports lazy. Imports reachable from interpreter finalization stay eager and are now commented as such: at teardown sys.modules has been cleared, so resolving a lazy import raises ImportError('sys.meta_path is None'). The Windows-only (_winapi, winreg) and optional third-party (win32evtlogutil) imports stay function-level on purpose: as module-level lazy imports they would break inspect.getmembers() and pydoc on non-Windows. import logging.config -28.3% import logging.handlers -17.8% import logging unchanged Call-time benchmarks unchanged (all within noise). --- Lib/logging/__init__.py | 8 +++--- Lib/logging/config.py | 23 ++++++++------- Lib/logging/handlers.py | 28 +++++++++---------- ...-08-31-08-20-00.gh-issue-999999.La7yLg.rst | 5 ++++ 4 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst diff --git a/Lib/logging/__init__.py b/Lib/logging/__init__.py index 53becca4e3a696..244e0ba56517ba 100644 --- a/Lib/logging/__init__.py +++ b/Lib/logging/__init__.py @@ -23,8 +23,11 @@ To use, simply 'import logging' and log away! """ +# io, os, traceback and warnings must stay eager to support finalization import sys, os, time, io, re, traceback, warnings, weakref, collections.abc +lazy import pickle + from types import GenericAlias from string import Template from string import Formatter as StrFormatter @@ -1841,7 +1844,6 @@ def __repr__(self): def __reduce__(self): if getLogger(self.name) is not self: - import pickle raise pickle.PicklingError('logger cannot be pickled') return getLogger, (self.name,) @@ -2365,9 +2367,7 @@ def captureWarnings(capture): def __getattr__(name): if name in ("__version__", "__date__"): - from warnings import _deprecated - - _deprecated(name, remove=(3, 20)) + warnings._deprecated(name, remove=(3, 20)) return { # Do not change "__version__": "0.5.1.2", "__date__": "07 February 2010", diff --git a/Lib/logging/config.py b/Lib/logging/config.py index f566de5750dbf5..835afcb3f85ba1 100644 --- a/Lib/logging/config.py +++ b/Lib/logging/config.py @@ -24,21 +24,26 @@ To use, simply 'import logging.config' and log away! """ +lazy import configparser import errno import functools import io +lazy import json import logging -import logging.handlers +# rebinds the name `logging`, so the first `logging.` use loads handlers +lazy import logging.handlers import os -import queue +lazy import queue import re -import socket -import struct +lazy import select +lazy import socket +lazy import struct import threading import traceback -from bisect import bisect_left -from socketserver import ThreadingTCPServer, StreamRequestHandler +lazy from bisect import bisect_left +lazy from multiprocessing.queues import Queue as MPQueue +lazy from socketserver import ThreadingTCPServer, StreamRequestHandler DEFAULT_LOGGING_CONFIG_PORT = 9030 @@ -61,8 +66,6 @@ def fileConfig(fname, defaults=None, disable_existing_loggers=True, encoding=Non developer provides a mechanism to present the choices and load the chosen configuration). """ - import configparser - if isinstance(fname, str): if not os.path.exists(fname): raise FileNotFoundError(f"{fname} doesn't exist") @@ -510,8 +513,6 @@ def _is_queue_like_object(obj): """Check that *obj* implements the Queue API.""" if isinstance(obj, (queue.Queue, queue.SimpleQueue)): return True - # defer importing multiprocessing as much as possible - from multiprocessing.queues import Queue as MPQueue if isinstance(obj, MPQueue): return True # Depending on the multiprocessing start context, we cannot create @@ -978,7 +979,6 @@ def handle(self): if chunk is not None: # verified, can process chunk = chunk.decode("utf-8") try: - import json d =json.loads(chunk) assert isinstance(d, dict) dictConfig(d) @@ -1023,7 +1023,6 @@ def __init__(self, host='localhost', port=DEFAULT_LOGGING_CONFIG_PORT, self.verify = verify def serve_until_stopped(self): - import select abort = 0 while not abort: rd, wr, ex = select.select([self.socket.fileno()], diff --git a/Lib/logging/handlers.py b/Lib/logging/handlers.py index fb6b5f3b411b22..8ca02721a85ebc 100644 --- a/Lib/logging/handlers.py +++ b/Lib/logging/handlers.py @@ -23,17 +23,26 @@ To use, simply 'import logging.handlers' and log away! """ -import copy +lazy import base64 +lazy import copy +lazy import email.utils +lazy import http.client +# io and os must stay eager to support finalization import io import logging import os -import pickle -import queue +lazy import pickle +lazy import queue import re -import socket -import struct +lazy import smtplib +lazy import socket +lazy import ssl +lazy import struct import threading import time +lazy import urllib.parse + +lazy from email.message import EmailMessage # # Some constants... @@ -1113,10 +1122,6 @@ def emit(self, record): Format the record and send it to the specified addressees. """ try: - import smtplib - from email.message import EmailMessage - import email.utils - port = self.mailport if not port: port = smtplib.SMTP_PORT @@ -1129,8 +1134,6 @@ def emit(self, record): msg.set_content(self.format(record)) if self.username: if self.secure is not None: - import ssl - try: keyfile = self.secure[0] except IndexError: @@ -1313,7 +1316,6 @@ def getConnection(self, host, secure): Override when a custom connection is required, for example if there is a proxy. """ - import http.client if secure: connection = http.client.HTTPSConnection(host, context=self.context) else: @@ -1327,7 +1329,6 @@ def emit(self, record): Send the record to the web server as a percent-encoded dictionary """ try: - import urllib.parse host = self.host h = self.getConnection(host, self.secure) url = self.url @@ -1352,7 +1353,6 @@ def emit(self, record): "application/x-www-form-urlencoded") h.putheader("Content-length", str(len(data))) if self.credentials: - import base64 s = ('%s:%s' % self.credentials).encode('utf-8') s = 'Basic ' + base64.b64encode(s).strip().decode('ascii') h.putheader('Authorization', s) diff --git a/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst b/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst new file mode 100644 index 00000000000000..866349b09fc6a2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst @@ -0,0 +1,5 @@ +Speed up importing :mod:`logging.handlers` and :mod:`logging.config` by +deferring rarely-used imports with :keyword:`lazy import`, and by lifting +function-level imports to module scope. Imports that logging needs during +interpreter finalization (:mod:`io`, :mod:`traceback`, :mod:`os` and +:mod:`warnings`) deliberately remain eager. From 61de034c7321c8ccfcf488dbda25410ba15e782a Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Tue, 1 Sep 2026 07:13:55 -0700 Subject: [PATCH 2/8] Update blurb + clean up comments --- Lib/logging/config.py | 1 - Lib/logging/handlers.py | 5 ++--- .../Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst | 2 ++ .../Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst | 5 ----- 4 files changed, 4 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst delete mode 100644 Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst diff --git a/Lib/logging/config.py b/Lib/logging/config.py index 835afcb3f85ba1..0258ff34e40b20 100644 --- a/Lib/logging/config.py +++ b/Lib/logging/config.py @@ -30,7 +30,6 @@ import io lazy import json import logging -# rebinds the name `logging`, so the first `logging.` use loads handlers lazy import logging.handlers import os lazy import queue diff --git a/Lib/logging/handlers.py b/Lib/logging/handlers.py index 8ca02721a85ebc..43efd3e9b9cf8a 100644 --- a/Lib/logging/handlers.py +++ b/Lib/logging/handlers.py @@ -27,10 +27,9 @@ lazy import copy lazy import email.utils lazy import http.client -# io and os must stay eager to support finalization -import io +import io # must stay eager to support finalization import logging -import os +import os # must stay eager to support finalization lazy import pickle lazy import queue import re diff --git a/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst b/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst new file mode 100644 index 00000000000000..4ab91b55ac4ac0 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst @@ -0,0 +1,2 @@ +Optimize import time for :mod:`logging.handlers` and :mod:`logging.config` with +lazy imports. Pull nested imports to module scope. diff --git a/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst b/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst deleted file mode 100644 index 866349b09fc6a2..00000000000000 --- a/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-999999.La7yLg.rst +++ /dev/null @@ -1,5 +0,0 @@ -Speed up importing :mod:`logging.handlers` and :mod:`logging.config` by -deferring rarely-used imports with :keyword:`lazy import`, and by lifting -function-level imports to module scope. Imports that logging needs during -interpreter finalization (:mod:`io`, :mod:`traceback`, :mod:`os` and -:mod:`warnings`) deliberately remain eager. From 29608cae2feab18e3c270d95107dd3460c02cfb3 Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Tue, 1 Sep 2026 13:01:51 -0400 Subject: [PATCH 3/8] Address comments on PR Co-authored-by: Pieter Eendebak --- Lib/logging/__init__.py | 1 - Lib/logging/handlers.py | 2 +- .../next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/logging/__init__.py b/Lib/logging/__init__.py index 244e0ba56517ba..0c8a45c769157d 100644 --- a/Lib/logging/__init__.py +++ b/Lib/logging/__init__.py @@ -23,7 +23,6 @@ To use, simply 'import logging' and log away! """ -# io, os, traceback and warnings must stay eager to support finalization import sys, os, time, io, re, traceback, warnings, weakref, collections.abc lazy import pickle diff --git a/Lib/logging/handlers.py b/Lib/logging/handlers.py index 43efd3e9b9cf8a..21a63cbfea38ff 100644 --- a/Lib/logging/handlers.py +++ b/Lib/logging/handlers.py @@ -29,7 +29,7 @@ lazy import http.client import io # must stay eager to support finalization import logging -import os # must stay eager to support finalization +import os lazy import pickle lazy import queue import re diff --git a/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst b/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst index 4ab91b55ac4ac0..8e24f2091b1d93 100644 --- a/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst +++ b/Misc/NEWS.d/next/Library/2026-08-31-08-20-00.gh-issue-156777.La7yLg.rst @@ -1,2 +1,2 @@ Optimize import time for :mod:`logging.handlers` and :mod:`logging.config` with -lazy imports. Pull nested imports to module scope. +lazy imports. From d73d223a3e524a381f1ed054cae12800c737af8d Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Tue, 1 Sep 2026 12:55:35 -0700 Subject: [PATCH 4/8] Sort imports isort-style; bind logging.handlers without reifying it Sort each import block into the isort/Ruff groups requested in review: plain imports, from-imports, lazy imports, lazy from-imports. In logging/config.py, replace ``lazy import logging.handlers`` with ``lazy from logging import handlers``. The former binds the name ``logging`` lazily, so it shadows the eager ``import logging`` above it and any use of ``logging.`` in the module pulls in logging.handlers. Aliased to ``logging_handlers`` because ``handlers`` is already a local variable in _install_handlers() and _configure_queue_handler(). Now logging.handlers is imported only when a handler is configured through the ``class`` key; previously dictConfig(), fileConfig() and stopListening() all reified it. Co-authored-by: Pieter Eendebak Co-authored-by: Hugo van Kemenade --- Lib/logging/__init__.py | 4 ++-- Lib/logging/config.py | 33 ++++++++++++++++----------------- Lib/logging/handlers.py | 13 ++++++------- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/Lib/logging/__init__.py b/Lib/logging/__init__.py index 0c8a45c769157d..4cde0247dadb4c 100644 --- a/Lib/logging/__init__.py +++ b/Lib/logging/__init__.py @@ -25,12 +25,12 @@ import sys, os, time, io, re, traceback, warnings, weakref, collections.abc -lazy import pickle - from types import GenericAlias from string import Template from string import Formatter as StrFormatter +lazy import pickle + __all__ = ['BASIC_FORMAT', 'BufferingFormatter', 'CRITICAL', 'DEBUG', 'ERROR', 'FATAL', 'FileHandler', 'Filter', 'Formatter', 'Handler', 'INFO', diff --git a/Lib/logging/config.py b/Lib/logging/config.py index 0258ff34e40b20..e37876b8a1e8a0 100644 --- a/Lib/logging/config.py +++ b/Lib/logging/config.py @@ -24,25 +24,24 @@ To use, simply 'import logging.config' and log away! """ -lazy import configparser import errno import functools import io -lazy import json import logging -lazy import logging.handlers import os -lazy import queue import re +import threading +import traceback +lazy import configparser +lazy import json +lazy import queue lazy import select lazy import socket lazy import struct -import threading -import traceback - lazy from bisect import bisect_left +lazy from logging import handlers as logging_handlers lazy from multiprocessing.queues import Queue as MPQueue -lazy from socketserver import ThreadingTCPServer, StreamRequestHandler +lazy from socketserver import StreamRequestHandler, ThreadingTCPServer DEFAULT_LOGGING_CONFIG_PORT = 9030 @@ -169,7 +168,7 @@ def _install_handlers(cp, formatters): h.setLevel(level) if len(fmt): h.setFormatter(formatters[fmt]) - if issubclass(klass, logging.handlers.MemoryHandler): + if issubclass(klass, logging_handlers.MemoryHandler): target = section.get("target", "") if len(target): #the target handler may not be loaded yet, so keep for later... fixups.append((h, target)) @@ -761,7 +760,7 @@ def _configure_queue_handler(self, klass, **kwargs): q = queue.Queue() # unbounded rhl = kwargs.pop('respect_handler_level', False) - lklass = kwargs.pop('listener', logging.handlers.QueueListener) + lklass = kwargs.pop('listener', logging_handlers.QueueListener) handlers = kwargs.pop('handlers', []) listener = lklass(q, *handlers, respect_handler_level=rhl) @@ -792,7 +791,7 @@ def configure_handler(self, config): klass = cname else: klass = self.resolve(cname) - if issubclass(klass, logging.handlers.MemoryHandler): + if issubclass(klass, logging_handlers.MemoryHandler): if 'flushLevel' in config: config['flushLevel'] = logging._checkLevel(config['flushLevel']) if 'target' in config: @@ -806,7 +805,7 @@ def configure_handler(self, config): config['target'] = th except Exception as e: raise ValueError('Unable to set target handler %r' % tn) from e - elif issubclass(klass, logging.handlers.QueueHandler): + elif issubclass(klass, logging_handlers.QueueHandler): # Another special case for handler which refers to other handlers # if 'handlers' not in config: # raise ValueError('No handlers specified for a QueueHandler') @@ -828,13 +827,13 @@ def configure_handler(self, config): if 'listener' in config: lspec = config['listener'] if isinstance(lspec, type): - if not issubclass(lspec, logging.handlers.QueueListener): + if not issubclass(lspec, logging_handlers.QueueListener): raise TypeError('Invalid listener specifier %r' % lspec) else: if isinstance(lspec, str): listener = self.resolve(lspec) if isinstance(listener, type) and\ - not issubclass(listener, logging.handlers.QueueListener): + not issubclass(listener, logging_handlers.QueueListener): raise TypeError('Invalid listener specifier %r' % lspec) elif isinstance(lspec, dict): if '()' not in lspec: @@ -858,13 +857,13 @@ def configure_handler(self, config): except Exception as e: raise ValueError('Unable to set required handler %r' % hn) from e config['handlers'] = hlist - elif issubclass(klass, logging.handlers.SMTPHandler) and\ + elif issubclass(klass, logging_handlers.SMTPHandler) and\ 'mailhost' in config: config['mailhost'] = self.as_tuple(config['mailhost']) - elif issubclass(klass, logging.handlers.SysLogHandler) and\ + elif issubclass(klass, logging_handlers.SysLogHandler) and\ 'address' in config: config['address'] = self.as_tuple(config['address']) - if issubclass(klass, logging.handlers.QueueHandler): + if issubclass(klass, logging_handlers.QueueHandler): factory = functools.partial(self._configure_queue_handler, klass) else: factory = klass diff --git a/Lib/logging/handlers.py b/Lib/logging/handlers.py index 21a63cbfea38ff..aa1e8519df3821 100644 --- a/Lib/logging/handlers.py +++ b/Lib/logging/handlers.py @@ -23,24 +23,23 @@ To use, simply 'import logging.handlers' and log away! """ +import io # must stay eager to support finalization +import logging +import os +import re +import threading +import time lazy import base64 lazy import copy lazy import email.utils lazy import http.client -import io # must stay eager to support finalization -import logging -import os lazy import pickle lazy import queue -import re lazy import smtplib lazy import socket lazy import ssl lazy import struct -import threading -import time lazy import urllib.parse - lazy from email.message import EmailMessage # From bafee5d17ff41ee2c43f42a187288db8c9870c4c Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Wed, 23 Sep 2026 07:30:14 -0700 Subject: [PATCH 5/8] Address review: keep optional imports nested, keep shutdown ones eager Keep ssl and multiprocessing.queues as function-level imports. A module-level lazy import is resolved by inspect.getmembers() and pydoc, so ssl breaks them on a build without _ssl, and multiprocessing defeats the deferral the comment there asks for. Neither costs anything: a nested import and a module-level lazy import defer identically. Leave pickle, socket and struct eager in handlers.py. A record emitted from a __del__ during finalization can no longer import, so a SocketHandler pickled its first record too late and dropped it. Resolve logging.handlers in fileConfig() before it evaluates any config expression against vars(logging), so the documented handlers.X spelling works again in a fresh process, both in class= and in defaults=. Add tests for each, and pin the laziness that is left. --- Lib/logging/config.py | 7 ++- Lib/logging/handlers.py | 9 ++-- Lib/test/test_logging.py | 100 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/Lib/logging/config.py b/Lib/logging/config.py index e37876b8a1e8a0..9241f6cef4a6fc 100644 --- a/Lib/logging/config.py +++ b/Lib/logging/config.py @@ -40,7 +40,6 @@ lazy import struct lazy from bisect import bisect_left lazy from logging import handlers as logging_handlers -lazy from multiprocessing.queues import Queue as MPQueue lazy from socketserver import StreamRequestHandler, ThreadingTCPServer @@ -83,6 +82,9 @@ def fileConfig(fname, defaults=None, disable_existing_loggers=True, encoding=Non except configparser.ParsingError as e: raise RuntimeError(f'{fname} is invalid: {e}') + # the eval()s below resolve "handlers.X" names against vars(logging) + _ = logging_handlers + formatters = _create_formatters(cp) # critical section @@ -511,6 +513,9 @@ def _is_queue_like_object(obj): """Check that *obj* implements the Queue API.""" if isinstance(obj, (queue.Queue, queue.SimpleQueue)): return True + # defer importing multiprocessing as much as possible; a lazy import at + # module level would still be resolved by getmembers() and pydoc + from multiprocessing.queues import Queue as MPQueue if isinstance(obj, MPQueue): return True # Depending on the multiprocessing start context, we cannot create diff --git a/Lib/logging/handlers.py b/Lib/logging/handlers.py index aa1e8519df3821..6f70cf3d8ac68d 100644 --- a/Lib/logging/handlers.py +++ b/Lib/logging/handlers.py @@ -26,19 +26,18 @@ import io # must stay eager to support finalization import logging import os +import pickle import re +import socket +import struct import threading import time lazy import base64 lazy import copy lazy import email.utils lazy import http.client -lazy import pickle lazy import queue lazy import smtplib -lazy import socket -lazy import ssl -lazy import struct lazy import urllib.parse lazy from email.message import EmailMessage @@ -1132,6 +1131,8 @@ def emit(self, record): msg.set_content(self.format(record)) if self.username: if self.secure is not None: + import ssl # not lazy: breaks getmembers() without _ssl + try: keyfile = self.secure[0] except IndexError: diff --git a/Lib/test/test_logging.py b/Lib/test/test_logging.py index 7cd0df3ea0b62d..bbf472563a85e4 100644 --- a/Lib/test/test_logging.py +++ b/Lib/test/test_logging.py @@ -1865,6 +1865,45 @@ def test_defaults_do_no_interpolation(self): finally: os.unlink(fn) + def test_names_from_handlers_module(self): + # gh-156777: "handlers.X" in a class or defaults entry is evaluated + # against vars(logging), so logging.handlers must be imported first. + # Run it in a subprocess, since importing this module already + # imports logging.handlers. + ini = textwrap.dedent(""" + [loggers] + keys=root + + [handlers] + keys=hand1 + + [formatters] + keys=form1 + + [logger_root] + handlers=hand1 + + [handler_hand1] + class=handlers.MemoryHandler + formatter=form1 + args=(10,) + + [formatter_form1] + format=%(levelname)s ++ %(message)s ++ %(port)s + defaults={'port': handlers.DEFAULT_TCP_LOGGING_PORT} + """).strip() + fd, fn = tempfile.mkstemp(prefix='test_logging_', suffix='.ini') + self.addCleanup(os.unlink, fn) + os.write(fd, ini.encode('ascii')) + os.close(fd) + code = textwrap.dedent(f""" + import logging, logging.config + logging.config.fileConfig({fn!r}, encoding="utf-8") + h = logging.getLogger().handlers[0] + assert isinstance(h, logging.handlers.MemoryHandler), h + """) + assert_python_ok("-c", code) + @support.requires_working_socket() @threading_helper.requires_working_threading() @@ -5437,6 +5476,35 @@ def __del__(self): with open(filename, encoding="utf-8") as fp: self.assertEqual(fp.read().rstrip(), "ERROR:root:log in __del__") + def test_socket_handler_at_shutdown(self): + # gh-156777: SocketHandler pickles the record before sending it, and + # that must keep working when importing no longer can. + code = textwrap.dedent(""" + import logging + import logging.handlers + import os + + class Handler(logging.handlers.SocketHandler): + # report what emit() did instead of doing network I/O + def send(self, s): + os.write(1, b"sent %d bytes" % len(s)) + + def handleError(self, record): + os.write(1, b"record dropped") + + h = Handler('localhost', logging.handlers.DEFAULT_TCP_LOGGING_PORT) + r = logging.LogRecord('n', logging.INFO, 'p', 1, 'msg', None, None) + + class A: + # the module globals are already cleared when __del__ runs + def __del__(self, h=h, r=r): + h.emit(r) + + a = A() + """) + rc, out, err = assert_python_ok("-c", code) + self.assertStartsWith(out.decode(), "sent ") + def test_recursion_error(self): # Issue 36272 code = textwrap.dedent(""" @@ -7536,6 +7604,38 @@ def test_without_pywin32(self): h.emit(r) +class LazyImportTest(unittest.TestCase): + + """Tests for the module level lazy imports of the logging package.""" + + def test_lazy_imports_config(self): + import_helper.ensure_lazy_imports( + "logging.config", + {"configparser", "json", "logging.handlers", "multiprocessing", + "select", "socket", "socketserver", "struct"}, + additional_code="logging.config.dictConfig({'version': 1})\n", + ) + + def test_lazy_imports_handlers(self): + import_helper.ensure_lazy_imports( + "logging.handlers", + {"base64", "copy", "email", "http", "queue", "smtplib", "ssl", + "urllib"}, + ) + + def test_getmembers_without_ssl(self): + # gh-156777: getmembers() and pydoc resolve lazy imports, so a module + # level "lazy import ssl" would break them on a build without _ssl. + code = textwrap.dedent(""" + import sys + sys.modules['_ssl'] = None + import inspect + import logging.handlers + inspect.getmembers(logging.handlers) + """) + assert_python_ok("-c", code) + + class MiscTestCase(unittest.TestCase): def test__all__(self): not_exported = { From c42e9d77a29c365754884cb53065de3eeca455d7 Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Wed, 23 Sep 2026 11:02:55 -0700 Subject: [PATCH 6/8] Resolve SocketHandler's imports at construction, not eagerly pickle, socket and struct go back to lazy. emit() can run during finalization, when importing no longer works, so SocketHandler.__init__ resolves them while it still can. Keeping them eager instead gives back three quarters of the handlers.py import-time win. --- Lib/logging/config.py | 3 +-- Lib/logging/handlers.py | 8 +++++--- Lib/test/test_logging.py | 22 ++++++++++++++++------ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Lib/logging/config.py b/Lib/logging/config.py index 9241f6cef4a6fc..bcea2c759e7a2e 100644 --- a/Lib/logging/config.py +++ b/Lib/logging/config.py @@ -513,8 +513,7 @@ def _is_queue_like_object(obj): """Check that *obj* implements the Queue API.""" if isinstance(obj, (queue.Queue, queue.SimpleQueue)): return True - # defer importing multiprocessing as much as possible; a lazy import at - # module level would still be resolved by getmembers() and pydoc + # defer importing multiprocessing as much as possible from multiprocessing.queues import Queue as MPQueue if isinstance(obj, MPQueue): return True diff --git a/Lib/logging/handlers.py b/Lib/logging/handlers.py index 6f70cf3d8ac68d..b770555a593e87 100644 --- a/Lib/logging/handlers.py +++ b/Lib/logging/handlers.py @@ -26,18 +26,18 @@ import io # must stay eager to support finalization import logging import os -import pickle import re -import socket -import struct import threading import time lazy import base64 lazy import copy lazy import email.utils lazy import http.client +lazy import pickle lazy import queue lazy import smtplib +lazy import socket +lazy import struct lazy import urllib.parse lazy from email.message import EmailMessage @@ -610,6 +610,8 @@ def __init__(self, host, port): self.retryStart = 1.0 self.retryMax = 30.0 self.retryFactor = 2.0 + # resolve what emit() needs now: it may run during finalization + _ = pickle, socket, struct def makeSocket(self, timeout=1): """ diff --git a/Lib/test/test_logging.py b/Lib/test/test_logging.py index bbf472563a85e4..5f4ddeef13e6b2 100644 --- a/Lib/test/test_logging.py +++ b/Lib/test/test_logging.py @@ -1866,10 +1866,8 @@ def test_defaults_do_no_interpolation(self): os.unlink(fn) def test_names_from_handlers_module(self): - # gh-156777: "handlers.X" in a class or defaults entry is evaluated - # against vars(logging), so logging.handlers must be imported first. - # Run it in a subprocess, since importing this module already - # imports logging.handlers. + # gh-156777: "handlers.X" is evaluated against vars(logging). Use a + # subprocess: importing this module already imports logging.handlers. ini = textwrap.dedent(""" [loggers] keys=root @@ -7619,10 +7617,22 @@ def test_lazy_imports_config(self): def test_lazy_imports_handlers(self): import_helper.ensure_lazy_imports( "logging.handlers", - {"base64", "copy", "email", "http", "queue", "smtplib", "ssl", - "urllib"}, + {"base64", "copy", "email", "http", "pickle", "queue", "smtplib", + "socket", "ssl", "struct", "urllib"}, ) + def test_socket_handler_resolves_imports_when_created(self): + # gh-156777: emit() may run during finalization, when importing no + # longer works, so the handler resolves what it needs up front. + code = textwrap.dedent(""" + import sys + import logging.handlers + logging.handlers.SocketHandler('localhost', 9020) + missing = {'pickle', 'socket', 'struct'} - sys.modules.keys() + assert not missing, missing + """) + assert_python_ok("-S", "-c", code) + def test_getmembers_without_ssl(self): # gh-156777: getmembers() and pydoc resolve lazy imports, so a module # level "lazy import ssl" would break them on a build without _ssl. From f68adec6bd150fdea23a0a19e0a4f4cf5753edff Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Wed, 23 Sep 2026 11:17:08 -0700 Subject: [PATCH 7/8] Keep logging.handlers eager in config.py Reverts the lazy alias and the nine logging_handlers.* renames that came with it. The alias left "handlers" out of vars(logging), which broke the documented handlers.X spelling in fileConfig() config files, and it was worth only 0.68 ms of the 6.1 ms win on importing logging.config. Also drop the two comments that no longer carry their weight, so the io and ssl hunks disappear from the diff, and trim the new tests. --- Lib/logging/config.py | 23 +++++++-------- Lib/logging/handlers.py | 4 +-- Lib/test/test_logging.py | 61 ++++++++++++++-------------------------- 3 files changed, 33 insertions(+), 55 deletions(-) diff --git a/Lib/logging/config.py b/Lib/logging/config.py index bcea2c759e7a2e..72caee79da9f03 100644 --- a/Lib/logging/config.py +++ b/Lib/logging/config.py @@ -28,6 +28,7 @@ import functools import io import logging +import logging.handlers import os import re import threading @@ -39,7 +40,6 @@ lazy import socket lazy import struct lazy from bisect import bisect_left -lazy from logging import handlers as logging_handlers lazy from socketserver import StreamRequestHandler, ThreadingTCPServer @@ -82,9 +82,6 @@ def fileConfig(fname, defaults=None, disable_existing_loggers=True, encoding=Non except configparser.ParsingError as e: raise RuntimeError(f'{fname} is invalid: {e}') - # the eval()s below resolve "handlers.X" names against vars(logging) - _ = logging_handlers - formatters = _create_formatters(cp) # critical section @@ -170,7 +167,7 @@ def _install_handlers(cp, formatters): h.setLevel(level) if len(fmt): h.setFormatter(formatters[fmt]) - if issubclass(klass, logging_handlers.MemoryHandler): + if issubclass(klass, logging.handlers.MemoryHandler): target = section.get("target", "") if len(target): #the target handler may not be loaded yet, so keep for later... fixups.append((h, target)) @@ -764,7 +761,7 @@ def _configure_queue_handler(self, klass, **kwargs): q = queue.Queue() # unbounded rhl = kwargs.pop('respect_handler_level', False) - lklass = kwargs.pop('listener', logging_handlers.QueueListener) + lklass = kwargs.pop('listener', logging.handlers.QueueListener) handlers = kwargs.pop('handlers', []) listener = lklass(q, *handlers, respect_handler_level=rhl) @@ -795,7 +792,7 @@ def configure_handler(self, config): klass = cname else: klass = self.resolve(cname) - if issubclass(klass, logging_handlers.MemoryHandler): + if issubclass(klass, logging.handlers.MemoryHandler): if 'flushLevel' in config: config['flushLevel'] = logging._checkLevel(config['flushLevel']) if 'target' in config: @@ -809,7 +806,7 @@ def configure_handler(self, config): config['target'] = th except Exception as e: raise ValueError('Unable to set target handler %r' % tn) from e - elif issubclass(klass, logging_handlers.QueueHandler): + elif issubclass(klass, logging.handlers.QueueHandler): # Another special case for handler which refers to other handlers # if 'handlers' not in config: # raise ValueError('No handlers specified for a QueueHandler') @@ -831,13 +828,13 @@ def configure_handler(self, config): if 'listener' in config: lspec = config['listener'] if isinstance(lspec, type): - if not issubclass(lspec, logging_handlers.QueueListener): + if not issubclass(lspec, logging.handlers.QueueListener): raise TypeError('Invalid listener specifier %r' % lspec) else: if isinstance(lspec, str): listener = self.resolve(lspec) if isinstance(listener, type) and\ - not issubclass(listener, logging_handlers.QueueListener): + not issubclass(listener, logging.handlers.QueueListener): raise TypeError('Invalid listener specifier %r' % lspec) elif isinstance(lspec, dict): if '()' not in lspec: @@ -861,13 +858,13 @@ def configure_handler(self, config): except Exception as e: raise ValueError('Unable to set required handler %r' % hn) from e config['handlers'] = hlist - elif issubclass(klass, logging_handlers.SMTPHandler) and\ + elif issubclass(klass, logging.handlers.SMTPHandler) and\ 'mailhost' in config: config['mailhost'] = self.as_tuple(config['mailhost']) - elif issubclass(klass, logging_handlers.SysLogHandler) and\ + elif issubclass(klass, logging.handlers.SysLogHandler) and\ 'address' in config: config['address'] = self.as_tuple(config['address']) - if issubclass(klass, logging_handlers.QueueHandler): + if issubclass(klass, logging.handlers.QueueHandler): factory = functools.partial(self._configure_queue_handler, klass) else: factory = klass diff --git a/Lib/logging/handlers.py b/Lib/logging/handlers.py index b770555a593e87..2638c4df2bf5d9 100644 --- a/Lib/logging/handlers.py +++ b/Lib/logging/handlers.py @@ -23,7 +23,7 @@ To use, simply 'import logging.handlers' and log away! """ -import io # must stay eager to support finalization +import io import logging import os import re @@ -1133,7 +1133,7 @@ def emit(self, record): msg.set_content(self.format(record)) if self.username: if self.secure is not None: - import ssl # not lazy: breaks getmembers() without _ssl + import ssl try: keyfile = self.secure[0] diff --git a/Lib/test/test_logging.py b/Lib/test/test_logging.py index 5f4ddeef13e6b2..25ae51db5936c3 100644 --- a/Lib/test/test_logging.py +++ b/Lib/test/test_logging.py @@ -1865,31 +1865,22 @@ def test_defaults_do_no_interpolation(self): finally: os.unlink(fn) - def test_names_from_handlers_module(self): - # gh-156777: "handlers.X" is evaluated against vars(logging). Use a - # subprocess: importing this module already imports logging.handlers. - ini = textwrap.dedent(""" + def test_class_from_handlers_module(self): + # gh-156777: "class=handlers.X" is evaluated against vars(logging). + # Use a subprocess: this module already imports logging.handlers. + ini = textwrap.dedent("""\ [loggers] keys=root - [handlers] keys=hand1 - [formatters] - keys=form1 - + keys= [logger_root] handlers=hand1 - [handler_hand1] class=handlers.MemoryHandler - formatter=form1 args=(10,) - - [formatter_form1] - format=%(levelname)s ++ %(message)s ++ %(port)s - defaults={'port': handlers.DEFAULT_TCP_LOGGING_PORT} - """).strip() + """) fd, fn = tempfile.mkstemp(prefix='test_logging_', suffix='.ini') self.addCleanup(os.unlink, fn) os.write(fd, ini.encode('ascii')) @@ -1897,8 +1888,8 @@ def test_names_from_handlers_module(self): code = textwrap.dedent(f""" import logging, logging.config logging.config.fileConfig({fn!r}, encoding="utf-8") - h = logging.getLogger().handlers[0] - assert isinstance(h, logging.handlers.MemoryHandler), h + assert isinstance(logging.getLogger().handlers[0], + logging.handlers.MemoryHandler) """) assert_python_ok("-c", code) @@ -5478,30 +5469,25 @@ def test_socket_handler_at_shutdown(self): # gh-156777: SocketHandler pickles the record before sending it, and # that must keep working when importing no longer can. code = textwrap.dedent(""" - import logging - import logging.handlers - import os + import logging, logging.handlers, os class Handler(logging.handlers.SocketHandler): - # report what emit() did instead of doing network I/O def send(self, s): - os.write(1, b"sent %d bytes" % len(s)) - + os.write(1, b"sent") def handleError(self, record): - os.write(1, b"record dropped") + os.write(1, b"dropped") - h = Handler('localhost', logging.handlers.DEFAULT_TCP_LOGGING_PORT) + h = Handler('localhost', 9020) r = logging.LogRecord('n', logging.INFO, 'p', 1, 'msg', None, None) class A: - # the module globals are already cleared when __del__ runs - def __del__(self, h=h, r=r): + def __del__(self, h=h, r=r): # globals are cleared by now h.emit(r) a = A() """) rc, out, err = assert_python_ok("-c", code) - self.assertStartsWith(out.decode(), "sent ") + self.assertEqual(out, b"sent") def test_recursion_error(self): # Issue 36272 @@ -7609,8 +7595,8 @@ class LazyImportTest(unittest.TestCase): def test_lazy_imports_config(self): import_helper.ensure_lazy_imports( "logging.config", - {"configparser", "json", "logging.handlers", "multiprocessing", - "select", "socket", "socketserver", "struct"}, + {"configparser", "json", "multiprocessing", "select", "socket", + "socketserver", "struct"}, additional_code="logging.config.dictConfig({'version': 1})\n", ) @@ -7622,25 +7608,20 @@ def test_lazy_imports_handlers(self): ) def test_socket_handler_resolves_imports_when_created(self): - # gh-156777: emit() may run during finalization, when importing no - # longer works, so the handler resolves what it needs up front. + # gh-156777: emit() may run when importing no longer works code = textwrap.dedent(""" - import sys - import logging.handlers + import sys, logging.handlers logging.handlers.SocketHandler('localhost', 9020) - missing = {'pickle', 'socket', 'struct'} - sys.modules.keys() - assert not missing, missing + assert {'pickle', 'socket', 'struct'} <= sys.modules.keys() """) assert_python_ok("-S", "-c", code) def test_getmembers_without_ssl(self): - # gh-156777: getmembers() and pydoc resolve lazy imports, so a module - # level "lazy import ssl" would break them on a build without _ssl. + # gh-156777: getmembers() resolves lazy imports, so ssl must not be one code = textwrap.dedent(""" import sys sys.modules['_ssl'] = None - import inspect - import logging.handlers + import inspect, logging.handlers inspect.getmembers(logging.handlers) """) assert_python_ok("-c", code) From a5838b5dd3e85e4d1dd32dc3f229e5d4534d93ae Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Wed, 23 Sep 2026 11:22:00 -0700 Subject: [PATCH 8/8] Defer logging.handlers with a plain lazy import in config.py Drops the "as logging_handlers" alias and the nine renames that came with it. The dotted spelling defers the submodule just as well for import time, and resolving the module's own logging global is what binds handlers in vars(logging) again, so the documented handlers.X spelling in fileConfig() config files works in a fresh process. --- Lib/logging/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/logging/config.py b/Lib/logging/config.py index 72caee79da9f03..6478d475277418 100644 --- a/Lib/logging/config.py +++ b/Lib/logging/config.py @@ -28,13 +28,13 @@ import functools import io import logging -import logging.handlers import os import re import threading import traceback lazy import configparser lazy import json +lazy import logging.handlers lazy import queue lazy import select lazy import socket