Skip to content
Open
24 changes: 24 additions & 0 deletions Doc/using/configure.rst
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,30 @@ General Options

.. versionadded:: 3.11

.. option:: --with-missing-stdlib-config=FILE

Path to a `JSON <https://www.json.org/json-en.html>`_ 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 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
: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",
}

.. versionadded:: next

.. option:: --enable-pystats

Turn on internal Python performance statistics gathering.
Expand Down
6 changes: 6 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1055,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 <https://www.json.org/json-en.html>`_
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
======================
Expand Down
23 changes: 22 additions & 1 deletion Lib/test/test_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand All @@ -5061,6 +5061,27 @@ 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)

@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
Expand Down
11 changes: 10 additions & 1 deletion Lib/traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@

from contextlib import suppress

try:
from _missing_stdlib_info import MISSING_STDLIB_MODULE_MESSAGES
except ImportError:
MISSING_STDLIB_MODULE_MESSAGES = {}

__all__ = ['extract_stack', 'extract_tb', 'format_exception',
'format_exception_only', 'format_list', 'format_stack',
'format_tb', 'print_exc', 'format_exc', 'print_exception',
Expand Down Expand Up @@ -1110,7 +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:
self._str = f"Standard library module '{module_name}' 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 "
Expand Down
6 changes: 6 additions & 0 deletions Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +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)
@if [ -n "@MISSING_STDLIB_CONFIG@" ]; then \
$(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-missing-stdlib-info; \
fi
@$(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/check_extension_modules.py

.PHONY: rundsymutil
Expand Down Expand Up @@ -2815,6 +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`/_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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Add configure option :option:`--with-missing-stdlib-config=FILE` allows
which distributors to pass a `JSON <https://www.json.org/json-en.html>`_
configuration file containing custom error messages for missing
:term:`standard library` modules.
50 changes: 50 additions & 0 deletions Tools/build/check_extension_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
import _imp
import argparse
import enum
import json
import logging
import os
import pathlib
import pprint
import re
import sys
import sysconfig
Expand Down Expand Up @@ -116,6 +118,18 @@
help="Print a list of module names to stdout and exit",
)

parser.add_argument(
"--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",
)


@enum.unique
class ModuleState(enum.Enum):
Expand Down Expand Up @@ -281,6 +295,39 @@ def list_module_names(self, *, all: bool = False) -> set[str]:
names.update(WINDOWS_MODULES)
return names

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:
raise RuntimeError("Failed to load missing stdlib config %s: %s", config_path, e)

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.update(config_messages)

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 = {pprint.pformat(messages)}
'''

output_path = self.builddir / "_missing_stdlib_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:
Expand Down Expand Up @@ -499,6 +546,9 @@ def main() -> None:
names = checker.list_module_names(all=True)
for name in sorted(names):
print(name)
elif args.generate_missing_stdlib_info:
checker.check()
checker.generate_missing_stdlib_info(args.with_missing_stdlib_config)
else:
checker.check()
checker.summary(verbose=args.verbose)
Expand Down
18 changes: 18 additions & 0 deletions configure

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions configure.ac
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading