Skip to content

Commit 5d90d97

Browse files
committed
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 supersedes pytest-dev#11931. Fix pytest-dev#11475 Close pytest-dev#11931
1 parent 5f241f3 commit 5d90d97

File tree

4 files changed

+250
-24
lines changed

4 files changed

+250
-24
lines changed

doc/en/explanation/goodpractices.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@ Within Python modules, ``pytest`` also discovers tests using the standard
6060
:ref:`unittest.TestCase <unittest.TestCase>` subclassing technique.
6161

6262

63-
Choosing a test layout / import rules
64-
-------------------------------------
63+
.. _`test layout`:
64+
65+
Choosing a test layout
66+
----------------------
6567

6668
``pytest`` supports two common test layouts:
6769

doc/en/explanation/pythonpath.rst

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ import process can be controlled through the ``--import-mode`` command-line flag
1616
these values:
1717

1818
* ``prepend`` (default): the directory path containing each module will be inserted into the *beginning*
19-
of :py:data:`sys.path` if not already there, and then imported with the :func:`importlib.import_module <importlib.import_module>` function.
19+
of :py:data:`sys.path` if not already there, and then imported with
20+
the :func:`importlib.import_module <importlib.import_module>` function.
2021

21-
This requires test module names to be unique when the test directory tree is not arranged in
22-
packages, because the modules will put in :py:data:`sys.modules` after importing.
22+
It is highly recommended to arrange your test modules as packages by adding ``__init__.py`` files to your directories
23+
containing tests. If the test directory tree is not arranged as packages, then each test file needs to have a unique name
24+
compared to the other test files, because the modules will put in :py:data:`sys.modules` after importing
25+
therefore they need to be globally unique.
2326

2427
This is the classic mechanism, dating back from the time Python 2 was still supported.
2528

@@ -38,26 +41,62 @@ these values:
3841
the tests will run against the installed version
3942
of ``pkg_under_test`` when ``--import-mode=append`` is used whereas
4043
with ``prepend`` they would pick up the local version. This kind of confusion is why
41-
we advocate for using :ref:`src <src-layout>` layouts.
44+
we advocate for using :ref:`src-layouts <src-layout>`.
4245

4346
Same as ``prepend``, requires test module names to be unique when the test directory tree is
4447
not arranged in packages, because the modules will put in :py:data:`sys.modules` after importing.
4548

46-
* ``importlib``: new in pytest-6.0, this mode uses more fine control mechanisms provided by :mod:`importlib` to import test modules. This gives full control over the import process, and doesn't require changing :py:data:`sys.path`.
49+
* ``importlib``: this mode uses more fine control mechanisms provided by :mod:`importlib` to import test modules, without changing :py:data:`sys.path`.
4750

48-
For this reason this doesn't require test module names to be unique.
51+
It works like this:
4952

50-
One drawback however is that test modules are non-importable by each other. Also, utility
51-
modules in the tests directories are not automatically importable because the tests directory is no longer
52-
added to :py:data:`sys.path`.
53+
1. Given a certain module path, for example ``tests/core/test_models.py``, derives a canonical name
54+
like ``tests.core.test_models`` and tries to import it.
5355

54-
Initially we intended to make ``importlib`` the default in future releases, however it is clear now that
55-
it has its own set of drawbacks so the default will remain ``prepend`` for the foreseeable future.
56+
For test modules this will usually fail, as test modules are not usually importable, but will work for modules that are installed into a virtualenv.
57+
58+
For non-test modules this also will usually work if they are installed/accessible via ``sys.path``, so
59+
for example ``.env/lib/site-packages/app/core.py`` will be importable as ``app.core``. This is desirable
60+
in general, and happens when plugins import non-test modules for other purposes, for example doctesting.
61+
62+
If this step succeeds, the module is returned.
63+
64+
2. If the previous step fails, we import the module directly using ``importlib`` facilities, which lets us import it without
65+
changing :py:data:`sys.path`.
66+
67+
Because Python requires the module to also be available in ``sys.modules``, pytest derives unique name for it based
68+
on its relative location from the ``rootdir`` (for example ``tests.core.test_models``)
69+
and adds the module to ``sys.modules``.
70+
71+
Advantages of this mode:
72+
73+
* pytest will not change ``sys.path`` at all.
74+
* Test module names do not need to be unique -- pytest will generate a unique name automatically based on the ``rootdir``.
75+
76+
Disadvantages:
77+
78+
* Test modules are non-importable by each other.
79+
* Testing utility modules (for example a ``tests.helpers`` module containing test-related functions/classes)
80+
in the tests directories are not importable because the tests directory is no longer
81+
added to :py:data:`sys.path`.
82+
83+
Note that by "test utility modules" we mean functions/classes which are imported by
84+
other tests directly; this does not include fixtures, which should be placed in ``conftest.py`` files and are
85+
discovered automatically by pytest.
86+
87+
.. versionadded:: 6.0
88+
89+
.. note::
90+
91+
Initially we intended to make ``importlib`` the default in future releases, however it is clear now that
92+
it has its own set of drawbacks so the default will remain ``prepend`` for the foreseeable future.
5693

5794
.. seealso::
5895

5996
The :confval:`pythonpath` configuration variable.
6097

98+
:ref:`test layout`.
99+
61100

62101
``prepend`` and ``append`` import modes scenarios
63102
-------------------------------------------------

src/_pytest/pathlib.py

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

524524
if mode is ImportMode.importlib:
525+
# Obtain the canonical name of this path if we can resolve it to a python package,
526+
# and try to import it without changing sys.path.
527+
# If it works, we import it and return the module.
528+
_, module_name = resolve_pkg_root_and_module_name(path)
529+
try:
530+
importlib.import_module(module_name)
531+
except (ImportError, ImportWarning):
532+
# Cannot be imported normally with the current sys.path, so fallback
533+
# to the more complex import-path mechanism.
534+
pass
535+
else:
536+
# Double check that the imported module is the one we
537+
# were given, otherwise it is easy to import modules with common names like "test"
538+
# from another location.
539+
mod = sys.modules[module_name]
540+
if mod.__file__ and Path(mod.__file__) == path:
541+
return mod
542+
543+
# Could not import the module with the current sys.path, so we fall back
544+
# to importing the file as a standalone module, not being a part of a package.
525545
module_name = module_name_from_path(path, root)
526546
with contextlib.suppress(KeyError):
527547
return sys.modules[module_name]
@@ -541,16 +561,7 @@ def import_path(
541561
insert_missing_modules(sys.modules, module_name)
542562
return mod
543563

544-
pkg_path = resolve_package_path(path)
545-
if pkg_path is not None:
546-
pkg_root = pkg_path.parent
547-
names = list(path.with_suffix("").relative_to(pkg_root).parts)
548-
if names[-1] == "__init__":
549-
names.pop()
550-
module_name = ".".join(names)
551-
else:
552-
pkg_root = path.parent
553-
module_name = path.stem
564+
pkg_root, module_name = resolve_pkg_root_and_module_name(path)
554565

555566
# Change sys.path permanently: restoring it at the end of this function would cause surprising
556567
# problems because of delayed imports: for example, a conftest.py file imported by this function
@@ -628,7 +639,14 @@ def module_name_from_path(path: Path, root: Path) -> str:
628639
if len(path_parts) >= 2 and path_parts[-1] == "__init__":
629640
path_parts = path_parts[:-1]
630641

631-
return ".".join(path_parts)
642+
module_name = ".".join(path_parts)
643+
# Modules starting with "." are considered relative, but given we
644+
# are returning a made-up path that is intended to be imported as a global package and
645+
# not as a relative module, replace the "." at the start with "_", which should be enough
646+
# for our purposes.
647+
if module_name.startswith("."):
648+
module_name = "_" + module_name[1:]
649+
return module_name
632650

633651

634652
def insert_missing_modules(modules: Dict[str, ModuleType], module_name: str) -> None:
@@ -689,6 +707,36 @@ def resolve_package_path(path: Path) -> Optional[Path]:
689707
return result
690708

691709

710+
def resolve_pkg_root_and_module_name(path: Path) -> Tuple[Path, str]:
711+
"""
712+
Return the path to the directory of the root package that contains the
713+
given Python file, and its module name:
714+
715+
src/
716+
app/
717+
__init__.py
718+
core/
719+
__init__.py
720+
models.py
721+
722+
Passing the full path to `models.py` will yield Path("src") and "app.core.models".
723+
724+
If the given path does not belong to a package (missing __init__.py files), returns
725+
just the parent path and the name of the file as module name.
726+
"""
727+
pkg_path = resolve_package_path(path)
728+
if pkg_path is not None:
729+
pkg_root = pkg_path.parent
730+
names = list(path.with_suffix("").relative_to(pkg_root).parts)
731+
if names[-1] == "__init__":
732+
names.pop()
733+
module_name = ".".join(names)
734+
else:
735+
pkg_root = path.parent
736+
module_name = path.stem
737+
return pkg_root, module_name
738+
739+
692740
def scandir(
693741
path: Union[str, "os.PathLike[str]"],
694742
sort_key: Callable[["os.DirEntry[str]"], object] = lambda entry: entry.name,

testing/test_pathlib.py

Lines changed: 137 additions & 0 deletions
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
@@ -25,6 +26,7 @@
2526
from _pytest.pathlib import maybe_delete_a_numbered_dir
2627
from _pytest.pathlib import module_name_from_path
2728
from _pytest.pathlib import resolve_package_path
29+
from _pytest.pathlib import resolve_pkg_root_and_module_name
2830
from _pytest.pathlib import safe_exists
2931
from _pytest.pathlib import symlink_or_skip
3032
from _pytest.pathlib import visit
@@ -598,6 +600,29 @@ def test_module_name_from_path(self, tmp_path: Path) -> None:
598600
result = module_name_from_path(tmp_path / "__init__.py", tmp_path)
599601
assert result == "__init__"
600602

603+
# Modules which start with "." are considered relative and will not be imported
604+
# unless part of a package, so we replace it with a "_" when generating the fake module name.
605+
result = module_name_from_path(tmp_path / ".env/tests/test_foo.py", tmp_path)
606+
assert result == "_env.tests.test_foo"
607+
608+
def test_resolve_pkg_root_and_module_name(self, tmp_path: Path) -> None:
609+
# Create a directory structure first without __init__.py files.
610+
(tmp_path / "src/app/core").mkdir(parents=True)
611+
models_py = tmp_path / "src/app/core/models.py"
612+
models_py.touch()
613+
assert resolve_pkg_root_and_module_name(models_py) == (
614+
models_py.parent,
615+
"models",
616+
)
617+
618+
# Create the __init__.py files, it should now resolve to a proper module name.
619+
(tmp_path / "src/app/__init__.py").touch()
620+
(tmp_path / "src/app/core/__init__.py").touch()
621+
assert resolve_pkg_root_and_module_name(models_py) == (
622+
tmp_path / "src",
623+
"app.core.models",
624+
)
625+
601626
def test_insert_missing_modules(
602627
self, monkeypatch: MonkeyPatch, tmp_path: Path
603628
) -> None:
@@ -669,6 +694,34 @@ def __init__(self) -> None:
669694
mod = import_path(init, root=tmp_path, mode=ImportMode.importlib)
670695
assert len(mod.instance.INSTANCES) == 1
671696

697+
def test_importlib_doctest(self, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
698+
"""
699+
Importing a package using --importmode=importlib should
700+
import the package using the canonical name
701+
"""
702+
proj_dir = tmp_path / "proj"
703+
proj_dir.mkdir()
704+
pkgs_dir = tmp_path / "pkgs"
705+
pkgs_dir.mkdir()
706+
monkeypatch.chdir(proj_dir)
707+
monkeypatch.syspath_prepend(pkgs_dir)
708+
# this is also there, but shouldn't be imported from
709+
monkeypatch.syspath_prepend(proj_dir)
710+
711+
package_name = "importlib_doctest"
712+
# pkgs_dir is second to set `init`
713+
for directory in [proj_dir / "src", pkgs_dir]:
714+
pkgdir = directory / package_name
715+
pkgdir.mkdir(parents=True)
716+
init = pkgdir / "__init__.py"
717+
init.write_text("", encoding="ascii")
718+
719+
# the PyTest root is `proj_dir`, but the package is imported from `pkgs_dir`
720+
mod = import_path(init, root=proj_dir, mode=ImportMode.importlib)
721+
# assert that it's imported with the canonical name, not “path.to.package.<name>”
722+
mod_names = [n for n, m in sys.modules.items() if m is mod]
723+
assert mod_names == ["importlib_doctest"]
724+
672725
def test_importlib_root_is_package(self, pytester: Pytester) -> None:
673726
"""
674727
Regression for importing a `__init__`.py file that is at the root
@@ -685,6 +738,90 @@ def test_my_test():
685738
result = pytester.runpytest("--import-mode=importlib")
686739
result.stdout.fnmatch_lines("* 1 passed *")
687740

741+
def create_installed_doctests_and_tests_dir(
742+
self, path: Path, monkeypatch: MonkeyPatch
743+
) -> None:
744+
"""
745+
Create a directory structure where the application code is installed in a virtual environment,
746+
and the tests are in an outside ".tests" directory.
747+
"""
748+
app = path / "src/app"
749+
app.mkdir(parents=True)
750+
(app / "__init__.py").touch()
751+
core_py = app / "core.py"
752+
core_py.write_text(
753+
dedent(
754+
"""
755+
def foo():
756+
'''
757+
>>> 1 + 1
758+
2
759+
'''
760+
"""
761+
),
762+
encoding="ascii",
763+
)
764+
765+
# Install it into a site-packages directory, and add it to sys.path, mimicking what
766+
# happens when installing into a virtualenv.
767+
site_packages = path / ".env/lib/site-packages"
768+
site_packages.mkdir(parents=True)
769+
shutil.copytree(app, site_packages / "app")
770+
assert (site_packages / "app/core.py").is_file()
771+
772+
monkeypatch.syspath_prepend(site_packages)
773+
774+
# Create the tests file, outside 'src' and the virtualenv.
775+
test_path = path / ".tests/test_core.py"
776+
test_path.parent.mkdir(parents=True)
777+
test_path.write_text(
778+
"import app.core\n\ndef test(): pass",
779+
encoding="ascii",
780+
)
781+
782+
def test_import_using_normal_mechanism_first(
783+
self, monkeypatch: MonkeyPatch, pytester: Pytester
784+
) -> None:
785+
"""
786+
Test import_path imports from the canonical location when possible first, only
787+
falling back to its normal flow when the module being imported is not reachable via sys.path (#11475).
788+
"""
789+
self.create_installed_doctests_and_tests_dir(pytester.path, monkeypatch)
790+
791+
# Imported from installed location via sys.path.
792+
core_py = pytester.path / ".env/lib/site-packages/app/core.py"
793+
assert core_py.is_file()
794+
mod = import_path(core_py, mode="importlib", root=pytester.path)
795+
assert mod.__name__ == "app.core"
796+
assert mod.__file__ and Path(mod.__file__) == core_py
797+
798+
# Imported as a standalone module.
799+
# Instead of '.tests.test_core', we import as "_tests.test_core" because
800+
# importlib considers module names starting with '.' to be local imports.
801+
test_path = pytester.path / ".tests/test_core.py"
802+
assert test_path.is_file()
803+
mod = import_path(test_path, mode="importlib", root=pytester.path)
804+
assert mod.__name__ == "_tests.test_core"
805+
806+
def test_import_using_normal_mechanism_first_integration(
807+
self, monkeypatch: MonkeyPatch, pytester: Pytester
808+
) -> None:
809+
"""
810+
Same test as above, but verify the behavior calling pytest.
811+
812+
We should not make this call in the same test as above, as the modules have already
813+
been imported by separate import_path() calls.
814+
"""
815+
self.create_installed_doctests_and_tests_dir(pytester.path, monkeypatch)
816+
result = pytester.runpytest(
817+
"--import-mode=importlib",
818+
"--doctest-modules",
819+
"--pyargs",
820+
"app",
821+
"./.tests",
822+
)
823+
assert result.parseoutcomes() == {"passed": 2}
824+
688825

689826
def test_safe_exists(tmp_path: Path) -> None:
690827
d = tmp_path.joinpath("some_dir")

0 commit comments

Comments
 (0)