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"