GH-145006: add ModuleNotFoundError hints when a module for a differen… (#145007)

* GH-145006: add ModuleNotFoundError hints when a module for a different ABI exists

Signed-off-by: Filipe Laíns <lains@riseup.net>

* Fix deprecation warnings

Signed-off-by: Filipe Laíns <lains@riseup.net>

* Use SHLIB_SUFFIX in test_find_incompatible_extension_modules when available

Signed-off-by: Filipe Laíns <lains@riseup.net>

* Add test_incompatible_extension_modules_hint

Signed-off-by: Filipe Laíns <lains@riseup.net>

* Fix Windows

Signed-off-by: Filipe Laíns <lains@riseup.net>

* Show the whole extension module file name in hint

Signed-off-by: Filipe Laíns <lains@riseup.net>

---------

Signed-off-by: Filipe Laíns <lains@riseup.net>
This commit is contained in:
Filipe Laíns 2026-02-25 00:53:01 +00:00 committed by GitHub
parent 4e45c9c309
commit 1ac9d138ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 92 additions and 1 deletions

View file

@ -9,15 +9,18 @@
import builtins
import unittest
import unittest.mock
import os
import re
import tempfile
import random
import string
import importlib.machinery
import sysconfig
from test import support
import shutil
from test.support import (Error, captured_output, cpython_only, ALWAYS_EQ,
requires_debug_ranges, has_no_debug_ranges,
requires_subprocess)
requires_subprocess, os_helper)
from test.support.os_helper import TESTFN, temp_dir, unlink
from test.support.script_helper import assert_python_ok, assert_python_failure, make_script
from test.support.import_helper import forget
@ -5194,6 +5197,56 @@ def test_windows_only_module_error(self):
else:
self.fail("ModuleNotFoundError was not raised")
@unittest.skipIf(not importlib.machinery.EXTENSION_SUFFIXES, 'Platform does not support extension modules')
def test_find_incompatible_extension_modules(self):
"""_find_incompatible_extension_modules assumes the last extension in
importlib.machinery.EXTENSION_SUFFIXES (defined in Python/dynload_*.c)
is untagged (eg. .so, .pyd).
This test exists to make sure that assumption is correct.
"""
last_extension_suffix = importlib.machinery.EXTENSION_SUFFIXES[-1]
if shlib_suffix := sysconfig.get_config_var('SHLIB_SUFFIX'):
self.assertEqual(last_extension_suffix, shlib_suffix)
else:
before_dot, *extensions = last_extension_suffix.split('.')
expected_prefixes = ['']
if os.name == 'nt':
# Windows puts the debug tag in the module file stem (eg. foo_d.pyd)
expected_prefixes.append('_d')
self.assertIn(before_dot, expected_prefixes, msg=(
f'Unexpected prefix {before_dot!r} in extension module '
f'suffix {last_extension_suffix!r}. '
'traceback._find_incompatible_extension_module needs to be '
'updated to take this into account!'
))
# if SHLIB_SUFFIX is not define, we assume the native
# shared library suffix only contains one extension
# (eg. '.so', bad eg. '.cpython-315-x86_64-linux-gnu.so')
self.assertEqual(len(extensions), 1, msg=(
'The last suffix in importlib.machinery.EXTENSION_SUFFIXES '
'contains more than one extension, so it is probably different '
'than SHLIB_SUFFIX. It probably contains an ABI tag! '
'If this is a false positive, define SHLIB_SUFFIX in sysconfig.'
))
@unittest.skipIf(not importlib.machinery.EXTENSION_SUFFIXES, 'Platform does not support extension modules')
def test_incompatible_extension_modules_hint(self):
untagged_suffix = importlib.machinery.EXTENSION_SUFFIXES[-1]
with os_helper.temp_dir() as tmp:
# create a module with a incompatible ABI tag
incompatible_module = f'foo.some-abi{untagged_suffix}'
open(os.path.join(tmp, incompatible_module), "wb").close()
# try importing it
code = f'''
import sys
sys.path.insert(0, {tmp!r})
import foo
'''
_, _, stderr = assert_python_failure('-c', code, __cwd=tmp)
hint = f'Although a module with this name was found for a different Python version ({incompatible_module}).'
self.assertIn(hint, stderr.decode())
class TestColorizedTraceback(unittest.TestCase):
maxDiff = None

View file

@ -3,6 +3,7 @@
import collections.abc
import itertools
import linecache
import os
import sys
import textwrap
import types
@ -12,6 +13,7 @@
import tokenize
import io
import importlib.util
import pathlib
import _colorize
from contextlib import suppress
@ -1129,6 +1131,11 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
self._str += (". Site initialization is disabled, did you forget to "
+ "add the site-packages directory to sys.path "
+ "or to enable your virtual environment?")
elif abi_tag := _find_incompatible_extension_module(module_name):
self._str += (
". Although a module with this name was found for a "
f"different Python version ({abi_tag})."
)
else:
suggestion = _compute_suggestion_error(exc_value, exc_traceback, module_name)
if suggestion:
@ -1880,3 +1887,32 @@ def _levenshtein_distance(a, b, max_cost):
# Everything in this row is too big, so bail early.
return max_cost + 1
return result
def _find_incompatible_extension_module(module_name):
import importlib.machinery
import importlib.resources.readers
if not module_name or not importlib.machinery.EXTENSION_SUFFIXES:
return
# We assume the last extension is untagged (eg. .so, .pyd)!
# tests.test_traceback.MiscTest.test_find_incompatible_extension_modules
# tests that assumption.
untagged_suffix = importlib.machinery.EXTENSION_SUFFIXES[-1]
# On Windows the debug tag is part of the module file stem, instead of the
# extension (eg. foo_d.pyd), so let's remove it and just look for .pyd.
if os.name == 'nt':
untagged_suffix = untagged_suffix.removeprefix('_d')
parent, _, child = module_name.rpartition('.')
if parent:
traversable = importlib.resources.files(parent)
else:
traversable = importlib.resources.readers.MultiplexedPath(
*map(pathlib.Path, filter(os.path.isdir, sys.path))
)
for entry in traversable.iterdir():
if entry.name.startswith(child + '.') and entry.name.endswith(untagged_suffix):
return entry.name

View file

@ -0,0 +1,2 @@
Add :exc:`ModuleNotFoundError` hints when a module for a different ABI
exists.