Skip to content

Commit 7335b76

Browse files
committed
Fix duplicated imports with importlib mode and doctest-modules
The initial implementation (in pytest-dev#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 pytest-dev#7870, but failed to realize that given we are now changing `sys.modules`, we might as well avoid importing it more than once. Then pytest-dev#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 pytest-dev#10811, pytest-dev#10341
1 parent ba60649 commit 7335b76

File tree

4 files changed

+58
-8
lines changed

4 files changed

+58
-8
lines changed

changelog/10811.bugfix.rst

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

src/_pytest/pathlib.py

+2
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,8 @@ def import_path(
523523

524524
if mode is ImportMode.importlib:
525525
module_name = module_name_from_path(path, root)
526+
with contextlib.suppress(KeyError):
527+
return sys.modules[module_name]
526528

527529
for meta_importer in sys.meta_path:
528530
spec = meta_importer.find_spec(module_name, [str(path.parent)])

testing/acceptance_test.py

+35
Original file line numberDiff line numberDiff line change
@@ -1315,3 +1315,38 @@ def test_stuff():
13151315
)
13161316
res = pytester.runpytest()
13171317
res.stdout.fnmatch_lines(["*Did you mean to use `assert` instead of `return`?*"])
1318+
1319+
1320+
def test_doctest_and_normal_imports_with_importlib(pytester: Pytester) -> None:
1321+
"""
1322+
Regression test for #10811: previously import_path with ImportMode.importlib would
1323+
not return a module if already in sys.modules, resulting in modules being imported
1324+
multiple times, which causes problems with modules that have import side effects.
1325+
"""
1326+
# Uses the exact reproducer form #10811, given it is very minimal
1327+
# and illustrates the problem well.
1328+
pytester.makepyfile(
1329+
**{
1330+
"pmxbot/commands.py": "from . import logging",
1331+
"pmxbot/logging.py": "",
1332+
"tests/__init__.py": "",
1333+
"tests/test_commands.py": """
1334+
import importlib
1335+
from pmxbot import logging
1336+
1337+
class TestCommands:
1338+
def test_boo(self):
1339+
assert importlib.import_module('pmxbot.logging') is logging
1340+
""",
1341+
}
1342+
)
1343+
pytester.makeini(
1344+
"""
1345+
[pytest]
1346+
addopts=
1347+
--doctest-modules
1348+
--import-mode importlib
1349+
"""
1350+
)
1351+
result = pytester.runpytest_subprocess()
1352+
result.stdout.fnmatch_lines("*1 passed*")

testing/test_pathlib.py

+19-8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from types import ModuleType
88
from typing import Any
99
from typing import Generator
10+
from typing import Iterator
1011

1112
import pytest
1213
from _pytest.monkeypatch import MonkeyPatch
@@ -282,29 +283,36 @@ def test_invalid_path(self, tmp_path: Path) -> None:
282283
import_path(tmp_path / "invalid.py", root=tmp_path)
283284

284285
@pytest.fixture
285-
def simple_module(self, tmp_path: Path) -> Path:
286-
fn = tmp_path / "_src/tests/mymod.py"
286+
def simple_module(
287+
self, tmp_path: Path, request: pytest.FixtureRequest
288+
) -> Iterator[Path]:
289+
name = f"mymod_{request.node.name}"
290+
fn = tmp_path / f"_src/tests/{name}.py"
287291
fn.parent.mkdir(parents=True)
288292
fn.write_text("def foo(x): return 40 + x", encoding="utf-8")
289-
return fn
293+
module_name = module_name_from_path(fn, root=tmp_path)
294+
yield fn
295+
sys.modules.pop(module_name, None)
290296

291-
def test_importmode_importlib(self, simple_module: Path, tmp_path: Path) -> None:
297+
def test_importmode_importlib(
298+
self, simple_module: Path, tmp_path: Path, request: pytest.FixtureRequest
299+
) -> None:
292300
"""`importlib` mode does not change sys.path."""
293301
module = import_path(simple_module, mode="importlib", root=tmp_path)
294302
assert module.foo(2) == 42 # type: ignore[attr-defined]
295303
assert str(simple_module.parent) not in sys.path
296304
assert module.__name__ in sys.modules
297-
assert module.__name__ == "_src.tests.mymod"
305+
assert module.__name__ == f"_src.tests.mymod_{request.node.name}"
298306
assert "_src" in sys.modules
299307
assert "_src.tests" in sys.modules
300308

301-
def test_importmode_twice_is_different_module(
309+
def test_remembers_previous_imports(
302310
self, simple_module: Path, tmp_path: Path
303311
) -> None:
304-
"""`importlib` mode always returns a new module."""
312+
"""`importlib` mode called remembers previous module (#10341, #10811)."""
305313
module1 = import_path(simple_module, mode="importlib", root=tmp_path)
306314
module2 = import_path(simple_module, mode="importlib", root=tmp_path)
307-
assert module1 is not module2
315+
assert module1 is module2
308316

309317
def test_no_meta_path_found(
310318
self, simple_module: Path, monkeypatch: MonkeyPatch, tmp_path: Path
@@ -317,6 +325,9 @@ def test_no_meta_path_found(
317325
# mode='importlib' fails if no spec is found to load the module
318326
import importlib.util
319327

328+
# Force module to be re-imported.
329+
del sys.modules[module.__name__]
330+
320331
monkeypatch.setattr(
321332
importlib.util, "spec_from_file_location", lambda *args: None
322333
)

0 commit comments

Comments
 (0)