Skip to content

Handle --version eagerly to avoid loading the entire infrastructure #13575

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions changelog/13574.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
The single argument ``--version`` no longer loads the entire plugin infrastructure, making it faster and more reliable when displaying only the pytest version.

Passing ``--version`` twice (e.g., ``pytest --version --version``) retains the original behavior, showing both the pytest version and plugin information.

.. note::

Since ``--version`` is now processed early, it only takes effect when passed directly via the command line. It will not work if set through other mechanisms, such as :envvar:`PYTEST_ADDOPTS` or :confval:`addopts`.
22 changes: 14 additions & 8 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,17 @@ def main(

:returns: An exit code.
"""
# Handle a single `--version` argument early to avoid starting up the entire pytest infrastructure.
new_args = sys.argv[1:] if args is None else args
if isinstance(new_args, Sequence) and new_args.count("--version") == 1:
sys.stdout.write(f"pytest {__version__}\n")
return ExitCode.OK

old_pytest_version = os.environ.get("PYTEST_VERSION")
try:
os.environ["PYTEST_VERSION"] = __version__
try:
config = _prepareconfig(args, plugins)
config = _prepareconfig(new_args, plugins)
except ConftestImportFailure as e:
exc_info = ExceptionInfo.from_exception(e.cause)
tw = TerminalWriter(sys.stderr)
Expand Down Expand Up @@ -317,12 +323,10 @@ def get_plugin_manager() -> PytestPluginManager:


def _prepareconfig(
args: list[str] | os.PathLike[str] | None = None,
args: list[str] | os.PathLike[str],
plugins: Sequence[str | _PluggyPlugin] | None = None,
) -> Config:
if args is None:
args = sys.argv[1:]
elif isinstance(args, os.PathLike):
if isinstance(args, os.PathLike):
args = [os.fspath(args)]
elif not isinstance(args, list):
msg = ( # type:ignore[unreachable]
Expand Down Expand Up @@ -1145,13 +1149,15 @@ def pytest_cmdline_parse(
try:
self.parse(args)
except UsageError:
# Handle --version and --help here in a minimal fashion.
# Handle `--version --version` and `--help` here in a minimal fashion.
# This gets done via helpconfig normally, but its
# pytest_cmdline_main is not called in case of errors.
if getattr(self.option, "version", False) or "--version" in args:
from _pytest.helpconfig import showversion
from _pytest.helpconfig import show_version_verbose

showversion(self)
# Note that `--version` (single argument) is handled early by `Config.main()`, so the only
# way we are reaching this point is via `--version --version`.
show_version_verbose(self)
elif (
getattr(self.option, "help", False) or "--help" in args or "-h" in args
):
Expand Down
30 changes: 15 additions & 15 deletions src/_pytest/helpconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,28 +140,28 @@ def unset_tracing() -> None:
return config


def showversion(config: Config) -> None:
if config.option.version > 1:
sys.stdout.write(
f"This is pytest version {pytest.__version__}, imported from {pytest.__file__}\n"
)
plugininfo = getpluginversioninfo(config)
if plugininfo:
for line in plugininfo:
sys.stdout.write(line + "\n")
else:
sys.stdout.write(f"pytest {pytest.__version__}\n")
def show_version_verbose(config: Config) -> None:
"""Show verbose pytest version installation, including plugins."""
sys.stdout.write(
f"This is pytest version {pytest.__version__}, imported from {pytest.__file__}\n"
)
plugininfo = getpluginversioninfo(config)
if plugininfo:
for line in plugininfo:
sys.stdout.write(line + "\n")


def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
if config.option.version > 0:
showversion(config)
return 0
# Note: a single `--version` argument is handled directly by `Config.main()` to avoid starting up the entire
# pytest infrastructure just to display the version (#13574).
if config.option.version > 1:
show_version_verbose(config)
return ExitCode.OK
elif config.option.help:
config._do_configure()
showhelp(config)
config._ensure_unconfigure()
return 0
return ExitCode.OK
return None


Expand Down
2 changes: 1 addition & 1 deletion testing/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,7 @@ def bad_snap(self):
FDCapture.snap = bad_snap
"""
)
result = pytester.runpytest_subprocess("-p", "pytest_xyz", "--version")
result = pytester.runpytest_subprocess("-p", "pytest_xyz")
result.stderr.fnmatch_lines(
["*in bad_snap", " raise Exception('boom')", "Exception: boom"]
)
Expand Down
14 changes: 4 additions & 10 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,20 +612,14 @@ def pytest_addoption(parser):
assert config.getini("custom") == "1"

def test_absolute_win32_path(self, pytester: Pytester) -> None:
Copy link
Member Author

Choose a reason for hiding this comment

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

I tracked this test to this changelog entry:

* Fix win32 path issue when puttinging custom config file with absolute path 
  in ``pytest.main("-c your_absolute_path")``.

Seems --version being in the config file has nothing to do with the original issue.

temp_ini_file = pytester.makefile(
".ini",
custom="""
[pytest]
addopts = --version
""",
)
temp_ini_file = pytester.makeini("[pytest]")
from os.path import normpath

temp_ini_file_norm = normpath(str(temp_ini_file))
ret = pytest.main(["-c", temp_ini_file_norm])
assert ret == ExitCode.OK
assert ret == ExitCode.NO_TESTS_COLLECTED
ret = pytest.main(["--config-file", temp_ini_file_norm])
assert ret == ExitCode.OK
assert ret == ExitCode.NO_TESTS_COLLECTED


class TestConfigAPI:
Expand Down Expand Up @@ -2121,7 +2115,7 @@ def pytest_addoption(parser):

result = pytester.runpytest("--version")
result.stdout.fnmatch_lines([f"pytest {pytest.__version__}"])
assert result.ret == ExitCode.USAGE_ERROR
assert result.ret == ExitCode.OK


def test_help_formatter_uses_py_get_terminal_width(monkeypatch: MonkeyPatch) -> None:
Expand Down
22 changes: 11 additions & 11 deletions testing/test_helpconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,29 @@ def test_version_verbose(pytester: Pytester, pytestconfig, monkeypatch) -> None:
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD")
monkeypatch.delenv("PYTEST_PLUGINS", raising=False)
result = pytester.runpytest("--version", "--version")
assert result.ret == 0
assert result.ret == ExitCode.OK
result.stdout.fnmatch_lines([f"*pytest*{pytest.__version__}*imported from*"])
if pytestconfig.pluginmanager.list_plugin_distinfo():
result.stdout.fnmatch_lines(["*registered third-party plugins:", "*at*"])


def test_version_less_verbose(pytester: Pytester, pytestconfig, monkeypatch) -> None:
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD")
monkeypatch.delenv("PYTEST_PLUGINS", raising=False)
result = pytester.runpytest("--version")
assert result.ret == 0
result.stdout.fnmatch_lines([f"pytest {pytest.__version__}"])
def test_version_less_verbose(pytester: Pytester) -> None:
"""Single ``--version`` parameter should display only the pytest version, without loading plugins (#13574)."""
pytester.makeconftest("print('This should not be printed')")
result = pytester.runpytest_subprocess("--version")
assert result.ret == ExitCode.OK
assert result.stdout.str().strip() == f"pytest {pytest.__version__}"


def test_versions():
def test_versions() -> None:
"""Regression check for the public version attributes in pytest."""
assert isinstance(pytest.__version__, str)
assert isinstance(pytest.version_tuple, tuple)


def test_help(pytester: Pytester) -> None:
result = pytester.runpytest("--help")
assert result.ret == 0
assert result.ret == ExitCode.OK
result.stdout.fnmatch_lines(
"""
-m MARKEXPR Only run tests matching given mark expression. For
Expand Down Expand Up @@ -73,7 +73,7 @@ def pytest_addoption(parser):
"""
)
result = pytester.runpytest("--help")
assert result.ret == 0
assert result.ret == ExitCode.OK
lines = [
" required_plugins (args):",
" Plugins that must be present for pytest to run*",
Expand All @@ -91,7 +91,7 @@ def pytest_hello(xyz):
"""
)
result = pytester.runpytest()
assert result.ret != 0
assert result.ret != ExitCode.OK
result.stdout.fnmatch_lines(["*unknown hook*pytest_hello*"])


Expand Down