├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── LICENSE ├── README.md ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── yesqa_test.py ├── tox.ini └── yesqa.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main, test-me-*] 6 | tags: '*' 7 | pull_request: 8 | 9 | jobs: 10 | main-windows: 11 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 12 | with: 13 | env: '["py39"]' 14 | os: windows-latest 15 | main-linux: 16 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 17 | with: 18 | env: '["py39", "py310"]' 19 | os: ubuntu-latest 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | /.coverage 4 | /.tox 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: double-quote-string-fixer 10 | - id: name-tests-test 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/asottile/setup-cfg-fmt 13 | rev: v2.8.0 14 | hooks: 15 | - id: setup-cfg-fmt 16 | - repo: https://github.com/asottile/reorder-python-imports 17 | rev: v3.15.0 18 | hooks: 19 | - id: reorder-python-imports 20 | args: [--py39-plus, --add-import, 'from __future__ import annotations'] 21 | - repo: https://github.com/asottile/add-trailing-comma 22 | rev: v3.2.0 23 | hooks: 24 | - id: add-trailing-comma 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v3.20.0 27 | hooks: 28 | - id: pyupgrade 29 | args: [--py39-plus] 30 | - repo: https://github.com/hhatto/autopep8 31 | rev: v2.3.2 32 | hooks: 33 | - id: autopep8 34 | - repo: https://github.com/PyCQA/flake8 35 | rev: 7.2.0 36 | hooks: 37 | - id: flake8 38 | - repo: https://github.com/pre-commit/mirrors-mypy 39 | rev: v1.16.0 40 | hooks: 41 | - id: mypy 42 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: yesqa 2 | name: Strip unnecessary `# noqa`s 3 | description: Automatically remove unnecessary `# noqa` comments 4 | entry: yesqa 5 | language: python 6 | types: [python] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build status](https://github.com/asottile/yesqa/actions/workflows/main.yml/badge.svg)](https://github.com/asottile/yesqa/actions/workflows/main.yml) 2 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/asottile/yesqa/main.svg)](https://results.pre-commit.ci/latest/github/asottile/yesqa/main) 3 | 4 | yesqa 5 | ===== 6 | 7 | A tool (and pre-commit hook) to automatically remove unnecessary `# noqa` 8 | comments, for example: a check that's no longer applicable (say you increased your 9 | max line length), a mistake (`# noqa` added to a line that wasn't failing), 10 | or other code in the file caused it to no longer need a `# noqa` (such as an unused import). 11 | 12 | ## Installation 13 | 14 | ```bash 15 | pip install yesqa 16 | ``` 17 | 18 | 19 | ## As a pre-commit hook 20 | 21 | See [pre-commit](https://github.com/pre-commit/pre-commit) for instructions 22 | 23 | Sample `.pre-commit-config.yaml`: 24 | 25 | ```yaml 26 | - repo: https://github.com/asottile/yesqa 27 | rev: v1.5.0 28 | hooks: 29 | - id: yesqa 30 | ``` 31 | 32 | If you need to select a specific version of flake8 and/or run with specific 33 | flake8 plugins, add them to [`additional_dependencies`][0]. 34 | 35 | [0]: http://pre-commit.com/#pre-commit-configyaml---hooks 36 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | pytest 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = yesqa 3 | version = 1.5.0 4 | description = Automatically remove unnecessary `# noqa` comments. 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/asottile/yesqa 8 | author = Anthony Sottile 9 | author_email = asottile@umich.edu 10 | license = MIT 11 | license_files = LICENSE 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3 :: Only 15 | Programming Language :: Python :: Implementation :: CPython 16 | Programming Language :: Python :: Implementation :: PyPy 17 | 18 | [options] 19 | py_modules = yesqa 20 | install_requires = 21 | flake8>=3.9 22 | tokenize-rt>=2.1 23 | python_requires = >=3.9 24 | 25 | [options.entry_points] 26 | console_scripts = 27 | yesqa = yesqa:main 28 | 29 | [bdist_wheel] 30 | universal = True 31 | 32 | [coverage:run] 33 | plugins = covdefaults 34 | 35 | [mypy] 36 | check_untyped_defs = true 37 | disallow_any_generics = true 38 | disallow_incomplete_defs = true 39 | disallow_untyped_defs = true 40 | warn_redundant_casts = true 41 | warn_unused_ignores = true 42 | 43 | [mypy-testing.*] 44 | disallow_untyped_defs = false 45 | 46 | [mypy-tests.*] 47 | disallow_untyped_defs = false 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile/yesqa/8e2205be915c5c2235e993cee3470eb57755ee25/tests/__init__.py -------------------------------------------------------------------------------- /tests/yesqa_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | import yesqa 6 | 7 | 8 | @pytest.fixture 9 | def assert_rewrite(tmpdir): 10 | def _assert(s, expected=None): 11 | expected_retc = 0 if expected is None else 1 12 | expected = s if expected is None else expected 13 | f = tmpdir.join('f.py') 14 | f.write(s) 15 | assert yesqa.fix_file(str(f)) == expected_retc 16 | assert f.read() == expected 17 | return _assert 18 | 19 | 20 | def test_non_utf8_bytes(tmpdir, capsys): 21 | f = tmpdir.join('f.py') 22 | f.write_binary('x = "€"'.encode('cp1252')) 23 | assert yesqa.fix_file(str(f)) == 1 24 | out, _ = capsys.readouterr() 25 | assert out == f'{f} is non-utf8 (not supported)\n' 26 | 27 | 28 | @pytest.mark.parametrize( 29 | 'src', 30 | ( 31 | '', # noop 32 | '# hello\n', # comment at beginning of file 33 | # still needed 34 | 'import os # noqa\n', 35 | 'import os # NOQA\n', 36 | 'import os # noqa: F401\n', 37 | 'import os # noqa:F401\n', 38 | 'import os # noqa: F401 isort:skip\n', 39 | 'import os # isort:skip # noqa\n', 40 | 'import os # isort:skip # noqa: F401\n', 41 | '"""\n' + 'a' * 40 + ' ' + 'b' * 60 + '\n""" # noqa\n', 42 | 'from foo\\\nimport bar # noqa\n', 43 | # don't rewrite syntax errors 44 | 'import x # noqa\nx() = 5\n', 45 | 46 | 'A' * 65 + ' = int\n\n\n' 47 | 'def f():\n' 48 | ' # type: () -> ' + 'A' * 65 + ' # noqa\n' 49 | ' pass\n', 50 | 'def foo(w: Sequence[int], x: Sequence[int], y: int, z: int) -> bar: ... # noqa: E501, F821\n', 51 | 'def foo(w: Sequence[int]) -> bar: # foobarfoobarfoobarfoobarfoobarfoo # noqa: E501, F821\n', 52 | ), 53 | ) 54 | def test_ok(assert_rewrite, src): 55 | assert_rewrite(src) 56 | 57 | 58 | @pytest.mark.parametrize( 59 | ('src', 'expected'), 60 | ( 61 | # line comments 62 | ('x = 1 # noqa\n', 'x = 1\n'), 63 | ('import os # noqa: F401,X999\n', 'import os # noqa: F401\n'), 64 | ('import os # noqa:F401,X999\n', 'import os # noqa: F401\n'), 65 | ('# foo # noqa\nx = 1\n', '# foo\nx = 1\n'), 66 | ('# noqa # foo\nx = 1\n', '# foo\nx = 1\n'), 67 | ( 68 | 'try:\n' 69 | ' pass\n' 70 | 'except OSError: # noqa hi\n' 71 | ' pass\n', 72 | 'try:\n' 73 | ' pass\n' 74 | 'except OSError: # hi\n' 75 | ' pass\n', 76 | ), 77 | ( 78 | 'import os # noqa\n' 79 | '# hello world\n' 80 | 'os\n', 81 | 'import os\n' 82 | '# hello world\n' 83 | 'os\n', 84 | ), 85 | ( 86 | '# a # noqa: E501\n', 87 | '# a\n', 88 | ), 89 | pytest.param( 90 | 'if x==1: # noqa: F401\n' 91 | ' pass\n', 92 | 'if x==1:\n' 93 | ' pass\n', 94 | id='wrong noqa', 95 | ), 96 | pytest.param( 97 | 'x = 1 # foo # noqa: ABC123\n', 98 | 'x = 1 # foo\n', 99 | id='multi-character noqa code', 100 | ), 101 | # file comments 102 | ('# flake8: noqa\nx = 1\n', 'x = 1\n'), 103 | ('x = 1 # flake8: noqa\n', 'x = 1\n'), 104 | ), 105 | ) 106 | def test_rewrite(assert_rewrite, src, expected): 107 | assert_rewrite(src, expected) 108 | 109 | 110 | def test_main(tmpdir, capsys): 111 | f = tmpdir.join('f.py').ensure() 112 | g = tmpdir.join('g.py') 113 | g.write('x = 1 # noqa\n') 114 | ret = yesqa.main((str(f), str(g))) 115 | assert ret == 1 116 | assert g.read() == 'x = 1\n' 117 | out, _ = capsys.readouterr() 118 | assert out == f'Rewriting {g}\n' 119 | 120 | 121 | def test_show_source_in_config(tmpdir, capsys): 122 | f = tmpdir.join('f.py') 123 | f.write('import os # noqa\n') 124 | tmpdir.join('tox.ini').write('[flake8]\nshow_source = true\n') 125 | with tmpdir.as_cwd(): 126 | ret = yesqa.main((str(f),)) 127 | assert ret == 0 128 | assert f.read() == 'import os # noqa\n' 129 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py,pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run -m pytest {posargs:tests} 9 | coverage report 10 | 11 | [testenv:pre-commit] 12 | skip_install = true 13 | deps = pre-commit 14 | commands = pre-commit run --all-files --show-diff-on-failure 15 | 16 | [pep8] 17 | ignore = E265,E501,W504 18 | -------------------------------------------------------------------------------- /yesqa.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import collections 5 | import os.path 6 | import re 7 | import subprocess 8 | import sys 9 | import tempfile 10 | from collections.abc import Sequence 11 | from re import Match 12 | 13 | import tokenize_rt 14 | 15 | Tokens = list[tokenize_rt.Token] 16 | 17 | NOQA_FILE_RE = re.compile(r'^# flake8[:=]\s*noqa', re.I) 18 | _code = '[a-z]{1,3}[0-9]+' 19 | _sep = r'[,\s]+' 20 | NOQA_RE = re.compile(f'# noqa(: ?{_code}({_sep}{_code})*)?', re.I) 21 | SEP_RE = re.compile(_sep) 22 | 23 | 24 | def _run_flake8(filename: str) -> dict[int, set[str]]: 25 | cmd = ( 26 | sys.executable, 27 | '-mflake8', 28 | '--format=%(row)d\t%(code)s', 29 | '--no-show-source', 30 | filename, 31 | ) 32 | out, _ = subprocess.Popen(cmd, stdout=subprocess.PIPE).communicate() 33 | ret: dict[int, set[str]] = collections.defaultdict(set) 34 | for line in out.decode().splitlines(): 35 | lineno, code = line.split('\t') 36 | ret[int(lineno)].add(code) 37 | return ret 38 | 39 | 40 | def _remove_comment(tokens: Tokens, i: int) -> None: 41 | if i > 0 and tokens[i - 1].name == tokenize_rt.UNIMPORTANT_WS: 42 | del tokens[i - 1:i + 1] 43 | else: 44 | del tokens[i] 45 | 46 | 47 | def _remove_comments(tokens: Tokens) -> Tokens: 48 | tokens = list(tokens) 49 | for i, token in tokenize_rt.reversed_enumerate(tokens): 50 | if token.name == 'COMMENT': 51 | if NOQA_RE.search(token.src): 52 | _mask_noqa_comment(tokens, i) 53 | elif NOQA_FILE_RE.search(token.src): 54 | _remove_comment(tokens, i) 55 | return tokens 56 | 57 | 58 | def _mask_noqa_comment(tokens: Tokens, i: int) -> None: 59 | token = tokens[i] 60 | match = NOQA_RE.search(token.src) 61 | assert match is not None 62 | 63 | def _sub(match: Match[str]) -> str: 64 | return f'# {"." * (len(match.group()) - 2)}' 65 | 66 | src = NOQA_RE.sub(_sub, token.src) 67 | tokens[i] = token._replace(src=src) 68 | 69 | 70 | def _rewrite_noqa_comment( 71 | tokens: Tokens, 72 | i: int, 73 | flake8_results: dict[int, set[str]], 74 | ) -> None: 75 | # find logical lines that this noqa comment may affect 76 | lines: set[int] = set() 77 | j = i 78 | while j >= 0 and tokens[j].name not in {'NL', 'NEWLINE'}: 79 | t = tokens[j] 80 | if t.line is not None: # pragma: no branch (tokenize-rt<4.2.1) 81 | lines.update(range(t.line, t.line + t.src.count('\n') + 1)) 82 | j -= 1 83 | 84 | lints = set() 85 | for line in lines: 86 | lints.update(flake8_results[line]) 87 | 88 | token = tokens[i] 89 | match = NOQA_RE.search(token.src) 90 | assert match is not None 91 | 92 | def _remove_noqa() -> None: 93 | assert match is not None 94 | if match.group() == token.src: 95 | _remove_comment(tokens, i) 96 | else: 97 | src = NOQA_RE.sub('', token.src).strip() 98 | if not src.startswith('#'): 99 | src = f'# {src}' 100 | tokens[i] = token._replace(src=src) 101 | 102 | # exclude all lints on the line but no lints 103 | if not lints: 104 | _remove_noqa() 105 | elif match.group().lower() != '# noqa': 106 | codes = set(SEP_RE.split(match.group(1)[1:])) 107 | expected_codes = codes & lints 108 | if not expected_codes: 109 | _remove_noqa() 110 | elif expected_codes != codes: 111 | comment = f'# noqa: {", ".join(sorted(expected_codes))}' 112 | tokens[i] = token._replace(src=NOQA_RE.sub(comment, token.src)) 113 | 114 | 115 | def fix_file(filename: str) -> int: 116 | with open(filename, 'rb') as f: 117 | contents_bytes = f.read() 118 | 119 | try: 120 | contents_text = contents_bytes.decode() 121 | except UnicodeDecodeError: 122 | print(f'{filename} is non-utf8 (not supported)') 123 | return 1 124 | 125 | tokens = tokenize_rt.src_to_tokens(contents_text) 126 | 127 | tokens_no_comments = _remove_comments(tokens) 128 | src_no_comments = tokenize_rt.tokens_to_src(tokens_no_comments) 129 | 130 | if src_no_comments == contents_text: 131 | return 0 132 | 133 | fd, path = tempfile.mkstemp( 134 | dir=os.path.dirname(filename), 135 | prefix=os.path.basename(filename), 136 | suffix='.py', 137 | ) 138 | try: 139 | with open(fd, 'wb') as f: 140 | f.write(src_no_comments.encode()) 141 | flake8_results = _run_flake8(path) 142 | finally: 143 | os.remove(path) 144 | 145 | if any('E999' in v for v in flake8_results.values()): 146 | print(f'{filename}: syntax error (skipping)') 147 | return 0 148 | 149 | for i, token in tokenize_rt.reversed_enumerate(tokens): 150 | if token.name != 'COMMENT': 151 | continue 152 | 153 | if NOQA_RE.search(token.src): 154 | _rewrite_noqa_comment(tokens, i, flake8_results) 155 | elif NOQA_FILE_RE.match(token.src) and not flake8_results: 156 | if i == 0 or tokens[i - 1].name == 'NEWLINE': 157 | del tokens[i: i + 2] 158 | else: 159 | _remove_comment(tokens, i) 160 | 161 | newsrc = tokenize_rt.tokens_to_src(tokens) 162 | if newsrc != contents_text: 163 | print(f'Rewriting {filename}') 164 | with open(filename, 'wb') as f: 165 | f.write(newsrc.encode()) 166 | return 1 167 | else: 168 | return 0 169 | 170 | 171 | def main(argv: Sequence[str] | None = None) -> int: 172 | parser = argparse.ArgumentParser() 173 | parser.add_argument('filenames', nargs='*') 174 | args = parser.parse_args(argv) 175 | 176 | retv = 0 177 | for filename in args.filenames: 178 | retv |= fix_file(filename) 179 | return retv 180 | 181 | 182 | if __name__ == '__main__': 183 | raise SystemExit(main()) 184 | --------------------------------------------------------------------------------