Skip to content

Commit

Permalink
Add support for silencing mypy errors
Browse files Browse the repository at this point in the history
  • Loading branch information
samueljsb committed Aug 27, 2024
1 parent 924c0df commit d92c2c8
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 0 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -163,6 +167,7 @@ trailrunner==1.4.0
# via fixit
typing-extensions==4.12.2
# via
# mypy
# opentelemetry-sdk
# semgrep
urllib3==2.2.2
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dev =
coverage
fixit
flake8
mypy
pytest
pytest-subprocess
ruff
Expand Down
2 changes: 2 additions & 0 deletions silence_lint_error/cli/silence_lint_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@
'fixit': fixit.Fixit,
'fixit-inline': fixit.FixitInline,
'flake8': flake8.Flake8,
'mypy': mypy.Mypy,
'ruff': ruff.Ruff,
'semgrep': semgrep.Semgrep,
}
Expand Down
89 changes: 89 additions & 0 deletions silence_lint_error/linters/mypy.py
Original file line number Diff line number Diff line change
@@ -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)
94 changes: 94 additions & 0 deletions tests/cli/silence_lint_error_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

0 comments on commit d92c2c8

Please sign in to comment.