From f81686c27e90e38bfb93a6c147791a52d3a0371a Mon Sep 17 00:00:00 2001 From: starx710 <342042680+starx710@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:55:36 +0800 Subject: [PATCH] Avoid live-log verbosity changes in sessions without local tests Keep controller progress at the requested verbosity while retaining serial live logging. Refs pytest-dev/pytest-xdist#1249. Co-authored-by: OpenAI Codex --- AUTHORS | 1 + changelog/0.bugfix.rst | 3 + src/_pytest/logging.py | 7 +- testing/logging/test_reporting.py | 110 ++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 changelog/0.bugfix.rst diff --git a/AUTHORS b/AUTHORS index bd1e27cb37d..6103999ccc8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -466,6 +466,7 @@ Simon Holesch Simon Kerr Skylar Downes Srinivas Reddy Thatiparthy +starx710 Stefaan Lippens Stefan Farmbauer Stefan Scherfke diff --git a/changelog/0.bugfix.rst b/changelog/0.bugfix.rst new file mode 100644 index 00000000000..a86412811c2 --- /dev/null +++ b/changelog/0.bugfix.rst @@ -0,0 +1,3 @@ +Live logging no longer increases progress verbosity when the current process has +no local tests to run, such as a pytest-xdist controller. Serial live logging +continues to show the current test and its outcome. diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 6e80e6ba353..8e524723dd1 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -820,8 +820,13 @@ def pytest_runtestloop(self, session: Session) -> Generator[None, object, object if session.config.option.collectonly: return (yield) - if self._log_cli_enabled() and self._config.get_verbosity() < 1: + if ( + session.items + and self._log_cli_enabled() + and self._config.get_verbosity() < 1 + ): # The verbose flag is needed to avoid messy test progress output. + # A distributed controller has no local items whose logs need it. self._config.option.verbose = 1 with catching_logs(self.log_cli_handler, level=self.log_cli_level): diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 165d774435d..71f31ac3889 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -253,6 +253,116 @@ def test_log_cli(): assert msg not in result.stdout.str() +@pytest.mark.parametrize("log_args", [["-o", "log_cli=true"], ["--log-cli-level=INFO"]]) +@pytest.mark.parametrize("verbosity", [[], ["-q"], ["-v"]]) +def test_log_cli_without_local_tests( + pytester: Pytester, log_args: list[str], verbosity: list[str] +) -> None: + pytester.makeconftest( + """ + import logging + from pathlib import Path + + def pytest_runtestloop(session): + logging.warning("message without local tests") + + def pytest_sessionfinish(session): + Path("verbosity").write_text(str(session.config.option.verbose), encoding="utf-8") + """ + ) + result = pytester.runpytest( + *log_args, *verbosity, "--log-file=hooks.log", "--log-file-level=WARNING" + ) + assert result.ret == ExitCode.NO_TESTS_COLLECTED + assert (pytester.path / "verbosity").read_text(encoding="utf-8") == str( + verbosity.count("-v") - verbosity.count("-q") + ) + result.stdout.fnmatch_lines(["*message without local tests*"]) + assert "message without local tests" in (pytester.path / "hooks.log").read_text( + encoding="utf-8" + ) + + +@pytest.mark.parametrize("log_args", [["-o", "log_cli=true"], ["--log-cli-level=INFO"]]) +@pytest.mark.parametrize("verbosity", [[], ["-q"], ["-qq"], ["-v"], ["-vv"]]) +def test_log_cli_xdist_verbosity( + pytester: Pytester, log_args: list[str], verbosity: list[str] +) -> None: + pytest.importorskip("xdist") + pytester.makeconftest( + """ + from pathlib import Path + + def pytest_sessionfinish(session): + if not hasattr(session.config, "workerinput"): + Path("verbosity").write_text(str(session.config.option.verbose), encoding="utf-8") + """ + ) + pytester.makepyfile( + """ + import logging + + def test_one(caplog): + logging.warning("worker log remains captured") + assert "worker log remains captured" in caplog.text + + def test_two(): + pass + """ + ) + result = pytester.runpytest_subprocess( + "-p", "xdist.plugin", "-n2", *log_args, *verbosity + ) + assert result.ret == ExitCode.OK + if verbosity != ["-qq"]: + result.assert_outcomes(passed=2) + expected_verbosity = {"-q": -1, "-qq": -2, "-v": 1, "-vv": 2} + assert (pytester.path / "verbosity").read_text(encoding="utf-8") == str( + expected_verbosity.get(verbosity[0], 0) if verbosity else 0 + ) + if verbosity in (["-v"], ["-vv"]): + result.stdout.fnmatch_lines(["*PASSED*test_log_cli_xdist_verbosity.py::test_*"]) + else: + result.stdout.re_match_lines([r"\.\.\s+\[100%\]"]) + result.stdout.no_fnmatch_line("*PASSED*test_log_cli_xdist_verbosity.py::test_*") + if verbosity == ["-qq"]: + result.stdout.no_fnmatch_line("*2 passed*") + + +@pytest.mark.parametrize("log_args", [["-o", "log_cli=true"], ["--log-cli-level=INFO"]]) +@pytest.mark.parametrize( + ("dist_args", "distributed"), + [ + (["-n1"], True), + (["--dist=load", "--tx=popen"], True), + (["-n0"], False), + (["--dist=no"], False), + ], +) +def test_log_cli_xdist_execution_modes( + pytester: Pytester, log_args: list[str], dist_args: list[str], distributed: bool +) -> None: + pytest.importorskip("xdist") + pytester.makepyfile( + """ + import logging + + def test_log(): + logging.warning("message from test") + """ + ) + result = pytester.runpytest_subprocess("-p", "xdist.plugin", *dist_args, *log_args) + result.assert_outcomes(passed=1) + assert result.ret == ExitCode.OK + if distributed: + result.stdout.re_match_lines([r"\.\s+\[100%\]"]) + result.stdout.no_fnmatch_line( + "*PASSED*test_log_cli_xdist_execution_modes.py::test_log*" + ) + else: + result.stdout.fnmatch_lines(["*message from test*", "PASSED*"]) + + def test_log_cli_default_level(pytester: Pytester) -> None: # Default log file level pytester.makepyfile(