From 105d5517745b3ce4b69acfbabecb96019bac910c Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Tue, 21 Oct 2025 17:18:21 +0100 Subject: [PATCH 01/16] Commit --- Lib/traceback.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index 9b4b8c7d566fe8..2609db1149364d 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1110,7 +1110,11 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, elif exc_type and issubclass(exc_type, ModuleNotFoundError): module_name = getattr(exc_value, "name", None) if module_name in sys.stdlib_module_names: - self._str = f"Standard library module '{module_name}' was not found" + message = _missing_stdlib_module.get( + module_name, + f"Standard library module '{module_name}' was not found" + ) + self._str = message elif sys.flags.no_site: self._str += (". Site initialization is disabled, did you forget to " + "add the site-packages directory to sys.path " @@ -1794,3 +1798,11 @@ def _levenshtein_distance(a, b, max_cost): # Everything in this row is too big, so bail early. return max_cost + 1 return result + +_windows_only_modules = ["winreg", "msvcrt", "winsound", "nt", "_winapi", "_msi"] + +_missing_stdlib_module = { + name: f"Windows-only standard library module '{name}' not found" + for name in _windows_only_modules + # Distributors can patch this dictionary to provide installation suggestions. +} From c3d802b9c8f6d002fd40fb2301c0aa8c5fe0595d Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Tue, 21 Oct 2025 18:16:46 +0100 Subject: [PATCH 02/16] Tidy up --- Lib/traceback.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index 2609db1149364d..7d582adeda0899 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1803,6 +1803,7 @@ def _levenshtein_distance(a, b, max_cost): _missing_stdlib_module = { name: f"Windows-only standard library module '{name}' not found" - for name in _windows_only_modules + for name in _windows_only_modules, +} | { # Distributors can patch this dictionary to provide installation suggestions. } From 6b2d934544d98781c26c58108ea759e04a0b88c5 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Tue, 21 Oct 2025 18:19:38 +0100 Subject: [PATCH 03/16] Fix up --- Lib/traceback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index 7d582adeda0899..6d6aa08e296db1 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1803,7 +1803,7 @@ def _levenshtein_distance(a, b, max_cost): _missing_stdlib_module = { name: f"Windows-only standard library module '{name}' not found" - for name in _windows_only_modules, + for name in _windows_only_modules } | { # Distributors can patch this dictionary to provide installation suggestions. } From 7f10e66d837c41e71b356ef575019b9ea72533b3 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Wed, 22 Oct 2025 18:07:05 +0100 Subject: [PATCH 04/16] Rework --- Lib/traceback.py | 27 ++++++++--------- Makefile.pre.in | 2 ++ Tools/build/check_extension_modules.py | 41 ++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index 6d6aa08e296db1..b793449be3ae6e 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -14,6 +14,11 @@ from contextlib import suppress +try: + from _stdlib_modules_info import MISSING_STDLIB_MODULE_MESSAGES +except ImportError: + MISSING_STDLIB_MODULE_MESSAGES = None + __all__ = ['extract_stack', 'extract_tb', 'format_exception', 'format_exception_only', 'format_list', 'format_stack', 'format_tb', 'print_exc', 'format_exc', 'print_exception', @@ -1110,11 +1115,14 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, elif exc_type and issubclass(exc_type, ModuleNotFoundError): module_name = getattr(exc_value, "name", None) if module_name in sys.stdlib_module_names: - message = _missing_stdlib_module.get( - module_name, - f"Standard library module '{module_name}' was not found" - ) - self._str = message + if MISSING_STDLIB_MODULE_MESSAGES is not None: + message = MISSING_STDLIB_MODULE_MESSAGES.get( + module_name, + f"Standard library module '{module_name}' was not found" + ) + self._str = message + else: + self._str = f"Standard library module '{module_name}' was not found" elif sys.flags.no_site: self._str += (". Site initialization is disabled, did you forget to " + "add the site-packages directory to sys.path " @@ -1798,12 +1806,3 @@ def _levenshtein_distance(a, b, max_cost): # Everything in this row is too big, so bail early. return max_cost + 1 return result - -_windows_only_modules = ["winreg", "msvcrt", "winsound", "nt", "_winapi", "_msi"] - -_missing_stdlib_module = { - name: f"Windows-only standard library module '{name}' not found" - for name in _windows_only_modules -} | { - # Distributors can patch this dictionary to provide installation suggestions. -} diff --git a/Makefile.pre.in b/Makefile.pre.in index 19423c11545c19..ad36ba4b835d9c 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -1601,6 +1601,7 @@ sharedmods: $(SHAREDMODS) pybuilddir.txt # dependency on BUILDPYTHON ensures that the target is run last .PHONY: checksharedmods checksharedmods: sharedmods $(PYTHON_FOR_BUILD_DEPS) $(BUILDPYTHON) + @$(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py --generate-stdlib-info @$(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py .PHONY: rundsymutil @@ -2815,6 +2816,7 @@ libinstall: all $(srcdir)/Modules/xxmodule.c $(INSTALL_DATA) `cat pybuilddir.txt`/_sysconfigdata_$(ABIFLAGS)_$(MACHDEP)_$(MULTIARCH).py $(DESTDIR)$(LIBDEST); \ $(INSTALL_DATA) `cat pybuilddir.txt`/_sysconfig_vars_$(ABIFLAGS)_$(MACHDEP)_$(MULTIARCH).json $(DESTDIR)$(LIBDEST); \ $(INSTALL_DATA) `cat pybuilddir.txt`/build-details.json $(DESTDIR)$(LIBDEST); \ + $(INSTALL_DATA) `cat pybuilddir.txt`/_stdlib_modules_info.py $(DESTDIR)$(LIBDEST); \ $(INSTALL_DATA) $(srcdir)/LICENSE $(DESTDIR)$(LIBDEST)/LICENSE.txt @ # If app store compliance has been configured, apply the patch to the @ # installed library code. The patch has been previously validated against diff --git a/Tools/build/check_extension_modules.py b/Tools/build/check_extension_modules.py index 668db8df0bd181..f702c360e488aa 100644 --- a/Tools/build/check_extension_modules.py +++ b/Tools/build/check_extension_modules.py @@ -116,6 +116,12 @@ help="Print a list of module names to stdout and exit", ) +parser.add_argument( + "--generate-stdlib-info", + action="store_true", + help="Generate file with stdlib module info", +) + @enum.unique class ModuleState(enum.Enum): @@ -281,6 +287,38 @@ def list_module_names(self, *, all: bool = False) -> set[str]: names.update(WINDOWS_MODULES) return names + def generate_stdlib_info(self) -> None: + + disabled_modules = {modinfo.name for modinfo in self.modules + if modinfo.state in (ModuleState.DISABLED, ModuleState.DISABLED_SETUP)} + missing_modules = {modinfo.name for modinfo in self.modules + if modinfo.state == ModuleState.MISSING} + na_modules = {modinfo.name for modinfo in self.modules + if modinfo.state == ModuleState.NA} + + content = f'''\ +# Standard library information used by the traceback module for more informative +# ModuleNotFound error messages. + +DISABLED_MODULES = {sorted(disabled_modules)!r} +MISSING_MODULES = {sorted(missing_modules)!r} +NOT_AVAILABLE_MODULES = {sorted(na_modules)!r} +WINDOWS_ONLY_MODULES = {sorted(WINDOWS_MODULES)!r} + +MISSING_STDLIB_MODULE_MESSAGES = {{ + **{{name: f"Windows-only standard library module '{{name}}' was not found" + for name in WINDOWS_ONLY_MODULES}}, + **{{name: f"Standard library module disabled during build '{{name}}' was not found" + for name in DISABLED_MODULES}}, + **{{name: f"Unsupported platform for standard library module '{{name}}'" + for name in NOT_AVAILABLE_MODULES}}, +}} +''' + + output_path = self.builddir / "_stdlib_modules_info.py" + with open(output_path, "w", encoding="utf-8") as f: + f.write(content) + def get_builddir(self) -> pathlib.Path: try: with open(self.pybuilddir_txt, encoding="utf-8") as f: @@ -499,6 +537,9 @@ def main() -> None: names = checker.list_module_names(all=True) for name in sorted(names): print(name) + elif args.generate_stdlib_info: + checker.check() + checker.generate_stdlib_info() else: checker.check() checker.summary(verbose=args.verbose) From a0dd99bec07b1609f5104df868169a94905f6f82 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 23 Oct 2025 16:55:28 +0100 Subject: [PATCH 05/16] Allow passing json file --- Makefile.pre.in | 6 +++- Tools/build/check_extension_modules.py | 39 ++++++++++++++++++-------- configure | 18 ++++++++++++ configure.ac | 9 ++++++ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/Makefile.pre.in b/Makefile.pre.in index ad36ba4b835d9c..72c623a3d0ed52 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -1601,7 +1601,11 @@ sharedmods: $(SHAREDMODS) pybuilddir.txt # dependency on BUILDPYTHON ensures that the target is run last .PHONY: checksharedmods checksharedmods: sharedmods $(PYTHON_FOR_BUILD_DEPS) $(BUILDPYTHON) - @$(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py --generate-stdlib-info + @if [ -n "@MISSING_STDLIB_CONFIG@" ]; then \ + $(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py --generate-stdlib-info="@MISSING_STDLIB_CONFIG@"; \ + else \ + $(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py --generate-stdlib-info; \ + fi @$(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py .PHONY: rundsymutil diff --git a/Tools/build/check_extension_modules.py b/Tools/build/check_extension_modules.py index f702c360e488aa..f5f9662e6b0aa9 100644 --- a/Tools/build/check_extension_modules.py +++ b/Tools/build/check_extension_modules.py @@ -23,6 +23,7 @@ import _imp import argparse import enum +import json import logging import os import pathlib @@ -118,8 +119,9 @@ parser.add_argument( "--generate-stdlib-info", - action="store_true", - help="Generate file with stdlib module info", + nargs="?", + const=True, + help="Generate file with stdlib module info, with optional config file", ) @@ -287,7 +289,7 @@ def list_module_names(self, *, all: bool = False) -> set[str]: names.update(WINDOWS_MODULES) return names - def generate_stdlib_info(self) -> None: + def generate_stdlib_info(self, config_path: str | None = None) -> None: disabled_modules = {modinfo.name for modinfo in self.modules if modinfo.state in (ModuleState.DISABLED, ModuleState.DISABLED_SETUP)} @@ -296,6 +298,25 @@ def generate_stdlib_info(self) -> None: na_modules = {modinfo.name for modinfo in self.modules if modinfo.state == ModuleState.NA} + config_messages = {} + if config_path: + try: + with open(config_path, encoding='utf-8') as f: + config_messages = json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.error("Failed to load distributor config %s: %s", config_path, e) + + default_messages = { + **{name: f"Windows-only standard library module '{name}' was not found" + for name in WINDOWS_MODULES}, + **{name: f"Standard library module disabled during build '{name}' was not found" + for name in disabled_modules}, + **{name: f"Unsupported platform for standard library module '{name}'" + for name in na_modules}, + } + + messages = {**default_messages, **config_messages} + content = f'''\ # Standard library information used by the traceback module for more informative # ModuleNotFound error messages. @@ -305,14 +326,7 @@ def generate_stdlib_info(self) -> None: NOT_AVAILABLE_MODULES = {sorted(na_modules)!r} WINDOWS_ONLY_MODULES = {sorted(WINDOWS_MODULES)!r} -MISSING_STDLIB_MODULE_MESSAGES = {{ - **{{name: f"Windows-only standard library module '{{name}}' was not found" - for name in WINDOWS_ONLY_MODULES}}, - **{{name: f"Standard library module disabled during build '{{name}}' was not found" - for name in DISABLED_MODULES}}, - **{{name: f"Unsupported platform for standard library module '{{name}}'" - for name in NOT_AVAILABLE_MODULES}}, -}} +MISSING_STDLIB_MODULE_MESSAGES = {messages!r} ''' output_path = self.builddir / "_stdlib_modules_info.py" @@ -539,7 +553,8 @@ def main() -> None: print(name) elif args.generate_stdlib_info: checker.check() - checker.generate_stdlib_info() + config_path = None if args.generate_stdlib_info is True else args.generate_stdlib_info + checker.generate_stdlib_info(config_path) else: checker.check() checker.summary(verbose=args.verbose) diff --git a/configure b/configure index f95355e2f57d69..4d096cd55af4bc 100755 --- a/configure +++ b/configure @@ -1009,6 +1009,7 @@ UNIVERSALSDK host_exec_prefix host_prefix MACHDEP +MISSING_STDLIB_CONFIG PKG_CONFIG_LIBDIR PKG_CONFIG_PATH PKG_CONFIG @@ -1080,6 +1081,7 @@ ac_user_opts=' enable_option_checking with_build_python with_pkg_config +with_missing_stdlib_config enable_universalsdk with_universal_archs with_framework_name @@ -1859,6 +1861,9 @@ Optional Packages: --with-pkg-config=[yes|no|check] use pkg-config to detect build options (default is check) + --with-missing-stdlib-config=FILE + File with custom module error messages for missing + stdlib modules --with-universal-archs=ARCH specify the kind of macOS universal binary that should be created. This option is only valid when @@ -4092,6 +4097,19 @@ if test "$with_pkg_config" = yes -a -z "$PKG_CONFIG"; then as_fn_error $? "pkg-config is required" "$LINENO" 5] fi + +# Check whether --with-missing-stdlib-config was given. +if test ${with_missing_stdlib_config+y} +then : + withval=$with_missing_stdlib_config; MISSING_STDLIB_CONFIG="$withval" +else case e in #( + e) MISSING_STDLIB_CONFIG="" + ;; +esac +fi + + + # Set name for machine-dependent library files { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking MACHDEP" >&5 diff --git a/configure.ac b/configure.ac index 4d0e5a1ca1d003..7540a2a77cc29b 100644 --- a/configure.ac +++ b/configure.ac @@ -307,6 +307,15 @@ if test "$with_pkg_config" = yes -a -z "$PKG_CONFIG"; then AC_MSG_ERROR([pkg-config is required])] fi +dnl Allow distributors to provide custom missing stdlib module error messages +AC_ARG_WITH([missing-stdlib-config], + [AS_HELP_STRING([--with-missing-stdlib-config=FILE], + [File with custom module error messages for missing stdlib modules])], + [MISSING_STDLIB_CONFIG="$withval"], + [MISSING_STDLIB_CONFIG=""] +) +AC_SUBST([MISSING_STDLIB_CONFIG]) + # Set name for machine-dependent library files AC_ARG_VAR([MACHDEP], [name for machine-dependent library files]) AC_MSG_CHECKING([MACHDEP]) From 7a36850e934d250ea7f48f32595916fa69cdc028 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 30 Oct 2025 10:28:57 +0000 Subject: [PATCH 06/16] Docs & test --- Doc/using/configure.rst | 21 +++++++++++++++++++++ Doc/whatsnew/3.15.rst | 6 ++++++ Lib/test/test_traceback.py | 13 ++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Doc/using/configure.rst b/Doc/using/configure.rst index 1f773a3a547c2b..22ec4effc6ecc6 100644 --- a/Doc/using/configure.rst +++ b/Doc/using/configure.rst @@ -255,6 +255,27 @@ General Options .. versionadded:: 3.11 +.. option:: --with-missing-stdlib-config=FILE + + Path to a `JSON `_ configuration file + containing custom error messages for missing :term:`standard library` modules. + + This option is intended for Python distributors who wish to provide + distribution-specific guidance when users encounter missing standard library + modules that are packaged separately. + + The JSON file should map missing module names to custom error message strings. + For example, a configuration for the :mod:`tkinter` module: + + .. code-block::json + + { + "tkinter": "Install the python-tk package to use tkinter", + "_tkinter": "Install the python-tk package to use tkinter" + } + + .. versionadded:: next + .. option:: --enable-pystats Turn on internal Python performance statistics gathering. diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 8503a4c7f973fc..5c51ef3da56e94 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -229,6 +229,12 @@ Improved error messages AttributeError: 'Container' object has no attribute 'area'. Did you mean: 'inner.area'? +* The new configure option :option:`--with-missing-stdlib-config=FILE` allows + distributors to pass a `JSON `_ + configuration file containing custom error messages for missing + :term:`standard library` modules. + + Other language changes ====================== diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index bf57867a8715c0..97ae02489122f1 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -5051,7 +5051,7 @@ def test_no_site_package_flavour(self): b"or to enable your virtual environment?"), stderr ) - def test_missing_stdlib_package(self): + def test_missing_stdlib_module(self): code = """ import sys sys.stdlib_module_names |= {'spam'} @@ -5061,6 +5061,17 @@ def test_missing_stdlib_package(self): self.assertIn(b"Standard library module 'spam' was not found", stderr) + code = """ + import sys + import traceback + traceback.MISSING_STDLIB_MODULE_MESSAGES = {'spam': 'Install 'spam4life' for 'spam''} + sys.stdlib_module_names |= {'spam'} + import spam + """ + _, _, stderr = assert_python_failure('-S', '-c', code) + + self.assertIn(b"Install 'spam4life' for 'spam'", stderr) + class TestColorizedTraceback(unittest.TestCase): maxDiff = None From 62ffb3998ce4beb1108b25f7724329b4a1ad64b3 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 30 Oct 2025 10:37:05 +0000 Subject: [PATCH 07/16] Blurb --- Doc/whatsnew/3.15.rst | 1 + .../next/Build/2025-10-30-10-36-15.gh-issue-139707.QJ1FfJ.rst | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 Misc/NEWS.d/next/Build/2025-10-30-10-36-15.gh-issue-139707.QJ1FfJ.rst diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 5c51ef3da56e94..4e2a7334a4378d 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -233,6 +233,7 @@ Improved error messages distributors to pass a `JSON `_ configuration file containing custom error messages for missing :term:`standard library` modules. + (Contributed by Stan Ulbrych and Petr Viktorin in :gh:`139707`.) Other language changes diff --git a/Misc/NEWS.d/next/Build/2025-10-30-10-36-15.gh-issue-139707.QJ1FfJ.rst b/Misc/NEWS.d/next/Build/2025-10-30-10-36-15.gh-issue-139707.QJ1FfJ.rst new file mode 100644 index 00000000000000..d9870d267042af --- /dev/null +++ b/Misc/NEWS.d/next/Build/2025-10-30-10-36-15.gh-issue-139707.QJ1FfJ.rst @@ -0,0 +1,4 @@ +Add configure option :option:`--with-missing-stdlib-config=FILE` allows +which distributors to pass a `JSON `_ +configuration file containing custom error messages for missing +:term:`standard library` modules. From 8d5ca012812c61c74c9984b227ba7dc30228dc98 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 30 Oct 2025 10:50:54 +0000 Subject: [PATCH 08/16] Fix test --- Lib/test/test_traceback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 97ae02489122f1..036e5d50d29442 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -5064,7 +5064,7 @@ def test_missing_stdlib_module(self): code = """ import sys import traceback - traceback.MISSING_STDLIB_MODULE_MESSAGES = {'spam': 'Install 'spam4life' for 'spam''} + traceback.MISSING_STDLIB_MODULE_MESSAGES = {'spam': "Install 'spam4life' for 'spam'"} sys.stdlib_module_names |= {'spam'} import spam """ From 185e890234450bc92aa531d5d9c12deaf7285536 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 30 Oct 2025 12:40:21 +0000 Subject: [PATCH 09/16] Fix example in doc --- Doc/using/configure.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/using/configure.rst b/Doc/using/configure.rst index 22ec4effc6ecc6..a8d6ae0bf88bd1 100644 --- a/Doc/using/configure.rst +++ b/Doc/using/configure.rst @@ -267,11 +267,11 @@ General Options The JSON file should map missing module names to custom error message strings. For example, a configuration for the :mod:`tkinter` module: - .. code-block::json + .. code-block:: json { "tkinter": "Install the python-tk package to use tkinter", - "_tkinter": "Install the python-tk package to use tkinter" + "_tkinter": "Install the python-tk package to use tkinter", } .. versionadded:: next From 902274cc1251a2c44443ebd3b09c6c54cef5bd8f Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Tue, 11 Nov 2025 17:49:36 +0000 Subject: [PATCH 10/16] Petr's review --- Doc/using/configure.rst | 5 +++- Lib/test/test_traceback.py | 10 ++++++++ Lib/traceback.py | 4 ++-- Tools/build/check_extension_modules.py | 32 ++++++++------------------ 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Doc/using/configure.rst b/Doc/using/configure.rst index a8d6ae0bf88bd1..18c5992518242f 100644 --- a/Doc/using/configure.rst +++ b/Doc/using/configure.rst @@ -265,11 +265,14 @@ General Options modules that are packaged separately. The JSON file should map missing module names to custom error message strings. - For example, a configuration for the :mod:`tkinter` module: + For example, if your distribution packages :mod:`tkinter` and + :mod:`_tkinter` separately and excludes :mod:`_gdbm` for legal reasons, + the configuration could contain: .. code-block:: json { + "_gdbm": "The '_gdbm' module is not available in this distribution" "tkinter": "Install the python-tk package to use tkinter", "_tkinter": "Install the python-tk package to use tkinter", } diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 036e5d50d29442..d6e4955b8011bb 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -5072,6 +5072,16 @@ def test_missing_stdlib_module(self): self.assertIn(b"Install 'spam4life' for 'spam'", stderr) + @unittest.skipIf(sys.platform == "win32", "Non-Windows test") + def test_windows_only_module_error(self): + try: + import msvcrt # noqa: F401 + except ModuleNotFoundError: + formatted = traceback.format_exc() + self.assertIn("Unsupported platform for Windows-only standard library module 'msvcrt'", formatted) + else: + self.fail("ModuleNotFoundError was not raised") + class TestColorizedTraceback(unittest.TestCase): maxDiff = None diff --git a/Lib/traceback.py b/Lib/traceback.py index b793449be3ae6e..02d6eb27c2b680 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1118,11 +1118,11 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, if MISSING_STDLIB_MODULE_MESSAGES is not None: message = MISSING_STDLIB_MODULE_MESSAGES.get( module_name, - f"Standard library module '{module_name}' was not found" + f"Standard library module {module_name!r} was not found" ) self._str = message else: - self._str = f"Standard library module '{module_name}' was not found" + self._str = f"Standard library module {module_name!r} was not found" elif sys.flags.no_site: self._str += (". Site initialization is disabled, did you forget to " + "add the site-packages directory to sys.path " diff --git a/Tools/build/check_extension_modules.py b/Tools/build/check_extension_modules.py index f5f9662e6b0aa9..2c21fcf3703d12 100644 --- a/Tools/build/check_extension_modules.py +++ b/Tools/build/check_extension_modules.py @@ -290,14 +290,6 @@ def list_module_names(self, *, all: bool = False) -> set[str]: return names def generate_stdlib_info(self, config_path: str | None = None) -> None: - - disabled_modules = {modinfo.name for modinfo in self.modules - if modinfo.state in (ModuleState.DISABLED, ModuleState.DISABLED_SETUP)} - missing_modules = {modinfo.name for modinfo in self.modules - if modinfo.state == ModuleState.MISSING} - na_modules = {modinfo.name for modinfo in self.modules - if modinfo.state == ModuleState.NA} - config_messages = {} if config_path: try: @@ -306,26 +298,22 @@ def generate_stdlib_info(self, config_path: str | None = None) -> None: except (FileNotFoundError, json.JSONDecodeError) as e: logger.error("Failed to load distributor config %s: %s", config_path, e) - default_messages = { - **{name: f"Windows-only standard library module '{name}' was not found" - for name in WINDOWS_MODULES}, - **{name: f"Standard library module disabled during build '{name}' was not found" - for name in disabled_modules}, - **{name: f"Unsupported platform for standard library module '{name}'" - for name in na_modules}, - } + messages = {} + for name in WINDOWS_MODULES: + messages[name] = f"Unsupported platform for Windows-only standard library module {name!r}" + + for modinfo in self.modules: + if modinfo.state in (ModuleState.DISABLED, ModuleState.DISABLED_SETUP): + messages[modinfo.name] = f"Standard library module disabled during build {modinfo.name!r} was not found" + elif modinfo.state == ModuleState.NA: + messages[modinfo.name] = f"Unsupported platform for standard library module {modinfo.name!r}" - messages = {**default_messages, **config_messages} + messages.update(config_messages) content = f'''\ # Standard library information used by the traceback module for more informative # ModuleNotFound error messages. -DISABLED_MODULES = {sorted(disabled_modules)!r} -MISSING_MODULES = {sorted(missing_modules)!r} -NOT_AVAILABLE_MODULES = {sorted(na_modules)!r} -WINDOWS_ONLY_MODULES = {sorted(WINDOWS_MODULES)!r} - MISSING_STDLIB_MODULE_MESSAGES = {messages!r} ''' From 9c74f697127214b324893e6e13af66aa23f13544 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Tue, 11 Nov 2025 18:11:45 +0000 Subject: [PATCH 11/16] Sphinx fix --- Doc/using/configure.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/using/configure.rst b/Doc/using/configure.rst index 18c5992518242f..077047583292a2 100644 --- a/Doc/using/configure.rst +++ b/Doc/using/configure.rst @@ -266,7 +266,7 @@ General Options The JSON file should map missing module names to custom error message strings. For example, if your distribution packages :mod:`tkinter` and - :mod:`_tkinter` separately and excludes :mod:`_gdbm` for legal reasons, + :mod:`_tkinter` separately and excludes :mod:`!_gdbm` for legal reasons, the configuration could contain: .. code-block:: json From 2ed0eb39696d3496bb12e493626845e9d70d3eaa Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 27 Nov 2025 15:21:40 +0000 Subject: [PATCH 12/16] Petr's review --- Doc/using/configure.rst | 4 ++-- Doc/whatsnew/3.15.rst | 13 ++++++------ Lib/traceback.py | 15 ++++++-------- Makefile.pre.in | 4 ++-- Tools/build/check_extension_modules.py | 28 ++++++++++++++++---------- 5 files changed, 33 insertions(+), 31 deletions(-) diff --git a/Doc/using/configure.rst b/Doc/using/configure.rst index 077047583292a2..c3bbb02f2cd71a 100644 --- a/Doc/using/configure.rst +++ b/Doc/using/configure.rst @@ -261,8 +261,8 @@ General Options containing custom error messages for missing :term:`standard library` modules. This option is intended for Python distributors who wish to provide - distribution-specific guidance when users encounter missing standard library - modules that are packaged separately. + distribution-specific guidance when users encounter standard library + modules that are missing or packaged separately. The JSON file should map missing module names to custom error message strings. For example, if your distribution packages :mod:`tkinter` and diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 4e2a7334a4378d..733914be09e4f4 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -229,13 +229,6 @@ Improved error messages AttributeError: 'Container' object has no attribute 'area'. Did you mean: 'inner.area'? -* The new configure option :option:`--with-missing-stdlib-config=FILE` allows - distributors to pass a `JSON `_ - configuration file containing custom error messages for missing - :term:`standard library` modules. - (Contributed by Stan Ulbrych and Petr Viktorin in :gh:`139707`.) - - Other language changes ====================== @@ -1062,6 +1055,12 @@ Build changes set to ``no`` or with :option:`!--without-system-libmpdec`. (Contributed by Sergey B Kirpichev in :gh:`115119`.) +* The new configure option :option:`--with-missing-stdlib-config=FILE` allows + distributors to pass a `JSON `_ + configuration file containing custom error messages for :term:`standard library` + modules that are missing or packaged separately. + (Contributed by Stan Ulbrych and Petr Viktorin in :gh:`139707`.) + Porting to Python 3.15 ====================== diff --git a/Lib/traceback.py b/Lib/traceback.py index 02d6eb27c2b680..ffccaf89a78b44 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -17,7 +17,7 @@ try: from _stdlib_modules_info import MISSING_STDLIB_MODULE_MESSAGES except ImportError: - MISSING_STDLIB_MODULE_MESSAGES = None + MISSING_STDLIB_MODULE_MESSAGES = {} __all__ = ['extract_stack', 'extract_tb', 'format_exception', 'format_exception_only', 'format_list', 'format_stack', @@ -1115,14 +1115,11 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, elif exc_type and issubclass(exc_type, ModuleNotFoundError): module_name = getattr(exc_value, "name", None) if module_name in sys.stdlib_module_names: - if MISSING_STDLIB_MODULE_MESSAGES is not None: - message = MISSING_STDLIB_MODULE_MESSAGES.get( - module_name, - f"Standard library module {module_name!r} was not found" - ) - self._str = message - else: - self._str = f"Standard library module {module_name!r} was not found" + message = MISSING_STDLIB_MODULE_MESSAGES.get( + module_name, + f"Standard library module {module_name!r} was not found" + ) + self._str = message elif sys.flags.no_site: self._str += (". Site initialization is disabled, did you forget to " + "add the site-packages directory to sys.path " diff --git a/Makefile.pre.in b/Makefile.pre.in index 72c623a3d0ed52..a0fcc65e305c4b 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -1602,9 +1602,9 @@ sharedmods: $(SHAREDMODS) pybuilddir.txt .PHONY: checksharedmods checksharedmods: sharedmods $(PYTHON_FOR_BUILD_DEPS) $(BUILDPYTHON) @if [ -n "@MISSING_STDLIB_CONFIG@" ]; then \ - $(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py --generate-stdlib-info="@MISSING_STDLIB_CONFIG@"; \ + $(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py --generate-missing-stdlib-info --with-missing-stdlib-config="@MISSING_STDLIB_CONFIG@"; \ else \ - $(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py --generate-stdlib-info; \ + $(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py --generate-missing-stdlib-info; \ fi @$(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py diff --git a/Tools/build/check_extension_modules.py b/Tools/build/check_extension_modules.py index 2c21fcf3703d12..edf850dcaf15d5 100644 --- a/Tools/build/check_extension_modules.py +++ b/Tools/build/check_extension_modules.py @@ -27,6 +27,7 @@ import logging import os import pathlib +import pprint import re import sys import sysconfig @@ -118,10 +119,15 @@ ) parser.add_argument( - "--generate-stdlib-info", - nargs="?", - const=True, - help="Generate file with stdlib module info, with optional config file", + "--generate-missing-stdlib-info", + action="store_true", + help="Generate file with stdlib module info", +) + +parser.add_argument( + "--with-missing-stdlib-config", + metavar="CONFIG_FILE", + help="Path to JSON config file with custom missing module messages", ) @@ -289,14 +295,14 @@ def list_module_names(self, *, all: bool = False) -> set[str]: names.update(WINDOWS_MODULES) return names - def generate_stdlib_info(self, config_path: str | None = None) -> None: + def generate_missing_stdlib_info(self, config_path: str | None = None) -> None: config_messages = {} if config_path: try: with open(config_path, encoding='utf-8') as f: config_messages = json.load(f) except (FileNotFoundError, json.JSONDecodeError) as e: - logger.error("Failed to load distributor config %s: %s", config_path, e) + raise RuntimeError("Failed to load missing stdlib config %s: %s", config_path, e) messages = {} for name in WINDOWS_MODULES: @@ -313,11 +319,12 @@ def generate_stdlib_info(self, config_path: str | None = None) -> None: content = f'''\ # Standard library information used by the traceback module for more informative # ModuleNotFound error messages. +# Generated by check_extension_modules.py -MISSING_STDLIB_MODULE_MESSAGES = {messages!r} +MISSING_STDLIB_MODULE_MESSAGES = {pprint.pformat(messages)} ''' - output_path = self.builddir / "_stdlib_modules_info.py" + output_path = self.builddir / "_missing_stdlib_module_info.py" with open(output_path, "w", encoding="utf-8") as f: f.write(content) @@ -539,10 +546,9 @@ def main() -> None: names = checker.list_module_names(all=True) for name in sorted(names): print(name) - elif args.generate_stdlib_info: + elif args.generate_missing_stdlib_info: checker.check() - config_path = None if args.generate_stdlib_info is True else args.generate_stdlib_info - checker.generate_stdlib_info(config_path) + checker.generate_missing_stdlib_info(args.with_missing_stdlib_config) else: checker.check() checker.summary(verbose=args.verbose) From f9b66b3603067f9aaff8ee599f1e98a30e84bfea Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 27 Nov 2025 15:23:25 +0000 Subject: [PATCH 13/16] Rename private module (again) --- Tools/build/check_extension_modules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/build/check_extension_modules.py b/Tools/build/check_extension_modules.py index edf850dcaf15d5..d37e51a0811087 100644 --- a/Tools/build/check_extension_modules.py +++ b/Tools/build/check_extension_modules.py @@ -324,7 +324,7 @@ def generate_missing_stdlib_info(self, config_path: str | None = None) -> None: MISSING_STDLIB_MODULE_MESSAGES = {pprint.pformat(messages)} ''' - output_path = self.builddir / "_missing_stdlib_module_info.py" + output_path = self.builddir / "_missing_stdlib_info.py" with open(output_path, "w", encoding="utf-8") as f: f.write(content) From 9ac50d9795f443b028f61d0a0e2392f98f4c2961 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 27 Nov 2025 15:57:01 +0000 Subject: [PATCH 14/16] Fix traceback.py import --- Lib/traceback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index ffccaf89a78b44..791c430537d79d 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -15,7 +15,7 @@ from contextlib import suppress try: - from _stdlib_modules_info import MISSING_STDLIB_MODULE_MESSAGES + from _missing_stdlib_info import MISSING_STDLIB_MODULE_MESSAGES except ImportError: MISSING_STDLIB_MODULE_MESSAGES = {} From faea092d58b3eab7060dc79b018831dac3ec2999 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Thu, 27 Nov 2025 17:16:58 +0000 Subject: [PATCH 15/16] Fix install --- Makefile.pre.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile.pre.in b/Makefile.pre.in index a0fcc65e305c4b..94aac003aa0e46 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -2820,7 +2820,7 @@ libinstall: all $(srcdir)/Modules/xxmodule.c $(INSTALL_DATA) `cat pybuilddir.txt`/_sysconfigdata_$(ABIFLAGS)_$(MACHDEP)_$(MULTIARCH).py $(DESTDIR)$(LIBDEST); \ $(INSTALL_DATA) `cat pybuilddir.txt`/_sysconfig_vars_$(ABIFLAGS)_$(MACHDEP)_$(MULTIARCH).json $(DESTDIR)$(LIBDEST); \ $(INSTALL_DATA) `cat pybuilddir.txt`/build-details.json $(DESTDIR)$(LIBDEST); \ - $(INSTALL_DATA) `cat pybuilddir.txt`/_stdlib_modules_info.py $(DESTDIR)$(LIBDEST); \ + $(INSTALL_DATA) `cat pybuilddir.txt`/_missing_stdlib_info.py $(DESTDIR)$(LIBDEST); \ $(INSTALL_DATA) $(srcdir)/LICENSE $(DESTDIR)$(LIBDEST)/LICENSE.txt @ # If app store compliance has been configured, apply the patch to the @ # installed library code. The patch has been previously validated against From 4c0c0bb49495dea551ad5bb75583482079f6e4e9 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Fri, 28 Nov 2025 16:00:34 +0000 Subject: [PATCH 16/16] Petr's notes --- Lib/test/test_traceback.py | 2 +- Lib/traceback.py | 6 +++--- Tools/build/check_extension_modules.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index d6e4955b8011bb..3876f1a74bbc1a 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -5064,7 +5064,7 @@ def test_missing_stdlib_module(self): code = """ import sys import traceback - traceback.MISSING_STDLIB_MODULE_MESSAGES = {'spam': "Install 'spam4life' for 'spam'"} + traceback._MISSING_STDLIB_MODULE_MESSAGES = {'spam': "Install 'spam4life' for 'spam'"} sys.stdlib_module_names |= {'spam'} import spam """ diff --git a/Lib/traceback.py b/Lib/traceback.py index 791c430537d79d..8a3e0f77e765dc 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -15,9 +15,9 @@ from contextlib import suppress try: - from _missing_stdlib_info import MISSING_STDLIB_MODULE_MESSAGES + from _missing_stdlib_info import _MISSING_STDLIB_MODULE_MESSAGES except ImportError: - MISSING_STDLIB_MODULE_MESSAGES = {} + _MISSING_STDLIB_MODULE_MESSAGES = {} __all__ = ['extract_stack', 'extract_tb', 'format_exception', 'format_exception_only', 'format_list', 'format_stack', @@ -1115,7 +1115,7 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, elif exc_type and issubclass(exc_type, ModuleNotFoundError): module_name = getattr(exc_value, "name", None) if module_name in sys.stdlib_module_names: - message = MISSING_STDLIB_MODULE_MESSAGES.get( + message = _MISSING_STDLIB_MODULE_MESSAGES.get( module_name, f"Standard library module {module_name!r} was not found" ) diff --git a/Tools/build/check_extension_modules.py b/Tools/build/check_extension_modules.py index d37e51a0811087..f23c1d5286f92a 100644 --- a/Tools/build/check_extension_modules.py +++ b/Tools/build/check_extension_modules.py @@ -302,7 +302,7 @@ def generate_missing_stdlib_info(self, config_path: str | None = None) -> None: with open(config_path, encoding='utf-8') as f: config_messages = json.load(f) except (FileNotFoundError, json.JSONDecodeError) as e: - raise RuntimeError("Failed to load missing stdlib config %s: %s", config_path, e) + raise RuntimeError(f"Failed to load missing stdlib config {config_path!r}") from e messages = {} for name in WINDOWS_MODULES: @@ -321,7 +321,7 @@ def generate_missing_stdlib_info(self, config_path: str | None = None) -> None: # ModuleNotFound error messages. # Generated by check_extension_modules.py -MISSING_STDLIB_MODULE_MESSAGES = {pprint.pformat(messages)} +_MISSING_STDLIB_MODULE_MESSAGES = {pprint.pformat(messages)} ''' output_path = self.builddir / "_missing_stdlib_info.py"