Skip to content

Commit 7d850cf

Browse files
authored
Print reduced pants test ... command to rerun, when on CI (#20747)
This augments the existing test summary output to show an appropriate invocation for rerunning any failed tests. This is controlled by the new `[test].show_rerun_command` option, which defaults to: - off for "local" dev: since just rerunning the exact same command will generally already only rerun the failures, due to caching - on in CI: since people often won't be sharing a cache with the CI machines, so rerunning some bulk `pants test ::` command may need to chug through all the successful tests too For instance, given three tests, where two fail: ``` ✓ //:good-but-slow succeeded in 123.00s (run locally). ✕ //:bad1 failed in 2.00s (run locally). ✕ path/to:bad2 failed in 3.00s (run locally). To rerun the failing tests, use: pants test //:bad1 path/to:bad2 ``` If this appears in CI a dev can copy and paste that invocation to just those two bad tests locally, without having to spend time running `//:good-but-slow`. Currently, without this suggested invocation, the dev would have to copy and paste each target line from the individual summary lines. With a lot of failures, the line might be very long, but I think that's okay: it should still be copy-paste-able just fine. Potentially it'd be good to do this for other goals too (e.g. after `pants fix ::`, `To fix the problematic files, try: pants fix ...`), but I'm not sure we have a generic infrastructure that would make this easy, and usually those goals will be faster than tests.
1 parent e23dc0a commit 7d850cf

File tree

2 files changed

+168
-30
lines changed

2 files changed

+168
-30
lines changed

src/python/pants/core/goals/test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import itertools
77
import logging
8+
import os
9+
import shlex
810
from abc import ABC, ABCMeta
911
from dataclasses import dataclass, field
1012
from enum import Enum
@@ -663,6 +665,24 @@ class EnvironmentAware:
663665
),
664666
)
665667

668+
show_rerun_command = BoolOption(
669+
default="CI" in os.environ,
670+
advanced=True,
671+
help=softwrap(
672+
f"""
673+
If tests fail, show an appropriate `{bin_name()} {name} ...` invocation to rerun just
674+
those tests.
675+
676+
This is to make it easy to run those tests on a new machine (for instance, run tests
677+
locally if they fail in CI): caching of successful tests means that rerunning the exact
678+
same command on the same machine will already automatically only rerun the failures.
679+
680+
This defaults to `True` when running in CI (as determined by the `CI` environment
681+
variable being set) but `False` elsewhere.
682+
"""
683+
),
684+
)
685+
666686
def report_dir(self, distdir: DistDir) -> PurePath:
667687
return PurePath(self._report_dir.format(distdir=distdir.relpath))
668688

@@ -943,6 +963,10 @@ async def run_tests(
943963
f"Wrote extra output from test `{result.addresses[0]}` to `{path_prefix}`."
944964
)
945965

966+
rerun_command = _format_test_rerun_command(results)
967+
if rerun_command and test_subsystem.show_rerun_command:
968+
console.print_stderr(f"\n{rerun_command}")
969+
946970
if test_subsystem.report:
947971
report_dir = test_subsystem.report_dir(distdir)
948972
merged_reports = await Get(
@@ -1066,6 +1090,19 @@ def _format_test_summary(result: TestResult, run_id: RunId, console: Console) ->
10661090
return f"{sigil} {result.description} {status}{attempt_msg} {elapsed_print}{source_desc}."
10671091

10681092

1093+
def _format_test_rerun_command(results: Iterable[TestResult]) -> None | str:
1094+
failures = [result for result in results if result.exit_code not in (None, 0)]
1095+
if not failures:
1096+
return None
1097+
1098+
# format an invocation like `pants test path/to/first:address path/to/second:address ...`
1099+
addresses = sorted(shlex.quote(str(addr)) for result in failures for addr in result.addresses)
1100+
goal = f"{bin_name()} {TestSubsystem.name}"
1101+
invocation = " ".join([goal, *addresses])
1102+
1103+
return f"To rerun the failing tests, use:\n\n {invocation}"
1104+
1105+
10691106
@dataclass(frozen=True)
10701107
class TestExtraEnv:
10711108
env: EnvironmentVars

src/python/pants/core/goals/test_test.py

Lines changed: 131 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
TestResult,
3434
TestSubsystem,
3535
TestTimeoutField,
36+
_format_test_rerun_command,
3637
_format_test_summary,
3738
build_runtime_package_dependencies,
3839
run_tests,
@@ -52,6 +53,7 @@
5253
EMPTY_DIGEST,
5354
EMPTY_FILE_DIGEST,
5455
Digest,
56+
FileDigest,
5557
MergeDigests,
5658
Snapshot,
5759
Workspace,
@@ -111,6 +113,31 @@ def make_process_result_metadata(
111113
)
112114

113115

116+
def make_test_result(
117+
addresses: Iterable[Address],
118+
exit_code: None | int,
119+
stdout_bytes: bytes = b"",
120+
stdout_digest: FileDigest = EMPTY_FILE_DIGEST,
121+
stderr_bytes: bytes = b"",
122+
stderr_digest: FileDigest = EMPTY_FILE_DIGEST,
123+
coverage_data: CoverageData | None = None,
124+
output_setting: ShowOutput = ShowOutput.NONE,
125+
result_metadata: None | ProcessResultMetadata = None,
126+
) -> TestResult:
127+
"""Create a TestResult with default values for most fields."""
128+
return TestResult(
129+
addresses=tuple(addresses),
130+
exit_code=exit_code,
131+
stdout_bytes=stdout_bytes,
132+
stdout_digest=stdout_digest,
133+
stderr_bytes=stderr_bytes,
134+
stderr_digest=stderr_digest,
135+
coverage_data=coverage_data,
136+
output_setting=output_setting,
137+
result_metadata=result_metadata,
138+
)
139+
140+
114141
class MockMultipleSourcesField(MultipleSourcesField):
115142
pass
116143

@@ -178,13 +205,9 @@ def skipped(_: Iterable[Address]) -> bool:
178205
@classmethod
179206
def test_result(cls, field_sets: Iterable[MockTestFieldSet]) -> TestResult:
180207
addresses = [field_set.address for field_set in field_sets]
181-
return TestResult(
208+
return make_test_result(
209+
addresses,
182210
exit_code=cls.exit_code(addresses),
183-
stdout_bytes=b"",
184-
stdout_digest=EMPTY_FILE_DIGEST,
185-
stderr_bytes=b"",
186-
stderr_digest=EMPTY_FILE_DIGEST,
187-
addresses=tuple(addresses),
188211
coverage_data=MockCoverageData(addresses),
189212
output_setting=ShowOutput.ALL,
190213
result_metadata=None if cls.skipped(addresses) else make_process_result_metadata("ran"),
@@ -247,6 +270,7 @@ def run_test_rule(
247270
report_dir: str = TestSubsystem.default_report_path,
248271
output: ShowOutput = ShowOutput.ALL,
249272
valid_targets: bool = True,
273+
show_rerun_command: bool = False,
250274
run_id: RunId = RunId(999),
251275
) -> tuple[int, str]:
252276
test_subsystem = create_goal_subsystem(
@@ -261,6 +285,7 @@ def run_test_rule(
261285
extra_env_vars=[],
262286
shard="",
263287
batch_size=1,
288+
show_rerun_command=show_rerun_command,
264289
)
265290
debug_adapter_subsystem = create_subsystem(
266291
DebugAdapterSubsystem,
@@ -408,7 +433,39 @@ def test_skipped_target_noops(rule_runner: PythonRuleRunner) -> None:
408433
assert stderr.strip() == ""
409434

410435

411-
def test_summary(rule_runner: PythonRuleRunner) -> None:
436+
@pytest.mark.parametrize(
437+
("show_rerun_command", "expected_stderr"),
438+
[
439+
(
440+
False,
441+
# the summary is for humans, so we test it literally, to make sure the formatting is good
442+
dedent(
443+
"""\
444+
445+
✓ //:good succeeded in 1.00s (memoized).
446+
✕ //:bad failed in 1.00s (memoized).
447+
"""
448+
),
449+
),
450+
(
451+
True,
452+
dedent(
453+
"""\
454+
455+
✓ //:good succeeded in 1.00s (memoized).
456+
✕ //:bad failed in 1.00s (memoized).
457+
458+
To rerun the failing tests, use:
459+
460+
pants test //:bad
461+
"""
462+
),
463+
),
464+
],
465+
)
466+
def test_summary(
467+
rule_runner: PythonRuleRunner, show_rerun_command: bool, expected_stderr: str
468+
) -> None:
412469
good_address = Address("", target_name="good")
413470
bad_address = Address("", target_name="bad")
414471
skipped_address = Address("", target_name="skipped")
@@ -417,15 +474,10 @@ def test_summary(rule_runner: PythonRuleRunner) -> None:
417474
rule_runner,
418475
request_type=ConditionallySucceedsRequest,
419476
targets=[make_target(good_address), make_target(bad_address), make_target(skipped_address)],
477+
show_rerun_command=show_rerun_command,
420478
)
421479
assert exit_code == ConditionallySucceedsRequest.exit_code((bad_address,))
422-
assert stderr == dedent(
423-
"""\
424-
425-
✓ //:good succeeded in 1.00s (memoized).
426-
✕ //:bad failed in 1.00s (memoized).
427-
"""
428-
)
480+
assert stderr == expected_stderr
429481

430482

431483
def _assert_test_summary(
@@ -436,15 +488,11 @@ def _assert_test_summary(
436488
result_metadata: ProcessResultMetadata | None,
437489
) -> None:
438490
assert expected == _format_test_summary(
439-
TestResult(
491+
make_test_result(
492+
[Address(spec_path="", target_name="dummy_address")],
440493
exit_code=exit_code,
441-
stdout_bytes=b"",
442-
stderr_bytes=b"",
443-
stdout_digest=EMPTY_FILE_DIGEST,
444-
stderr_digest=EMPTY_FILE_DIGEST,
445-
addresses=(Address(spec_path="", target_name="dummy_address"),),
446-
output_setting=ShowOutput.FAILED,
447494
result_metadata=result_metadata,
495+
output_setting=ShowOutput.FAILED,
448496
),
449497
RunId(run_id),
450498
Console(use_colors=False),
@@ -493,6 +541,64 @@ def test_format_summary_memoized_remote(rule_runner: PythonRuleRunner) -> None:
493541
)
494542

495543

544+
@pytest.mark.parametrize(
545+
("results", "expected"),
546+
[
547+
pytest.param([], None, id="no_results"),
548+
pytest.param(
549+
[make_test_result([Address("", target_name="t1")], exit_code=0)], None, id="one_success"
550+
),
551+
pytest.param(
552+
[make_test_result([Address("", target_name="t2")], exit_code=None)],
553+
None,
554+
id="one_no_run",
555+
),
556+
pytest.param(
557+
[make_test_result([Address("", target_name="t3")], exit_code=1)],
558+
"To rerun the failing tests, use:\n\n pants test //:t3",
559+
id="one_failure",
560+
),
561+
pytest.param(
562+
[
563+
make_test_result([Address("", target_name="t1")], exit_code=0),
564+
make_test_result([Address("", target_name="t2")], exit_code=None),
565+
make_test_result([Address("", target_name="t3")], exit_code=1),
566+
],
567+
"To rerun the failing tests, use:\n\n pants test //:t3",
568+
id="one_of_each",
569+
),
570+
pytest.param(
571+
[
572+
make_test_result([Address("path/to", target_name="t1")], exit_code=1),
573+
make_test_result([Address("another/path", target_name="t2")], exit_code=2),
574+
make_test_result([Address("", target_name="t3")], exit_code=3),
575+
],
576+
"To rerun the failing tests, use:\n\n pants test //:t3 another/path:t2 path/to:t1",
577+
id="multiple_failures",
578+
),
579+
pytest.param(
580+
[
581+
make_test_result(
582+
[
583+
Address(
584+
"path with spaces",
585+
target_name="$*",
586+
parameters=dict(key="value"),
587+
generated_name="gn",
588+
)
589+
],
590+
exit_code=1,
591+
)
592+
],
593+
"To rerun the failing tests, use:\n\n pants test 'path with spaces:$*#gn@key=value'",
594+
id="special_characters_require_quoting",
595+
),
596+
],
597+
)
598+
def test_format_rerun_command(results: list[TestResult], expected: None | str) -> None:
599+
assert expected == _format_test_rerun_command(results)
600+
601+
496602
def test_debug_target(rule_runner: PythonRuleRunner) -> None:
497603
exit_code, _ = run_test_rule(
498604
rule_runner,
@@ -597,14 +703,12 @@ def assert_streaming_output(
597703
expected_message: str,
598704
result_metadata: ProcessResultMetadata = make_process_result_metadata("dummy"),
599705
) -> None:
600-
result = TestResult(
706+
result = make_test_result(
707+
addresses=(Address("demo_test"),),
601708
exit_code=exit_code,
602709
stdout_bytes=stdout.encode(),
603-
stdout_digest=EMPTY_FILE_DIGEST,
604710
stderr_bytes=stderr.encode(),
605-
stderr_digest=EMPTY_FILE_DIGEST,
606711
output_setting=output_setting,
607-
addresses=(Address("demo_test"),),
608712
result_metadata=result_metadata,
609713
)
610714
assert result.level() == expected_level
@@ -720,14 +824,11 @@ def assert_timeout_calculated(
720824

721825

722826
def test_non_utf8_output() -> None:
723-
test_result = TestResult(
827+
test_result = make_test_result(
828+
[],
724829
exit_code=1, # "test error" so stdout/stderr are output in message
725830
stdout_bytes=b"\x80\xBF", # invalid UTF-8 as required by the test
726-
stdout_digest=EMPTY_FILE_DIGEST, # incorrect but we do not check in this test
727831
stderr_bytes=b"\x80\xBF", # invalid UTF-8 as required by the test
728-
stderr_digest=EMPTY_FILE_DIGEST, # incorrect but we do not check in this test
729-
addresses=(),
730832
output_setting=ShowOutput.ALL,
731-
result_metadata=None,
732833
)
733834
assert test_result.message() == "failed (exit code 1).\n��\n��\n\n"

0 commit comments

Comments
 (0)