Load plugins from paths in 'pythonpath' option

This commit is contained in:
Daniel Miller 2024-06-26 11:56:32 -04:00
parent f74e947c1f
commit 7843f289c9
6 changed files with 43 additions and 41 deletions

View File

@ -0,0 +1,3 @@
Allow plugins to be loaded with `-p` from paths specified in `pythonpath`.
-- by :user:`millerdev`

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"),
]

View File

@ -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