From c82bda259c1e424e04151a2fc82d5fed6438045e Mon Sep 17 00:00:00 2001 From: Brian Okken <1568356+okken@users.noreply.github.com> Date: Mon, 4 Oct 2021 23:36:38 -0700 Subject: [PATCH] Add a `pythonpath` setting to allow paths to be added to `sys.path`. (#9134) --- changelog/9114.feature.rst | 1 + doc/en/reference/reference.rst | 15 +++++ src/_pytest/config/__init__.py | 1 + src/_pytest/pythonpath.py | 24 +++++++ testing/test_config.py | 8 ++- testing/test_pythonpath.py | 110 +++++++++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 changelog/9114.feature.rst create mode 100644 src/_pytest/pythonpath.py create mode 100644 testing/test_pythonpath.py diff --git a/changelog/9114.feature.rst b/changelog/9114.feature.rst new file mode 100644 index 000000000..0a576c3b7 --- /dev/null +++ b/changelog/9114.feature.rst @@ -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. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index b5d0faf90..1a89d8eaf 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -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 ` 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. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 1f98484d5..89402bcc6 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -254,6 +254,7 @@ default_plugins = essential_plugins + ( "warnings", "logging", "reports", + "pythonpath", *(["unraisableexception", "threadexception"] if sys.version_info >= (3, 8) else []), "faulthandler", ) diff --git a/src/_pytest/pythonpath.py b/src/_pytest/pythonpath.py new file mode 100644 index 000000000..cceabbca1 --- /dev/null +++ b/src/_pytest/pythonpath.py @@ -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) diff --git a/testing/test_config.py b/testing/test_config.py index 996a332a0..1252f31fc 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1268,7 +1268,13 @@ def test_load_initial_conftest_last_ordering(_config_for_test): 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 diff --git a/testing/test_pythonpath.py b/testing/test_pythonpath.py new file mode 100644 index 000000000..97c439ce0 --- /dev/null +++ b/testing/test_pythonpath.py @@ -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)