PY-88533 Capture logging output — show only for failing tests

Enable skippassedoutput in teamcity pytest plugin. Pytest's built-in
log capture collects logging output per test. With skippassedoutput,
log output only appears for failing tests.

(cherry picked from commit dab8b63d5221e76ab8a53d37396df9c3ee777464)

IJ-MR-206446

GitOrigin-RevId: b33b2e1b75910cd128fdfff6f22f617317b947fa
This commit is contained in:
Anton Efimchuk
2026-07-08 18:21:15 +00:00
committed by intellij-monorepo-bot
parent 8ea37bb823
commit 7ab4e19b14
8 changed files with 189 additions and 3 deletions
@@ -25,6 +25,8 @@ from teamcity import pytest_plugin
import os
_DOCTEST_MODULES_ARG = "--doctest-modules"
_JB_SKIP_PASSED_OUTPUT_DEFAULT_ARG = "--jb-skippassedoutput-default"
_JB_REPORT_LOGS_AS_TEST_LOG_ARG = "--jb-report-logs-as-test-log"
def _add_module_to_target(module_name, python_parts):
# Doctest: Find the fully qualified name of the target module by checking each
@@ -42,6 +44,13 @@ def _add_module_to_target(module_name, python_parts):
if __name__ == '__main__':
skip_passed_output_default = _JB_SKIP_PASSED_OUTPUT_DEFAULT_ARG in sys.argv
while _JB_SKIP_PASSED_OUTPUT_DEFAULT_ARG in sys.argv:
sys.argv.remove(_JB_SKIP_PASSED_OUTPUT_DEFAULT_ARG)
report_logs_as_test_log = _JB_REPORT_LOGS_AS_TEST_LOG_ARG in sys.argv
while _JB_REPORT_LOGS_AS_TEST_LOG_ARG in sys.argv:
sys.argv.remove(_JB_REPORT_LOGS_AS_TEST_LOG_ARG)
path, targets, additional_args = parse_arguments()
sys.argv += additional_args
@@ -65,6 +74,11 @@ if __name__ == '__main__':
plugins_to_load.append(pytest_plugin)
args = sys.argv[1:]
if skip_passed_output_default:
pytest_plugin.set_skip_passed_output_default(True)
if report_logs_as_test_log:
pytest_plugin.set_report_logs_as_test_log(True)
if "--jb-show-summary" in args:
args.remove("--jb-show-summary")
elif int(pytest.__version__.split('.')[0]) >= 6:
@@ -21,6 +21,7 @@ import com.intellij.util.execution.ParametersListUtil
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PythonHelper
import com.jetbrains.python.psi.resolve.PackageAvailabilitySpec
import com.jetbrains.python.run.PythonScriptExecution
import com.jetbrains.python.run.target.HelpersAwareTargetEnvironmentRequest
import com.jetbrains.python.run.targetBasedConfiguration.PyRunTargetVariant
import com.jetbrains.python.testing.PyTestSharedForm.create
@@ -50,11 +51,22 @@ class PyPyTestExecutionEnvironment(configuration: PyTestConfiguration, environme
}
override fun customizePythonExecutionEnvironmentVars(helpersAwareTargetRequest: HelpersAwareTargetEnvironmentRequest,
envs: MutableMap<String, TargetEnvironmentFunction<String>>,
passParentEnvs: Boolean) {
envs: MutableMap<String, TargetEnvironmentFunction<String>>,
passParentEnvs: Boolean) {
super.customizePythonExecutionEnvironmentVars(helpersAwareTargetRequest, envs, passParentEnvs)
envs[PYTEST_RUN_CONFIG] = constant("True")
}
override fun addAfterParameters(cmd: GeneralCommandLine) {
cmd.parametersList.getParamsGroup(GROUP_SCRIPT)?.addParameter(PYTEST_SKIP_PASSED_OUTPUT_DEFAULT_ARG)
cmd.parametersList.getParamsGroup(GROUP_SCRIPT)?.addParameter(PYTEST_REPORT_LOGS_AS_TEST_LOG_ARG)
}
override fun addAfterParameters(targetEnvironmentRequest: TargetEnvironmentRequest,
testScriptExecution: PythonScriptExecution) {
testScriptExecution.addParameter(constant(PYTEST_SKIP_PASSED_OUTPUT_DEFAULT_ARG))
testScriptExecution.addParameter(constant(PYTEST_REPORT_LOGS_AS_TEST_LOG_ARG))
}
}
@@ -150,3 +162,5 @@ class PyTestFactory(type: PythonTestConfigurationType) : PyAbstractTestFactory<P
}
private const val PYTEST_RUN_CONFIG: String = "PYTEST_RUN_CONFIG"
private const val PYTEST_SKIP_PASSED_OUTPUT_DEFAULT_ARG: String = "--jb-skippassedoutput-default"
private const val PYTEST_REPORT_LOGS_AS_TEST_LOG_ARG: String = "--jb-report-logs-as-test-log"
@@ -3,22 +3,34 @@ package com.jetbrains.python.testing;
import com.intellij.execution.Executor;
import com.intellij.execution.configurations.ModuleRunConfiguration;
import com.intellij.execution.testframework.TestConsoleProperties;
import com.intellij.execution.testframework.sm.SMCustomMessagesParsing;
import com.intellij.execution.testframework.sm.runner.GeneralIdBasedToSMTRunnerEventsConvertor;
import com.intellij.execution.testframework.sm.runner.OutputToGeneralTestEventsConverter;
import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties;
import com.intellij.execution.testframework.sm.runner.SMTRunnerEventsAdapter;
import com.intellij.execution.testframework.sm.runner.SMTRunnerEventsListener;
import com.intellij.execution.testframework.sm.runner.SMTestLocator;
import com.intellij.execution.testframework.sm.runner.SMTestProxy;
import com.intellij.execution.testframework.sm.runner.events.TestDurationStrategy;
import com.intellij.execution.testframework.sm.runner.events.TestOutputEvent;
import com.jetbrains.python.PyBundle;
import jetbrains.buildServer.messages.serviceMessages.ServiceMessage;
import jetbrains.buildServer.messages.serviceMessages.ServiceMessageVisitor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.text.ParseException;
/**
* @author Roman.Chernyatchik
*/
public class PythonTRunnerConsoleProperties extends SMTRunnerConsoleProperties {
public class PythonTRunnerConsoleProperties extends SMTRunnerConsoleProperties implements SMCustomMessagesParsing {
public static final String FRAMEWORK_NAME = "PythonUnitTestRunner";
private static final String TEST_LOG_SERVICE_MESSAGE = "testLog";
private static final String NAME_ATTRIBUTE = "name";
private static final String NODE_ID_ATTRIBUTE = "nodeId";
private static final String OUT_ATTRIBUTE = "out";
private final boolean myIsEditable;
private final SMTestLocator myLocator;
@@ -45,6 +57,12 @@ public class PythonTRunnerConsoleProperties extends SMTRunnerConsoleProperties {
return myLocator;
}
@Override
public OutputToGeneralTestEventsConverter createTestEventsConverter(@NotNull String testFrameworkName,
@NotNull TestConsoleProperties consoleProperties) {
return new PythonOutputToGeneralTestEventsConverter(testFrameworkName, consoleProperties);
}
/**
* Makes configuration id-based,
*
@@ -79,4 +97,27 @@ public class PythonTRunnerConsoleProperties extends SMTRunnerConsoleProperties {
return PyBundle.message("runcfg.tests.empty_suite");
}
}
private static final class PythonOutputToGeneralTestEventsConverter extends OutputToGeneralTestEventsConverter {
private PythonOutputToGeneralTestEventsConverter(@NotNull String testFrameworkName,
@NotNull TestConsoleProperties consoleProperties) {
super(testFrameworkName, consoleProperties);
}
@Override
protected void processServiceMessage(@NotNull ServiceMessage message,
@NotNull ServiceMessageVisitor visitor) throws ParseException {
if (TEST_LOG_SERVICE_MESSAGE.equals(message.getMessageName())) {
String testName = message.getAttributes().get(NAME_ATTRIBUTE);
String nodeId = message.getAttributes().get(NODE_ID_ATTRIBUTE);
String output = message.getAttributes().get(OUT_ATTRIBUTE);
var processor = getProcessor();
if (testName != null && output != null && processor != null) {
processor.onTestOutput(new TestOutputEvent(testName, nodeId, output, true));
}
return;
}
super.processServiceMessage(message, visitor);
}
}
}
@@ -0,0 +1,2 @@
[pytest]
skippassedoutput = false
@@ -0,0 +1,7 @@
import logging
logger = logging.getLogger(__name__)
def test_fail_with_logging():
logger.warning("warning_from_failing_test")
assert False, "intentional failure"
@@ -0,0 +1,7 @@
import logging
logger = logging.getLogger(__name__)
def test_pass_with_logging():
logger.warning("warning_from_passing_test")
assert True
@@ -0,0 +1,10 @@
import os
import logging
logger = logging.getLogger(__name__)
def test_pass_with_logging(pytestconfig):
assert "PYTEST_TEAMCITY_SKIP_PASSED_OUTPUT_DEFAULT" not in os.environ
assert pytestconfig.getini("skippassedoutput") is True
logger.warning("warning_from_passing_test")
assert True
@@ -1413,6 +1413,97 @@ public final class PythonPyTestingTest extends PyEnvTestCase {
});
}
@Test
public void testLoggingCapturedForFailingTest() {
runPythonTest(new PyProcessWithConsoleTestTask<PyTestTestProcessRunner>("/testRunner/env/pytest/capture_logging", SdkCreationType.EMPTY_SDK) {
@NotNull
@Override
protected PyTestTestProcessRunner createProcessRunner() {
return new PyTestTestProcessRunner("test_logging_fail.py", 0);
}
@Override
protected void checkTestResults(@NotNull final PyTestTestProcessRunner runner,
@NotNull final String stdout,
@NotNull final String stderr,
@NotNull final String all,
int exitCode) {
var consoleText = runner.getAllConsoleText();
Assertions.assertThat(consoleText.split("\n|(\r\n)"))
.containsSubsequence(
"test_logging_fail.py::test_fail_with_logging FAILED [100%]",
"WARNING test_logging_fail:test_logging_fail.py:6 warning_from_failing_test"
);
MatcherAssert.assertThat("Captured logs should use a dedicated service message",
all, containsString("[testLog"));
MatcherAssert.assertThat("Captured logs should not be reported as stdout",
all, not(containsString("[testStdOut")));
}
});
}
@Test
public void testLoggingSkippedForPassingTest() {
runPythonTest(new PyProcessWithConsoleTestTask<PyTestTestProcessRunner>("/testRunner/env/pytest/capture_logging_default", SdkCreationType.EMPTY_SDK) {
@NotNull
@Override
protected PyTestTestProcessRunner createProcessRunner() {
return new PyTestTestProcessRunner("test_logging_pass.py", 0);
}
@Override
protected void checkTestResults(@NotNull final PyTestTestProcessRunner runner,
@NotNull final String stdout,
@NotNull final String stderr,
@NotNull final String all,
int exitCode) {
var consoleText = runner.getAllConsoleText();
Assertions.assertThat(consoleText)
.doesNotContain("warning_from_passing_test");
MatcherAssert.assertThat("Captured logs from passing tests should not leak into the process output",
all, not(containsString("warning_from_passing_test")));
}
});
}
@Test
public void testLoggingCapturedForPassingTestWhenConfigured() {
runPythonTest(new PyProcessWithConsoleTestTask<PyTestTestProcessRunner>("/testRunner/env/pytest/capture_logging", SdkCreationType.EMPTY_SDK) {
@NotNull
@Override
protected PyTestTestProcessRunner createProcessRunner() {
return new PyTestTestProcessRunner("test_logging_pass.py", 0) {
@Override
protected void configurationCreatedAndWillLaunch(@NotNull PyTestConfiguration configuration) throws IOException {
super.configurationCreatedAndWillLaunch(configuration);
configuration.setAdditionalArguments("-c pytest.ini");
}
};
}
@Override
protected void checkTestResults(@NotNull final PyTestTestProcessRunner runner,
@NotNull final String stdout,
@NotNull final String stderr,
@NotNull final String all,
int exitCode) {
var consoleText = runner.getAllConsoleText();
Assertions.assertThat(consoleText.split("\n|(\r\n)"))
.containsSubsequence(
"test_logging_pass.py::test_pass_with_logging PASSED [100%]",
"WARNING test_logging_pass:test_logging_pass.py:6 warning_from_passing_test"
);
MatcherAssert.assertThat("Captured logs should use a dedicated service message",
all, containsString("[testLog"));
MatcherAssert.assertThat("Captured logs should not be reported as stdout",
all, not(containsString("[testStdOut")));
}
});
}
@NotNull
private static String getFrameworkId() {
return PyTestFactory.id;