diff --git a/Lib/runpy.py b/Lib/runpy.py index 9f62d20e9a2322..e7aa819b0d4281 100644 --- a/Lib/runpy.py +++ b/Lib/runpy.py @@ -100,6 +100,22 @@ def _run_module_code(code, init_globals=None, # may be cleared when the temporary module goes away return mod_globals.copy() + +def _get_possible_name_list(wrong_name): + try: + if parent_name := wrong_name.rpartition('.')[0]: + parent = importlib.util.find_spec(parent_name) + else: + parent = None + d = [] + for finder in sys.meta_path: + if discover := getattr(finder, 'discover', None): + d += [spec.name for spec in discover(parent)] + return d + except Exception: + return None + + # Helper to get the full name, spec and code for a module def _get_module_details(mod_name, error=ImportError): if mod_name.startswith("."): @@ -138,7 +154,12 @@ def _get_module_details(mod_name, error=ImportError): f"'{mod_name}' as the module name.") raise error(msg.format(mod_name, type(ex).__name__, ex)) from ex if spec is None: - raise error("No module named %s" % mod_name) + message = "No module named %r" % mod_name + if (d := _get_possible_name_list(mod_name)): + from traceback import _calculate_closed_name + if (suggestion := _calculate_closed_name(mod_name, d)): + message += ". Did you mean: %r?" % suggestion + raise error(message) if spec.submodule_search_locations is not None: if mod_name == "__main__" or mod_name.endswith(".__main__"): raise error("Cannot use package as __main__ module") diff --git a/Lib/test/test_cmd_line_script.py b/Lib/test/test_cmd_line_script.py index 73b1f671c58555..6895a38ffbf44e 100644 --- a/Lib/test/test_cmd_line_script.py +++ b/Lib/test/test_cmd_line_script.py @@ -771,7 +771,7 @@ def test_consistent_sys_path_for_module_execution(self): "-Im", "script_pkg", cwd=work_dir ) traceback_lines = stderr.decode().splitlines() - self.assertIn("No module named script_pkg", traceback_lines[-1]) + self.assertIn("No module named 'script_pkg'", traceback_lines[-1]) def test_nonexisting_script(self): # bpo-34783: "./python script.py" must not crash @@ -829,6 +829,11 @@ def test_zipfile_run_filter_syntax_warnings_by_module(self): ) self.assertEqual(err.count(b': SyntaxWarning: '), 12) + def test_typo_in_module_name_suggests_similar_module(self): + # python -m randon -> expect suggestion for 'random' + rc, out, err = assert_python_failure("-m", "randon") + self.assertIn(b"No module named 'randon'", err) + self.assertIn(b"Did you mean: 'random'?", err) def tearDownModule(): support.reap_children() diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py index 0e23cd6604379c..4fb97008954597 100644 --- a/Lib/test/test_pdb.py +++ b/Lib/test/test_pdb.py @@ -4210,7 +4210,7 @@ def test_module_without_a_main(self): stdout, stderr = self._run_pdb( ['-m', module_name], "", expected_returncode=1 ) - self.assertIn("ImportError: No module named t_main.__main__;", stdout) + self.assertIn("ImportError: No module named 't_main.__main__'", stdout) def test_package_without_a_main(self): pkg_name = 't_pkg' @@ -4231,7 +4231,7 @@ def test_package_without_a_main(self): def test_nonexistent_module(self): assert not os.path.exists(os_helper.TESTFN) stdout, stderr = self._run_pdb(["-m", os_helper.TESTFN], "", expected_returncode=1) - self.assertIn(f"ImportError: No module named {os_helper.TESTFN}", stdout) + self.assertIn(f"ImportError: No module named {os_helper.TESTFN!r}", stdout) def test_dir_as_script(self): with os_helper.temp_dir() as temp_dir: diff --git a/Lib/traceback.py b/Lib/traceback.py index b16cd8646e43f1..d15e207f2c8ebf 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1778,6 +1778,20 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): if not_normalized and wrong_name in d: return wrong_name + + suggestion = _calculate_closed_name(wrong_name, d) + + # If no direct attribute match found, check for nested attributes + if not suggestion and isinstance(exc_value, AttributeError): + with suppress(Exception): + nested_suggestion = _check_for_nested_attribute(exc_value.obj, wrong_name, d) + if nested_suggestion: + return nested_suggestion + + return suggestion + + +def _calculate_closed_name(wrong_name, d): try: import _suggestions except ImportError: @@ -1810,14 +1824,6 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): if not suggestion or current_distance < best_distance: suggestion = possible_name best_distance = current_distance - - # If no direct attribute match found, check for nested attributes - if not suggestion and isinstance(exc_value, AttributeError): - with suppress(Exception): - nested_suggestion = _check_for_nested_attribute(exc_value.obj, wrong_name, d) - if nested_suggestion: - return nested_suggestion - return suggestion diff --git a/Misc/NEWS.d/next/Library/2026-02-22-12-39-31.gh-issue-145031.-4yezd.rst b/Misc/NEWS.d/next/Library/2026-02-22-12-39-31.gh-issue-145031.-4yezd.rst new file mode 100644 index 00000000000000..abf75287c70990 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-22-12-39-31.gh-issue-145031.-4yezd.rst @@ -0,0 +1 @@ +Suggest a possible module name in ``python -m module`` if the module name is wrong.