Fix duplicated imports with importlib mode and doctest-modules (#11148)

The initial implementation (in #7246) introduced the `importlib` mode, which
never added the imported module to `sys.modules`, so it included a test
to ensure calling `import_path` twice would yield different modules.

Not adding modules to `sys.modules` proved problematic, so we began to add the imported module to `sys.modules`
in #7870, but failed to realize that given we are now changing `sys.modules`, we might
as well avoid importing it more than once.

Then #10088 came along, passing `importlib` also when importing application modules
(as opposed to only test modules before), which caused problems due to imports
having side-effects and the expectation being that they are imported only once.

With this PR, `import_path` returns the module immediately if already in
`sys.modules`.

Fix #10811
Fix #10341
This commit is contained in:
Bruno Oliveira 2023-07-01 12:37:46 -03:00 committed by GitHub
parent 2f7415cfbc
commit b77d0deaf5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 58 additions and 8 deletions

View File

@ -0,0 +1,2 @@
Fixed issue when using ``--import-mode=importlib`` together with ``--doctest-modules`` that caused modules
to be imported more than once, causing problems with modules that have import side effects.

View File

@ -523,6 +523,8 @@ def import_path(
if mode is ImportMode.importlib: if mode is ImportMode.importlib:
module_name = module_name_from_path(path, root) module_name = module_name_from_path(path, root)
with contextlib.suppress(KeyError):
return sys.modules[module_name]
for meta_importer in sys.meta_path: for meta_importer in sys.meta_path:
spec = meta_importer.find_spec(module_name, [str(path.parent)]) spec = meta_importer.find_spec(module_name, [str(path.parent)])

View File

@ -1315,3 +1315,38 @@ def test_function_return_non_none_warning(pytester: Pytester) -> None:
) )
res = pytester.runpytest() res = pytester.runpytest()
res.stdout.fnmatch_lines(["*Did you mean to use `assert` instead of `return`?*"]) res.stdout.fnmatch_lines(["*Did you mean to use `assert` instead of `return`?*"])
def test_doctest_and_normal_imports_with_importlib(pytester: Pytester) -> None:
"""
Regression test for #10811: previously import_path with ImportMode.importlib would
not return a module if already in sys.modules, resulting in modules being imported
multiple times, which causes problems with modules that have import side effects.
"""
# Uses the exact reproducer form #10811, given it is very minimal
# and illustrates the problem well.
pytester.makepyfile(
**{
"pmxbot/commands.py": "from . import logging",
"pmxbot/logging.py": "",
"tests/__init__.py": "",
"tests/test_commands.py": """
import importlib
from pmxbot import logging
class TestCommands:
def test_boo(self):
assert importlib.import_module('pmxbot.logging') is logging
""",
}
)
pytester.makeini(
"""
[pytest]
addopts=
--doctest-modules
--import-mode importlib
"""
)
result = pytester.runpytest_subprocess()
result.stdout.fnmatch_lines("*1 passed*")

View File

@ -7,6 +7,7 @@ from textwrap import dedent
from types import ModuleType from types import ModuleType
from typing import Any from typing import Any
from typing import Generator from typing import Generator
from typing import Iterator
import pytest import pytest
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
@ -282,29 +283,36 @@ class TestImportPath:
import_path(tmp_path / "invalid.py", root=tmp_path) import_path(tmp_path / "invalid.py", root=tmp_path)
@pytest.fixture @pytest.fixture
def simple_module(self, tmp_path: Path) -> Path: def simple_module(
fn = tmp_path / "_src/tests/mymod.py" self, tmp_path: Path, request: pytest.FixtureRequest
) -> Iterator[Path]:
name = f"mymod_{request.node.name}"
fn = tmp_path / f"_src/tests/{name}.py"
fn.parent.mkdir(parents=True) fn.parent.mkdir(parents=True)
fn.write_text("def foo(x): return 40 + x", encoding="utf-8") fn.write_text("def foo(x): return 40 + x", encoding="utf-8")
return fn module_name = module_name_from_path(fn, root=tmp_path)
yield fn
sys.modules.pop(module_name, None)
def test_importmode_importlib(self, simple_module: Path, tmp_path: Path) -> None: def test_importmode_importlib(
self, simple_module: Path, tmp_path: Path, request: pytest.FixtureRequest
) -> None:
"""`importlib` mode does not change sys.path.""" """`importlib` mode does not change sys.path."""
module = import_path(simple_module, mode="importlib", root=tmp_path) module = import_path(simple_module, mode="importlib", root=tmp_path)
assert module.foo(2) == 42 # type: ignore[attr-defined] assert module.foo(2) == 42 # type: ignore[attr-defined]
assert str(simple_module.parent) not in sys.path assert str(simple_module.parent) not in sys.path
assert module.__name__ in sys.modules assert module.__name__ in sys.modules
assert module.__name__ == "_src.tests.mymod" assert module.__name__ == f"_src.tests.mymod_{request.node.name}"
assert "_src" in sys.modules assert "_src" in sys.modules
assert "_src.tests" in sys.modules assert "_src.tests" in sys.modules
def test_importmode_twice_is_different_module( def test_remembers_previous_imports(
self, simple_module: Path, tmp_path: Path self, simple_module: Path, tmp_path: Path
) -> None: ) -> None:
"""`importlib` mode always returns a new module.""" """`importlib` mode called remembers previous module (#10341, #10811)."""
module1 = import_path(simple_module, mode="importlib", root=tmp_path) module1 = import_path(simple_module, mode="importlib", root=tmp_path)
module2 = import_path(simple_module, mode="importlib", root=tmp_path) module2 = import_path(simple_module, mode="importlib", root=tmp_path)
assert module1 is not module2 assert module1 is module2
def test_no_meta_path_found( def test_no_meta_path_found(
self, simple_module: Path, monkeypatch: MonkeyPatch, tmp_path: Path self, simple_module: Path, monkeypatch: MonkeyPatch, tmp_path: Path
@ -317,6 +325,9 @@ class TestImportPath:
# mode='importlib' fails if no spec is found to load the module # mode='importlib' fails if no spec is found to load the module
import importlib.util import importlib.util
# Force module to be re-imported.
del sys.modules[module.__name__]
monkeypatch.setattr( monkeypatch.setattr(
importlib.util, "spec_from_file_location", lambda *args: None importlib.util, "spec_from_file_location", lambda *args: None
) )