From df7c82f49a5612cd924fb81267211225885019fe Mon Sep 17 00:00:00 2001 From: Charlie Denton Date: Wed, 3 Jan 2024 15:23:41 +0000 Subject: [PATCH] Add flag for printing reports in colour (#89) * Remove unused test constant This was last referenced in 44a7fed992d4b917832b3456439cc2580205cafa. * Extract logic for writing change reports Before this change the logic for writing the report to stdout was a part of ChangeTracker. By extracting this to a separate class, we allow for other writer classes to be implemented. * Add ColorChangeReportWriter This class can be used to print out change reports in colour. The colours selected closely match those output by Mypy, with the exception that links are not highlighted in notes. The logic is inspired by Mypy's highlighting code. (See https://github.com/python/mypy/blob/f9e8e0bda5cfbb54d6a8f9e482aa25da28a1a635/mypy/util.py#L761) This supports only ANSI colours, so I suspect that this will not work on Windows. * Add flag for printing change reports in colour * Print out mypy test results in colour * Add docstring to DefaultChangeReportWriter * Update change log with new `--color` flag * Change spacing around report summary Before this change an empty line would be printed above the summary when there were no new errors, and no empty line would be printed when there were errors. This change reverses that. An empty line is printed to separate the summary from the new errors only if there are new errors. --- CHANGELOG.md | 1 + mypy_json_report.py | 123 ++++++++++++++++-- tests/test_mypy_json_report.py | 220 ++++++++++++++++++++++++++++++--- tox.ini | 2 +- 4 files changed, 321 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f08a7..05d228d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Drop support for Python 3.7 +- Add `parse --color` flag for printing out change reports in color. (Aliases `-c`, `--colour`.) ## v1.0.4 [2023-05-09] diff --git a/mypy_json_report.py b/mypy_json_report.py index 3613856..723069f 100644 --- a/mypy_json_report.py +++ b/mypy_json_report.py @@ -31,6 +31,7 @@ Iterator, List, Optional, + Protocol, Union, cast, ) @@ -87,6 +88,13 @@ def main() -> None: """ ), ) + parse_parser.add_argument( + "-c", + "--color", + "--colour", + action="store_true", + help="Whether to colorize the diff-report output. Defaults to False.", + ) parse_parser.set_defaults(func=_parse_command) @@ -116,7 +124,12 @@ def _parse_command(args: argparse.Namespace) -> None: tracker = None if args.diff_old_report is not None: old_report = cast(ErrorSummary, _load_json_file(args.diff_old_report)) - tracker = ChangeTracker(old_report) + change_report_writer: _ChangeReportWriter + if args.color: + change_report_writer = ColorChangeReportWriter() + else: + change_report_writer = DefaultChangeReportWriter() + tracker = ChangeTracker(old_report, report_writer=change_report_writer) processors.append(tracker) messages = MypyMessage.from_lines(sys.stdin) @@ -229,6 +242,102 @@ class DiffReport: num_fixed_errors: int +class _ChangeReportWriter(Protocol): + def write_report(self, diff: DiffReport) -> None: + ... + + +class DefaultChangeReportWriter: + """Writes an error summary without color.""" + + def __init__(self, _write: Callable[[str], Any] = sys.stdout.write) -> None: + self.write = _write + + def write_report(self, diff: DiffReport) -> None: + new_errors = "\n".join(diff.error_lines) + if new_errors: + self.write(new_errors + "\n\n") + self.write(f"Fixed errors: {diff.num_fixed_errors}\n") + self.write(f"New errors: {diff.num_new_errors}\n") + self.write(f"Total errors: {diff.total_errors}\n") + + +class ColorChangeReportWriter: + """ + Writes an error summary in color. + + Inspired by the FancyFormatter in mypy.util. + + Ref: https://github.com/python/mypy/blob/f9e8e0bda5cfbb54d6a8f9e482aa25da28a1a635/mypy/util.py#L761 + """ + + _RESET = "\033[0m" + _BOLD = "\033[1m" + _BOLD_RED = "\033[31;1m" + _BOLD_YELLOW = "\033[33;1m" + _GREEN = "\033[32m" + _YELLOW = "\033[33m" + _BLUE = "\033[34m" + + def __init__(self, _write: Callable[[str], Any] = sys.stdout.write) -> None: + self.write = _write + + def write_report(self, diff: DiffReport) -> None: + new_errors = "\n".join([self._format_line(line) for line in diff.error_lines]) + if new_errors: + self.write(new_errors + "\n\n") + + fixed_color = self._BOLD_YELLOW if diff.num_fixed_errors else self._GREEN + error_color = self._BOLD_RED if diff.num_new_errors else self._GREEN + + self.write(self._style(fixed_color, f"Fixed errors: {diff.num_fixed_errors}\n")) + self.write(self._style(error_color, f"New errors: {diff.num_new_errors}\n")) + self.write(self._style(self._BOLD, f"Total errors: {diff.total_errors}\n")) + + def _style(self, style: str, message: str) -> str: + return f"{style}{message}{self._RESET}" + + def _highlight_quotes(self, msg: str) -> str: + if msg.count('"') % 2: + return msg + parts = msg.split('"') + out = "" + for i, part in enumerate(parts): + if i % 2 == 0: + out += part + else: + out += self._style(self._BOLD, f'"{part}"') + return out + + def _format_line(self, line: str) -> str: + if ": error: " in line: + # Separate the location from the message. + location, _, message = line.partition(" error: ") + + # Extract the error code from the end of the message if it's there. + if message.endswith("]") and " [" in message: + error_msg, _, code = message.rpartition(" [") + code = self._style(self._YELLOW, f" [{code}") + else: + error_msg = message + code = "" + + return ( + location + + self._style(self._BOLD_RED, " error: ") + + self._highlight_quotes(error_msg) + + code + ) + if ": note: " in line: + location, _, message = line.partition(" note: ") + return ( + location + + self._style(self._BLUE, " note: ") + + self._highlight_quotes(message) + ) + return line + + class ChangeTracker: """ Compares the current Mypy report against a previous summary. @@ -239,8 +348,11 @@ class ChangeTracker: that are cached in memory. """ - def __init__(self, summary: ErrorSummary) -> None: + def __init__( + self, summary: ErrorSummary, report_writer: _ChangeReportWriter + ) -> None: self.old_report = summary + self.report_writer = report_writer self.error_lines: List[str] = [] self.num_errors = 0 self.num_new_errors = 0 @@ -282,13 +394,8 @@ def diff_report(self) -> DiffReport: ) def write_report(self) -> Optional[ErrorCodes]: - write = sys.stderr.write diff = self.diff_report() - new_errors = "\n".join(diff.error_lines) - write(new_errors + "\n") - write(f"Fixed errors: {diff.num_fixed_errors}\n") - write(f"New errors: {diff.num_new_errors}\n") - write(f"Total errors: {diff.total_errors}\n") + self.report_writer.write_report(diff) if diff.num_new_errors or diff.num_fixed_errors: return ErrorCodes.ERROR_DIFF diff --git a/tests/test_mypy_json_report.py b/tests/test_mypy_json_report.py index 9a7f839..bb0af49 100644 --- a/tests/test_mypy_json_report.py +++ b/tests/test_mypy_json_report.py @@ -1,10 +1,13 @@ import sys +from typing import List from unittest import mock import pytest from mypy_json_report import ( ChangeTracker, + ColorChangeReportWriter, + DefaultChangeReportWriter, DiffReport, ErrorCounter, MypyMessage, @@ -12,13 +15,6 @@ ) -EXAMPLE_MYPY_STDOUT = """\ -mypy_json_report.py:8: error: Function is missing a return type annotation -mypy_json_report.py:8: note: Use "-> None" if function does not return a value -mypy_json_report.py:68: error: Call to untyped function "main" in typed context -Found 2 errors in 1 file (checked 3 source files)""" - - class TestMypyMessageFromLine: def test_error(self) -> None: line = "test.py:8: error: Function is missing a return type annotation\n" @@ -141,7 +137,7 @@ def test_write_populated_report(self) -> None: class TestChangeTracker: def test_no_errors(self) -> None: - tracker = ChangeTracker(summary={}) + tracker = ChangeTracker(summary={}, report_writer=mock.MagicMock()) report = tracker.diff_report() @@ -150,7 +146,7 @@ def test_no_errors(self) -> None: ) def test_new_error(self) -> None: - tracker = ChangeTracker(summary={}) + tracker = ChangeTracker(summary={}, report_writer=mock.MagicMock()) tracker.process_messages( "file.py", [MypyMessage.from_line("file.py:8: error: An example type error")], @@ -166,7 +162,10 @@ def test_new_error(self) -> None: ) def test_known_error(self) -> None: - tracker = ChangeTracker(summary={"file.py": {"An example type error": 1}}) + tracker = ChangeTracker( + summary={"file.py": {"An example type error": 1}}, + report_writer=mock.MagicMock(), + ) tracker.process_messages( "file.py", [MypyMessage.from_line("file.py:8: error: An example type error")], @@ -179,7 +178,10 @@ def test_known_error(self) -> None: ) def test_error_completely_fixed(self) -> None: - tracker = ChangeTracker(summary={"file.py": {"An example type error": 2}}) + tracker = ChangeTracker( + summary={"file.py": {"An example type error": 2}}, + report_writer=mock.MagicMock(), + ) report = tracker.diff_report() @@ -188,7 +190,10 @@ def test_error_completely_fixed(self) -> None: ) def test_error_partially_fixed(self) -> None: - tracker = ChangeTracker(summary={"file.py": {"An example type error": 2}}) + tracker = ChangeTracker( + summary={"file.py": {"An example type error": 2}}, + report_writer=mock.MagicMock(), + ) tracker.process_messages( "file.py", [MypyMessage.from_line("file.py:8: error: An example type error")], @@ -201,7 +206,10 @@ def test_error_partially_fixed(self) -> None: ) def test_more_errors_of_same_type(self) -> None: - tracker = ChangeTracker(summary={"file.py": {"An example type error": 1}}) + tracker = ChangeTracker( + summary={"file.py": {"An example type error": 1}}, + report_writer=mock.MagicMock(), + ) tracker.process_messages( "file.py", [ @@ -225,7 +233,7 @@ def test_more_errors_of_same_type(self) -> None: ) def test_note_on_same_line(self) -> None: - tracker = ChangeTracker(summary={}) + tracker = ChangeTracker(summary={}, report_writer=mock.MagicMock()) tracker.process_messages( "file.py", [ @@ -247,7 +255,10 @@ def test_note_on_same_line(self) -> None: ) def test_error_in_new_file(self) -> None: - tracker = ChangeTracker(summary={"file.py": {"An example type error": 1}}) + tracker = ChangeTracker( + summary={"file.py": {"An example type error": 1}}, + report_writer=mock.MagicMock(), + ) tracker.process_messages( "file.py", [MypyMessage.from_line("file.py:1: error: An example type error")], @@ -267,7 +278,7 @@ def test_error_in_new_file(self) -> None: ) def test_multiple_errors_on_same_line(self) -> None: - tracker = ChangeTracker(summary={}) + tracker = ChangeTracker(summary={}, report_writer=mock.MagicMock()) tracker.process_messages( "file.py", [ @@ -287,3 +298,180 @@ def test_multiple_errors_on_same_line(self) -> None: num_new_errors=2, num_fixed_errors=0, ) + + +class TestChangeTrackerPrinting: + def test_delegates_to_report_writer(self) -> None: + writer = mock.MagicMock() + tracker = ChangeTracker(summary={}, report_writer=writer) + tracker.process_messages( + "file.py", + [MypyMessage.from_line("file.py:8: error: An example type error")], + ) + + tracker.write_report() + + writer.write_report.assert_called_once_with( + DiffReport( + error_lines=["file.py:8: error: An example type error"], + total_errors=1, + num_new_errors=1, + num_fixed_errors=0, + ) + ) + + +class TestDefaultChangeReportWriter: + def test_no_errors(self) -> None: + messages: List[str] = [] + writer = DefaultChangeReportWriter(_write=messages.append) + + writer.write_report( + DiffReport( + error_lines=[], total_errors=0, num_new_errors=0, num_fixed_errors=0 + ) + ) + + assert messages == ["Fixed errors: 0\n", "New errors: 0\n", "Total errors: 0\n"] + + def test_with_errors(self) -> None: + messages: List[str] = [] + writer = DefaultChangeReportWriter(_write=messages.append) + + writer.write_report( + DiffReport( + error_lines=["file.py:8: error: An example type error"], + total_errors=2, + num_new_errors=1, + num_fixed_errors=0, + ) + ) + + assert messages == [ + "file.py:8: error: An example type error\n\n", + "Fixed errors: 0\n", + "New errors: 1\n", + "Total errors: 2\n", + ] + + +class TestColorChangeReportWriter: + def test_no_errors(self) -> None: + messages: List[str] = [] + writer = ColorChangeReportWriter(_write=messages.append) + + writer.write_report( + DiffReport( + error_lines=[], total_errors=0, num_new_errors=0, num_fixed_errors=0 + ) + ) + + assert messages == [ + "\x1b[32mFixed errors: 0\n\x1b[0m", + "\x1b[32mNew errors: 0\n\x1b[0m", + "\x1b[1mTotal errors: 0\n\x1b[0m", + ] + + def test_with_error(self) -> None: + messages: List[str] = [] + writer = ColorChangeReportWriter(_write=messages.append) + + writer.write_report( + DiffReport( + error_lines=["file.py:8: error: An example type error"], + total_errors=2, + num_new_errors=1, + num_fixed_errors=1, + ) + ) + + assert messages == [ + "file.py:8:\x1b[31;1m error: \x1b[0mAn example type error\n\n", + "\x1b[33;1mFixed errors: 1\n\x1b[0m", + "\x1b[31;1mNew errors: 1\n\x1b[0m", + "\x1b[1mTotal errors: 2\n\x1b[0m", + ] + + def test_with_error_containing_braces(self) -> None: + messages: List[str] = [] + writer = ColorChangeReportWriter(_write=messages.append) + + writer.write_report( + DiffReport( + error_lines=[ + "file.py:8: error: Contains [braces] but not an error code" + ], + total_errors=2, + num_new_errors=1, + num_fixed_errors=1, + ) + ) + + assert messages == [ + "file.py:8:\x1b[31;1m error: \x1b[0mContains [braces] but not an error code\n\n", + "\x1b[33;1mFixed errors: 1\n\x1b[0m", + "\x1b[31;1mNew errors: 1\n\x1b[0m", + "\x1b[1mTotal errors: 2\n\x1b[0m", + ] + + def test_unclosed_error_code(self) -> None: + messages: List[str] = [] + writer = ColorChangeReportWriter(_write=messages.append) + + writer.write_report( + DiffReport( + error_lines=[ + "file.py:8: error: Resembles error code but [is-not-closed" + ], + total_errors=2, + num_new_errors=1, + num_fixed_errors=1, + ) + ) + + assert messages == [ + "file.py:8:\x1b[31;1m error: \x1b[0mResembles error code but [is-not-closed\n\n", + "\x1b[33;1mFixed errors: 1\n\x1b[0m", + "\x1b[31;1mNew errors: 1\n\x1b[0m", + "\x1b[1mTotal errors: 2\n\x1b[0m", + ] + + def test_with_error_with_code(self) -> None: + messages: List[str] = [] + writer = ColorChangeReportWriter(_write=messages.append) + + writer.write_report( + DiffReport( + error_lines=["file.py:8: error: An example type error [error-code]"], + total_errors=2, + num_new_errors=1, + num_fixed_errors=0, + ) + ) + + assert messages == [ + "file.py:8:\x1b[31;1m error: \x1b[0mAn example type error\x1b[33m [error-code]\x1b[0m\n\n", + "\x1b[32mFixed errors: 0\n\x1b[0m", + "\x1b[31;1mNew errors: 1\n\x1b[0m", + "\x1b[1mTotal errors: 2\n\x1b[0m", + ] + + def test_with_note(self) -> None: + messages: List[str] = [] + writer = ColorChangeReportWriter(_write=messages.append) + + writer.write_report( + DiffReport( + error_lines=["file.py:8: note: An example note"], + total_errors=2, + num_new_errors=1, + num_fixed_errors=1, + ) + ) + + assert messages == [ + "file.py:8:\x1b[34m note: \x1b[0mAn example note\n\n", + "\x1b[33;1mFixed errors: 1\n\x1b[0m", + "\x1b[31;1mNew errors: 1\n\x1b[0m", + "\x1b[1mTotal errors: 2\n\x1b[0m", + ] diff --git a/tox.ini b/tox.ini index 3d379f6..d07b18b 100644 --- a/tox.ini +++ b/tox.ini @@ -38,4 +38,4 @@ allowlist_externals = commands_pre = poetry install commands = - poetry run bash -c "mypy . --strict | mypy-json-report parse --output-file known-mypy-errors.json --diff-old-report known-mypy-errors.json" + poetry run bash -c "mypy . --strict | mypy-json-report parse --color --output-file known-mypy-errors.json --diff-old-report known-mypy-errors.json"