Skip to content

Commit

Permalink
Add a pythonpath setting to allow paths to be added to sys.path. (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
okken committed Oct 5, 2021
1 parent 05a9737 commit c82bda2
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 1 deletion.
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)

0 comments on commit c82bda2

Please sign in to comment.