diff --git a/Doc/c-api/import.rst b/Doc/c-api/import.rst index 367490732b994f..e2d363b911a87c 100644 --- a/Doc/c-api/import.rst +++ b/Doc/c-api/import.rst @@ -372,8 +372,10 @@ Importing Modules Sets the current lazy imports filter. The *filter* should be a callable that will receive ``(importing_module_name, imported_module_name, [fromlist])`` - when an import can potentially be lazy and that must return ``True`` if - the import should be lazy and ``False`` otherwise. + when an import can potentially be lazy. The ``imported_module_name`` value + is the resolved module name, so ``lazy from .spam import eggs`` passes + ``package.spam``. The callable must return ``True`` if the import should be + lazy and ``False`` otherwise. Return ``0`` on success and ``-1`` with an exception set otherwise. diff --git a/Doc/library/sys.rst b/Doc/library/sys.rst index b1461b0cbaf528..6946eb6eeaa5fa 100644 --- a/Doc/library/sys.rst +++ b/Doc/library/sys.rst @@ -1788,7 +1788,9 @@ always available. Unless explicitly noted otherwise, all variables are read-only Where: * *importing_module* is the name of the module doing the import - * *imported_module* is the name of the module being imported + * *imported_module* is the resolved name of the module being imported + (for example, ``lazy from .spam import eggs`` passes + ``package.spam``) * *fromlist* is the tuple of names being imported (for ``from ... import`` statements), or ``None`` for regular imports diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 69cb96cf4a0c1a..a9a8cd143e0d75 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -1205,6 +1205,36 @@ def tearDown(self): sys.set_lazy_imports_filter(None) sys.set_lazy_imports("normal") + def _run_subprocess_with_modules(self, code, files): + with tempfile.TemporaryDirectory() as tmpdir: + for relpath, contents in files.items(): + path = os.path.join(tmpdir, relpath) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as file: + file.write(textwrap.dedent(contents)) + + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + entry for entry in (tmpdir, env.get("PYTHONPATH")) if entry + ) + env["PYTHON_LAZY_IMPORTS"] = "normal" + + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(code)], + capture_output=True, + cwd=tmpdir, + env=env, + text=True, + ) + return result + + def _assert_subprocess_ok(self, code, files): + result = self._run_subprocess_with_modules(code, files) + self.assertEqual( + result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}" + ) + return result + def test_filter_receives_correct_arguments_for_import(self): """Filter should receive (importer, name, fromlist=None) for 'import x'.""" code = textwrap.dedent(""" @@ -1290,6 +1320,159 @@ def deny_filter(importer, name, fromlist): self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}") self.assertIn("EAGER", result.stdout) + def test_filter_distinguishes_absolute_and_relative_from_imports(self): + """Relative imports should pass resolved module names to the filter.""" + files = { + "target.py": """ + VALUE = "absolute" + """, + "pkg/__init__.py": "", + "pkg/target.py": """ + VALUE = "relative" + """, + "pkg/runner.py": """ + import sys + + seen = [] + + def my_filter(importer, name, fromlist): + seen.append((importer, name, fromlist)) + return True + + sys.set_lazy_imports_filter(my_filter) + + lazy from target import VALUE as absolute_value + lazy from .target import VALUE as relative_value + + assert seen == [ + (__name__, "target", ("VALUE",)), + (__name__, "pkg.target", ("VALUE",)), + ], seen + """, + } + + result = self._assert_subprocess_ok( + """ + import pkg.runner + print("OK") + """, + files, + ) + self.assertIn("OK", result.stdout) + + def test_filter_receives_resolved_name_for_relative_package_import(self): + """'lazy from . import x' should report the resolved package name.""" + files = { + "pkg/__init__.py": "", + "pkg/sibling.py": """ + VALUE = 1 + """, + "pkg/runner.py": """ + import sys + + seen = [] + + def my_filter(importer, name, fromlist): + seen.append((importer, name, fromlist)) + return True + + sys.set_lazy_imports_filter(my_filter) + + lazy from . import sibling + + assert seen == [ + (__name__, "pkg", ("sibling",)), + ], seen + """, + } + + result = self._assert_subprocess_ok( + """ + import pkg.runner + print("OK") + """, + files, + ) + self.assertIn("OK", result.stdout) + + def test_filter_receives_resolved_name_for_parent_relative_import(self): + """Parent relative imports should also use the resolved module name.""" + files = { + "pkg/__init__.py": "", + "pkg/target.py": """ + VALUE = 1 + """, + "pkg/sub/__init__.py": "", + "pkg/sub/runner.py": """ + import sys + + seen = [] + + def my_filter(importer, name, fromlist): + seen.append((importer, name, fromlist)) + return True + + sys.set_lazy_imports_filter(my_filter) + + lazy from ..target import VALUE + + assert seen == [ + (__name__, "pkg.target", ("VALUE",)), + ], seen + """, + } + + result = self._assert_subprocess_ok( + """ + import pkg.sub.runner + print("OK") + """, + files, + ) + self.assertIn("OK", result.stdout) + + def test_filter_can_force_eager_only_for_resolved_relative_target(self): + """Resolved names should let filters treat relative and absolute imports differently.""" + files = { + "target.py": """ + VALUE = "absolute" + """, + "pkg/__init__.py": "", + "pkg/target.py": """ + VALUE = "relative" + """, + "pkg/runner.py": """ + import sys + + def my_filter(importer, name, fromlist): + return name != "pkg.target" + + sys.set_lazy_imports_filter(my_filter) + + lazy from target import VALUE as absolute_value + lazy from .target import VALUE as relative_value + + assert "pkg.target" in sys.modules, sorted( + name for name in sys.modules + if name in {"target", "pkg.target"} + ) + assert "target" not in sys.modules, sorted( + name for name in sys.modules + if name in {"target", "pkg.target"} + ) + assert relative_value == "relative", relative_value + """, + } + + result = self._assert_subprocess_ok( + """ + import pkg.runner + print("OK") + """, + files, + ) + self.assertIn("OK", result.stdout) + class AdditionalSyntaxRestrictionTests(unittest.TestCase): """Additional syntax restriction tests per PEP 810.""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-04-22-20-00.gh-issue-148110.cL5x2Q.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-04-22-20-00.gh-issue-148110.cL5x2Q.rst new file mode 100644 index 00000000000000..dc7df0e4a299c9 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-04-22-20-00.gh-issue-148110.cL5x2Q.rst @@ -0,0 +1,2 @@ +Fix :func:`sys.set_lazy_imports_filter` so relative lazy imports pass the +resolved imported module name to the filter callback. Patch by Pablo Galindo. diff --git a/Python/clinic/sysmodule.c.h b/Python/clinic/sysmodule.c.h index f8ae7f18acc809..86e942ec2b8afb 100644 --- a/Python/clinic/sysmodule.c.h +++ b/Python/clinic/sysmodule.c.h @@ -1830,7 +1830,7 @@ PyDoc_STRVAR(sys_set_lazy_imports_filter__doc__, "would otherwise be enabled. Returns True if the import is still enabled\n" "or False to disable it. The callable is called with:\n" "\n" -"(importing_module_name, imported_module_name, [fromlist])\n" +"(importing_module_name, resolved_imported_module_name, [fromlist])\n" "\n" "Pass None to clear the filter."); @@ -2121,4 +2121,4 @@ _jit_is_active(PyObject *module, PyObject *Py_UNUSED(ignored)) #ifndef SYS_GETANDROIDAPILEVEL_METHODDEF #define SYS_GETANDROIDAPILEVEL_METHODDEF #endif /* !defined(SYS_GETANDROIDAPILEVEL_METHODDEF) */ -/*[clinic end generated code: output=adbadb629b98eabf input=a9049054013a1b77]*/ +/*[clinic end generated code: output=e8333fe10c01ae66 input=a9049054013a1b77]*/ diff --git a/Python/import.c b/Python/import.c index e298fbee536c1b..7aa96196ec1e10 100644 --- a/Python/import.c +++ b/Python/import.c @@ -4523,7 +4523,7 @@ _PyImport_LazyImportModuleLevelObject(PyThreadState *tstate, assert(!PyErr_Occurred()); fromlist = Py_NewRef(Py_None); } - PyObject *args[] = {modname, name, fromlist}; + PyObject *args[] = {modname, abs_name, fromlist}; PyObject *res = PyObject_Vectorcall(filter, args, 3, NULL); Py_DECREF(modname); diff --git a/Python/sysmodule.c b/Python/sysmodule.c index ce9c03bda7bd57..408d04684a9193 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2796,14 +2796,14 @@ The filter is a callable which disables lazy imports when they would otherwise be enabled. Returns True if the import is still enabled or False to disable it. The callable is called with: -(importing_module_name, imported_module_name, [fromlist]) +(importing_module_name, resolved_imported_module_name, [fromlist]) Pass None to clear the filter. [clinic start generated code]*/ static PyObject * sys_set_lazy_imports_filter_impl(PyObject *module, PyObject *filter) -/*[clinic end generated code: output=10251d49469c278c input=2eb48786bdd4ee42]*/ +/*[clinic end generated code: output=10251d49469c278c input=fd51ed8df6ab54b7]*/ { if (PyImport_SetLazyImportsFilter(filter) < 0) { return NULL;