Skip to content

Commit 3fcfd0b

Browse files
nicoddemusbluetech
authored andcommitted
Change importlib to first try to import modules using the standard mechanism
As detailed in pytest-dev#11475 (comment), currently with `--import-mode=importlib` pytest will try to import every file by using a unique module name, regardless if that module could be imported using the normal import mechanism without touching `sys.path`. This has the consequence that non-test modules available in `sys.path` (via other mechanism, such as being installed into a virtualenv, PYTHONPATH, etc) would end up being imported as standalone modules, instead of imported with their expected module names. To illustrate: ``` .env/ lib/ site-packages/ anndata/ core.py ``` Given `anndata` is installed into the virtual environment, `python -c "import anndata.core"` works, but pytest with `importlib` mode would import that module as a standalone module named `".env.lib.site-packages.anndata.core"`, because importlib module was designed to import test files which are not reachable from `sys.path`, but now it is clear that normal modules should be imported using the standard mechanisms if possible. Now `imporlib` mode will first try to import the module normally, without changing `sys.path`, and if that fails it falls back to importing the module as a standalone module. This also makes `importlib` respect namespace packages. This supersedes pytest-dev#11931. Fix pytest-dev#11475 Close pytest-dev#11931
1 parent a342918 commit 3fcfd0b

File tree

3 files changed

+160
-1
lines changed

3 files changed

+160
-1
lines changed

changelog/11475.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:ref:`--import-mode=importlib <import-mode-importlib>` now tries to import modules using the standard import mechanism (but still without changing :py:data:`sys.path`), falling back to importing modules directly only if that fails.

src/_pytest/pathlib.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,23 @@ def import_path(
522522
raise ImportError(path)
523523

524524
if mode is ImportMode.importlib:
525+
# Try to import this module using the standard import mechanisms, but
526+
# without touching sys.path.
527+
try:
528+
pkg_root, module_name = resolve_pkg_root_and_module_name(
529+
path, consider_ns_packages=True
530+
)
531+
except CouldNotResolvePathError:
532+
pass
533+
else:
534+
mod = _import_module_using_spec(
535+
module_name, path, pkg_root, insert_modules=False
536+
)
537+
if mod is not None:
538+
return mod
539+
540+
# Could not import the module with the current sys.path, so we fall back
541+
# to importing the file as a single module, not being a part of a package.
525542
module_name = module_name_from_path(path, root)
526543
with contextlib.suppress(KeyError):
527544
return sys.modules[module_name]

testing/test_pathlib.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os.path
44
from pathlib import Path
55
import pickle
6+
import shutil
67
import sys
78
from textwrap import dedent
89
from types import ModuleType
@@ -720,6 +721,146 @@ def test_my_test():
720721
result = pytester.runpytest("--import-mode=importlib")
721722
result.stdout.fnmatch_lines("* 1 passed *")
722723

724+
def create_installed_doctests_and_tests_dir(
725+
self, path: Path, monkeypatch: MonkeyPatch
726+
) -> Tuple[Path, Path, Path]:
727+
"""
728+
Create a directory structure where the application code is installed in a virtual environment,
729+
and the tests are in an outside ".tests" directory.
730+
731+
Return the paths to the core module (installed in the virtualenv), and the test modules.
732+
"""
733+
app = path / "src/app"
734+
app.mkdir(parents=True)
735+
(app / "__init__.py").touch()
736+
core_py = app / "core.py"
737+
core_py.write_text(
738+
dedent(
739+
"""
740+
def foo():
741+
'''
742+
>>> 1 + 1
743+
2
744+
'''
745+
"""
746+
),
747+
encoding="ascii",
748+
)
749+
750+
# Install it into a site-packages directory, and add it to sys.path, mimicking what
751+
# happens when installing into a virtualenv.
752+
site_packages = path / ".env/lib/site-packages"
753+
site_packages.mkdir(parents=True)
754+
shutil.copytree(app, site_packages / "app")
755+
assert (site_packages / "app/core.py").is_file()
756+
757+
monkeypatch.syspath_prepend(site_packages)
758+
759+
# Create the tests files, outside 'src' and the virtualenv.
760+
# We use the same test name on purpose, but in different directories, to ensure
761+
# this works as advertised.
762+
conftest_path1 = path / ".tests/a/conftest.py"
763+
conftest_path1.parent.mkdir(parents=True)
764+
conftest_path1.write_text(
765+
dedent(
766+
"""
767+
import pytest
768+
@pytest.fixture
769+
def a_fix(): return "a"
770+
"""
771+
),
772+
encoding="ascii",
773+
)
774+
test_path1 = path / ".tests/a/test_core.py"
775+
test_path1.write_text(
776+
dedent(
777+
"""
778+
import app.core
779+
def test(a_fix):
780+
assert a_fix == "a"
781+
""",
782+
),
783+
encoding="ascii",
784+
)
785+
786+
conftest_path2 = path / ".tests/b/conftest.py"
787+
conftest_path2.parent.mkdir(parents=True)
788+
conftest_path2.write_text(
789+
dedent(
790+
"""
791+
import pytest
792+
@pytest.fixture
793+
def b_fix(): return "b"
794+
"""
795+
),
796+
encoding="ascii",
797+
)
798+
799+
test_path2 = path / ".tests/b/test_core.py"
800+
test_path2.write_text(
801+
dedent(
802+
"""
803+
import app.core
804+
def test(b_fix):
805+
assert b_fix == "b"
806+
""",
807+
),
808+
encoding="ascii",
809+
)
810+
return (site_packages / "app/core.py"), test_path1, test_path2
811+
812+
def test_import_using_normal_mechanism_first(
813+
self, monkeypatch: MonkeyPatch, pytester: Pytester
814+
) -> None:
815+
"""
816+
Test import_path imports from the canonical location when possible first, only
817+
falling back to its normal flow when the module being imported is not reachable via sys.path (#11475).
818+
"""
819+
core_py, test_path1, test_path2 = self.create_installed_doctests_and_tests_dir(
820+
pytester.path, monkeypatch
821+
)
822+
823+
# core_py is reached from sys.path, so should be imported normally.
824+
mod = import_path(core_py, mode="importlib", root=pytester.path)
825+
assert mod.__name__ == "app.core"
826+
assert mod.__file__ and Path(mod.__file__) == core_py
827+
828+
# tests are not reachable from sys.path, so they are imported as a standalone modules.
829+
# Instead of '.tests.a.test_core', we import as "_tests.a.test_core" because
830+
# importlib considers module names starting with '.' to be local imports.
831+
mod = import_path(test_path1, mode="importlib", root=pytester.path)
832+
assert mod.__name__ == "_tests.a.test_core"
833+
mod = import_path(test_path2, mode="importlib", root=pytester.path)
834+
assert mod.__name__ == "_tests.b.test_core"
835+
836+
def test_import_using_normal_mechanism_first_integration(
837+
self, monkeypatch: MonkeyPatch, pytester: Pytester
838+
) -> None:
839+
"""
840+
Same test as above, but verify the behavior calling pytest.
841+
842+
We should not make this call in the same test as above, as the modules have already
843+
been imported by separate import_path() calls.
844+
"""
845+
core_py, test_path1, test_path2 = self.create_installed_doctests_and_tests_dir(
846+
pytester.path, monkeypatch
847+
)
848+
result = pytester.runpytest(
849+
"--import-mode=importlib",
850+
"--doctest-modules",
851+
"--pyargs",
852+
"app",
853+
"./.tests",
854+
)
855+
result.stdout.fnmatch_lines(
856+
[
857+
f"{core_py.relative_to(pytester.path)} . *",
858+
f"{test_path1.relative_to(pytester.path)} . *",
859+
f"{test_path2.relative_to(pytester.path)} . *",
860+
"* 3 passed*",
861+
]
862+
)
863+
723864
def test_import_path_imports_correct_file(self, pytester: Pytester) -> None:
724865
"""
725866
Import the module by the given path, even if other module with the same name
@@ -818,7 +959,7 @@ def setup_directories(
818959
monkeypatch.syspath_prepend(tmp_path / "src/dist2")
819960
return models_py, algorithms_py
820961

821-
@pytest.mark.parametrize("import_mode", ["prepend", "append"])
962+
@pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"])
822963
def test_resolve_pkg_root_and_module_name_ns_multiple_levels(
823964
self,
824965
tmp_path: Path,

0 commit comments

Comments
 (0)