From 96fa62f962c57beb59e328a0a29c5bbdb204a665 Mon Sep 17 00:00:00 2001 From: MWR27 <64335495+MWR27@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:32:11 -0400 Subject: [PATCH] Add warning for implicit relative imports and fix testing environment --- Lib/test/test_py3kwarn.py | 86 ++++++++++++++++++++++++++++++++++++++- Lib/warnings.py | 5 ++- Python/import.c | 42 +++++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_py3kwarn.py b/Lib/test/test_py3kwarn.py index 0aa981089606775..761138e99b9aa23 100644 --- a/Lib/test/test_py3kwarn.py +++ b/Lib/test/test_py3kwarn.py @@ -1,11 +1,11 @@ import unittest import sys +import os import contextlib from test.test_support import check_py3k_warnings, CleanImport, run_unittest -from test.script_helper import assert_python_ok import warnings import base64 -from test import test_support +from test import test_support, script_helper if not sys.py3kwarning: raise unittest.SkipTest('%s must be run with the -3 flag' % __name__) @@ -61,6 +61,88 @@ def assertWarning(self, _, warning, expected_message): def assertNoWarning(self, _, recorder): self.assertEqual(len(recorder.warnings), 0) + def assertWarningWithFix(self, _, warning, expected_msg, expected_fix): + self.assertTrue(hasattr(warning, 'fix')) + self.assertEqual('{}: {}'.format(warning.message, warning.fix), '{}: {}'.format(expected_msg, expected_fix)) + + def assertNoWarningsFromFile(self, _, recorder, filename='test_py3kwarn.py'): + for warning in recorder._warnings: + self.assertNotEqual(warning.filename, filename) + + def test_implicit_relative_import(self): + expected_msg = ("implicit relative import 'importee' resolved to 'testpkg.subpkg.importee'; " + "in 3.x imports are absolute by default, so this may resolve differently") + expected_fix = "use 'import testpkg.subpkg.importee' if the parent package is intended" + with check_py3k_warnings(("", DeprecationWarning), ("", Py3xWarning), quiet=True) as w, test_support.temp_dir() as test_dir: + try: + sys.path.append(test_dir) + + pkg_dir = os.path.join(test_dir, 'testpkg') + subpkg_dir = os.path.join(pkg_dir, 'subpkg') + script_helper.make_pkg(pkg_dir) + script_helper.make_pkg(subpkg_dir) + script_helper.make_script(subpkg_dir, 'importee', '') + script_helper.make_script(subpkg_dir, 'importer', 'import importee') + + import testpkg.subpkg.importer + self.assertWarningWithFix(None, w, expected_msg, expected_fix) + finally: + sys.path.remove(test_dir) + + def test_implicit_relative_import_from(self): + expected_msg = ("implicit relative import from 'importee' resolved to 'testpkg2.subpkg.importee'; " + "in 3.x imports are absolute by default, so this may resolve differently") + expected_fix = "use 'from testpkg2.subpkg.importee import ...' if the parent package is intended" + with check_py3k_warnings(("", DeprecationWarning), ("", Py3xWarning), quiet=True) as w, test_support.temp_dir() as test_dir: + try: + sys.path.append(test_dir) + + pkg_dir = os.path.join(test_dir, 'testpkg2') + subpkg_dir = os.path.join(pkg_dir, 'subpkg') + script_helper.make_pkg(pkg_dir) + script_helper.make_pkg(subpkg_dir) + script_helper.make_script(subpkg_dir, 'importee', 'foo = 0') + script_helper.make_script(subpkg_dir, 'importer', 'from importee import foo') + + import testpkg2.subpkg.importer + self.assertWarningWithFix(None, w, expected_msg, expected_fix) + finally: + sys.path.remove(test_dir) + + def test_absolute_import_no_warning(self): + with check_py3k_warnings(("", DeprecationWarning), ("", Py3xWarning), quiet=True) as w, test_support.temp_dir() as test_dir: + try: + sys.path.append(test_dir) + + pkg_dir = os.path.join(test_dir, 'testpkg3') + subpkg_dir = os.path.join(pkg_dir, 'subpkg') + script_helper.make_pkg(pkg_dir) + script_helper.make_pkg(subpkg_dir) + script_helper.make_script(subpkg_dir, 'importee', 'foo = 0') + script_helper.make_script(subpkg_dir, 'importer', 'import testpkg3.subpkg.importee') + + import testpkg3.subpkg.importer + self.assertNoWarningsFromFile(None, w) + finally: + sys.path.remove(test_dir) + + def test_absolute_import_from_no_warning(self): + with check_py3k_warnings(("", DeprecationWarning), ("", Py3xWarning), quiet=True) as w, test_support.temp_dir() as test_dir: + try: + sys.path.append(test_dir) + + pkg_dir = os.path.join(test_dir, 'testpkg4') + subpkg_dir = os.path.join(pkg_dir, 'subpkg') + script_helper.make_pkg(pkg_dir) + script_helper.make_pkg(subpkg_dir) + script_helper.make_script(subpkg_dir, 'importee', 'foo = 0') + script_helper.make_script(subpkg_dir, 'importer', 'from testpkg4.subpkg.importee import foo') + + import testpkg4.subpkg.importer + self.assertNoWarningsFromFile(None, w) + finally: + sys.path.remove(test_dir) + def test_backquote(self): expected = 'backquote not supported in 3.x; use repr()' with check_py3k_warnings((expected, SyntaxWarning)): diff --git a/Lib/warnings.py b/Lib/warnings.py index d1724875dc38cc0..3740b01507e2370 100644 --- a/Lib/warnings.py +++ b/Lib/warnings.py @@ -502,7 +502,7 @@ def __init__(self, message, fix, category, filename, lineno, file=None, def __str__(self): return ("{message : %r, fix : %r, category : %r, filename : %r, lineno : %s, " - "line : %r}" % (self.message, self._category_name, + "line : %r}" % (self.message, self.fix, self._category_name, self.filename, self.lineno, self.line)) @@ -551,6 +551,7 @@ def __enter__(self): self._filters = self._module.filters self._module.filters = self._filters[:] self._showwarning = self._module.showwarning + self._showwarningwithfix = self._module.showwarningwithfix if self._record: log = [] def showwarning(*args, **kwargs): @@ -558,6 +559,7 @@ def showwarning(*args, **kwargs): def showwarningwithfix(*args, **kwargs): log.append(WarningMessageWithFix(*args, **kwargs)) self._module.showwarning = showwarning + self._module.showwarningwithfix = showwarningwithfix return log else: return None @@ -567,6 +569,7 @@ def __exit__(self, *exc_info): raise RuntimeError("Cannot exit %r without entering first" % self) self._module.filters = self._filters self._module.showwarning = self._showwarning + self._module.showwarningwithfix = self._showwarningwithfix # filters contains a sequence of filter 5-tuples diff --git a/Python/import.c b/Python/import.c index b79354b37a40645..7f6731952e6760c 100644 --- a/Python/import.c +++ b/Python/import.c @@ -2247,6 +2247,11 @@ import_module_level(char *name, PyObject *globals, PyObject *locals, Py_ssize_t buflen = 0; PyObject *parent, *head, *next, *tail; + char parentstr[MAXPATHLEN + 1]; + char *orig_name = name; + int is_from_form = 0; + int might_warn_implicit; + if (strchr(name, '/') != NULL #ifdef MS_WINDOWS || strchr(name, '\\') != NULL @@ -2265,6 +2270,14 @@ import_module_level(char *name, PyObject *globals, PyObject *locals, if (parent == NULL) goto error_exit; + might_warn_implicit = level < 0 && + parent != Py_None && + strchr(name, '.') == NULL && + buflen + strlen(name) + 1 <= MAXPATHLEN; + if (might_warn_implicit) { + memcpy(parentstr, buf, buflen + 1); + } + Py_INCREF(parent); head = load_next(parent, level < 0 ? Py_None : parent, &name, buf, &buflen); @@ -2303,6 +2316,35 @@ import_module_level(char *name, PyObject *globals, PyObject *locals, } if (!b) fromlist = NULL; + is_from_form = b; + } + + if (might_warn_implicit) { + char potential_resolved[MAXPATHLEN + 2]; + sprintf(potential_resolved, "%s.%s", parentstr, orig_name); + + if (strcmp(potential_resolved, buf) == 0) { + char msg[524]; + char fix[260]; + if (is_from_form) { + sprintf(msg, + "implicit relative import from '%.200s' resolved to '%.200s'; " + "in 3.x imports are absolute by default, so this may resolve differently", + orig_name, buf); + sprintf(fix, + "use 'from %.200s import ...' if the parent package is intended", buf); + } else { + sprintf(msg, + "implicit relative import '%.200s' resolved to '%.200s'; " + "in 3.x imports are absolute by default, so this may resolve differently", + orig_name, buf); + sprintf(fix, + "use 'import %.200s' if the parent package is intended", buf); + } + if (PyErr_WarnPy3k_WithFix(msg, fix, 1) < 0) { + goto error_exit; + } + } } if (fromlist == NULL) {