Skip to content

Commit b706a2c

Browse files
authored
Fix error with --import-mode=importlib and modules containing dataclasses or pickle (#7870)
Co-authored-by: Bruno Oliveira <[email protected]> Fixes #7856, fixes #7859
1 parent 366c36a commit b706a2c

16 files changed

+348
-106
lines changed

AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Dominic Mortlock
9898
Duncan Betts
9999
Edison Gustavo Muenz
100100
Edoardo Batini
101+
Edson Tadeu M. Manoel
101102
Eduardo Schettino
102103
Eli Boyarski
103104
Elizaveta Shashkova

changelog/7856.feature.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:ref:`--import-mode=importlib <import-modes>` now works with features that
2+
depend on modules being on :py:data:`sys.modules`, such as :mod:`pickle` and :mod:`dataclasses`.

doc/en/explanation/goodpractices.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ This layout prevents a lot of common pitfalls and has many benefits, which are b
151151

152152
.. note::
153153
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
154+
any of the drawbacks above because ``sys.path`` is not changed when importing
155155
test modules, so users that run
156156
into this issue are strongly encouraged to try it and report if the new option works well for them.
157157

doc/en/explanation/pythonpath.rst

+8-11
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ 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 ``sys.path`` if not already there, and then imported with the `__import__ <https://docs.python.org/3/library/functions.html#__import__>`__ builtin.
19+
of :py:data:`sys.path` if not already there, and then imported with the `__import__ <https://docs.python.org/3/library/functions.html#__import__>`__ builtin.
2020

2121
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.
22+
packages, because the modules will put in :py:data:`sys.modules` after importing.
2323

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

26-
* ``append``: the directory containing each module is appended to the end of ``sys.path`` if not already
26+
* ``append``: the directory containing each module is appended to the end of :py:data:`sys.path` if not already
2727
there, and imported with ``__import__``.
2828

2929
This better allows to run test modules against installed versions of a package even if the
@@ -41,17 +41,14 @@ these values:
4141
we advocate for using :ref:`src <src-layout>` layouts.
4242

4343
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.
44+
not arranged in packages, because the modules will put in :py:data:`sys.modules` after importing.
4545

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.
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 changing :py:data:`sys.path`.
4847

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.
48+
For this reason this doesn't require test module names to be unique, but also makes test
49+
modules non-importable by each other.
5350

54-
We intend to make ``importlib`` the default in future releases.
51+
We intend to make ``importlib`` the default in future releases, depending on feedback.
5552

5653
``prepend`` and ``append`` import modes scenarios
5754
-------------------------------------------------

src/_pytest/config/__init__.py

+21-18
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,9 @@ def pytest_configure(self, config: "Config") -> None:
481481
#
482482
# Internal API for local conftest plugin handling.
483483
#
484-
def _set_initial_conftests(self, namespace: argparse.Namespace) -> None:
484+
def _set_initial_conftests(
485+
self, namespace: argparse.Namespace, rootpath: Path
486+
) -> None:
485487
"""Load initial conftest files given a preparsed "namespace".
486488
487489
As conftest files may add their own command line options which have
@@ -507,26 +509,24 @@ def _set_initial_conftests(self, namespace: argparse.Namespace) -> None:
507509
path = path[:i]
508510
anchor = absolutepath(current / path)
509511
if anchor.exists(): # we found some file object
510-
self._try_load_conftest(anchor, namespace.importmode)
512+
self._try_load_conftest(anchor, namespace.importmode, rootpath)
511513
foundanchor = True
512514
if not foundanchor:
513-
self._try_load_conftest(current, namespace.importmode)
515+
self._try_load_conftest(current, namespace.importmode, rootpath)
514516

515517
def _try_load_conftest(
516-
self, anchor: Path, importmode: Union[str, ImportMode]
518+
self, anchor: Path, importmode: Union[str, ImportMode], rootpath: Path
517519
) -> None:
518-
self._getconftestmodules(anchor, importmode)
520+
self._getconftestmodules(anchor, importmode, rootpath)
519521
# let's also consider test* subdirs
520522
if anchor.is_dir():
521523
for x in anchor.glob("test*"):
522524
if x.is_dir():
523-
self._getconftestmodules(x, importmode)
525+
self._getconftestmodules(x, importmode, rootpath)
524526

525527
@lru_cache(maxsize=128)
526528
def _getconftestmodules(
527-
self,
528-
path: Path,
529-
importmode: Union[str, ImportMode],
529+
self, path: Path, importmode: Union[str, ImportMode], rootpath: Path
530530
) -> List[types.ModuleType]:
531531
if self._noconftest:
532532
return []
@@ -545,7 +545,7 @@ def _getconftestmodules(
545545
continue
546546
conftestpath = parent / "conftest.py"
547547
if conftestpath.is_file():
548-
mod = self._importconftest(conftestpath, importmode)
548+
mod = self._importconftest(conftestpath, importmode, rootpath)
549549
clist.append(mod)
550550
self._dirpath2confmods[directory] = clist
551551
return clist
@@ -555,8 +555,9 @@ def _rget_with_confmod(
555555
name: str,
556556
path: Path,
557557
importmode: Union[str, ImportMode],
558+
rootpath: Path,
558559
) -> Tuple[types.ModuleType, Any]:
559-
modules = self._getconftestmodules(path, importmode)
560+
modules = self._getconftestmodules(path, importmode, rootpath=rootpath)
560561
for mod in reversed(modules):
561562
try:
562563
return mod, getattr(mod, name)
@@ -565,9 +566,7 @@ def _rget_with_confmod(
565566
raise KeyError(name)
566567

567568
def _importconftest(
568-
self,
569-
conftestpath: Path,
570-
importmode: Union[str, ImportMode],
569+
self, conftestpath: Path, importmode: Union[str, ImportMode], rootpath: Path
571570
) -> types.ModuleType:
572571
# Use a resolved Path object as key to avoid loading the same conftest
573572
# twice with build systems that create build directories containing
@@ -584,7 +583,7 @@ def _importconftest(
584583
_ensure_removed_sysmodule(conftestpath.stem)
585584

586585
try:
587-
mod = import_path(conftestpath, mode=importmode)
586+
mod = import_path(conftestpath, mode=importmode, root=rootpath)
588587
except Exception as e:
589588
assert e.__traceback__ is not None
590589
exc_info = (type(e), e, e.__traceback__)
@@ -1086,7 +1085,9 @@ def _processopt(self, opt: "Argument") -> None:
10861085

10871086
@hookimpl(trylast=True)
10881087
def pytest_load_initial_conftests(self, early_config: "Config") -> None:
1089-
self.pluginmanager._set_initial_conftests(early_config.known_args_namespace)
1088+
self.pluginmanager._set_initial_conftests(
1089+
early_config.known_args_namespace, rootpath=early_config.rootpath
1090+
)
10901091

10911092
def _initini(self, args: Sequence[str]) -> None:
10921093
ns, unknown_args = self._parser.parse_known_and_unknown_args(
@@ -1437,10 +1438,12 @@ def _getini(self, name: str):
14371438
assert type in [None, "string"]
14381439
return value
14391440

1440-
def _getconftest_pathlist(self, name: str, path: Path) -> Optional[List[Path]]:
1441+
def _getconftest_pathlist(
1442+
self, name: str, path: Path, rootpath: Path
1443+
) -> Optional[List[Path]]:
14411444
try:
14421445
mod, relroots = self.pluginmanager._rget_with_confmod(
1443-
name, path, self.getoption("importmode")
1446+
name, path, self.getoption("importmode"), rootpath
14441447
)
14451448
except KeyError:
14461449
return None

src/_pytest/doctest.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -534,11 +534,13 @@ def _find(
534534

535535
if self.path.name == "conftest.py":
536536
module = self.config.pluginmanager._importconftest(
537-
self.path, self.config.getoption("importmode")
537+
self.path,
538+
self.config.getoption("importmode"),
539+
rootpath=self.config.rootpath,
538540
)
539541
else:
540542
try:
541-
module = import_path(self.path)
543+
module = import_path(self.path, root=self.config.rootpath)
542544
except ImportError:
543545
if self.config.getvalue("doctest_ignore_import_errors"):
544546
pytest.skip("unable to import module %r" % self.path)

src/_pytest/main.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,9 @@ def _in_venv(path: Path) -> bool:
378378

379379

380380
def pytest_ignore_collect(fspath: Path, config: Config) -> Optional[bool]:
381-
ignore_paths = config._getconftest_pathlist("collect_ignore", path=fspath.parent)
381+
ignore_paths = config._getconftest_pathlist(
382+
"collect_ignore", path=fspath.parent, rootpath=config.rootpath
383+
)
382384
ignore_paths = ignore_paths or []
383385
excludeopt = config.getoption("ignore")
384386
if excludeopt:
@@ -388,7 +390,7 @@ def pytest_ignore_collect(fspath: Path, config: Config) -> Optional[bool]:
388390
return True
389391

390392
ignore_globs = config._getconftest_pathlist(
391-
"collect_ignore_glob", path=fspath.parent
393+
"collect_ignore_glob", path=fspath.parent, rootpath=config.rootpath
392394
)
393395
ignore_globs = ignore_globs or []
394396
excludeglobopt = config.getoption("ignore_glob")
@@ -546,7 +548,9 @@ def gethookproxy(self, fspath: "os.PathLike[str]"):
546548
# hooks with all conftest.py files.
547549
pm = self.config.pluginmanager
548550
my_conftestmodules = pm._getconftestmodules(
549-
Path(fspath), self.config.getoption("importmode")
551+
Path(fspath),
552+
self.config.getoption("importmode"),
553+
rootpath=self.config.rootpath,
550554
)
551555
remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
552556
if remove_mods:

src/_pytest/pathlib.py

+51-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from posixpath import sep as posix_sep
2424
from types import ModuleType
2525
from typing import Callable
26+
from typing import Dict
2627
from typing import Iterable
2728
from typing import Iterator
2829
from typing import Optional
@@ -454,6 +455,7 @@ def import_path(
454455
p: Union[str, "os.PathLike[str]"],
455456
*,
456457
mode: Union[str, ImportMode] = ImportMode.prepend,
458+
root: Path,
457459
) -> ModuleType:
458460
"""Import and return a module from the given path, which can be a file (a module) or
459461
a directory (a package).
@@ -471,6 +473,11 @@ def import_path(
471473
to import the module, which avoids having to use `__import__` and muck with `sys.path`
472474
at all. It effectively allows having same-named test modules in different places.
473475
476+
:param root:
477+
Used as an anchor when mode == ImportMode.importlib to obtain
478+
a unique name for the module being imported so it can safely be stored
479+
into ``sys.modules``.
480+
474481
:raises ImportPathMismatchError:
475482
If after importing the given `path` and the module `__file__`
476483
are different. Only raised in `prepend` and `append` modes.
@@ -483,7 +490,7 @@ def import_path(
483490
raise ImportError(path)
484491

485492
if mode is ImportMode.importlib:
486-
module_name = path.stem
493+
module_name = module_name_from_path(path, root)
487494

488495
for meta_importer in sys.meta_path:
489496
spec = meta_importer.find_spec(module_name, [str(path.parent)])
@@ -497,7 +504,9 @@ def import_path(
497504
"Can't find module {} at location {}".format(module_name, str(path))
498505
)
499506
mod = importlib.util.module_from_spec(spec)
507+
sys.modules[module_name] = mod
500508
spec.loader.exec_module(mod) # type: ignore[union-attr]
509+
insert_missing_modules(sys.modules, module_name)
501510
return mod
502511

503512
pkg_path = resolve_package_path(path)
@@ -562,6 +571,47 @@ def _is_same(f1: str, f2: str) -> bool:
562571
return os.path.samefile(f1, f2)
563572

564573

574+
def module_name_from_path(path: Path, root: Path) -> str:
575+
"""
576+
Return a dotted module name based on the given path, anchored on root.
577+
578+
For example: path="projects/src/tests/test_foo.py" and root="/projects", the
579+
resulting module name will be "src.tests.test_foo".
580+
"""
581+
path = path.with_suffix("")
582+
try:
583+
relative_path = path.relative_to(root)
584+
except ValueError:
585+
# If we can't get a relative path to root, use the full path, except
586+
# for the first part ("d:\\" or "/" depending on the platform, for example).
587+
path_parts = path.parts[1:]
588+
else:
589+
# Use the parts for the relative path to the root path.
590+
path_parts = relative_path.parts
591+
592+
return ".".join(path_parts)
593+
594+
595+
def insert_missing_modules(modules: Dict[str, ModuleType], module_name: str) -> None:
596+
"""
597+
Used by ``import_path`` to create intermediate modules when using mode=importlib.
598+
599+
When we want to import a module as "src.tests.test_foo" for example, we need
600+
to create empty modules "src" and "src.tests" after inserting "src.tests.test_foo",
601+
otherwise "src.tests.test_foo" is not importable by ``__import__``.
602+
"""
603+
module_parts = module_name.split(".")
604+
while module_name:
605+
if module_name not in modules:
606+
module = ModuleType(
607+
module_name,
608+
doc="Empty module created by pytest's importmode=importlib.",
609+
)
610+
modules[module_name] = module
611+
module_parts.pop(-1)
612+
module_name = ".".join(module_parts)
613+
614+
565615
def resolve_package_path(path: Path) -> Optional[Path]:
566616
"""Return the Python package path by looking for the last
567617
directory upwards which still contains an __init__.py.

src/_pytest/python.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ def _importtestmodule(self):
577577
# We assume we are only called once per module.
578578
importmode = self.config.getoption("--import-mode")
579579
try:
580-
mod = import_path(self.path, mode=importmode)
580+
mod = import_path(self.path, mode=importmode, root=self.config.rootpath)
581581
except SyntaxError as e:
582582
raise self.CollectError(
583583
ExceptionInfo.from_current().getrepr(style="short")

testing/code/test_excinfo.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def test_traceback_cut(self) -> None:
162162
def test_traceback_cut_excludepath(self, pytester: Pytester) -> None:
163163
p = pytester.makepyfile("def f(): raise ValueError")
164164
with pytest.raises(ValueError) as excinfo:
165-
import_path(p).f() # type: ignore[attr-defined]
165+
import_path(p, root=pytester.path).f() # type: ignore[attr-defined]
166166
basedir = Path(pytest.__file__).parent
167167
newtraceback = excinfo.traceback.cut(excludepath=basedir)
168168
for x in newtraceback:
@@ -443,7 +443,7 @@ def importasmod(source):
443443
tmp_path.joinpath("__init__.py").touch()
444444
modpath.write_text(source)
445445
importlib.invalidate_caches()
446-
return import_path(modpath)
446+
return import_path(modpath, root=tmp_path)
447447

448448
return importasmod
449449

testing/code/test_source.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ def method(self):
298298
)
299299
path = tmp_path.joinpath("a.py")
300300
path.write_text(str(source))
301-
mod: Any = import_path(path)
301+
mod: Any = import_path(path, root=tmp_path)
302302
s2 = Source(mod.A)
303303
assert str(source).strip() == str(s2).strip()
304304

testing/python/fixtures.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -2069,9 +2069,9 @@ def test_2(self):
20692069
reprec = pytester.inline_run("-v", "-s", "--confcutdir", pytester.path)
20702070
reprec.assertoutcome(passed=8)
20712071
config = reprec.getcalls("pytest_unconfigure")[0].config
2072-
values = config.pluginmanager._getconftestmodules(p, importmode="prepend")[
2073-
0
2074-
].values
2072+
values = config.pluginmanager._getconftestmodules(
2073+
p, importmode="prepend", rootpath=pytester.path
2074+
)[0].values
20752075
assert values == ["fin_a1", "fin_a2", "fin_b1", "fin_b2"] * 2
20762076

20772077
def test_scope_ordering(self, pytester: Pytester) -> None:

testing/test_config.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -597,8 +597,14 @@ def test_getconftest_pathlist(self, pytester: Pytester, tmp_path: Path) -> None:
597597
p = tmp_path.joinpath("conftest.py")
598598
p.write_text(f"pathlist = ['.', {str(somepath)!r}]")
599599
config = pytester.parseconfigure(p)
600-
assert config._getconftest_pathlist("notexist", path=tmp_path) is None
601-
pl = config._getconftest_pathlist("pathlist", path=tmp_path) or []
600+
assert (
601+
config._getconftest_pathlist("notexist", path=tmp_path, rootpath=tmp_path)
602+
is None
603+
)
604+
pl = (
605+
config._getconftest_pathlist("pathlist", path=tmp_path, rootpath=tmp_path)
606+
or []
607+
)
602608
print(pl)
603609
assert len(pl) == 2
604610
assert pl[0] == tmp_path

0 commit comments

Comments
 (0)