From 7843f289c99cba985c78ded49170bee300984ba2 Mon Sep 17 00:00:00 2001 From: Daniel Miller Date: Wed, 26 Jun 2024 11:56:32 -0400 Subject: [PATCH] Load plugins from paths in 'pythonpath' option --- changelog/11118.improvement.rst | 3 +++ doc/en/reference/reference.rst | 5 ----- src/_pytest/config/__init__.py | 17 ++++++++++++++++- src/_pytest/python_path.py | 26 -------------------------- testing/test_config.py | 1 - testing/test_python_path.py | 32 ++++++++++++++++++++++++-------- 6 files changed, 43 insertions(+), 41 deletions(-) create mode 100644 changelog/11118.improvement.rst delete mode 100644 src/_pytest/python_path.py diff --git a/changelog/11118.improvement.rst b/changelog/11118.improvement.rst new file mode 100644 index 000000000..f15ddbb4b --- /dev/null +++ b/changelog/11118.improvement.rst @@ -0,0 +1,3 @@ +Allow plugins to be loaded with `-p` from paths specified in `pythonpath`. + +-- by :user:`millerdev` diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 7c7b99d81..951e35b45 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1789,11 +1789,6 @@ passed multiple times. The expected format is ``name=value``. For example:: [pytest] pythonpath = src1 src2 - .. note:: - - ``pythonpath`` does not affect some imports that happen very early, - most notably plugins loaded using the ``-p`` command line option. - .. confval:: required_plugins diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 0c1850df5..cae81ad63 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -268,7 +268,6 @@ default_plugins = ( "warnings", "logging", "reports", - "python_path", "unraisableexception", "threadexception", "faulthandler", @@ -1245,6 +1244,9 @@ class Config: self._parser.extra_info["inifile"] = str(self.inipath) self._parser.addini("addopts", "Extra command line options", "args") self._parser.addini("minversion", "Minimally required pytest version") + self._parser.addini( + "pythonpath", type="paths", help="Add paths to sys.path", default=[] + ) self._parser.addini( "required_plugins", "Plugins that must be present for pytest to run", @@ -1294,6 +1296,18 @@ class Config: for name in _iter_rewritable_modules(package_files): hook.mark_rewrite(name) + def _configure_python_path(self): + # `pythonpath = a b` will set `sys.path` to `[a, b, x, y, z, ...]` + for path in reversed(self.getini("pythonpath")): + sys.path.insert(0, str(path)) + self.add_cleanup(self._unconfigure_python_path) + + def _unconfigure_python_path(self): + for path in self.getini("pythonpath"): + path_str = str(path) + if path_str in sys.path: + sys.path.remove(path_str) + def _validate_args(self, args: list[str], via: str) -> list[str]: """Validate known args.""" self._parser._config_source_hint = via # type: ignore @@ -1370,6 +1384,7 @@ class Config: ) self._checkversion() self._consider_importhook(args) + self._configure_python_path() self.pluginmanager.consider_preparse(args, exclude_only=False) if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"): # Don't autoload from distribution package entry point. Only diff --git a/src/_pytest/python_path.py b/src/_pytest/python_path.py deleted file mode 100644 index 6e33c8a39..000000000 --- a/src/_pytest/python_path.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -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 232839399..aa3e41479 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1409,7 +1409,6 @@ def test_load_initial_conftest_last_ordering(_config_for_test): ("_pytest.config", "nonwrapper"), (m.__module__, "nonwrapper"), ("_pytest.legacypath", "nonwrapper"), - ("_pytest.python_path", "nonwrapper"), ("_pytest.capture", "wrapper"), ("_pytest.warnings", "wrapper"), ] diff --git a/testing/test_python_path.py b/testing/test_python_path.py index 1db02252d..155aac29f 100644 --- a/testing/test_python_path.py +++ b/testing/test_python_path.py @@ -3,7 +3,6 @@ from __future__ import annotations import sys from textwrap import dedent -from typing import Generator from _pytest.pytester import Pytester import pytest @@ -62,6 +61,26 @@ def test_two_dirs(pytester: Pytester, file_structure) -> None: result.assert_outcomes(passed=2) +def test_local_plugin(pytester: Pytester, file_structure) -> None: + localplugin_py = pytester.path / "sub" / "localplugin.py" + content = dedent( + """ + def pytest_load_initial_conftests(): + print("local plugin load") + + def pytest_unconfigure(): + print("local plugin unconfig") + """ + ) + localplugin_py.write_text(content, encoding="utf-8") + + pytester.makefile(".ini", pytest="[pytest]\npythonpath=sub\n") + result = pytester.runpytest("-plocalplugin", "-s", "test_foo.py") + result.stdout.fnmatch_lines(["local plugin load", "local plugin unconfig"]) + assert result.ret == 0 + result.assert_outcomes(passed=1) + + 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") @@ -95,16 +114,13 @@ def test_clean_up(pytester: Pytester) -> None: after: list[str] | None = None class Plugin: - @pytest.hookimpl(wrapper=True, tryfirst=True) - def pytest_unconfigure(self) -> Generator[None, None, None]: - nonlocal before, after + @pytest.hookimpl(tryfirst=True) + def pytest_unconfigure(self) -> None: + nonlocal before before = sys.path.copy() - try: - return (yield) - finally: - after = sys.path.copy() result = pytester.runpytest_inprocess(plugins=[Plugin()]) + after = sys.path.copy() assert result.ret == 0 assert before is not None