Skip to content

Commit 85760bf

Browse files
Apply pythonpath option as early as possible (#12536)
This allows plugins loaded via '-p' to benefit from the new PYTHONPATH, making the option useful in more cases and less surprising. For this to work it was required for the functionality to be part of Config rather than a separate plugin, which is unfortunate but in the end considered a small price to pay. Fix #11118. --------- Co-authored-by: Bruno Oliveira <[email protected]>
1 parent 2b99703 commit 85760bf

File tree

6 files changed

+44
-41
lines changed

6 files changed

+44
-41
lines changed

changelog/11118.improvement.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Now :confval:`pythonpath` configures `$PYTHONPATH` earlier than before during the initialization process, which now also affects plugins loaded via the `-p` command-line option.
2+
3+
-- by :user:`millerdev`

doc/en/reference/reference.rst

-5
Original file line numberDiff line numberDiff line change
@@ -1796,11 +1796,6 @@ passed multiple times. The expected format is ``name=value``. For example::
17961796
[pytest]
17971797
pythonpath = src1 src2
17981798
1799-
.. note::
1800-
1801-
``pythonpath`` does not affect some imports that happen very early,
1802-
most notably plugins loaded using the ``-p`` command line option.
1803-
18041799
18051800
.. confval:: required_plugins
18061801

src/_pytest/config/__init__.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,6 @@ def directory_arg(path: str, optname: str) -> str:
268268
"warnings",
269269
"logging",
270270
"reports",
271-
"python_path",
272271
"unraisableexception",
273272
"threadexception",
274273
"faulthandler",
@@ -1245,6 +1244,9 @@ def _initini(self, args: Sequence[str]) -> None:
12451244
self._parser.extra_info["inifile"] = str(self.inipath)
12461245
self._parser.addini("addopts", "Extra command line options", "args")
12471246
self._parser.addini("minversion", "Minimally required pytest version")
1247+
self._parser.addini(
1248+
"pythonpath", type="paths", help="Add paths to sys.path", default=[]
1249+
)
12481250
self._parser.addini(
12491251
"required_plugins",
12501252
"Plugins that must be present for pytest to run",
@@ -1294,6 +1296,18 @@ def _mark_plugins_for_rewrite(self, hook) -> None:
12941296
for name in _iter_rewritable_modules(package_files):
12951297
hook.mark_rewrite(name)
12961298

1299+
def _configure_python_path(self) -> None:
1300+
# `pythonpath = a b` will set `sys.path` to `[a, b, x, y, z, ...]`
1301+
for path in reversed(self.getini("pythonpath")):
1302+
sys.path.insert(0, str(path))
1303+
self.add_cleanup(self._unconfigure_python_path)
1304+
1305+
def _unconfigure_python_path(self) -> None:
1306+
for path in self.getini("pythonpath"):
1307+
path_str = str(path)
1308+
if path_str in sys.path:
1309+
sys.path.remove(path_str)
1310+
12971311
def _validate_args(self, args: list[str], via: str) -> list[str]:
12981312
"""Validate known args."""
12991313
self._parser._config_source_hint = via # type: ignore
@@ -1370,6 +1384,7 @@ def _preparse(self, args: list[str], addopts: bool = True) -> None:
13701384
)
13711385
self._checkversion()
13721386
self._consider_importhook(args)
1387+
self._configure_python_path()
13731388
self.pluginmanager.consider_preparse(args, exclude_only=False)
13741389
if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"):
13751390
# Don't autoload from distribution package entry point. Only

src/_pytest/python_path.py

-26
This file was deleted.

testing/test_config.py

-1
Original file line numberDiff line numberDiff line change
@@ -1409,7 +1409,6 @@ def pytest_load_initial_conftests(self):
14091409
("_pytest.config", "nonwrapper"),
14101410
(m.__module__, "nonwrapper"),
14111411
("_pytest.legacypath", "nonwrapper"),
1412-
("_pytest.python_path", "nonwrapper"),
14131412
("_pytest.capture", "wrapper"),
14141413
("_pytest.warnings", "wrapper"),
14151414
]

testing/test_python_path.py

+25-8
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
import sys
55
from textwrap import dedent
6-
from typing import Generator
76

87
from _pytest.pytester import Pytester
98
import pytest
@@ -62,6 +61,27 @@ def test_two_dirs(pytester: Pytester, file_structure) -> None:
6261
result.assert_outcomes(passed=2)
6362

6463

64+
def test_local_plugin(pytester: Pytester, file_structure) -> None:
65+
"""`pythonpath` kicks early enough to load plugins via -p (#11118)."""
66+
localplugin_py = pytester.path / "sub" / "localplugin.py"
67+
content = dedent(
68+
"""
69+
def pytest_load_initial_conftests():
70+
print("local plugin load")
71+
72+
def pytest_unconfigure():
73+
print("local plugin unconfig")
74+
"""
75+
)
76+
localplugin_py.write_text(content, encoding="utf-8")
77+
78+
pytester.makeini("[pytest]\npythonpath=sub\n")
79+
result = pytester.runpytest("-plocalplugin", "-s", "test_foo.py")
80+
result.stdout.fnmatch_lines(["local plugin load", "local plugin unconfig"])
81+
assert result.ret == 0
82+
result.assert_outcomes(passed=1)
83+
84+
6585
def test_module_not_found(pytester: Pytester, file_structure) -> None:
6686
"""Without the pythonpath setting, the module should not be found."""
6787
pytester.makefile(".ini", pytest="[pytest]\n")
@@ -95,16 +115,13 @@ def test_clean_up(pytester: Pytester) -> None:
95115
after: list[str] | None = None
96116

97117
class Plugin:
98-
@pytest.hookimpl(wrapper=True, tryfirst=True)
99-
def pytest_unconfigure(self) -> Generator[None, None, None]:
100-
nonlocal before, after
118+
@pytest.hookimpl(tryfirst=True)
119+
def pytest_unconfigure(self) -> None:
120+
nonlocal before
101121
before = sys.path.copy()
102-
try:
103-
return (yield)
104-
finally:
105-
after = sys.path.copy()
106122

107123
result = pytester.runpytest_inprocess(plugins=[Plugin()])
124+
after = sys.path.copy()
108125
assert result.ret == 0
109126

110127
assert before is not None

0 commit comments

Comments
 (0)