Skip to content

Commit 731f679

Browse files
nicoddemusflying-sheep
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 b568f8e commit 731f679

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
@@ -727,6 +728,146 @@ def test_my_test():
727728
result = pytester.runpytest("--import-mode=importlib")
728729
result.stdout.fnmatch_lines("* 1 passed *")
729730

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

828-
@pytest.mark.parametrize("import_mode", ["prepend", "append"])
969+
@pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"])
829970
def test_resolve_pkg_root_and_module_name_ns_multiple_levels(
830971
self,
831972
tmp_path: Path,

0 commit comments

Comments
 (0)