From d92c2c85fb4a4c21d185826902a806ee968c68c5 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Tue, 27 Aug 2024 16:18:38 +0100 Subject: [PATCH] Add support for silencing mypy errors --- CHANGELOG.md | 4 + README.md | 9 ++ requirements.txt | 5 ++ setup.cfg | 1 + silence_lint_error/cli/silence_lint_error.py | 2 + silence_lint_error/linters/mypy.py | 89 ++++++++++++++++++ tests/cli/silence_lint_error_test.py | 94 ++++++++++++++++++++ 7 files changed, 204 insertions(+) create mode 100644 silence_lint_error/linters/mypy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c98f432..461f097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added + +- Silence errors with `mypy`. + ## 1.4.2 (2024-07-01) ### Fixed diff --git a/README.md b/README.md index ef54c7a..4fa4094 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This tool currently works with: - [`fixit`](https://github.com/Instagram/Fixit) - [`flake8`](https://github.com/PyCQA/flake8) (silence only) +- [`mypy`](https://www.mypy-lang.org) (silence only) - [`ruff`](https://docs.astral.sh/ruff/) - [`semgrep`](https://semgrep.dev/docs/) (silence only) @@ -55,6 +56,14 @@ N.B. The rules must be configured in an environment variable. For more information about configuring semgrep rules, see the `--config` entry in the [`semgrep` documentation](https://semgrep.dev/docs/cli-reference-oss/) +To add `type: ignore` comments +to ignore the `truthy-bool` error from `mypy`, +run: + +```shell +silence-lint-error mypy truthy-bool path/to/files/ path/to/more/files/ +``` + ### fix silenced errors If there is an auto-fix for a linting error, you can remove the `ignore` or diff --git a/requirements.txt b/requirements.txt index aeccb46..67cd082 100644 --- a/requirements.txt +++ b/requirements.txt @@ -72,6 +72,10 @@ mdurl==0.1.2 # via markdown-it-py moreorless==0.4.0 # via fixit +mypy==1.11.2 + # via silence-lint-error (setup.cfg) +mypy-extensions==1.0.0 + # via mypy opentelemetry-api==1.25.0 # via # opentelemetry-exporter-otlp-proto-http @@ -163,6 +167,7 @@ trailrunner==1.4.0 # via fixit typing-extensions==4.12.2 # via + # mypy # opentelemetry-sdk # semgrep urllib3==2.2.2 diff --git a/setup.cfg b/setup.cfg index 237e4be..171fc49 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ dev = coverage fixit flake8 + mypy pytest pytest-subprocess ruff diff --git a/silence_lint_error/cli/silence_lint_error.py b/silence_lint_error/cli/silence_lint_error.py index 36dc5f1..bd52232 100644 --- a/silence_lint_error/cli/silence_lint_error.py +++ b/silence_lint_error/cli/silence_lint_error.py @@ -7,6 +7,7 @@ from silence_lint_error.linters import fixit from silence_lint_error.linters import flake8 +from silence_lint_error.linters import mypy from silence_lint_error.linters import ruff from silence_lint_error.linters import semgrep from silence_lint_error.silencing import ErrorRunningTool @@ -18,6 +19,7 @@ 'fixit': fixit.Fixit, 'fixit-inline': fixit.FixitInline, 'flake8': flake8.Flake8, + 'mypy': mypy.Mypy, 'ruff': ruff.Ruff, 'semgrep': semgrep.Semgrep, } diff --git a/silence_lint_error/linters/mypy.py b/silence_lint_error/linters/mypy.py new file mode 100644 index 0000000..1b79fc6 --- /dev/null +++ b/silence_lint_error/linters/mypy.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import subprocess +from collections import defaultdict +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import tokenize_rt + +from silence_lint_error.silencing import ErrorRunningTool +from silence_lint_error.silencing import Violation + +if TYPE_CHECKING: + from typing import TypeAlias + + FileName: TypeAlias = str + RuleName: TypeAlias = str + + +class Mypy: + name = 'mypy' + + def find_violations( + self, rule_name: RuleName, filenames: Sequence[FileName], + ) -> dict[FileName, list[Violation]]: + proc = subprocess.run( + ( + 'mypy', + '--follow-imports', 'silent', # do not report errors in other modules + '--enable-error-code', rule_name, + '--show-error-codes', '--no-pretty', '--no-error-summary', + *filenames, + ), + capture_output=True, + text=True, + ) + + if proc.returncode > 1: + raise ErrorRunningTool(proc) + + # extract filenames and line numbers + results: dict[FileName, list[Violation]] = defaultdict(list) + for line in proc.stdout.splitlines(): + if not line.endswith(f'[{rule_name}]'): + continue + + location, *__ = line.split() + filename_, lineno_, *__ = location.split(':') + + results[filename_].append(Violation(rule_name, int(lineno_))) + + return results + + def silence_violations( + self, src: str, violations: Sequence[Violation], + ) -> str: + rule_name = violations[0].rule_name + lines_with_errors = { + violation.lineno + for violation in violations + } + + tokens = tokenize_rt.src_to_tokens(src) + for idx, token in tokenize_rt.reversed_enumerate(tokens): + if token.line not in lines_with_errors: + continue + if not token.src.strip(): + continue + + if token.name == 'COMMENT': + if 'type: ignore' in token.src: + prefix, __, ignored = token.src.partition('type: ignore') + codes = ignored.strip('[]').split(',') + codes += [rule_name] + new_comment = f'{prefix}type: ignore[{",".join(codes)}]' + else: + new_comment = token.src + f' # type: ignore[{rule_name}]' + tokens[idx] = tokens[idx]._replace(src=new_comment) + else: + tokens.insert( + idx+1, tokenize_rt.Token( + 'COMMENT', f'# type: ignore[{rule_name}]', + ), + ) + tokens.insert(idx+1, tokenize_rt.Token('UNIMPORTANT_WS', ' ')) + + lines_with_errors.remove(token.line) + + return tokenize_rt.tokens_to_src(tokens) diff --git a/tests/cli/silence_lint_error_test.py b/tests/cli/silence_lint_error_test.py index 20a635a..8b2bf91 100644 --- a/tests/cli/silence_lint_error_test.py +++ b/tests/cli/silence_lint_error_test.py @@ -396,3 +396,97 @@ def test_not_installed(self, capsys: pytest.CaptureFixture[str]): -> finding errors with semgrep ERROR: zsh: command not found: semgrep """ + + +class TestMypy: + def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + (tmp_path / '__init__.py').touch() + python_module = tmp_path / 't.py' + python_module.write_text("""\ +from . import y + +def f() -> str: + return 1 + +def g() -> str: + return 1 # type: ignore[misc] + +def g() -> str: + return 1 # a number +""") + other_module = tmp_path / 'y.py' + other_module.write_text("""\ +def unrelated() -> str: + return 1 +""") + + ret = main(('mypy', 'return-value', str(python_module))) + + assert ret == 1 + assert python_module.read_text() == """\ +from . import y + +def f() -> str: + return 1 # type: ignore[return-value] + +def g() -> str: + return 1 # type: ignore[misc,return-value] + +def g() -> str: + return 1 # a number # type: ignore[return-value] +""" + assert other_module.read_text() == """\ +def unrelated() -> str: + return 1 +""" + + captured = capsys.readouterr() + assert captured.out == f"""\ +{python_module} +""" + assert captured.err == """\ +-> finding errors with mypy +found errors in 1 files +-> adding comments to silence errors +""" + + def test_main_no_violations( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], + ): + src = """\ +def f() -> int: + return 1 +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main(('mypy', 'return-value', str(python_module))) + + assert ret == 0 + assert python_module.read_text() == src + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with mypy +no errors found +""" + + def test_not_installed(self, capsys: pytest.CaptureFixture[str]): + with FakeProcess() as process: + process.register( + ('mypy', process.any()), + returncode=127, stderr='zsh: command not found: mypy\n', + ) + + ret = main(('mypy', 'return-value', 'path/to/file.py')) + + assert ret == 127 + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with mypy +ERROR: zsh: command not found: mypy +"""