Skip to content

Commit fa4a414

Browse files
okkenhoefling
authored andcommitted
Add a pythonpath setting to allow paths to be added to sys.path. (pytest-dev#9134)
1 parent 389d791 commit fa4a414

File tree

6 files changed

+158
-1
lines changed

6 files changed

+158
-1
lines changed

changelog/9114.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added :confval:`pythonpath` setting that adds listed paths to :data:`sys.path` for the duration of the test session. If you currently use the pytest-pythonpath or pytest-srcpaths plugins, you should be able to replace them with built-in `pythonpath` setting.

doc/en/reference/reference.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,21 @@ passed multiple times. The expected format is ``name=value``. For example::
16761676
See :ref:`change naming conventions` for more detailed examples.
16771677

16781678

1679+
.. confval:: pythonpath
1680+
1681+
Sets list of directories that should be added to the python search path.
1682+
Directories will be added to the head of :data:`sys.path`.
1683+
Similar to the :envvar:`PYTHONPATH` environment variable, the directories will be
1684+
included in where Python will look for imported modules.
1685+
Paths are relative to the :ref:`rootdir <rootdir>` directory.
1686+
Directories remain in path for the duration of the test session.
1687+
1688+
.. code-block:: ini
1689+
1690+
[pytest]
1691+
pythonpath = src1 src2
1692+
1693+
16791694
.. confval:: required_plugins
16801695

16811696
A space separated list of plugins that must be present for pytest to run.

src/_pytest/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ def directory_arg(path: str, optname: str) -> str:
254254
"warnings",
255255
"logging",
256256
"reports",
257+
"pythonpath",
257258
*(["unraisableexception", "threadexception"] if sys.version_info >= (3, 8) else []),
258259
"faulthandler",
259260
)

src/_pytest/pythonpath.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import sys
2+
3+
import pytest
4+
from pytest import Config
5+
from pytest import Parser
6+
7+
8+
def pytest_addoption(parser: Parser) -> None:
9+
parser.addini("pythonpath", type="paths", help="Add paths to sys.path", default=[])
10+
11+
12+
@pytest.hookimpl(tryfirst=True)
13+
def pytest_load_initial_conftests(early_config: Config) -> None:
14+
# `pythonpath = a b` will set `sys.path` to `[a, b, x, y, z, ...]`
15+
for path in reversed(early_config.getini("pythonpath")):
16+
sys.path.insert(0, str(path))
17+
18+
19+
@pytest.hookimpl(trylast=True)
20+
def pytest_unconfigure(config: Config) -> None:
21+
for path in config.getini("pythonpath"):
22+
path_str = str(path)
23+
if path_str in sys.path:
24+
sys.path.remove(path_str)

testing/test_config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1268,7 +1268,13 @@ def pytest_load_initial_conftests(self):
12681268
pm.register(m)
12691269
hc = pm.hook.pytest_load_initial_conftests
12701270
values = hc._nonwrappers + hc._wrappers
1271-
expected = ["_pytest.config", m.__module__, "_pytest.capture", "_pytest.warnings"]
1271+
expected = [
1272+
"_pytest.config",
1273+
m.__module__,
1274+
"_pytest.pythonpath",
1275+
"_pytest.capture",
1276+
"_pytest.warnings",
1277+
]
12721278
assert [x.function.__module__ for x in values] == expected
12731279

12741280

testing/test_pythonpath.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import sys
2+
from textwrap import dedent
3+
from typing import Generator
4+
from typing import List
5+
from typing import Optional
6+
7+
import pytest
8+
from _pytest.pytester import Pytester
9+
10+
11+
@pytest.fixture()
12+
def file_structure(pytester: Pytester) -> None:
13+
pytester.makepyfile(
14+
test_foo="""
15+
from foo import foo
16+
17+
def test_foo():
18+
assert foo() == 1
19+
"""
20+
)
21+
22+
pytester.makepyfile(
23+
test_bar="""
24+
from bar import bar
25+
26+
def test_bar():
27+
assert bar() == 2
28+
"""
29+
)
30+
31+
foo_py = pytester.mkdir("sub") / "foo.py"
32+
content = dedent(
33+
"""
34+
def foo():
35+
return 1
36+
"""
37+
)
38+
foo_py.write_text(content, encoding="utf-8")
39+
40+
bar_py = pytester.mkdir("sub2") / "bar.py"
41+
content = dedent(
42+
"""
43+
def bar():
44+
return 2
45+
"""
46+
)
47+
bar_py.write_text(content, encoding="utf-8")
48+
49+
50+
def test_one_dir(pytester: Pytester, file_structure) -> None:
51+
pytester.makefile(".ini", pytest="[pytest]\npythonpath=sub\n")
52+
result = pytester.runpytest("test_foo.py")
53+
assert result.ret == 0
54+
result.assert_outcomes(passed=1)
55+
56+
57+
def test_two_dirs(pytester: Pytester, file_structure) -> None:
58+
pytester.makefile(".ini", pytest="[pytest]\npythonpath=sub sub2\n")
59+
result = pytester.runpytest("test_foo.py", "test_bar.py")
60+
assert result.ret == 0
61+
result.assert_outcomes(passed=2)
62+
63+
64+
def test_module_not_found(pytester: Pytester, file_structure) -> None:
65+
"""Without the pythonpath setting, the module should not be found."""
66+
pytester.makefile(".ini", pytest="[pytest]\n")
67+
result = pytester.runpytest("test_foo.py")
68+
assert result.ret == pytest.ExitCode.INTERRUPTED
69+
result.assert_outcomes(errors=1)
70+
expected_error = "E ModuleNotFoundError: No module named 'foo'"
71+
result.stdout.fnmatch_lines([expected_error])
72+
73+
74+
def test_no_ini(pytester: Pytester, file_structure) -> None:
75+
"""If no ini file, test should error."""
76+
result = pytester.runpytest("test_foo.py")
77+
assert result.ret == pytest.ExitCode.INTERRUPTED
78+
result.assert_outcomes(errors=1)
79+
expected_error = "E ModuleNotFoundError: No module named 'foo'"
80+
result.stdout.fnmatch_lines([expected_error])
81+
82+
83+
def test_clean_up(pytester: Pytester) -> None:
84+
"""Test that the pythonpath plugin cleans up after itself."""
85+
# This is tough to test behaviorly because the cleanup really runs last.
86+
# So the test make several implementation assumptions:
87+
# - Cleanup is done in pytest_unconfigure().
88+
# - Not a hookwrapper.
89+
# So we can add a hookwrapper ourselves to test what it does.
90+
pytester.makefile(".ini", pytest="[pytest]\npythonpath=I_SHALL_BE_REMOVED\n")
91+
pytester.makepyfile(test_foo="""def test_foo(): pass""")
92+
93+
before: Optional[List[str]] = None
94+
after: Optional[List[str]] = None
95+
96+
class Plugin:
97+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
98+
def pytest_unconfigure(self) -> Generator[None, None, None]:
99+
nonlocal before, after
100+
before = sys.path.copy()
101+
yield
102+
after = sys.path.copy()
103+
104+
result = pytester.runpytest_inprocess(plugins=[Plugin()])
105+
assert result.ret == 0
106+
107+
assert before is not None
108+
assert after is not None
109+
assert any("I_SHALL_BE_REMOVED" in entry for entry in before)
110+
assert not any("I_SHALL_BE_REMOVED" in entry for entry in after)

0 commit comments

Comments
 (0)