Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/9114.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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.
15 changes: 15 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,21 @@ passed multiple times. The expected format is ``name=value``. For example::
See :ref:`change naming conventions` for more detailed examples.


.. confval:: pythonpath

Sets list of directories that should be added to the python search path.
Directories will be added to the head of :data:`sys.path`.
Similar to the :envvar:`PYTHONPATH` environment variable, the directories will be
included in where Python will look for imported modules.
Paths are relative to the :ref:`rootdir <rootdir>` directory.
Directories remain in path for the duration of the test session.

.. code-block:: ini

[pytest]
pythonpath = src1 src2


.. confval:: required_plugins

A space separated list of plugins that must be present for pytest to run.
Expand Down
1 change: 1 addition & 0 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def directory_arg(path: str, optname: str) -> str:
"warnings",
"logging",
"reports",
"pythonpath",
*(["unraisableexception", "threadexception"] if sys.version_info >= (3, 8) else []),
"faulthandler",
)
Expand Down
24 changes: 24 additions & 0 deletions src/_pytest/pythonpath.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import sys

import pytest
from pytest import Config
from pytest import Parser


def pytest_addoption(parser: Parser) -> None:
parser.addini("pythonpath", type="paths", help="Add paths to sys.path", default=[])


@pytest.hookimpl(tryfirst=True)
def pytest_load_initial_conftests(early_config: Config) -> None:
# `pythonpath = a b` will set `sys.path` to `[a, b, x, y, z, ...]`
for path in reversed(early_config.getini("pythonpath")):
sys.path.insert(0, str(path))


@pytest.hookimpl(trylast=True)
def pytest_unconfigure(config: Config) -> None:
for path in config.getini("pythonpath"):
path_str = str(path)
if path_str in sys.path:
sys.path.remove(path_str)
8 changes: 7 additions & 1 deletion testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,13 @@ def pytest_load_initial_conftests(self):
pm.register(m)
hc = pm.hook.pytest_load_initial_conftests
values = hc._nonwrappers + hc._wrappers
expected = ["_pytest.config", m.__module__, "_pytest.capture", "_pytest.warnings"]
expected = [
"_pytest.config",
m.__module__,
"_pytest.pythonpath",
"_pytest.capture",
"_pytest.warnings",
]
assert [x.function.__module__ for x in values] == expected


Expand Down
110 changes: 110 additions & 0 deletions testing/test_pythonpath.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import sys
from textwrap import dedent
from typing import Generator
from typing import List
from typing import Optional

import pytest
from _pytest.pytester import Pytester


@pytest.fixture()
def file_structure(pytester: Pytester) -> None:
pytester.makepyfile(
test_foo="""
from foo import foo

def test_foo():
assert foo() == 1
"""
)

pytester.makepyfile(
test_bar="""
from bar import bar

def test_bar():
assert bar() == 2
"""
)

foo_py = pytester.mkdir("sub") / "foo.py"
content = dedent(
"""
def foo():
return 1
"""
)
foo_py.write_text(content, encoding="utf-8")

bar_py = pytester.mkdir("sub2") / "bar.py"
content = dedent(
"""
def bar():
return 2
"""
)
bar_py.write_text(content, encoding="utf-8")


def test_one_dir(pytester: Pytester, file_structure) -> None:
pytester.makefile(".ini", pytest="[pytest]\npythonpath=sub\n")
result = pytester.runpytest("test_foo.py")
assert result.ret == 0
result.assert_outcomes(passed=1)


def test_two_dirs(pytester: Pytester, file_structure) -> None:
pytester.makefile(".ini", pytest="[pytest]\npythonpath=sub sub2\n")
result = pytester.runpytest("test_foo.py", "test_bar.py")
assert result.ret == 0
result.assert_outcomes(passed=2)


def test_module_not_found(pytester: Pytester, file_structure) -> None:
"""Without the pythonpath setting, the module should not be found."""
pytester.makefile(".ini", pytest="[pytest]\n")
result = pytester.runpytest("test_foo.py")
assert result.ret == pytest.ExitCode.INTERRUPTED
result.assert_outcomes(errors=1)
expected_error = "E ModuleNotFoundError: No module named 'foo'"
result.stdout.fnmatch_lines([expected_error])


def test_no_ini(pytester: Pytester, file_structure) -> None:
"""If no ini file, test should error."""
result = pytester.runpytest("test_foo.py")
assert result.ret == pytest.ExitCode.INTERRUPTED
result.assert_outcomes(errors=1)
expected_error = "E ModuleNotFoundError: No module named 'foo'"
result.stdout.fnmatch_lines([expected_error])


def test_clean_up(pytester: Pytester) -> None:
"""Test that the pythonpath plugin cleans up after itself."""
# This is tough to test behaviorly because the cleanup really runs last.
# So the test make several implementation assumptions:
# - Cleanup is done in pytest_unconfigure().
# - Not a hookwrapper.
# So we can add a hookwrapper ourselves to test what it does.
pytester.makefile(".ini", pytest="[pytest]\npythonpath=I_SHALL_BE_REMOVED\n")
pytester.makepyfile(test_foo="""def test_foo(): pass""")

before: Optional[List[str]] = None
after: Optional[List[str]] = None

class Plugin:
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_unconfigure(self) -> Generator[None, None, None]:
nonlocal before, after
before = sys.path.copy()
yield
after = sys.path.copy()

result = pytester.runpytest_inprocess(plugins=[Plugin()])
assert result.ret == 0

assert before is not None
assert after is not None
assert any("I_SHALL_BE_REMOVED" in entry for entry in before)
assert not any("I_SHALL_BE_REMOVED" in entry for entry in after)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool tests. Thanks @bluetech