Skip to content

Commit e6e912c

Browse files
committed
Introduce --import-mode=importlib
This introduces --import-mode=importlib, which uses fine-grained facilities from importlib to import test modules and conftest files, bypassing the need to change sys.path and sys.modules as side-effect of that. I've also opened pytest-dev#7245 to gather feedback on the new import mode.
1 parent b38edec commit e6e912c

File tree

8 files changed

+148
-25
lines changed

8 files changed

+148
-25
lines changed

changelog/7245.feature.rst

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
New ``--import-mode=importlib`` option that uses `importlib <https://docs.python.org/3/library/importlib.html>`__ to import test modules.
2+
3+
Traditionally pytest used ``__import__`` while changing ``sys.path`` to import test modules (which
4+
also changes ``sys.modules`` as a side-effect), which works but has a number of drawbacks, like requiring test modules
5+
that don't live in packages to have unique names (as they need to reside under a unique name in ``sys.modules``).
6+
7+
``--import-mode=importlib`` uses more fine grained import mechanisms from ``importlib`` which don't
8+
require pytest to change ``sys.path`` or ``sys.modules`` at all, eliminating much of the drawbacks
9+
of the previous mode.
10+
11+
We intend to make ``--import-mode=importlib`` the default in future versions, so users are encouraged
12+
to try the new mode and provide feedback (both positive or negative) in issue `#7245 <https://github.com/pytest-dev/pytest/issues/7245>`__.
13+
14+
You can read more about this option in `the documentation <https://docs.pytest.org/en/latest/pythonpath.html#import-modes>`__.

doc/en/goodpractices.rst

+20-3
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ This has the following benefits:
9191
See :ref:`pytest vs python -m pytest` for more information about the difference between calling ``pytest`` and
9292
``python -m pytest``.
9393

94-
Note that using this scheme your test files must have **unique names**, because
94+
Note that this scheme has a drawback if you are using ``prepend`` :ref:`import mode <import-modes>`
95+
(which is the default): your test files must have **unique names**, because
9596
``pytest`` will import them as *top-level* modules since there are no packages
9697
to derive a full package name from. In other words, the test files in the example above will
9798
be imported as ``test_app`` and ``test_view`` top-level modules by adding ``tests/`` to
@@ -118,9 +119,12 @@ Now pytest will load the modules as ``tests.foo.test_view`` and ``tests.bar.test
118119
you to have modules with the same name. But now this introduces a subtle problem: in order to load
119120
the test modules from the ``tests`` directory, pytest prepends the root of the repository to
120121
``sys.path``, which adds the side-effect that now ``mypkg`` is also importable.
122+
121123
This is problematic if you are using a tool like `tox`_ to test your package in a virtual environment,
122124
because you want to test the *installed* version of your package, not the local code from the repository.
123125

126+
.. _`src-layout`:
127+
124128
In this situation, it is **strongly** suggested to use a ``src`` layout where application root package resides in a
125129
sub-directory of your root:
126130

@@ -145,6 +149,15 @@ sub-directory of your root:
145149
This layout prevents a lot of common pitfalls and has many benefits, which are better explained in this excellent
146150
`blog post by Ionel Cristian Mărieș <https://blog.ionelmc.ro/2014/05/25/python-packaging/#the-structure>`_.
147151

152+
.. note::
153+
The new ``--import-mode=importlib`` (see :ref:`import-modes`) doesn't have
154+
any of the drawbacks above because ``sys.path`` and ``sys.modules`` are not changed when importing
155+
test modules, so users that run
156+
into this issue are strongly encouraged to try it and report if the new option works well for them.
157+
158+
The ``src`` directory layout is still strongly recommended however.
159+
160+
148161
Tests as part of application code
149162
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
150163

@@ -190,8 +203,8 @@ Note that this layout also works in conjunction with the ``src`` layout mentione
190203

191204
.. note::
192205

193-
If ``pytest`` finds an "a/b/test_module.py" test file while
194-
recursing into the filesystem it determines the import name
206+
In ``prepend`` and ``append`` import-modes, if pytest finds a ``"a/b/test_module.py"``
207+
test file while recursing into the filesystem it determines the import name
195208
as follows:
196209

197210
* determine ``basedir``: this is the first "upward" (towards the root)
@@ -212,6 +225,10 @@ Note that this layout also works in conjunction with the ``src`` layout mentione
212225
from each other and thus deriving a canonical import name helps
213226
to avoid surprises such as a test module getting imported twice.
214227

228+
With ``--import-mode=importlib`` things are less convoluted because
229+
pytest doesn't need to change ``sys.path`` or ``sys.modules``, making things
230+
much less surprising.
231+
215232

216233
.. _`virtualenv`: https://pypi.org/project/virtualenv/
217234
.. _`buildout`: http://www.buildout.org/

doc/en/pythonpath.rst

+55-6
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,62 @@
33
pytest import mechanisms and ``sys.path``/``PYTHONPATH``
44
========================================================
55

6-
Here's a list of scenarios where pytest may need to change ``sys.path`` in order
7-
to import test modules or ``conftest.py`` files.
6+
.. _`import-modes`:
7+
8+
Import modes
9+
------------
10+
11+
pytest as a testing framework needs to import test moduels and ``conftest.py`` files for execution.
12+
13+
Importing files in Python (at least until recently) is a non-trivial processes, often requiring
14+
changing `sys.path <https://docs.python.org/3/library/sys.html#sys.path>`__. Some aspects of the
15+
import process can be controlled through the ``--import-mode`` command-line flag, which can assume
16+
these values:
17+
18+
* ``prepend`` (default): the directory path containing each module will be inserted into the *beginning*
19+
of ``sys.path`` if not already there, and then imported with the `__import__ <https://docs.python.org/3/library/functions.html#__import__>`__ builtin.
20+
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 ``sys.modules`` after importing.
23+
24+
This is the classic mechanism, dating back from the time Python 2 was still supported.
25+
26+
* ``append``: the directory containing each module is appended to the end of ``sys.path`` if not already
27+
there, and imported with ``__import__``.
28+
29+
This better allows to run test modules against installed versions of a package even if the
30+
package under test has the same import root. For example:
31+
32+
::
33+
34+
testing/__init__.py
35+
testing/test_pkg_under_test.py
36+
pkg_under_test/
37+
38+
the tests will run against the installed version
39+
of ``pkg_under_test`` when ``--import-mode=append`` is used whereas
40+
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.
42+
43+
Same as ``prepend``, requires test module names to be unique when the test directory tree is
44+
not arranged in packages, because the modules will put in ``sys.modules`` after importing.
45+
46+
* ``importlib``: new in pytest-6.0, this mode uses `importlib <https://docs.python.org/3/library/importlib.html>`__ to import test modules. This gives full control over the import process, and doesn't require
47+
changing ``sys.path`` or ``sys.modules`` at all.
48+
49+
For this reason this doesn't require test module names to be unique at all.
50+
51+
We intend to make ``importlib`` the default in future releases.
52+
53+
``prepend`` and ``append`` import modes scenarios
54+
-------------------------------------------------
55+
56+
Here's a list of scenarios when using ``prepend`` or ``append`` import modes where pytest needs to
57+
change ``sys.path`` in order to import test modules or ``conftest.py`` files, and the issues users
58+
might encounter because of that.
859

960
Test modules / ``conftest.py`` files inside packages
10-
----------------------------------------------------
61+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1162

1263
Consider this file and directory layout::
1364

@@ -28,8 +79,6 @@ When executing:
2879
2980
pytest root/
3081
31-
32-
3382
pytest will find ``foo/bar/tests/test_foo.py`` and realize it is part of a package given that
3483
there's an ``__init__.py`` file in the same folder. It will then search upwards until it can find the
3584
last folder which still contains an ``__init__.py`` file in order to find the package *root* (in
@@ -44,7 +93,7 @@ and allow test modules to have duplicated names. This is also discussed in detai
4493
:ref:`test discovery`.
4594

4695
Standalone test modules / ``conftest.py`` files
47-
-----------------------------------------------
96+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
4897

4998
Consider this file and directory layout::
5099

src/_pytest/config/__init__.py

+14-12
Original file line numberDiff line numberDiff line change
@@ -450,21 +450,21 @@ def _set_initial_conftests(self, namespace):
450450
path = path[:i]
451451
anchor = current.join(path, abs=1)
452452
if exists(anchor): # we found some file object
453-
self._try_load_conftest(anchor)
453+
self._try_load_conftest(anchor, namespace.importmode)
454454
foundanchor = True
455455
if not foundanchor:
456-
self._try_load_conftest(current)
456+
self._try_load_conftest(current, namespace.importmode)
457457

458-
def _try_load_conftest(self, anchor):
459-
self._getconftestmodules(anchor)
458+
def _try_load_conftest(self, anchor, importmode):
459+
self._getconftestmodules(anchor, importmode)
460460
# let's also consider test* subdirs
461461
if anchor.check(dir=1):
462462
for x in anchor.listdir("test*"):
463463
if x.check(dir=1):
464-
self._getconftestmodules(x)
464+
self._getconftestmodules(x, importmode)
465465

466466
@lru_cache(maxsize=128)
467-
def _getconftestmodules(self, path):
467+
def _getconftestmodules(self, path, importmode):
468468
if self._noconftest:
469469
return []
470470

@@ -482,21 +482,21 @@ def _getconftestmodules(self, path):
482482
continue
483483
conftestpath = parent.join("conftest.py")
484484
if conftestpath.isfile():
485-
mod = self._importconftest(conftestpath)
485+
mod = self._importconftest(conftestpath, importmode)
486486
clist.append(mod)
487487
self._dirpath2confmods[directory] = clist
488488
return clist
489489

490-
def _rget_with_confmod(self, name, path):
491-
modules = self._getconftestmodules(path)
490+
def _rget_with_confmod(self, name, path, importmode):
491+
modules = self._getconftestmodules(path, importmode)
492492
for mod in reversed(modules):
493493
try:
494494
return mod, getattr(mod, name)
495495
except AttributeError:
496496
continue
497497
raise KeyError(name)
498498

499-
def _importconftest(self, conftestpath):
499+
def _importconftest(self, conftestpath, importmode):
500500
# Use a resolved Path object as key to avoid loading the same conftest twice
501501
# with build systems that create build directories containing
502502
# symlinks to actual files.
@@ -512,7 +512,7 @@ def _importconftest(self, conftestpath):
512512
_ensure_removed_sysmodule(conftestpath.purebasename)
513513

514514
try:
515-
mod = conftestpath.pyimport()
515+
mod = conftestpath.pyimport(ensuresyspath=importmode)
516516
except Exception as e:
517517
raise ConftestImportFailure(conftestpath, sys.exc_info()) from e
518518

@@ -1149,7 +1149,9 @@ def _getini(self, name: str) -> Any:
11491149

11501150
def _getconftest_pathlist(self, name, path):
11511151
try:
1152-
mod, relroots = self.pluginmanager._rget_with_confmod(name, path)
1152+
mod, relroots = self.pluginmanager._rget_with_confmod(
1153+
name, path, self.getoption("importmode")
1154+
)
11531155
except KeyError:
11541156
return None
11551157
modpath = py.path.local(mod.__file__).dirpath()

src/_pytest/nodes.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,9 @@ def _gethookproxy(self, fspath: py.path.local):
503503
# check if we have the common case of running
504504
# hooks with all conftest.py files
505505
pm = self.config.pluginmanager
506-
my_conftestmodules = pm._getconftestmodules(fspath)
506+
my_conftestmodules = pm._getconftestmodules(
507+
fspath, self.config.getoption("importmode")
508+
)
507509
remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
508510
if remove_mods:
509511
# one or more conftests are not in use at this fspath

src/_pytest/python.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def pytest_addoption(parser):
105105
group.addoption(
106106
"--import-mode",
107107
default="prepend",
108-
choices=["prepend", "append"],
108+
choices=["prepend", "append", "importlib"],
109109
dest="importmode",
110110
help="prepend/append to sys.path when importing test modules, "
111111
"default is to prepend.",

testing/acceptance_test.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,16 @@ def my_dists():
149149
else:
150150
assert loaded == ["myplugin1", "myplugin2", "mycov"]
151151

152-
def test_assertion_magic(self, testdir):
152+
@pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"])
153+
def test_assertion_rewrite(self, testdir, import_mode):
153154
p = testdir.makepyfile(
154155
"""
155156
def test_this():
156157
x = 0
157158
assert x
158159
"""
159160
)
160-
result = testdir.runpytest(p)
161+
result = testdir.runpytest(p, "--import-mode={}".format(import_mode))
161162
result.stdout.fnmatch_lines(["> assert x", "E assert 0"])
162163
assert result.ret == 1
163164

testing/test_collection.py

+38
Original file line numberDiff line numberDiff line change
@@ -1353,3 +1353,41 @@ def from_parent(cls, parent, *, fspath, x):
13531353
parent=request.session, fspath=tmpdir / "foo", x=10
13541354
)
13551355
assert collector.x == 10
1356+
1357+
1358+
class TestImportModeImportlib:
1359+
def test_collect_duplicate_names(self, testdir):
1360+
"""--import-mode=importlib can import modules with same names that are not in packages."""
1361+
testdir.makepyfile(
1362+
**{
1363+
"tests_a/test_foo.py": "def test_foo1(): pass",
1364+
"tests_b/test_foo.py": "def test_foo2(): pass",
1365+
}
1366+
)
1367+
result = testdir.runpytest("-v", "--import-mode=importlib")
1368+
result.stdout.fnmatch_lines(
1369+
[
1370+
"tests_a/test_foo.py::test_foo1 *",
1371+
"tests_b/test_foo.py::test_foo2 *",
1372+
"* 2 passed in *",
1373+
]
1374+
)
1375+
1376+
def test_conftest(self, testdir):
1377+
"""Directory containing conftest modules are not put in sys.path as a side-effect of
1378+
importing them."""
1379+
tests_dir = testdir.tmpdir.join("tests")
1380+
testdir.makepyfile(
1381+
**{
1382+
"tests/conftest.py": "",
1383+
"tests/test_foo.py": """
1384+
import sys
1385+
def test_check():
1386+
assert r"{tests_dir}" not in sys.path
1387+
""".format(
1388+
tests_dir=tests_dir
1389+
),
1390+
}
1391+
)
1392+
result = testdir.runpytest("-v", "--import-mode=importlib")
1393+
result.stdout.fnmatch_lines(["* 1 passed in *"])

0 commit comments

Comments
 (0)