diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f225e6..02a541e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Restructure project to use sub-packages, rather than putting all code in one module. + ## v1.1.0 [2024-01-03] - Drop support for Python 3.7 diff --git a/mypy_json_report/__init__.py b/mypy_json_report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mypy_json_report/__main__.py b/mypy_json_report/__main__.py new file mode 100644 index 0000000..52099aa --- /dev/null +++ b/mypy_json_report/__main__.py @@ -0,0 +1,18 @@ +# Copyright 2022 Memrise + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .cli import main + + +main() diff --git a/mypy_json_report/cli.py b/mypy_json_report/cli.py new file mode 100644 index 0000000..7d621c3 --- /dev/null +++ b/mypy_json_report/cli.py @@ -0,0 +1,122 @@ +# Copyright 2022 Memrise + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import json +import pathlib +import sys +import textwrap +from typing import Any, List, cast + +from . import parse +from .exit_codes import ExitCode + + +def main() -> None: + """ + The primary entrypoint of the program. + + Parses the CLI flags, and delegates to other functions as appropriate. + For details of how to invoke the program, call it with `--help`. + """ + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(title="subcommand") + + parser.set_defaults(func=_no_command) + + parse_parser = subparsers.add_parser( + "parse", help="Transform Mypy output into JSON." + ) + parse_parser.add_argument( + "-i", + "--indentation", + type=int, + default=2, + help="Number of spaces to indent JSON output.", + ) + parse_parser.add_argument( + "-o", + "--output-file", + type=pathlib.Path, + help="The file to write the JSON report to. If omitted, the report will be written to STDOUT.", + ) + parse_parser.add_argument( + "-d", + "--diff-old-report", + default=None, + type=pathlib.Path, + help=textwrap.dedent( + f"""\ + An old report to compare against. We will compare the errors in there to the new report. + Fail with return code {ExitCode.ERROR_DIFF} if we discover any new errors. + New errors will be printed to stderr. + Similar errors from the same file will also be printed + (because we don't know which error is the new one). + For completeness other hints and errors on the same lines are also printed. + """ + ), + ) + 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) + + parsed = parser.parse_args() + parsed.func(parsed) + + +def _load_json_file(filepath: pathlib.Path) -> Any: + with filepath.open() as json_file: + return json.load(json_file) + + +def _parse_command(args: argparse.Namespace) -> None: + """Handle the `parse` command.""" + if args.output_file: + report_writer = args.output_file.write_text + else: + report_writer = sys.stdout.write + processors: List[parse.MessageProcessor] = [ + parse.ErrorCounter(report_writer=report_writer, indentation=args.indentation) + ] + + # If we have access to an old report, add the ChangeTracker processor. + tracker = None + if args.diff_old_report is not None: + old_report = cast(parse.ErrorSummary, _load_json_file(args.diff_old_report)) + change_report_writer: parse._ChangeReportWriter + if args.color: + change_report_writer = parse.ColorChangeReportWriter() + else: + change_report_writer = parse.DefaultChangeReportWriter() + tracker = parse.ChangeTracker(old_report, report_writer=change_report_writer) + processors.append(tracker) + + exit_code = parse.parse_message_lines(processors, sys.stdin) + sys.exit(exit_code) + + +def _no_command(args: argparse.Namespace) -> None: + """ + Handle the lack of an explicit command. + + This will be hit when the program is called without arguments. + """ + print("A subcommand is required. Pass --help for usage info.", file=sys.stderr) + sys.exit(ExitCode.DEPRECATED) diff --git a/mypy_json_report/exit_codes.py b/mypy_json_report/exit_codes.py new file mode 100644 index 0000000..03d312b --- /dev/null +++ b/mypy_json_report/exit_codes.py @@ -0,0 +1,25 @@ +# Copyright 2022 Memrise + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import enum + + +class ExitCode(enum.IntEnum): + SUCCESS = 0 + # 1 is returned when an uncaught exception is raised. + UNCAUGHT_EXCEPTION = 1 + # Argparse returns 2 when bad args are passed. + ARGUMENT_PARSING_ERROR = 2 + ERROR_DIFF = 3 + DEPRECATED = 4 diff --git a/mypy_json_report.py b/mypy_json_report/parse.py similarity index 71% rename from mypy_json_report.py rename to mypy_json_report/parse.py index 723069f..ff14bd5 100644 --- a/mypy_json_report.py +++ b/mypy_json_report/parse.py @@ -12,14 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import argparse -import enum import itertools import json import operator -import pathlib import sys -import textwrap from collections import Counter, defaultdict from dataclasses import dataclass from typing import ( @@ -30,109 +26,27 @@ Iterable, Iterator, List, - Optional, Protocol, - Union, - cast, ) - -class ErrorCodes(enum.IntEnum): - # 1 is returned when an uncaught exception is raised. - # Argparse returns 2 when bad args are passed. - ERROR_DIFF = 3 - DEPRECATED = 4 - - -def main() -> None: - """ - The primary entrypoint of the program. - - Parses the CLI flags, and delegates to other functions as appropriate. - For details of how to invoke the program, call it with `--help`. - """ - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(title="subcommand") - - parser.set_defaults(func=_no_command) - - parse_parser = subparsers.add_parser( - "parse", help="Transform Mypy output into JSON." - ) - parse_parser.add_argument( - "-i", - "--indentation", - type=int, - default=2, - help="Number of spaces to indent JSON output.", - ) - parse_parser.add_argument( - "-o", - "--output-file", - type=pathlib.Path, - help="The file to write the JSON report to. If omitted, the report will be written to STDOUT.", - ) - parse_parser.add_argument( - "-d", - "--diff-old-report", - default=None, - type=pathlib.Path, - help=textwrap.dedent( - f"""\ - An old report to compare against. We will compare the errors in there to the new report. - Fail with return code {ErrorCodes.ERROR_DIFF} if we discover any new errors. - New errors will be printed to stderr. - Similar errors from the same file will also be printed - (because we don't know which error is the new one). - For completeness other hints and errors on the same lines are also printed. - """ - ), - ) - 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) - - parsed = parser.parse_args() - parsed.func(parsed) +from mypy_json_report.exit_codes import ExitCode ErrorSummary = Dict[str, Dict[str, int]] -def _load_json_file(filepath: pathlib.Path) -> Any: - with filepath.open() as json_file: - return json.load(json_file) - +class MessageProcessor(Protocol): + def process_messages(self, filename: str, messages: List["MypyMessage"]) -> None: + ... -def _parse_command(args: argparse.Namespace) -> None: - """Handle the `parse` command.""" - if args.output_file: - report_writer = args.output_file.write_text - else: - report_writer = sys.stdout.write - processors: List[Union[ErrorCounter, ChangeTracker]] = [ - ErrorCounter(report_writer=report_writer, indentation=args.indentation) - ] + def write_report(self) -> ExitCode: + ... - # If we have access to an old report, add the ChangeTracker processor. - tracker = None - if args.diff_old_report is not None: - old_report = cast(ErrorSummary, _load_json_file(args.diff_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) +def parse_message_lines( + processors: List[MessageProcessor], lines: Iterable[str] +) -> ExitCode: + messages = MypyMessage.from_lines(lines) # Sort the lines by the filename otherwise itertools.groupby() will make # multiple groups for the same file name if the lines are out of order. @@ -147,19 +61,11 @@ def _parse_command(args: argparse.Namespace) -> None: processor.process_messages(filename, message_group) for processor in processors: - error_code = processor.write_report() - if error_code is not None: - sys.exit(error_code) - + exit_code = processor.write_report() + if exit_code is not ExitCode.SUCCESS: + return exit_code -def _no_command(args: argparse.Namespace) -> None: - """ - Handle the lack of an explicit command. - - This will be hit when the program is called without arguments. - """ - print("A subcommand is required. Pass --help for usage info.", file=sys.stderr) - sys.exit(ErrorCodes.DEPRECATED) + return ExitCode.SUCCESS class ParseError(Exception): @@ -228,10 +134,11 @@ def process_messages(self, filename: str, messages: List[MypyMessage]) -> None: if counted_errors: self.grouped_errors[filename] = counted_errors - def write_report(self) -> None: + def write_report(self) -> ExitCode: errors = self.grouped_errors error_json = json.dumps(errors, sort_keys=True, indent=self.indentation) self.report_writer(error_json + "\n") + return ExitCode.SUCCESS @dataclass(frozen=True) @@ -393,14 +300,10 @@ def diff_report(self) -> DiffReport: num_fixed_errors=self.num_fixed_errors + unseen_errors, ) - def write_report(self) -> Optional[ErrorCodes]: + def write_report(self) -> ExitCode: diff = self.diff_report() self.report_writer.write_report(diff) if diff.num_new_errors or diff.num_fixed_errors: - return ErrorCodes.ERROR_DIFF - return None - - -if __name__ == "__main__": - main() + return ExitCode.ERROR_DIFF + return ExitCode.SUCCESS diff --git a/pyproject.toml b/pyproject.toml index f78d487..ec7dc3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ tox = "*" tox-gh-actions = "*" [tool.poetry.scripts] -mypy-json-report = "mypy_json_report:main" +mypy-json-report = "mypy_json_report.cli:main" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/test_mypy_json_report.py b/tests/test_parse_command.py similarity index 99% rename from tests/test_mypy_json_report.py rename to tests/test_parse_command.py index bb0af49..4e0127c 100644 --- a/tests/test_mypy_json_report.py +++ b/tests/test_parse_command.py @@ -4,7 +4,7 @@ import pytest -from mypy_json_report import ( +from mypy_json_report.parse import ( ChangeTracker, ColorChangeReportWriter, DefaultChangeReportWriter,