]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-145006: add ModuleNotFoundError hints when a module for a differen… (#145007)
authorFilipe Laíns <lains@riseup.net>
Wed, 25 Feb 2026 00:53:01 +0000 (00:53 +0000)
committerGitHub <noreply@github.com>
Wed, 25 Feb 2026 00:53:01 +0000 (00:53 +0000)
* 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>
Lib/test/test_traceback.py
Lib/traceback.py
Misc/NEWS.d/next/Library/2026-02-19-17-50-47.gh-issue-145006.9gqA0Q.rst [new file with mode: 0644]

index 99ac7fd83d91cb8c8dd58aa5a6f7574eaa561144..3896f34a34c8d6612aee3c2ce7b18fdfd7deff26 100644 (file)
@@ -9,15 +9,18 @@ import inspect
 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 @@ class MiscTest(unittest.TestCase):
         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
index b16cd8646e43f1592b8c01f24ec8ac955165eac1..4e809acb7a01bb51ffe54122751cd854a1c75bd9 100644 (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 keyword
 import tokenize
 import io
 import importlib.util
+import pathlib
 import _colorize
 
 from contextlib import suppress
@@ -1129,6 +1131,11 @@ class TracebackException:
                 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
diff --git a/Misc/NEWS.d/next/Library/2026-02-19-17-50-47.gh-issue-145006.9gqA0Q.rst b/Misc/NEWS.d/next/Library/2026-02-19-17-50-47.gh-issue-145006.9gqA0Q.rst
new file mode 100644 (file)
index 0000000..69052c7
--- /dev/null
@@ -0,0 +1,2 @@
+Add :exc:`ModuleNotFoundError` hints when a module for a different ABI
+exists.