gh-148110: Resolve lazy import filter names for relative imports (#148111)

This commit is contained in:
Pablo Galindo Salgado 2026-04-06 22:29:02 +01:00 committed by GitHub
parent a0c57a8d17
commit ca960b6f38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 197 additions and 8 deletions

View file

@ -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.

View file

@ -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

View file

@ -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."""

View file

@ -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.

View file

@ -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 @@ exit:
#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]*/

View file

@ -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);

View file

@ -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;