Skip to content

Commit ab6dacf

Browse files
nicoddemusbluetech
andauthored
Introduce --import-mode=importlib (#7246)
Fix #5821 Co-authored-by: Ran Benita <[email protected]>
1 parent 2c37585 commit ab6dacf

19 files changed

+734
-70
lines changed

changelog/7245.feature.rst

Lines changed: 14 additions & 0 deletions
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

Lines changed: 20 additions & 3 deletions
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

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,65 @@
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 modules 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, but also makes test
50+
modules non-importable by each other. This was made possible in previous modes, for tests not residing
51+
in Python packages, because of the side-effects of changing ``sys.path`` and ``sys.modules``
52+
mentioned above. Users which require this should turn their tests into proper packages instead.
53+
54+
We intend to make ``importlib`` the default in future releases.
55+
56+
``prepend`` and ``append`` import modes scenarios
57+
-------------------------------------------------
58+
59+
Here's a list of scenarios when using ``prepend`` or ``append`` import modes where pytest needs to
60+
change ``sys.path`` in order to import test modules or ``conftest.py`` files, and the issues users
61+
might encounter because of that.
862

963
Test modules / ``conftest.py`` files inside packages
10-
----------------------------------------------------
64+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1165

1266
Consider this file and directory layout::
1367

@@ -28,8 +82,6 @@ When executing:
2882
2983
pytest root/
3084
31-
32-
3385
pytest will find ``foo/bar/tests/test_foo.py`` and realize it is part of a package given that
3486
there's an ``__init__.py`` file in the same folder. It will then search upwards until it can find the
3587
last folder which still contains an ``__init__.py`` file in order to find the package *root* (in
@@ -44,7 +96,7 @@ and allow test modules to have duplicated names. This is also discussed in detai
4496
:ref:`test discovery`.
4597

4698
Standalone test modules / ``conftest.py`` files
47-
-----------------------------------------------
99+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
48100

49101
Consider this file and directory layout::
50102

src/_pytest/_code/code.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1204,7 +1204,10 @@ def getfslineno(obj: Any) -> Tuple[Union[str, py.path.local], int]:
12041204

12051205

12061206
def filter_traceback(entry: TracebackEntry) -> bool:
1207-
"""Return True if a TracebackEntry instance should be removed from tracebacks:
1207+
"""Return True if a TracebackEntry instance should be included in tracebacks.
1208+
1209+
We hide traceback entries of:
1210+
12081211
* dynamically generated code (no code to show up for it);
12091212
* internal traceback from pytest or its internal libraries, py and pluggy.
12101213
"""

src/_pytest/compat.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434

3535
if TYPE_CHECKING:
36+
from typing import NoReturn
3637
from typing import Type
3738
from typing_extensions import Final
3839

@@ -401,3 +402,38 @@ def __get__(self, instance, owner=None): # noqa: F811
401402
from collections import OrderedDict
402403

403404
order_preserving_dict = OrderedDict
405+
406+
407+
# Perform exhaustiveness checking.
408+
#
409+
# Consider this example:
410+
#
411+
# MyUnion = Union[int, str]
412+
#
413+
# def handle(x: MyUnion) -> int {
414+
# if isinstance(x, int):
415+
# return 1
416+
# elif isinstance(x, str):
417+
# return 2
418+
# else:
419+
# raise Exception('unreachable')
420+
#
421+
# Now suppose we add a new variant:
422+
#
423+
# MyUnion = Union[int, str, bytes]
424+
#
425+
# After doing this, we must remember ourselves to go and update the handle
426+
# function to handle the new variant.
427+
#
428+
# With `assert_never` we can do better:
429+
#
430+
# // throw new Error('unreachable');
431+
# return assert_never(x)
432+
#
433+
# Now, if we forget to handle the new variant, the type-checker will emit a
434+
# compile-time error, instead of the runtime error we would have gotten
435+
# previously.
436+
#
437+
# This also work for Enums (if you use `is` to compare) and Literals.
438+
def assert_never(value: "NoReturn") -> "NoReturn":
439+
assert False, "Unhandled value: {} ({})".format(value, type(value).__name__)

src/_pytest/config/__init__.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from _pytest.compat import TYPE_CHECKING
4242
from _pytest.outcomes import fail
4343
from _pytest.outcomes import Skipped
44+
from _pytest.pathlib import import_path
4445
from _pytest.pathlib import Path
4546
from _pytest.store import Store
4647
from _pytest.warning_types import PytestConfigWarning
@@ -98,6 +99,15 @@ def __str__(self):
9899
)
99100

100101

102+
def filter_traceback_for_conftest_import_failure(entry) -> bool:
103+
"""filters tracebacks entries which point to pytest internals or importlib.
104+
105+
Make a special case for importlib because we use it to import test modules and conftest files
106+
in _pytest.pathlib.import_path.
107+
"""
108+
return filter_traceback(entry) and "importlib" not in str(entry.path).split(os.sep)
109+
110+
101111
def main(args=None, plugins=None) -> Union[int, ExitCode]:
102112
""" return exit code, after performing an in-process test run.
103113
@@ -115,7 +125,9 @@ def main(args=None, plugins=None) -> Union[int, ExitCode]:
115125
tw.line(
116126
"ImportError while loading conftest '{e.path}'.".format(e=e), red=True
117127
)
118-
exc_info.traceback = exc_info.traceback.filter(filter_traceback)
128+
exc_info.traceback = exc_info.traceback.filter(
129+
filter_traceback_for_conftest_import_failure
130+
)
119131
exc_repr = (
120132
exc_info.getrepr(style="short", chain=False)
121133
if exc_info.traceback
@@ -450,21 +462,21 @@ def _set_initial_conftests(self, namespace):
450462
path = path[:i]
451463
anchor = current.join(path, abs=1)
452464
if anchor.exists(): # we found some file object
453-
self._try_load_conftest(anchor)
465+
self._try_load_conftest(anchor, namespace.importmode)
454466
foundanchor = True
455467
if not foundanchor:
456-
self._try_load_conftest(current)
468+
self._try_load_conftest(current, namespace.importmode)
457469

458-
def _try_load_conftest(self, anchor):
459-
self._getconftestmodules(anchor)
470+
def _try_load_conftest(self, anchor, importmode):
471+
self._getconftestmodules(anchor, importmode)
460472
# let's also consider test* subdirs
461473
if anchor.check(dir=1):
462474
for x in anchor.listdir("test*"):
463475
if x.check(dir=1):
464-
self._getconftestmodules(x)
476+
self._getconftestmodules(x, importmode)
465477

466478
@lru_cache(maxsize=128)
467-
def _getconftestmodules(self, path):
479+
def _getconftestmodules(self, path, importmode):
468480
if self._noconftest:
469481
return []
470482

@@ -482,21 +494,21 @@ def _getconftestmodules(self, path):
482494
continue
483495
conftestpath = parent.join("conftest.py")
484496
if conftestpath.isfile():
485-
mod = self._importconftest(conftestpath)
497+
mod = self._importconftest(conftestpath, importmode)
486498
clist.append(mod)
487499
self._dirpath2confmods[directory] = clist
488500
return clist
489501

490-
def _rget_with_confmod(self, name, path):
491-
modules = self._getconftestmodules(path)
502+
def _rget_with_confmod(self, name, path, importmode):
503+
modules = self._getconftestmodules(path, importmode)
492504
for mod in reversed(modules):
493505
try:
494506
return mod, getattr(mod, name)
495507
except AttributeError:
496508
continue
497509
raise KeyError(name)
498510

499-
def _importconftest(self, conftestpath):
511+
def _importconftest(self, conftestpath, importmode):
500512
# Use a resolved Path object as key to avoid loading the same conftest twice
501513
# with build systems that create build directories containing
502514
# symlinks to actual files.
@@ -512,7 +524,7 @@ def _importconftest(self, conftestpath):
512524
_ensure_removed_sysmodule(conftestpath.purebasename)
513525

514526
try:
515-
mod = conftestpath.pyimport()
527+
mod = import_path(conftestpath, mode=importmode)
516528
except Exception as e:
517529
raise ConftestImportFailure(conftestpath, sys.exc_info()) from e
518530

@@ -1213,7 +1225,9 @@ def _getini(self, name: str) -> Any:
12131225

12141226
def _getconftest_pathlist(self, name, path):
12151227
try:
1216-
mod, relroots = self.pluginmanager._rget_with_confmod(name, path)
1228+
mod, relroots = self.pluginmanager._rget_with_confmod(
1229+
name, path, self.getoption("importmode")
1230+
)
12171231
except KeyError:
12181232
return None
12191233
modpath = py.path.local(mod.__file__).dirpath()

src/_pytest/doctest.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from _pytest.config.argparsing import Parser
3434
from _pytest.fixtures import FixtureRequest
3535
from _pytest.outcomes import OutcomeException
36+
from _pytest.pathlib import import_path
3637
from _pytest.python_api import approx
3738
from _pytest.warning_types import PytestWarning
3839

@@ -530,10 +531,12 @@ def _find(
530531
)
531532

532533
if self.fspath.basename == "conftest.py":
533-
module = self.config.pluginmanager._importconftest(self.fspath)
534+
module = self.config.pluginmanager._importconftest(
535+
self.fspath, self.config.getoption("importmode")
536+
)
534537
else:
535538
try:
536-
module = self.fspath.pyimport()
539+
module = import_path(self.fspath)
537540
except ImportError:
538541
if self.config.getvalue("doctest_ignore_import_errors"):
539542
pytest.skip("unable to import module %r" % self.fspath)

src/_pytest/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ def pytest_addoption(parser: Parser) -> None:
173173
default=False,
174174
help="Don't ignore tests in a local virtualenv directory",
175175
)
176+
group.addoption(
177+
"--import-mode",
178+
default="prepend",
179+
choices=["prepend", "append", "importlib"],
180+
dest="importmode",
181+
help="prepend/append to sys.path when importing test modules and conftest files, "
182+
"default is to prepend.",
183+
)
176184

177185
group = parser.getgroup("debugconfig", "test session debugging and configuration")
178186
group.addoption(

src/_pytest/nodes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,9 @@ def _gethookproxy(self, fspath: py.path.local):
547547
# check if we have the common case of running
548548
# hooks with all conftest.py files
549549
pm = self.config.pluginmanager
550-
my_conftestmodules = pm._getconftestmodules(fspath)
550+
my_conftestmodules = pm._getconftestmodules(
551+
fspath, self.config.getoption("importmode")
552+
)
551553
remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
552554
if remove_mods:
553555
# one or more conftests are not in use at this fspath

0 commit comments

Comments
 (0)