💅 Make the output easier to scan

This patch adds colors to the gate report by highlighting voting
lines with green and red color in the ANSI console output.
It also moves crosses and checkmarks in front of the job names while
showing true job statuses next to their text status labels.

Resolves #31
This commit is contained in:
Sviatoslav Sydorenko
2026-08-22 01:06:11 +02:00
parent e68df088d4
commit 3967c81187
3 changed files with 333 additions and 37 deletions
+52 -16
View File
@@ -104,6 +104,34 @@ _STATUS_LABELS: dict[job_outcome.JobRequirement, str] = {
job_outcome.JobRequirement.ALLOWED_EITHER: 'allowed to fail',
}
_RESULT_SYMBOLS: dict[job_outcome.JobResult, str] = {
'success': '🟢',
'failure': '🔴',
'skipped': '',
'cancelled': '',
}
_ANSI_GREEN = '\x1b[32m'
_ANSI_RED = '\x1b[31m'
_ANSI_RESET = '\x1b[0m'
def _ansi_color_enabled() -> bool:
"""Decide whether to colorize console output.
Respects `https://no-color.org`__: presence of :envvar:`NO_COLOR`
disables color regardless of its value.
"""
return 'NO_COLOR' not in os.environ
def _colorize_line(*, text: str, acceptable: bool) -> str:
"""Wrap a whole line in ANSI color if enabled, based on acceptability."""
if not _ansi_color_enabled():
return text
color = _ANSI_GREEN if acceptable else _ANSI_RED
return f'{color}{text}{_ANSI_RESET}'
def log_decision_details(
*,
@@ -111,7 +139,8 @@ def log_decision_details(
jobs_allowed_to_fail: _t.AbstractSet[str],
jobs_allowed_to_be_skipped: _t.AbstractSet[str],
verdicts: list[job_outcome.JobVerdict],
summary_file_streams: _t.Iterable[_t.TextIO],
summary_file: _t.TextIO,
console_file: _t.TextIO,
) -> None:
"""Record the decisions made into console output."""
allowed_to_fail_jobs_succeeded = all(
@@ -163,21 +192,30 @@ def log_decision_details(
markdown_summary_lines += {
'🔮 Job statuses:',
}
write_lines_to_streams(
markdown_summary_lines,
(console_file, summary_file),
)
plain_job_lines: list[str] = []
console_job_lines: list[str] = []
for verdict in verdicts:
markdown_summary_lines += {
'📝 {name}{emoji} {result} [{status}]'.format(
emoji=''
if verdict.result == 'success'
else ''
if verdict.result == 'failure'
else '',
name=verdict.name,
result=verdict.result,
status=_STATUS_LABELS[verdict.requirement],
plain_verdict_line = (
f'{"" if verdict.acceptable else ""} '
f'{verdict.name}{_RESULT_SYMBOLS[verdict.result]} '
f'{verdict.result} [{_STATUS_LABELS[verdict.requirement]}]'
)
plain_job_lines += {plain_verdict_line}
console_job_lines += {
_colorize_line(
text=plain_verdict_line,
acceptable=verdict.acceptable,
),
}
write_lines_to_streams(markdown_summary_lines, summary_file_streams)
write_lines_to_streams(plain_job_lines, (summary_file,))
write_lines_to_streams(console_job_lines, (console_file,))
def main(argv: list[str]) -> int:
@@ -228,10 +266,8 @@ def main(argv: list[str]) -> int:
jobs_allowed_to_fail=jobs_allowed_to_fail,
jobs_allowed_to_be_skipped=jobs_allowed_to_be_skipped,
verdicts=verdicts,
summary_file_streams=(
sys.stderr,
_t.cast('_t.TextIO', summary_file),
),
summary_file=_t.cast('_t.TextIO', summary_file),
console_file=_t.cast('_t.TextIO', sys.stderr),
)
return int(not job_matrix_succeeded)
+260
View File
@@ -0,0 +1,260 @@
"""Unit tests for ``log_decision_details``'s status-display formatting."""
import io
import pytest
from job_outcome import JobRequirement, JobVerdict
from normalize_needed_jobs_status import log_decision_details
@pytest.fixture
def summary_file() -> io.StringIO:
"""Make a fresh in-memory summary-file stream."""
return io.StringIO()
@pytest.fixture
def console_file() -> io.StringIO:
"""Make a fresh in-memory console-file stream."""
return io.StringIO()
@pytest.fixture
def no_color(
request: pytest.FixtureRequest,
monkeypatch: pytest.MonkeyPatch,
) -> bool:
"""Indirect fixture: toggle NO_COLOR based on the parametrized value.
:raises: TypeError
""" # noqa: DOC501
enabled = request.param
if not isinstance(enabled, bool): # pragma: no cover
exc_msg = 'Type narrowing the param for MyPy'
raise TypeError(exc_msg)
if enabled:
monkeypatch.setenv('NO_COLOR', '1')
else:
monkeypatch.delenv('NO_COLOR', raising=False)
return enabled
def _invoke(
*,
verdicts: list[JobVerdict],
summary_file: io.StringIO,
console_file: io.StringIO,
) -> None:
log_decision_details(
job_matrix_succeeded=all(verdict.acceptable for verdict in verdicts),
jobs_allowed_to_fail=frozenset(),
jobs_allowed_to_be_skipped=frozenset(),
verdicts=verdicts,
summary_file=summary_file,
console_file=console_file,
)
@pytest.mark.parametrize(
('verdicts', 'expected_symbol'),
(
pytest.param(
[
JobVerdict(
name='job',
result='success',
requirement=JobRequirement.REQUIRED,
acceptable=True,
),
],
'🟢',
id='success',
),
pytest.param(
[
JobVerdict(
name='job',
result='failure',
requirement=JobRequirement.REQUIRED,
acceptable=False,
),
],
'🔴',
id='failure',
),
pytest.param(
[
JobVerdict(
name='job',
result='skipped',
requirement=JobRequirement.REQUIRED,
acceptable=False,
),
],
'',
id='skipped',
),
pytest.param(
[
JobVerdict(
name='job',
result='cancelled',
requirement=JobRequirement.REQUIRED,
acceptable=False,
),
],
'',
id='cancelled',
),
),
)
def test_result_symbol_appears_in_summary(
verdicts: list[JobVerdict],
expected_symbol: str,
summary_file: io.StringIO,
console_file: io.StringIO,
) -> None:
"""Every JobResult maps to its expected status symbol."""
_invoke(
verdicts=verdicts,
summary_file=summary_file,
console_file=console_file,
)
assert expected_symbol in summary_file.getvalue()
@pytest.mark.parametrize(
('verdicts', 'expected_mark', 'unexpected_mark'),
(
pytest.param(
[
JobVerdict(
name='job',
result='skipped',
requirement=JobRequirement.REQUIRED,
acceptable=False,
),
],
'',
'',
id='unacceptable-skip',
),
pytest.param(
[
JobVerdict(
name='job',
result='skipped',
requirement=JobRequirement.REQUIRED,
acceptable=True,
),
],
'',
'',
id='acceptable-skip',
),
pytest.param(
[
JobVerdict(
name='job',
result='failure',
requirement=JobRequirement.REQUIRED,
acceptable=True,
),
],
'',
'',
id='acceptable-failure',
),
pytest.param(
[
JobVerdict(
name='job',
result='failure',
requirement=JobRequirement.REQUIRED,
acceptable=False,
),
],
'',
'',
id='unacceptable-failure',
),
),
)
def test_accept_mark_follows_acceptable_not_result(
verdicts: list[JobVerdict],
expected_mark: str,
unexpected_mark: str,
summary_file: io.StringIO,
console_file: io.StringIO,
) -> None:
"""Ensure The leading mark tracks the acceptable status.
And it's independent of the literal result.
"""
_invoke(
verdicts=verdicts,
summary_file=summary_file,
console_file=console_file,
)
summary_text = summary_file.getvalue()
assert expected_mark in summary_text
assert unexpected_mark not in summary_text
_SAMPLE_UNACCEPTABLE_VERDICTS = [
JobVerdict(
name='job',
result='failure',
requirement=JobRequirement.REQUIRED,
acceptable=False,
),
]
@pytest.mark.parametrize(
'no_color',
(True, False),
indirect=True,
ids=('no-color', 'color'),
)
def test_summary_is_always_plain_regardless_of_no_color(
no_color: bool, # noqa: ARG001 # monkey-patches env vars under the hood
summary_file: io.StringIO,
console_file: io.StringIO,
) -> None:
"""The markdown summary never contains ANSI escapes, NO_COLOR or not."""
_invoke(
verdicts=_SAMPLE_UNACCEPTABLE_VERDICTS,
summary_file=summary_file,
console_file=console_file,
)
assert '\x1b[' not in summary_file.getvalue()
@pytest.mark.parametrize(
'no_color',
(True, False),
indirect=True,
ids=('no-color', 'color'),
)
def test_console_is_colorized_unless_no_color(
no_color: bool,
summary_file: io.StringIO,
console_file: io.StringIO,
) -> None:
"""The console stream gets ANSI color unless NO_COLOR is set."""
_invoke(
verdicts=_SAMPLE_UNACCEPTABLE_VERDICTS,
summary_file=summary_file,
console_file=console_file,
)
console_text = console_file.getvalue()
if no_color:
assert '\x1b[' not in console_text
else:
assert (
'\x1b[31m❌ job → 🔴 failure [required to succeed]\x1b[0m'
in console_text
)
+21 -21
View File
@@ -43,9 +43,9 @@ from normalize_needed_jobs_status import main as _invoke_helper_cli
{
'All of the required dependency jobs succeeded',
'Some of the allowed to fail jobs did not succeed',
'failing-job → failure [allowed to fail]',
'succeeding-job → success [required to succeed]',
'skipped-job → ⬜ skipped [allowed to fail]',
'failing-job → 🔴 failure [allowed to fail]',
'succeeding-job → 🟢 success [required to succeed]',
'skipped-job → ⬜ skipped [allowed to fail]',
},
id='success-despite-failure-and-skip',
),
@@ -121,21 +121,21 @@ from normalize_needed_jobs_status import main as _invoke_helper_cli
{
'Some of the required to succeed jobs failed',
'Some of the allowed to fail jobs did not succeed',
'build-web → success [required to succeed]',
'check-links-book → success [required to succeed]',
'check-links-markdown → failure [allowed to fail]',
'lint-megalinter → success [required to succeed]',
'nightly → failure [allowed to fail]',
'publish-web → ⬜ skipped [required to succeed]',
'test-dotnet → success [required to succeed]',
'test-elixir → success [required to succeed]',
'test-java → success [required to succeed]',
'test-js → success [required to succeed]',
'test-lib → success [required to succeed]',
'test-php → success [required to succeed]',
'test-python → success [required to succeed]',
'test-rust → success [required to succeed]',
'test-rust-main → success [required to succeed]',
'build-web → 🟢 success [required to succeed]',
'check-links-book → 🟢 success [required to succeed]',
'check-links-markdown → 🔴 failure [allowed to fail]',
'lint-megalinter → 🟢 success [required to succeed]',
'nightly → 🔴 failure [allowed to fail]',
'publish-web → ⬜ skipped [required to succeed]',
'test-dotnet → 🟢 success [required to succeed]',
'test-elixir → 🟢 success [required to succeed]',
'test-java → 🟢 success [required to succeed]',
'test-js → 🟢 success [required to succeed]',
'test-lib → 🟢 success [required to succeed]',
'test-php → 🟢 success [required to succeed]',
'test-python → 🟢 success [required to succeed]',
'test-rust → 🟢 success [required to succeed]',
'test-rust-main → 🟢 success [required to succeed]',
},
id='failure-due-to-skip',
),
@@ -156,7 +156,7 @@ from normalize_needed_jobs_status import main as _invoke_helper_cli
'All of the required dependency jobs succeeded',
'All of the allowed to fail dependency jobs succeeded',
'All of the allowed to be skipped dependency jobs succeeded',
'succeeding-job → success [allowed to fail]',
'succeeding-job → 🟢 success [allowed to fail]',
},
id='success-of-all-allowed-to-skip-or-fail',
),
@@ -177,7 +177,7 @@ from normalize_needed_jobs_status import main as _invoke_helper_cli
'All of the required dependency jobs succeeded',
'Some of the allowed to fail jobs did not succeed',
'Some of the allowed to be skipped jobs did not succeed',
'failing-job → failure [allowed to fail]',
'failing-job → 🔴 failure [allowed to fail]',
},
id='success-of-some-allowed-to-skip-or-fail',
),
@@ -196,7 +196,7 @@ from normalize_needed_jobs_status import main as _invoke_helper_cli
{'failure=false', 'result=success', 'success=true'},
{
'All of the required dependency jobs succeeded',
'succeeding-job → success [required to succeed]',
'succeeding-job → 🟢 success [required to succeed]',
},
id='everything-required',
),