diff --git a/changelog/13574.improvement.rst b/changelog/13574.improvement.rst new file mode 100644 index 00000000000..7820cd03fac --- /dev/null +++ b/changelog/13574.improvement.rst @@ -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`. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 0248f046407..d076a59c6ac 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -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) @@ -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] @@ -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 ): diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index b5ac0e6a50c..ee2c5a5541e 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -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 diff --git a/testing/test_capture.py b/testing/test_capture.py index d9dacebd938..1f0625bedce 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -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"] ) diff --git a/testing/test_config.py b/testing/test_config.py index 3e8635fd1fc..f5a730db3fc 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -612,20 +612,14 @@ def pytest_addoption(parser): assert config.getini("custom") == "1" def test_absolute_win32_path(self, pytester: Pytester) -> None: - 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: @@ -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: diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index dc7e709b65d..455ed5276a8 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -10,21 +10,21 @@ 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) @@ -32,7 +32,7 @@ def test_versions(): 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 @@ -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*", @@ -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*"])