├── .activate.sh ├── .deactivate.sh ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── build_publish.yaml │ ├── dependencies.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements.txt ├── silence_lint_error ├── __init__.py ├── cli │ ├── __init__.py │ ├── config.py │ ├── fix_silenced_error.py │ └── silence_lint_error.py ├── comments.py ├── fixing.py ├── linters │ ├── __init__.py │ ├── fixit.py │ ├── flake8.py │ ├── mypy.py │ ├── ruff.py │ └── semgrep.py └── silencing.py ├── tests ├── __init__.py ├── cli │ ├── __init__.py │ ├── fix_silenced_error_test.py │ └── silence_lint_error_test.py ├── comments_test.py └── linters │ ├── __init__.py │ └── fixit_test.py └── tox.ini /.activate.sh: -------------------------------------------------------------------------------- 1 | venv/bin/activate -------------------------------------------------------------------------------- /.deactivate.sh: -------------------------------------------------------------------------------- 1 | deactivate 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-select = B9 3 | extend-ignore = E501 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: weekly 11 | groups: 12 | testing-dependencies: 13 | patterns: 14 | - "*" # group all updates 15 | -------------------------------------------------------------------------------- /.github/workflows/build_publish.yaml: -------------------------------------------------------------------------------- 1 | name: Build and publish 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - v* 8 | 9 | # Only allow one instance of this workflow for each PR. 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref_name }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 5 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: hynek/build-and-inspect-python-package@v2 22 | 23 | publish: 24 | runs-on: ubuntu-latest 25 | timeout-minutes: 5 26 | 27 | needs: [build] 28 | if: github.repository_owner == 'samueljsb' && github.ref_type == 'tag' 29 | 30 | environment: publish 31 | permissions: 32 | id-token: write 33 | 34 | steps: 35 | - uses: actions/download-artifact@v4 36 | with: 37 | name: Packages 38 | path: dist 39 | 40 | - uses: pypa/gh-action-pypi-publish@release/v1 41 | -------------------------------------------------------------------------------- /.github/workflows/dependencies.yaml: -------------------------------------------------------------------------------- 1 | name: dependencies 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - requirements* 7 | 8 | # Only allow one instance of this workflow for each PR. 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | compile: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.12' 22 | cache: 'pip' 23 | - run: python -m pip install uv 24 | - run: uv pip compile --universal --extra=dev --output-file=requirements.txt pyproject.toml 25 | - run: git diff --exit-code 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | # Only allow one instance of this workflow for each PR. 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: | 22 | 3.9 23 | 3.10 24 | 3.11 25 | 3.12 26 | 3.13 27 | allow-prereleases: true 28 | cache: 'pip' 29 | cache-dependency-path: | 30 | requirements.txt 31 | - run: python -m pip install tox 32 | - run: tox run-parallel --parallel-no-spinner 33 | env: 34 | FORCE_COLOR: true 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | 4 | build/ 5 | dist/ 6 | 7 | venv/ 8 | 9 | .tox/ 10 | .coverage 11 | .coverage.* 12 | -------------------------------------------------------------------------------- /.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: check-merge-conflict 6 | - id: check-yaml 7 | - id: debug-statements 8 | - id: double-quote-string-fixer 9 | - id: end-of-file-fixer 10 | - id: mixed-line-ending 11 | - id: name-tests-test 12 | - id: requirements-txt-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/asottile/pyupgrade 15 | rev: v3.20.0 16 | hooks: 17 | - id: pyupgrade 18 | args: [--py39-plus] 19 | - repo: https://github.com/asottile/reorder-python-imports 20 | rev: v3.15.0 21 | hooks: 22 | - id: reorder-python-imports 23 | args: [--py37-plus, --add-import, 'from __future__ import annotations'] 24 | - repo: https://github.com/asottile/add-trailing-comma 25 | rev: v3.2.0 26 | hooks: 27 | - id: add-trailing-comma 28 | - repo: https://github.com/asottile/yesqa 29 | rev: v1.5.0 30 | hooks: 31 | - id: yesqa 32 | additional_dependencies: [flake8-bugbear] 33 | - repo: https://github.com/PyCQA/flake8 34 | rev: '7.2.0' 35 | hooks: 36 | - id: flake8 37 | additional_dependencies: [flake8-bugbear] 38 | - repo: https://github.com/pre-commit/mirrors-mypy 39 | rev: v1.16.0 40 | hooks: 41 | - id: mypy 42 | additional_dependencies: [attrs, pytest] 43 | - repo: https://github.com/pre-commit/pygrep-hooks 44 | rev: v1.10.0 45 | hooks: 46 | - id: python-check-blanket-noqa 47 | - id: python-check-blanket-type-ignore 48 | - id: python-check-mock-methods 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | ## Unreleased 8 | 9 | ## 1.6.0 (2025-04-22) 10 | 11 | ### Changed 12 | 13 | - No longer require `fixit`, `flake8`, and `ruff` 14 | to be installed in the same virtual environment 15 | as `silence-lint-error`. 16 | The tool will now invoke the tasks directly, 17 | using whichever version is available. 18 | This matches thebehaviour for `semgrep` and `mypy`. 19 | 20 | ## 1.5.1 (2024-11-27) 21 | 22 | ### Fixed 23 | 24 | - Handle syntax errors from `ruff>=0.8.0`. 25 | 26 | ## 1.5.0 (2024-08-27) 27 | 28 | ### Added 29 | 30 | - Silence errors with `mypy`. 31 | 32 | ## 1.4.2 (2024-07-01) 33 | 34 | ### Fixed 35 | 36 | - Use `ruff check` instead of `ruff` for running `ruff`. 37 | This makes the tool compatible with ruff>=0.5.0. 38 | 39 | ## 1.4.1 (2024-05-09) 40 | 41 | The tests are no longer included in the built package. 42 | 43 | ## 1.4.0 (2024-04-11) 44 | 45 | ### Added 46 | 47 | - Silence errors with `semgrep`. 48 | 49 | ## 1.3.0 (2023-12-15) 50 | 51 | ### Added 52 | 53 | - Apply auto-fixes with `ruff`. 54 | 55 | ## 1.2.1 (2023-12-11) 56 | 57 | ### Fixed 58 | 59 | - Fix bug where `noqa` is used for all linters' silence comments 60 | 61 | We forgot to pass through the comment type when adding to existing comments. 62 | These were always added as `noqa` comments, even for `fixit`. 63 | 64 | ## 1.2.0 (2023-12-11) 65 | 66 | ### Added 67 | 68 | - Silence `fixit` errors with *inline* comments. 69 | 70 | `fixit` does not always respect `lint-fixme` comments when they are on the 71 | line above the line causing the error. This is a known bug and is reported 72 | in https://github.com/Instagram/Fixit/issues/405. 73 | 74 | In some of these cases (e.g. decorators), placing the comment on the same line 75 | as the error can ensure it is respected. The `fixme-inline` linter option 76 | allows the comments to be added inline instead of on the lien above. 77 | 78 | N.B. This might prevent the comments from being successfully removed by the 79 | `fix fixit` command, if there are other errors ignored on the same line. 80 | 81 | ## 1.1.0 (2023-11-24) 82 | 83 | ### Added 84 | 85 | - Silence errors with `ruff` 86 | 87 | ### Fixed 88 | 89 | - Show helpful error message if the linting tool is not installed 90 | 91 | Until this release, if the tool you were trying to use to find errors wasn't 92 | installed, this tool would succeed with a 'no errors found' message. This is 93 | unhelpful, so we now show the 'No module named ...' error from Python and exit 94 | with a non-zero return code. 95 | 96 | ## 1.0.0 (2023-11-17) 97 | 98 | This tool replaces 99 | [`ignore-flake8-error`](https://github.com/samueljsb/ignore-flake8-error) and 100 | [`silence-fixit-error`](https://github.com/samueljsb/silence-fixit-error). It is 101 | feature-compatible with those tools. 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Samuel Searles-Bryant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # silence-lint-error 2 | 3 | Silent linting errors 4 | by adding `ignore` or `fixme` comments. 5 | 6 | This tool currently works with: 7 | 8 | - [`fixit`](https://github.com/Instagram/Fixit) 9 | - [`flake8`](https://github.com/PyCQA/flake8) (silence only) 10 | - [`mypy`](https://www.mypy-lang.org) (silence only) 11 | - [`ruff`](https://docs.astral.sh/ruff/) 12 | - [`semgrep`](https://semgrep.dev/docs/) (silence only) 13 | 14 | ## Usage 15 | 16 | Install with pip: 17 | 18 | ```shell 19 | python -m pip install silence-lint-error 20 | ``` 21 | 22 | You must also install the linting tool you wish to use. 23 | 24 | ### silence linting errors 25 | 26 | Find linting errors 27 | and add the `ignore` or `fixme` comments as applicable. 28 | 29 | For example, 30 | to add `lint-fixme: CollapseIsinstanceChecks` comments 31 | to ignore the `fixit.rules:CollapseIsinstanceChecks` rule from `fixit`, 32 | run: 33 | 34 | ```shell 35 | silence-lint-error fixit fixit.rules:CollapseIsinstanceChecks path/to/files/ path/to/more/files/ 36 | ``` 37 | 38 | To add `noqa: F401` comments 39 | to ignore the `F401` rule in `flake8`, 40 | run: 41 | 42 | ```shell 43 | silence-lint-error flake8 F401 path/to/files/ path/to/more/files/ 44 | ``` 45 | 46 | To add `noqa: F401` comments 47 | to ignore the `F401` rule in `ruff`, 48 | run: 49 | 50 | ```shell 51 | silence-lint-error ruff F401 path/to/files/ path/to/more/files/ 52 | ``` 53 | 54 | To add `nosemgrep: python.lang.best-practice.sleep.arbitrary-sleep` comments 55 | to ignore the `python.lang.best-practice.sleep.arbitrary-sleep` rule in `semgrep`, 56 | run: 57 | 58 | ```shell 59 | SEMGREP_RULES=r/python silence-lint-error semgrep python.lang.best-practice.sleep.arbitrary-sleep path/to/files/ path/to/more/files/ 60 | ``` 61 | 62 | N.B. The rules must be configured in an environment variable. 63 | For more information about configuring semgrep rules, 64 | see the `--config` entry in the [`semgrep` documentation](https://semgrep.dev/docs/cli-reference-oss/) 65 | 66 | To add `type: ignore` comments 67 | to ignore the `truthy-bool` error from `mypy`, 68 | run: 69 | 70 | ```shell 71 | silence-lint-error mypy truthy-bool path/to/files/ path/to/more/files/ 72 | ``` 73 | 74 | ### fix silenced errors 75 | 76 | If there is an auto-fix for a linting error, 77 | you can remove the `ignore` or `fixme` comments 78 | and apply the auto-fix. 79 | 80 | For example 81 | to remove all `lint-fixme: CollapseIsinstanceChecks` comments 82 | and apply the auto-fix for that rule, 83 | run: 84 | 85 | ```shell 86 | fix-silenced-error fixit fixit.rules:CollapseIsinstanceChecks path/to/files/ path/to/more/files/ 87 | ``` 88 | 89 | To remove `noqa: F401` comments 90 | and apply the auto-fix for that rule, 91 | run: 92 | 93 | ```shell 94 | fix-silenced-error ruff F401 path/to/files/ path/to/more/files/ 95 | ``` 96 | 97 | ## Rationale 98 | 99 | When adding a new rule (or enabling more rules) for a linter 100 | on a large code-base, 101 | fixing the existing violations can be too large a task to do quickly. 102 | However, starting to check the rule sooner 103 | will prevent new violations from being introduced. 104 | 105 | Ignoring existing violations is a quick way to allow new rules to be enabled. 106 | You can then burn down those existing violations over time. 107 | 108 | This tool makes it easy to find and ignore all current violations of a rule 109 | so that it can be enabled. 110 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "silence_lint_error" 3 | version = "1.6.0" 4 | authors = [ 5 | {name = "Samuel Searles-Bryant", email = "sam@samueljsb.co.uk"}, 6 | ] 7 | description = "silence linting errors by adding ignore/fixme comments" 8 | readme = "README.md" 9 | license = "MIT" 10 | license-files = ["LICENSE"] 11 | classifiers = [ # pragma: alphabetize 12 | "Programming Language :: Python :: 3 :: Only", 13 | "Programming Language :: Python :: 3", 14 | "Programming Language :: Python :: 3.9", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Python :: 3.13", 19 | ] 20 | 21 | requires-python = ">=3.9" 22 | dependencies = [ # pragma: alphabetize[case-insensitive] 23 | "attrs", 24 | "tokenize-rt", 25 | ] 26 | 27 | [project.optional-dependencies] 28 | dev = [ # pragma: alphabetize[case-insensitive] 29 | "covdefaults", 30 | "coverage", 31 | "fixit", 32 | "flake8", 33 | "mypy", 34 | "pytest", 35 | "pytest-subprocess", 36 | "ruff", 37 | "semgrep", 38 | ] 39 | 40 | [project.scripts] 41 | silence-lint-error = "silence_lint_error.cli.silence_lint_error:main" 42 | fix-silenced-error = "silence_lint_error.cli.fix_silenced_error:main" 43 | 44 | [project.urls] 45 | Source = "https://github.com/samueljsb/silence-lint-error" 46 | Changelog = "https://github.com/samueljsb/silence-lint-error/blob/main/CHANGELOG.md" 47 | 48 | 49 | # Packaging 50 | # --------- 51 | 52 | [build-system] 53 | requires = ["setuptools>=77.0.3"] 54 | build-backend = "setuptools.build_meta" 55 | 56 | [tool.setuptools] 57 | # This is the default but we include it to be explicit. 58 | include-package-data = true 59 | 60 | 61 | # Mypy 62 | # ==== 63 | 64 | [tool.mypy] 65 | files = "." 66 | exclude = [ 67 | "build/", 68 | "venv", 69 | ] 70 | 71 | pretty = true 72 | show_error_codes = true 73 | 74 | enable_error_code = [ # pragma: alphabetize 75 | "ignore-without-code", 76 | "possibly-undefined", 77 | "truthy-bool", 78 | "truthy-iterable", 79 | ] 80 | strict = true 81 | ignore_missing_imports = true 82 | no_implicit_optional = true 83 | strict_equality = true 84 | warn_unreachable = true 85 | warn_no_return = true 86 | 87 | [[tool.mypy.overrides]] 88 | module = "tests.*" 89 | disallow_incomplete_defs = false 90 | disallow_untyped_defs = false 91 | 92 | 93 | # Pytest 94 | # ====== 95 | 96 | [tool.pytest.ini_options] 97 | addopts = [ 98 | "--strict-markers", 99 | ] 100 | filterwarnings = [ 101 | "error::DeprecationWarning", 102 | "error::pytest.PytestCollectionWarning", 103 | ] 104 | xfail_strict = true 105 | 106 | 107 | # Coverage 108 | # ======== 109 | 110 | [tool.coverage.run] 111 | plugins = ["covdefaults"] 112 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --universal --extra=dev --output-file=requirements.txt pyproject.toml 3 | attrs==25.3.0 4 | # via 5 | # silence-lint-error (pyproject.toml) 6 | # glom 7 | # jsonschema 8 | # referencing 9 | # semgrep 10 | boltons==21.0.0 11 | # via 12 | # face 13 | # glom 14 | # semgrep 15 | bracex==2.5.post1 16 | # via wcmatch 17 | certifi==2025.1.31 18 | # via requests 19 | charset-normalizer==3.4.1 20 | # via requests 21 | click==8.1.8 22 | # via 23 | # click-option-group 24 | # fixit 25 | # moreorless 26 | # semgrep 27 | click-option-group==0.5.7 28 | # via semgrep 29 | colorama==0.4.6 30 | # via 31 | # click 32 | # pytest 33 | # semgrep 34 | covdefaults==2.3.0 35 | # via silence-lint-error (pyproject.toml) 36 | coverage==7.8.0 37 | # via 38 | # silence-lint-error (pyproject.toml) 39 | # covdefaults 40 | defusedxml==0.7.1 41 | # via semgrep 42 | deprecated==1.2.18 43 | # via 44 | # opentelemetry-api 45 | # opentelemetry-exporter-otlp-proto-http 46 | exceptiongroup==1.2.2 47 | # via semgrep 48 | face==24.0.0 49 | # via glom 50 | fixit==2.1.0 51 | # via silence-lint-error (pyproject.toml) 52 | flake8==7.2.0 53 | # via silence-lint-error (pyproject.toml) 54 | glom==22.1.0 55 | # via semgrep 56 | googleapis-common-protos==1.69.2 57 | # via opentelemetry-exporter-otlp-proto-http 58 | idna==3.10 59 | # via requests 60 | importlib-metadata==7.1.0 61 | # via opentelemetry-api 62 | iniconfig==2.1.0 63 | # via pytest 64 | jsonschema==4.23.0 65 | # via semgrep 66 | jsonschema-specifications==2024.10.1 67 | # via jsonschema 68 | libcst==1.7.0 69 | # via fixit 70 | markdown-it-py==3.0.0 71 | # via rich 72 | mccabe==0.7.0 73 | # via flake8 74 | mdurl==0.1.2 75 | # via markdown-it-py 76 | moreorless==0.4.0 77 | # via fixit 78 | mypy==1.15.0 79 | # via silence-lint-error (pyproject.toml) 80 | mypy-extensions==1.0.0 81 | # via mypy 82 | opentelemetry-api==1.25.0 83 | # via 84 | # opentelemetry-exporter-otlp-proto-http 85 | # opentelemetry-instrumentation 86 | # opentelemetry-instrumentation-requests 87 | # opentelemetry-sdk 88 | # opentelemetry-semantic-conventions 89 | # semgrep 90 | opentelemetry-exporter-otlp-proto-common==1.25.0 91 | # via opentelemetry-exporter-otlp-proto-http 92 | opentelemetry-exporter-otlp-proto-http==1.25.0 93 | # via semgrep 94 | opentelemetry-instrumentation==0.46b0 95 | # via opentelemetry-instrumentation-requests 96 | opentelemetry-instrumentation-requests==0.46b0 97 | # via semgrep 98 | opentelemetry-proto==1.25.0 99 | # via 100 | # opentelemetry-exporter-otlp-proto-common 101 | # opentelemetry-exporter-otlp-proto-http 102 | opentelemetry-sdk==1.25.0 103 | # via 104 | # opentelemetry-exporter-otlp-proto-http 105 | # semgrep 106 | opentelemetry-semantic-conventions==0.46b0 107 | # via 108 | # opentelemetry-instrumentation-requests 109 | # opentelemetry-sdk 110 | opentelemetry-util-http==0.46b0 111 | # via opentelemetry-instrumentation-requests 112 | packaging==24.2 113 | # via 114 | # fixit 115 | # pytest 116 | # semgrep 117 | pathspec==0.12.1 118 | # via trailrunner 119 | peewee==3.17.9 120 | # via semgrep 121 | pluggy==1.5.0 122 | # via pytest 123 | protobuf==4.25.6 124 | # via 125 | # googleapis-common-protos 126 | # opentelemetry-proto 127 | pycodestyle==2.13.0 128 | # via flake8 129 | pyflakes==3.3.2 130 | # via flake8 131 | pygments==2.19.1 132 | # via rich 133 | pytest==8.3.5 134 | # via 135 | # silence-lint-error (pyproject.toml) 136 | # pytest-subprocess 137 | pytest-subprocess==1.5.3 138 | # via silence-lint-error (pyproject.toml) 139 | pyyaml==6.0.2 140 | # via libcst 141 | referencing==0.36.2 142 | # via 143 | # jsonschema 144 | # jsonschema-specifications 145 | requests==2.32.3 146 | # via 147 | # opentelemetry-exporter-otlp-proto-http 148 | # semgrep 149 | rich==13.5.3 150 | # via semgrep 151 | rpds-py==0.24.0 152 | # via 153 | # jsonschema 154 | # referencing 155 | ruamel-yaml==0.18.10 156 | # via semgrep 157 | ruamel-yaml-clib==0.2.12 ; python_full_version < '3.13' and platform_python_implementation == 'CPython' 158 | # via ruamel-yaml 159 | ruff==0.11.4 160 | # via silence-lint-error (pyproject.toml) 161 | semgrep==1.117.0 162 | # via silence-lint-error (pyproject.toml) 163 | setuptools==78.1.1 164 | # via opentelemetry-instrumentation 165 | tokenize-rt==6.1.0 166 | # via silence-lint-error (pyproject.toml) 167 | tomli==2.0.2 168 | # via semgrep 169 | trailrunner==1.4.0 170 | # via fixit 171 | typing-extensions==4.13.1 172 | # via 173 | # mypy 174 | # opentelemetry-sdk 175 | # referencing 176 | # semgrep 177 | urllib3==2.3.0 178 | # via 179 | # requests 180 | # semgrep 181 | wcmatch==8.5.2 182 | # via semgrep 183 | wrapt==1.17.2 184 | # via 185 | # deprecated 186 | # opentelemetry-instrumentation 187 | zipp==3.21.0 188 | # via importlib-metadata 189 | -------------------------------------------------------------------------------- /silence_lint_error/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samueljsb/silence-lint-error/e8aeba545dd77ea07152fe5328ce545817265af5/silence_lint_error/__init__.py -------------------------------------------------------------------------------- /silence_lint_error/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samueljsb/silence-lint-error/e8aeba545dd77ea07152fe5328ce545817265af5/silence_lint_error/cli/__init__.py -------------------------------------------------------------------------------- /silence_lint_error/cli/config.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samueljsb/silence-lint-error/e8aeba545dd77ea07152fe5328ce545817265af5/silence_lint_error/cli/config.py -------------------------------------------------------------------------------- /silence_lint_error/cli/fix_silenced_error.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import sys 5 | from collections.abc import Sequence 6 | from typing import NamedTuple 7 | 8 | from silence_lint_error.fixing import Fixer 9 | from silence_lint_error.fixing import Linter 10 | from silence_lint_error.linters import fixit 11 | from silence_lint_error.linters import ruff 12 | 13 | 14 | LINTERS: dict[str, type[Linter]] = { 15 | 'fixit': fixit.Fixit, 16 | 'ruff': ruff.Ruff, 17 | } 18 | 19 | 20 | class Context(NamedTuple): 21 | rule_name: str 22 | file_names: list[str] 23 | linter: Linter 24 | 25 | 26 | def _parse_args(argv: Sequence[str] | None) -> Context: 27 | parser = argparse.ArgumentParser( 28 | description=( 29 | 'Fix linting errors by removing ignore/fixme comments ' 30 | 'and running auto-fixes.' 31 | ), 32 | ) 33 | parser.add_argument( 34 | 'linter', choices=LINTERS, 35 | help='The linter to use to fix the errors', 36 | ) 37 | parser.add_argument('rule_name') 38 | parser.add_argument('filenames', nargs='*') 39 | args = parser.parse_args(argv) 40 | 41 | return Context( 42 | rule_name=args.rule_name, 43 | file_names=args.filenames, 44 | linter=LINTERS[args.linter](), 45 | ) 46 | 47 | 48 | def main(argv: Sequence[str] | None = None) -> int: 49 | rule_name, file_names, linter = _parse_args(argv) 50 | fixer = Fixer(linter) 51 | 52 | print('-> removing comments that silence errors', file=sys.stderr) 53 | changed_files = [] 54 | for filename in file_names: 55 | try: 56 | fixer.unsilence_violations(rule_name=rule_name, filename=filename) 57 | except fixer.NoChangesMade: 58 | continue 59 | else: 60 | print(filename) 61 | changed_files.append(filename) 62 | 63 | if not changed_files: 64 | print('no silenced errors found', file=sys.stderr) 65 | return 0 66 | 67 | print(f'-> applying auto-fixes with {linter.name}', file=sys.stderr) 68 | ret, message = fixer.apply_fixes(rule_name=rule_name, filenames=changed_files) 69 | print(message, file=sys.stderr) 70 | 71 | return ret 72 | 73 | 74 | if __name__ == '__main__': 75 | raise SystemExit(main()) 76 | -------------------------------------------------------------------------------- /silence_lint_error/cli/silence_lint_error.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import sys 5 | from collections.abc import Sequence 6 | from typing import NamedTuple 7 | 8 | from silence_lint_error.linters import fixit 9 | from silence_lint_error.linters import flake8 10 | from silence_lint_error.linters import mypy 11 | from silence_lint_error.linters import ruff 12 | from silence_lint_error.linters import semgrep 13 | from silence_lint_error.silencing import ErrorRunningTool 14 | from silence_lint_error.silencing import Linter 15 | from silence_lint_error.silencing import Silencer 16 | 17 | 18 | LINTERS: dict[str, type[Linter]] = { 19 | 'fixit': fixit.Fixit, 20 | 'fixit-inline': fixit.FixitInline, 21 | 'flake8': flake8.Flake8, 22 | 'mypy': mypy.Mypy, 23 | 'ruff': ruff.Ruff, 24 | 'semgrep': semgrep.Semgrep, 25 | } 26 | 27 | 28 | class Context(NamedTuple): 29 | rule_name: str 30 | file_names: list[str] 31 | linter: Linter 32 | 33 | 34 | def _parse_args(argv: Sequence[str] | None) -> Context: 35 | parser = argparse.ArgumentParser( 36 | description='Ignore linting errors by adding ignore/fixme comments.', 37 | ) 38 | parser.add_argument( 39 | 'linter', choices=LINTERS, 40 | help='The linter for which to ignore errors', 41 | ) 42 | parser.add_argument('rule_name') 43 | parser.add_argument('filenames', nargs='*') 44 | args = parser.parse_args(argv) 45 | 46 | return Context( 47 | rule_name=args.rule_name, 48 | file_names=args.filenames, 49 | linter=LINTERS[args.linter](), 50 | ) 51 | 52 | 53 | def main(argv: Sequence[str] | None = None) -> int: 54 | rule_name, file_names, linter = _parse_args(argv) 55 | silencer = Silencer(linter) 56 | 57 | print(f'-> finding errors with {linter.name}', file=sys.stderr) 58 | try: 59 | violations = silencer.find_violations( 60 | rule_name=rule_name, file_names=file_names, 61 | ) 62 | except ErrorRunningTool as e: 63 | print(f'ERROR: {e.proc.stderr.strip()}', file=sys.stderr) 64 | return e.proc.returncode 65 | except silencer.NoViolationsFound: 66 | print('no errors found', file=sys.stderr) 67 | return 0 68 | except silencer.MultipleRulesViolated as e: 69 | print( 70 | 'ERROR: errors found for multiple rules:', sorted(e.rule_names), 71 | file=sys.stderr, 72 | ) 73 | return 1 74 | else: 75 | print(f'found errors in {len(violations)} files', file=sys.stderr) 76 | 77 | print('-> adding comments to silence errors', file=sys.stderr) 78 | ret = 0 79 | for filename, file_violations in violations.items(): 80 | print(filename) 81 | ret |= silencer.silence_violations( 82 | filename=filename, violations=file_violations, 83 | ) 84 | 85 | return ret 86 | 87 | 88 | if __name__ == '__main__': 89 | raise SystemExit(main()) 90 | -------------------------------------------------------------------------------- /silence_lint_error/comments.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import tokenize_rt 4 | 5 | 6 | def add_error_silencing_comments( 7 | src: str, error_lines: set[int], 8 | comment_type: str, error_code: str, 9 | ) -> str: 10 | """Add comments to some code to silence linting errors. 11 | 12 | Args: 13 | src: The content of the module to add comments to. 14 | error_lines: The lines on which to silence errors. 15 | comment_type: The type of comment to add (e.g. `noqa` or `lint-fixme`) 16 | code: The error code to silence. 17 | 18 | Returns: 19 | The content of the module with the additional comments added. 20 | """ 21 | tokens = tokenize_rt.src_to_tokens(src) 22 | 23 | for idx, token in tokenize_rt.reversed_enumerate(tokens): 24 | if token.line not in error_lines: 25 | continue 26 | if not token.src.strip(): 27 | continue 28 | 29 | if token.name == 'COMMENT': 30 | new_comment = add_code_to_comment(token.src, comment_type, error_code) 31 | tokens[idx] = tokens[idx]._replace(src=new_comment) 32 | else: 33 | tokens.insert( 34 | idx+1, tokenize_rt.Token( 35 | 'COMMENT', f'# {comment_type}: {error_code}', 36 | ), 37 | ) 38 | tokens.insert(idx+1, tokenize_rt.Token('UNIMPORTANT_WS', ' ')) 39 | 40 | error_lines.remove(token.line) 41 | 42 | return tokenize_rt.tokens_to_src(tokens) # type: ignore[no-any-return] 43 | # tokenize-rt is a single-file distribution so cannot provide type 44 | # information. See https://github.com/asottile/tokenize-rt/issues/147. 45 | 46 | 47 | def add_noqa_comments(src: str, lines: set[int], error_code: str) -> str: 48 | """Add `noqa` comments to some code. 49 | 50 | Args: 51 | src: The content of the module to add `noqa` comments to. 52 | lines: The lines on which to add `noqa` coments. 53 | code: The error code to silence. 54 | 55 | Returns: 56 | The content of the module with the additional comments added. 57 | """ 58 | return add_error_silencing_comments(src, lines, 'noqa', error_code) 59 | 60 | 61 | def add_code_to_comment(comment: str, comment_type: str, code: str) -> str: 62 | """Add to a comment to make it a error-silencing comment. 63 | 64 | If the comment already includes an error silencing section of the same type, this 65 | will add the code to the list of silenced errors. 66 | """ 67 | if f'{comment_type}: ' in comment: 68 | return comment.replace( 69 | f'{comment_type}: ', f'{comment_type}: {code},', 1, 70 | ) 71 | else: 72 | return comment + f' # {comment_type}: {code}' 73 | 74 | 75 | def remove_error_silencing_comments( 76 | src: str, 77 | comment_type: str, error_code: str, 78 | ) -> str: 79 | """Remove comments that silence linting errors from some code. 80 | 81 | Args: 82 | src: The content of the module to remove comments from. 83 | comment_type: The type of comment to remove (e.g. `noqa` or `lint-fixme`) 84 | error_code: The error code that is silenced. 85 | 86 | Returns: 87 | The content of the module without the comments that slence this error code. 88 | """ 89 | tokens = tokenize_rt.src_to_tokens(src) 90 | 91 | for idx, token in tokenize_rt.reversed_enumerate(tokens): 92 | if ( 93 | token.name == 'COMMENT' 94 | and comment_type in token.src and error_code in token.src 95 | ): 96 | new_comment = remove_code_from_comment( 97 | token.src, comment_type, error_code, 98 | ) 99 | if new_comment: 100 | tokens[idx] = tokens[idx]._replace(src=new_comment) 101 | else: 102 | tokens.pop(idx) 103 | 104 | # delete trailing whitespace caused by removing comments 105 | elif ( 106 | token.name == 'UNIMPORTANT_WS' 107 | and tokens[idx+1].name in {'NEWLINE', 'NL'} 108 | ): 109 | tokens.pop(idx) 110 | 111 | return tokenize_rt.tokens_to_src(tokens) # type: ignore[no-any-return] 112 | # tokenize-rt is a single-file distribution so cannot provide type 113 | # information. See https://github.com/asottile/tokenize-rt/issues/147. 114 | 115 | 116 | def remove_code_from_comment(comment: str, comment_type: str, code: str) -> str: 117 | """Remove the error-silencing portion from a comment.""" 118 | return ( 119 | comment 120 | .replace(f'{comment_type}: {code},', f'{comment_type}: ') 121 | .replace(f',{code}', '') 122 | .removeprefix(f'# {comment_type}: {code}') 123 | .removesuffix(f'{comment_type}: {code}') 124 | .strip() 125 | .removesuffix(' #') 126 | ) 127 | -------------------------------------------------------------------------------- /silence_lint_error/fixing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Sequence 4 | from typing import Protocol 5 | 6 | import attrs 7 | 8 | 9 | class Linter(Protocol): 10 | name: str 11 | 12 | def remove_silence_comments(self, src: str, rule_name: str) -> str: 13 | """Remove comments that silence rule violations. 14 | 15 | Returns: 16 | Modified `src` without comments that silence the `violations`. 17 | """ 18 | 19 | def apply_fixes( 20 | self, rule_name: str, filenames: Sequence[str], 21 | ) -> tuple[int, str]: 22 | """Fix violations of a rule. 23 | 24 | Returns: 25 | Return code and stdout from the process that fixed the violations. 26 | """ 27 | 28 | 29 | @attrs.frozen 30 | class Fixer: 31 | linter: Linter 32 | 33 | class NoChangesMade(Exception): 34 | pass 35 | 36 | def unsilence_violations( 37 | self, *, rule_name: str, filename: str, 38 | ) -> None: 39 | with open(filename) as f: 40 | src = f.read() 41 | 42 | src_without_comments = self.linter.remove_silence_comments(src, rule_name) 43 | 44 | if src_without_comments == src: 45 | raise self.NoChangesMade 46 | 47 | with open(filename, 'w') as f: 48 | f.write(src_without_comments) 49 | 50 | def apply_fixes( 51 | self, *, rule_name: str, filenames: Sequence[str], 52 | ) -> tuple[int, str]: 53 | return self.linter.apply_fixes(rule_name, filenames) 54 | -------------------------------------------------------------------------------- /silence_lint_error/linters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samueljsb/silence-lint-error/e8aeba545dd77ea07152fe5328ce545817265af5/silence_lint_error/linters/__init__.py -------------------------------------------------------------------------------- /silence_lint_error/linters/fixit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import subprocess 5 | from collections import defaultdict 6 | from collections.abc import Iterator 7 | from collections.abc import Sequence 8 | from typing import TYPE_CHECKING 9 | 10 | from silence_lint_error import comments 11 | from silence_lint_error.silencing import ErrorRunningTool 12 | from silence_lint_error.silencing import Violation 13 | 14 | if TYPE_CHECKING: 15 | from typing import TypeAlias 16 | 17 | FileName: TypeAlias = str 18 | RuleName: TypeAlias = str 19 | 20 | 21 | class Fixit: 22 | name = 'fixit' 23 | 24 | def __init__(self) -> None: 25 | self.error_line_re = re.compile(r'^.*?@\d+:\d+ ') 26 | 27 | def find_violations( 28 | self, rule_name: RuleName, filenames: Sequence[FileName], 29 | ) -> dict[FileName, list[Violation]]: 30 | proc = subprocess.run( 31 | ( 32 | 'fixit', 33 | '--rules', rule_name, 34 | 'lint', *filenames, 35 | ), 36 | capture_output=True, 37 | text=True, 38 | ) 39 | 40 | if proc.returncode and proc.stderr.endswith('No module named fixit\n'): 41 | raise ErrorRunningTool(proc) 42 | 43 | # extract filenames and line numbers 44 | results: dict[str, list[Violation]] = defaultdict(list) 45 | for line in proc.stdout.splitlines(): 46 | found_error = self._parse_output_line(line) 47 | if found_error: 48 | filename, violation = found_error 49 | results[filename].append(violation) 50 | else: # pragma: no cover 51 | pass 52 | 53 | return results 54 | 55 | def _parse_output_line( 56 | self, line: str, 57 | ) -> tuple[FileName, Violation] | None: 58 | if not self.error_line_re.match(line): 59 | return None 60 | 61 | location, violated_rule_name, *__ = line.split(maxsplit=2) 62 | filename, position = location.split('@', maxsplit=1) 63 | lineno, *__ = position.split(':', maxsplit=1) 64 | 65 | rule_name_ = violated_rule_name.removesuffix(':') 66 | return filename, Violation(rule_name_, int(lineno)) 67 | 68 | def silence_violations( 69 | self, src: str, violations: Sequence[Violation], 70 | ) -> str: 71 | [rule_name] = {violation.rule_name for violation in violations} 72 | linenos_to_silence = {violation.lineno for violation in violations} 73 | 74 | lines = src.splitlines(keepends=True) 75 | 76 | new_lines = [] 77 | for current_lineno, line in enumerate(lines, start=1): 78 | if current_lineno in linenos_to_silence: 79 | leading_ws = line.removesuffix(line.lstrip()) 80 | new_lines.append(f'{leading_ws}# lint-fixme: {rule_name}\n') 81 | new_lines.append(line) 82 | 83 | return ''.join(new_lines) 84 | 85 | def remove_silence_comments(self, src: str, rule_name: RuleName) -> str: 86 | return ''.join( 87 | self._remove_comments( 88 | src.splitlines(keepends=True), rule_name, 89 | ), 90 | ) 91 | 92 | def _remove_comments( 93 | self, lines: Sequence[str], rule_name: RuleName, 94 | ) -> Iterator[str]: 95 | __, rule_id = rule_name.rsplit(':', maxsplit=1) 96 | fixme_comment = f'# lint-fixme: {rule_id}' 97 | for line in lines: 98 | if line.strip() == fixme_comment: # fixme comment only 99 | continue 100 | elif line.rstrip().endswith(fixme_comment): # code then fixme comment 101 | trailing_ws = line.removeprefix(line.rstrip()) 102 | line_without_comment = ( 103 | line.rstrip().removesuffix(fixme_comment) # remove comment 104 | .rstrip() # and remove any intermediate ws 105 | ) 106 | yield line_without_comment + trailing_ws 107 | else: 108 | yield line 109 | 110 | def apply_fixes( 111 | self, rule_name: RuleName, filenames: Sequence[str], 112 | ) -> tuple[int, str]: 113 | proc = subprocess.run( 114 | ( 115 | 'fixit', 116 | '--rules', rule_name, 117 | 'fix', '--automatic', *filenames, 118 | ), 119 | capture_output=True, text=True, 120 | ) 121 | return proc.returncode, proc.stderr.strip() 122 | 123 | 124 | class FixitInline(Fixit): 125 | """An alternative `fixit` implementation that adds `lint-fixme` comment inline. 126 | 127 | This is sometimes necessary because `fixit` does not always respect `lint-fixme` 128 | comments when they are on the line above the line causing the error. This is a 129 | known bug and is reported in https://github.com/Instagram/Fixit/issues/405. 130 | 131 | In some of these cases, placing the comment on the same line as the error can 132 | ensure it is respected (e.g. for decorators). 133 | """ 134 | 135 | def silence_violations( 136 | self, src: str, violations: Sequence[Violation], 137 | ) -> str: 138 | [rule_name] = {violation.rule_name for violation in violations} 139 | linenos_to_silence = {violation.lineno for violation in violations} 140 | return comments.add_error_silencing_comments( 141 | src, linenos_to_silence, 142 | 'lint-fixme', rule_name, 143 | ) 144 | -------------------------------------------------------------------------------- /silence_lint_error/linters/flake8.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | from collections import defaultdict 5 | from collections.abc import Sequence 6 | from typing import TYPE_CHECKING 7 | 8 | from silence_lint_error import comments 9 | from silence_lint_error.silencing import ErrorRunningTool 10 | from silence_lint_error.silencing import Violation 11 | 12 | if TYPE_CHECKING: 13 | from typing import TypeAlias 14 | 15 | FileName: TypeAlias = str 16 | RuleName: TypeAlias = str 17 | 18 | 19 | class Flake8: 20 | name = 'flake8' 21 | 22 | def find_violations( 23 | self, rule_name: RuleName, filenames: Sequence[FileName], 24 | ) -> dict[FileName, list[Violation]]: 25 | proc = subprocess.run( 26 | ( 27 | 'flake8', 28 | '--select', rule_name, 29 | '--format', '%(path)s %(row)s', 30 | *filenames, 31 | ), 32 | capture_output=True, 33 | text=True, 34 | ) 35 | 36 | if proc.returncode and proc.stderr.endswith('No module named flake8\n'): 37 | raise ErrorRunningTool(proc) 38 | 39 | # extract filenames and line numbers 40 | results: dict[FileName, list[Violation]] = defaultdict(list) 41 | for line in proc.stdout.splitlines(): 42 | filename_, lineno_ = line.rsplit(maxsplit=1) 43 | results[filename_].append(Violation(rule_name, int(lineno_))) 44 | 45 | return results 46 | 47 | def silence_violations( 48 | self, src: str, violations: Sequence[Violation], 49 | ) -> str: 50 | [rule_name] = {violation.rule_name for violation in violations} 51 | linenos_to_silence = {violation.lineno for violation in violations} 52 | return comments.add_noqa_comments(src, linenos_to_silence, rule_name) 53 | -------------------------------------------------------------------------------- /silence_lint_error/linters/mypy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | from collections import defaultdict 5 | from collections.abc import Sequence 6 | from typing import TYPE_CHECKING 7 | 8 | import tokenize_rt 9 | 10 | from silence_lint_error.silencing import ErrorRunningTool 11 | from silence_lint_error.silencing import Violation 12 | 13 | if TYPE_CHECKING: 14 | from typing import TypeAlias 15 | 16 | FileName: TypeAlias = str 17 | RuleName: TypeAlias = str 18 | 19 | 20 | class Mypy: 21 | name = 'mypy' 22 | 23 | def find_violations( 24 | self, rule_name: RuleName, filenames: Sequence[FileName], 25 | ) -> dict[FileName, list[Violation]]: 26 | proc = subprocess.run( 27 | ( 28 | 'mypy', 29 | '--follow-imports', 'silent', # do not report errors in other modules 30 | '--enable-error-code', rule_name, 31 | '--show-error-codes', '--no-pretty', '--no-error-summary', 32 | *filenames, 33 | ), 34 | capture_output=True, 35 | text=True, 36 | ) 37 | 38 | if proc.returncode > 1: 39 | raise ErrorRunningTool(proc) 40 | 41 | # extract filenames and line numbers 42 | results: dict[FileName, list[Violation]] = defaultdict(list) 43 | for line in proc.stdout.splitlines(): 44 | if not line.endswith(f'[{rule_name}]'): 45 | continue 46 | 47 | location, *__ = line.split() 48 | filename_, lineno_, *__ = location.split(':') 49 | 50 | results[filename_].append(Violation(rule_name, int(lineno_))) 51 | 52 | return results 53 | 54 | def silence_violations( 55 | self, src: str, violations: Sequence[Violation], 56 | ) -> str: 57 | rule_name = violations[0].rule_name 58 | lines_with_errors = { 59 | violation.lineno 60 | for violation in violations 61 | } 62 | 63 | tokens = tokenize_rt.src_to_tokens(src) 64 | for idx, token in tokenize_rt.reversed_enumerate(tokens): 65 | if token.line not in lines_with_errors: 66 | continue 67 | if not token.src.strip(): 68 | continue 69 | 70 | if token.name == 'COMMENT': 71 | if 'type: ignore' in token.src: 72 | prefix, __, ignored = token.src.partition('type: ignore') 73 | codes = ignored.strip('[]').split(',') 74 | codes += [rule_name] 75 | new_comment = f'{prefix}type: ignore[{",".join(codes)}]' 76 | else: 77 | new_comment = token.src + f' # type: ignore[{rule_name}]' 78 | tokens[idx] = tokens[idx]._replace(src=new_comment) 79 | else: 80 | tokens.insert( 81 | idx+1, tokenize_rt.Token( 82 | 'COMMENT', f'# type: ignore[{rule_name}]', 83 | ), 84 | ) 85 | tokens.insert(idx+1, tokenize_rt.Token('UNIMPORTANT_WS', ' ')) 86 | 87 | lines_with_errors.remove(token.line) 88 | 89 | return tokenize_rt.tokens_to_src(tokens) # type: ignore[no-any-return] 90 | # tokenize-rt is a single-file distribution so cannot provide type 91 | # information. See https://github.com/asottile/tokenize-rt/issues/147. 92 | -------------------------------------------------------------------------------- /silence_lint_error/linters/ruff.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import subprocess 5 | from collections import defaultdict 6 | from collections.abc import Sequence 7 | from typing import TYPE_CHECKING 8 | 9 | from silence_lint_error import comments 10 | from silence_lint_error.silencing import ErrorRunningTool 11 | from silence_lint_error.silencing import Violation 12 | 13 | if TYPE_CHECKING: 14 | from typing import TypeAlias 15 | 16 | FileName: TypeAlias = str 17 | RuleName: TypeAlias = str 18 | 19 | 20 | class Ruff: 21 | name = 'ruff' 22 | 23 | def find_violations( 24 | self, rule_name: RuleName, filenames: Sequence[FileName], 25 | ) -> dict[FileName, list[Violation]]: 26 | proc = subprocess.run( 27 | ( 28 | 'ruff', 'check', 29 | '--select', rule_name, 30 | '--output-format', 'json', 31 | *filenames, 32 | ), 33 | capture_output=True, 34 | text=True, 35 | ) 36 | 37 | if proc.returncode and proc.stderr.endswith('No module named ruff\n'): 38 | raise ErrorRunningTool(proc) 39 | 40 | # extract filenames and line numbers 41 | all_violations = json.loads(proc.stdout) 42 | results: dict[FileName, list[Violation]] = defaultdict(list) 43 | for violation in all_violations: 44 | if violation['code'] is None: 45 | # ignore syntax errors while parsing the file 46 | continue 47 | 48 | results[violation['filename']].append( 49 | Violation( 50 | rule_name=violation['code'], 51 | lineno=violation['location']['row'], 52 | ), 53 | ) 54 | 55 | return results 56 | 57 | def silence_violations( 58 | self, src: str, violations: Sequence[Violation], 59 | ) -> str: 60 | [rule_name] = {violation.rule_name for violation in violations} 61 | linenos_to_silence = {violation.lineno for violation in violations} 62 | return comments.add_noqa_comments(src, linenos_to_silence, rule_name) 63 | 64 | def remove_silence_comments(self, src: str, rule_name: RuleName) -> str: 65 | return comments.remove_error_silencing_comments( 66 | src, comment_type='noqa', error_code=rule_name, 67 | ) 68 | 69 | def apply_fixes( 70 | self, rule_name: RuleName, filenames: Sequence[str], 71 | ) -> tuple[int, str]: 72 | proc = subprocess.run( 73 | ( 74 | 'ruff', 'check', 75 | '--fix', 76 | '--select', rule_name, 77 | *filenames, 78 | ), 79 | capture_output=True, text=True, 80 | ) 81 | return proc.returncode, proc.stdout.strip() 82 | -------------------------------------------------------------------------------- /silence_lint_error/linters/semgrep.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import subprocess 5 | from collections import defaultdict 6 | from collections.abc import Sequence 7 | from typing import TYPE_CHECKING 8 | 9 | from silence_lint_error.silencing import ErrorRunningTool 10 | from silence_lint_error.silencing import Violation 11 | 12 | if TYPE_CHECKING: 13 | from typing import TypeAlias 14 | 15 | FileName: TypeAlias = str 16 | RuleName: TypeAlias = str 17 | 18 | 19 | class Semgrep: 20 | name = 'semgrep' 21 | 22 | def find_violations( 23 | self, rule_name: RuleName, filenames: Sequence[FileName], 24 | ) -> dict[FileName, list[Violation]]: 25 | proc = subprocess.run( 26 | ( 27 | 'semgrep', 'scan', 28 | '--metrics=off', '--oss-only', 29 | '--json', 30 | *filenames, 31 | ), 32 | capture_output=True, 33 | text=True, 34 | ) 35 | 36 | if proc.returncode: 37 | raise ErrorRunningTool(proc) 38 | 39 | # extract filenames and line numbers 40 | results: dict[FileName, list[Violation]] = defaultdict(list) 41 | data = json.loads(proc.stdout) 42 | for result in data['results']: 43 | if result['check_id'] != rule_name: 44 | continue 45 | 46 | results[result['path']].append( 47 | Violation( 48 | rule_name=result['check_id'], 49 | lineno=result['start']['line'], 50 | ), 51 | ) 52 | 53 | return dict(results) 54 | 55 | def silence_violations( 56 | self, src: str, violations: Sequence[Violation], 57 | ) -> str: 58 | [rule_name] = {violation.rule_name for violation in violations} 59 | linenos_to_silence = {violation.lineno for violation in violations} 60 | 61 | lines = src.splitlines(keepends=True) 62 | 63 | new_lines = [] 64 | for current_lineno, line in enumerate(lines, start=1): 65 | if current_lineno in linenos_to_silence: 66 | leading_ws = line.removesuffix(line.lstrip()) 67 | new_lines.append(f'{leading_ws}# nosemgrep: {rule_name}\n') 68 | new_lines.append(line) 69 | 70 | return ''.join(new_lines) 71 | -------------------------------------------------------------------------------- /silence_lint_error/silencing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | from collections.abc import Sequence 5 | from typing import Protocol 6 | 7 | import attrs 8 | 9 | 10 | @attrs.frozen 11 | class Violation: 12 | rule_name: str 13 | lineno: int 14 | 15 | 16 | @attrs.frozen 17 | class ErrorRunningTool(Exception): 18 | proc: subprocess.CompletedProcess[str] 19 | 20 | 21 | class Linter(Protocol): 22 | name: str 23 | 24 | def find_violations( 25 | self, rule_name: str, filenames: Sequence[str], 26 | ) -> dict[str, list[Violation]]: 27 | """Find violations of a rule. 28 | 29 | Returns: 30 | Mapping of file path to the violations found in that file. 31 | 32 | Raises: 33 | ErrorRunningTool: There was an error whilst running the linter. 34 | """ 35 | 36 | def silence_violations( 37 | self, src: str, violations: Sequence[Violation], 38 | ) -> str: 39 | """Modify module source to silence violations. 40 | 41 | Returns: 42 | Modified `src` with comments that silence the `violations`. 43 | """ 44 | 45 | 46 | @attrs.frozen 47 | class Silencer: 48 | linter: Linter 49 | 50 | class NoViolationsFound(Exception): 51 | pass 52 | 53 | @attrs.frozen 54 | class MultipleRulesViolated(Exception): 55 | rule_names: set[str] 56 | 57 | def find_violations( 58 | self, *, rule_name: str, file_names: Sequence[str], 59 | ) -> dict[str, list[Violation]]: 60 | violations = self.linter.find_violations(rule_name, file_names) 61 | 62 | if not violations: 63 | raise self.NoViolationsFound 64 | 65 | violation_names = { 66 | violation.rule_name 67 | for file_violations in violations.values() 68 | for violation in file_violations 69 | } 70 | if len(violation_names) != 1: 71 | raise self.MultipleRulesViolated(violation_names) 72 | 73 | return violations 74 | 75 | def silence_violations( 76 | self, *, filename: str, violations: Sequence[Violation], 77 | ) -> bool: 78 | with open(filename) as f: 79 | src = f.read() 80 | 81 | src_with_comments = self.linter.silence_violations(src, violations) 82 | 83 | with open(filename, 'w') as f: 84 | f.write(src_with_comments) 85 | 86 | return src_with_comments != src 87 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samueljsb/silence-lint-error/e8aeba545dd77ea07152fe5328ce545817265af5/tests/__init__.py -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samueljsb/silence-lint-error/e8aeba545dd77ea07152fe5328ce545817265af5/tests/cli/__init__.py -------------------------------------------------------------------------------- /tests/cli/fix_silenced_error_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from silence_lint_error.cli.fix_silenced_error import main 8 | 9 | 10 | class TestFixit: 11 | def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): 12 | src = """\ 13 | x = None 14 | # lint-fixme: CollapseIsinstanceChecks 15 | isinstance(x, str) or isinstance(x, int) 16 | isinstance(x, bool) or isinstance(x, float) # lint-fixme: CollapseIsinstanceChecks 17 | 18 | def f(x): 19 | # lint-ignore: CollapseIsinstanceChecks 20 | return isinstance(x, str) or isinstance(x, int) 21 | """ 22 | 23 | python_module = tmp_path / 't.py' 24 | python_module.write_text(src) 25 | 26 | ret = main( 27 | ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), 28 | ) 29 | 30 | assert ret == 0 31 | assert python_module.read_text() == """\ 32 | x = None 33 | isinstance(x, (str, int)) 34 | isinstance(x, (bool, float)) 35 | 36 | def f(x): 37 | # lint-ignore: CollapseIsinstanceChecks 38 | return isinstance(x, str) or isinstance(x, int) 39 | """ 40 | 41 | captured = capsys.readouterr() 42 | assert captured.out == f"""\ 43 | {python_module} 44 | """ 45 | assert captured.err == """\ 46 | -> removing comments that silence errors 47 | -> applying auto-fixes with fixit 48 | 🛠️ 1 file checked, 1 file with errors, 2 auto-fixes available, 2 fixes applied 🛠️ 49 | """ 50 | 51 | def test_main_no_violations( 52 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 53 | ): 54 | src = """\ 55 | def foo(): 56 | print('hello there') 57 | """ 58 | 59 | python_module = tmp_path / 't.py' 60 | python_module.write_text(src) 61 | 62 | ret = main( 63 | ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), 64 | ) 65 | 66 | assert ret == 0 67 | assert python_module.read_text() == src 68 | 69 | captured = capsys.readouterr() 70 | assert captured.out == '' 71 | assert captured.err == """\ 72 | -> removing comments that silence errors 73 | no silenced errors found 74 | """ 75 | 76 | 77 | class TestRuff: 78 | def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): 79 | src = """\ 80 | import math 81 | import os # noqa: F401 82 | import sys 83 | 84 | print(math.pi, file=sys.stderr) 85 | """ 86 | 87 | python_module = tmp_path / 't.py' 88 | python_module.write_text(src) 89 | 90 | ret = main(('ruff', 'F401', str(python_module))) 91 | 92 | assert ret == 0 93 | assert python_module.read_text() == """\ 94 | import math 95 | import sys 96 | 97 | print(math.pi, file=sys.stderr) 98 | """ 99 | 100 | captured = capsys.readouterr() 101 | assert captured.out == f"""\ 102 | {python_module} 103 | """ 104 | assert captured.err == """\ 105 | -> removing comments that silence errors 106 | -> applying auto-fixes with ruff 107 | Found 1 error (1 fixed, 0 remaining). 108 | """ 109 | 110 | def test_main_no_violations( 111 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 112 | ): 113 | src = """\ 114 | import math 115 | import sys 116 | 117 | print(math.pi, file=sys.stderr) 118 | """ 119 | 120 | python_module = tmp_path / 't.py' 121 | python_module.write_text(src) 122 | 123 | ret = main(('ruff', 'F401', str(python_module))) 124 | 125 | assert ret == 0 126 | assert python_module.read_text() == src 127 | 128 | captured = capsys.readouterr() 129 | assert captured.out == '' 130 | assert captured.err == """\ 131 | -> removing comments that silence errors 132 | no silenced errors found 133 | """ 134 | -------------------------------------------------------------------------------- /tests/cli/silence_lint_error_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from unittest import mock 6 | 7 | import pytest 8 | from pytest_subprocess import FakeProcess 9 | 10 | from silence_lint_error.cli.silence_lint_error import main 11 | 12 | 13 | class TestFixit: 14 | def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): 15 | python_module = tmp_path / 't.py' 16 | python_module.write_text( 17 | """\ 18 | x = None 19 | isinstance(x, str) or isinstance(x, int) 20 | 21 | def f(x): 22 | return isinstance(x, str) or isinstance(x, int) 23 | """, 24 | ) 25 | 26 | ret = main( 27 | ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), 28 | ) 29 | 30 | assert ret == 1 31 | assert python_module.read_text() == """\ 32 | x = None 33 | # lint-fixme: CollapseIsinstanceChecks 34 | isinstance(x, str) or isinstance(x, int) 35 | 36 | def f(x): 37 | # lint-fixme: CollapseIsinstanceChecks 38 | return isinstance(x, str) or isinstance(x, int) 39 | """ 40 | 41 | captured = capsys.readouterr() 42 | assert captured.out == f"""\ 43 | {python_module} 44 | """ 45 | assert captured.err == """\ 46 | -> finding errors with fixit 47 | found errors in 1 files 48 | -> adding comments to silence errors 49 | """ 50 | 51 | def test_main_no_violations( 52 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 53 | ): 54 | src = """\ 55 | def foo(): 56 | print('hello there') 57 | """ 58 | 59 | python_module = tmp_path / 't.py' 60 | python_module.write_text(src) 61 | 62 | ret = main( 63 | ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), 64 | ) 65 | 66 | assert ret == 0 67 | assert python_module.read_text() == src 68 | 69 | captured = capsys.readouterr() 70 | assert captured.out == '' 71 | assert captured.err == """\ 72 | -> finding errors with fixit 73 | no errors found 74 | """ 75 | 76 | def test_main_multiple_different_violations( 77 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 78 | ): 79 | src = """\ 80 | x = None 81 | isinstance(x, str) or isinstance(x, int) 82 | 83 | if True: 84 | pass 85 | """ 86 | 87 | python_module = tmp_path / 't.py' 88 | python_module.write_text(src) 89 | 90 | ret = main(('fixit', 'fixit.rules', str(python_module))) 91 | 92 | assert ret == 1 93 | 94 | captured = capsys.readouterr() 95 | assert captured.out == '' 96 | assert captured.err == """\ 97 | -> finding errors with fixit 98 | ERROR: errors found for multiple rules: ['CollapseIsinstanceChecks', 'NoStaticIfCondition'] 99 | """ # noqa: B950 100 | 101 | def test_not_installed(self, capsys: pytest.CaptureFixture[str]): 102 | with FakeProcess() as process: 103 | process.register( 104 | ('fixit', process.any()), 105 | returncode=1, stderr='/path/to/python3: No module named fixit\n', 106 | ) 107 | 108 | ret = main(('fixit', 'fixit.rules', 'path/to/file.py')) 109 | 110 | assert ret == 1 111 | 112 | captured = capsys.readouterr() 113 | assert captured.out == '' 114 | assert captured.err == """\ 115 | -> finding errors with fixit 116 | ERROR: /path/to/python3: No module named fixit 117 | """ 118 | 119 | 120 | class TestFixitInline: 121 | def test_main_inline(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): 122 | python_module = tmp_path / 't.py' 123 | python_module.write_text( 124 | """\ 125 | x = None 126 | isinstance(x, str) or isinstance(x, int) 127 | 128 | def f(x): 129 | return isinstance(x, str) or isinstance(x, int) 130 | """, 131 | ) 132 | 133 | ret = main( 134 | ( 135 | 'fixit-inline', 136 | 'fixit.rules:CollapseIsinstanceChecks', 137 | str(python_module), 138 | ), 139 | ) 140 | 141 | assert ret == 1 142 | assert python_module.read_text() == """\ 143 | x = None 144 | isinstance(x, str) or isinstance(x, int) # lint-fixme: CollapseIsinstanceChecks 145 | 146 | def f(x): 147 | return isinstance(x, str) or isinstance(x, int) # lint-fixme: CollapseIsinstanceChecks 148 | """ # noqa: B950 149 | 150 | captured = capsys.readouterr() 151 | assert captured.out == f"""\ 152 | {python_module} 153 | """ 154 | assert captured.err == """\ 155 | -> finding errors with fixit 156 | found errors in 1 files 157 | -> adding comments to silence errors 158 | """ 159 | 160 | 161 | class TestFlake8: 162 | def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): 163 | python_module = tmp_path / 't.py' 164 | python_module.write_text("""\ 165 | import sys 166 | import glob # noqa: F401 167 | from json import * # additional comment 168 | from os import * # noqa: F403 169 | from pathlib import * # noqa: F403 # additional comment 170 | """) 171 | 172 | ret = main(('flake8', 'F401', str(python_module))) 173 | 174 | assert ret == 1 175 | assert python_module.read_text() == """\ 176 | import sys # noqa: F401 177 | import glob # noqa: F401 178 | from json import * # additional comment # noqa: F401 179 | from os import * # noqa: F401,F403 180 | from pathlib import * # noqa: F401,F403 # additional comment 181 | """ 182 | 183 | captured = capsys.readouterr() 184 | assert captured.out == f"""\ 185 | {python_module} 186 | """ 187 | assert captured.err == """\ 188 | -> finding errors with flake8 189 | found errors in 1 files 190 | -> adding comments to silence errors 191 | """ 192 | 193 | def test_main_no_violations( 194 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 195 | ): 196 | src = """\ 197 | def foo(): 198 | print('hello there') 199 | """ 200 | 201 | python_module = tmp_path / 't.py' 202 | python_module.write_text(src) 203 | 204 | ret = main(('flake8', 'F401', str(python_module))) 205 | 206 | assert ret == 0 207 | assert python_module.read_text() == src 208 | 209 | captured = capsys.readouterr() 210 | assert captured.out == '' 211 | assert captured.err == """\ 212 | -> finding errors with flake8 213 | no errors found 214 | """ 215 | 216 | def test_not_installed(self, capsys: pytest.CaptureFixture[str]): 217 | with FakeProcess() as process: 218 | process.register( 219 | ('flake8', process.any()), 220 | returncode=1, stderr='/path/to/python3: No module named flake8\n', 221 | ) 222 | 223 | ret = main(('flake8', 'F401', 'path/to/file.py')) 224 | 225 | assert ret == 1 226 | 227 | captured = capsys.readouterr() 228 | assert captured.out == '' 229 | assert captured.err == """\ 230 | -> finding errors with flake8 231 | ERROR: /path/to/python3: No module named flake8 232 | """ 233 | 234 | 235 | class TestRuff: 236 | def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): 237 | python_module = tmp_path / 't.py' 238 | python_module.write_text("""\ 239 | import sys 240 | import os # noqa: ABC1 241 | import json # additional comment 242 | import glob # noqa: F401 243 | """) 244 | 245 | ret = main(('ruff', 'F401', str(python_module))) 246 | 247 | assert ret == 1 248 | assert python_module.read_text() == """\ 249 | import sys # noqa: F401 250 | import os # noqa: F401,ABC1 251 | import json # additional comment # noqa: F401 252 | import glob # noqa: F401 253 | """ 254 | 255 | captured = capsys.readouterr() 256 | assert captured.out == f"""\ 257 | {python_module} 258 | """ 259 | assert captured.err == """\ 260 | -> finding errors with ruff 261 | found errors in 1 files 262 | -> adding comments to silence errors 263 | """ 264 | 265 | def test_main_no_violations( 266 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 267 | ): 268 | src = """\ 269 | def foo(): 270 | print('hello there') 271 | """ 272 | 273 | python_module = tmp_path / 't.py' 274 | python_module.write_text(src) 275 | 276 | ret = main(('ruff', 'F401', str(python_module))) 277 | 278 | assert ret == 0 279 | assert python_module.read_text() == src 280 | 281 | captured = capsys.readouterr() 282 | assert captured.out == '' 283 | assert captured.err == """\ 284 | -> finding errors with ruff 285 | no errors found 286 | """ 287 | 288 | def test_ignores_modules_with_syntax_error( 289 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 290 | ): 291 | python_module = tmp_path / 't.py' 292 | python_module.write_text("""\ 293 | import sys 294 | """) 295 | invalid_module = tmp_path / 'y.py' 296 | invalid_module.write_text("""\ 297 | import sys 298 | 299 | print( 300 | """) 301 | 302 | ret = main(('ruff', 'F401', str(tmp_path))) 303 | 304 | assert ret == 1 305 | assert python_module.read_text() == """\ 306 | import sys # noqa: F401 307 | """ 308 | assert invalid_module.read_text() == """\ 309 | import sys 310 | 311 | print( 312 | """ 313 | 314 | captured = capsys.readouterr() 315 | assert captured.out == f"""\ 316 | {python_module} 317 | """ 318 | assert captured.err == """\ 319 | -> finding errors with ruff 320 | found errors in 1 files 321 | -> adding comments to silence errors 322 | """ 323 | 324 | def test_not_installed(self, capsys: pytest.CaptureFixture[str]): 325 | with FakeProcess() as process: 326 | process.register( 327 | ('ruff', process.any()), 328 | returncode=1, stderr='/path/to/python3: No module named ruff\n', 329 | ) 330 | 331 | ret = main(('ruff', 'F401', 'path/to/file.py')) 332 | 333 | assert ret == 1 334 | 335 | captured = capsys.readouterr() 336 | assert captured.out == '' 337 | assert captured.err == """\ 338 | -> finding errors with ruff 339 | ERROR: /path/to/python3: No module named ruff 340 | """ 341 | 342 | 343 | class TestSemgrep: 344 | def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): 345 | python_module = tmp_path / 't.py' 346 | python_module.write_text( 347 | """\ 348 | import time 349 | 350 | time.sleep(5) 351 | 352 | # a different error (open-never-closed) 353 | fd = open('foo') 354 | """, 355 | ) 356 | 357 | with mock.patch.dict(os.environ, {'SEMGREP_RULES': 'r/python'}): 358 | ret = main( 359 | ( 360 | 'semgrep', 361 | 'python.lang.best-practice.sleep.arbitrary-sleep', 362 | str(python_module), 363 | ), 364 | ) 365 | 366 | assert ret == 1 367 | assert python_module.read_text() == """\ 368 | import time 369 | 370 | # nosemgrep: python.lang.best-practice.sleep.arbitrary-sleep 371 | time.sleep(5) 372 | 373 | # a different error (open-never-closed) 374 | fd = open('foo') 375 | """ 376 | 377 | captured = capsys.readouterr() 378 | assert captured.out == f"""\ 379 | {python_module} 380 | """ 381 | assert captured.err == """\ 382 | -> finding errors with semgrep 383 | found errors in 1 files 384 | -> adding comments to silence errors 385 | """ 386 | 387 | def test_main_no_violations( 388 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 389 | ): 390 | src = """\ 391 | def foo(): 392 | print('hello there') 393 | """ 394 | 395 | python_module = tmp_path / 't.py' 396 | python_module.write_text(src) 397 | 398 | with mock.patch.dict(os.environ, {'SEMGREP_RULES': 'r/python'}): 399 | ret = main( 400 | ( 401 | 'semgrep', 402 | 'python.lang.best-practice.sleep.arbitrary-sleep', 403 | str(python_module), 404 | ), 405 | ) 406 | 407 | assert ret == 0 408 | assert python_module.read_text() == src 409 | 410 | captured = capsys.readouterr() 411 | assert captured.out == '' 412 | assert captured.err == """\ 413 | -> finding errors with semgrep 414 | no errors found 415 | """ 416 | 417 | def test_not_installed(self, capsys: pytest.CaptureFixture[str]): 418 | with FakeProcess() as process: 419 | process.register( 420 | ('semgrep', process.any()), 421 | returncode=1, stderr='zsh: command not found: semgrep\n', 422 | ) 423 | 424 | ret = main(('semgrep', 'semgrep.rule', 'path/to/file.py')) 425 | 426 | assert ret == 1 427 | 428 | captured = capsys.readouterr() 429 | assert captured.out == '' 430 | assert captured.err == """\ 431 | -> finding errors with semgrep 432 | ERROR: zsh: command not found: semgrep 433 | """ 434 | 435 | 436 | class TestMypy: 437 | def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): 438 | (tmp_path / '__init__.py').touch() 439 | python_module = tmp_path / 't.py' 440 | python_module.write_text("""\ 441 | from . import y 442 | 443 | def f() -> str: 444 | return 1 445 | 446 | def g() -> str: 447 | return 1 # type: ignore[misc] 448 | 449 | def g() -> str: 450 | return 1 # a number 451 | """) 452 | other_module = tmp_path / 'y.py' 453 | other_module.write_text("""\ 454 | def unrelated() -> str: 455 | return 1 456 | """) 457 | 458 | ret = main(('mypy', 'return-value', str(python_module))) 459 | 460 | assert ret == 1 461 | assert python_module.read_text() == """\ 462 | from . import y 463 | 464 | def f() -> str: 465 | return 1 # type: ignore[return-value] 466 | 467 | def g() -> str: 468 | return 1 # type: ignore[misc,return-value] 469 | 470 | def g() -> str: 471 | return 1 # a number # type: ignore[return-value] 472 | """ 473 | assert other_module.read_text() == """\ 474 | def unrelated() -> str: 475 | return 1 476 | """ 477 | 478 | captured = capsys.readouterr() 479 | assert captured.out == f"""\ 480 | {python_module} 481 | """ 482 | assert captured.err == """\ 483 | -> finding errors with mypy 484 | found errors in 1 files 485 | -> adding comments to silence errors 486 | """ 487 | 488 | def test_main_no_violations( 489 | self, tmp_path: Path, capsys: pytest.CaptureFixture[str], 490 | ): 491 | src = """\ 492 | def f() -> int: 493 | return 1 494 | """ 495 | 496 | python_module = tmp_path / 't.py' 497 | python_module.write_text(src) 498 | 499 | ret = main(('mypy', 'return-value', str(python_module))) 500 | 501 | assert ret == 0 502 | assert python_module.read_text() == src 503 | 504 | captured = capsys.readouterr() 505 | assert captured.out == '' 506 | assert captured.err == """\ 507 | -> finding errors with mypy 508 | no errors found 509 | """ 510 | 511 | def test_not_installed(self, capsys: pytest.CaptureFixture[str]): 512 | with FakeProcess() as process: 513 | process.register( 514 | ('mypy', process.any()), 515 | returncode=127, stderr='zsh: command not found: mypy\n', 516 | ) 517 | 518 | ret = main(('mypy', 'return-value', 'path/to/file.py')) 519 | 520 | assert ret == 127 521 | 522 | captured = capsys.readouterr() 523 | assert captured.out == '' 524 | assert captured.err == """\ 525 | -> finding errors with mypy 526 | ERROR: zsh: command not found: mypy 527 | """ 528 | -------------------------------------------------------------------------------- /tests/comments_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from silence_lint_error.comments import add_code_to_comment 6 | from silence_lint_error.comments import add_error_silencing_comments 7 | from silence_lint_error.comments import add_noqa_comments 8 | from silence_lint_error.comments import remove_code_from_comment 9 | from silence_lint_error.comments import remove_error_silencing_comments 10 | 11 | 12 | def test_add_error_silencing_comments(): 13 | src = """\ 14 | # a single-line statement on line 2 15 | foo = 'bar' 16 | 17 | # a function on line 5 18 | def baz( 19 | a: int, 20 | b: int, 21 | ) -> str: 22 | ... 23 | 24 | # a multi-line string on line 12 25 | s = ''' 26 | hello there 27 | ''' 28 | 29 | # a single-line statement with a pre-existing comment on line 17 30 | foo = 'bar' # TODO: make this better 31 | 32 | # a single-line statement with a pre-existing ignore comment on line 20 33 | foo = 'bar' # silence-me: DEF456 34 | """ 35 | 36 | assert add_error_silencing_comments( 37 | src, {2, 5, 12, 17, 20}, 'silence-me', 'ABC123', 38 | # use 'silence-me' to ensure we haven't hard-coded any real 39 | # linter-specific types 40 | ) == """\ 41 | # a single-line statement on line 2 42 | foo = 'bar' # silence-me: ABC123 43 | 44 | # a function on line 5 45 | def baz( # silence-me: ABC123 46 | a: int, 47 | b: int, 48 | ) -> str: 49 | ... 50 | 51 | # a multi-line string on line 12 52 | s = ''' 53 | hello there 54 | ''' # silence-me: ABC123 55 | 56 | # a single-line statement with a pre-existing comment on line 17 57 | foo = 'bar' # TODO: make this better # silence-me: ABC123 58 | 59 | # a single-line statement with a pre-existing ignore comment on line 20 60 | foo = 'bar' # silence-me: ABC123,DEF456 61 | """ 62 | 63 | 64 | def test_remove_error_silencing_comments(): 65 | src = """\ 66 | foo = 'bar' # silence-me: ABC123 67 | 68 | def baz( # silence-me: ABC123 69 | a: int, b: int, 70 | ) -> str: 71 | ... 72 | 73 | s = ''' 74 | hello there 75 | ''' # silence-me: ABC123 76 | 77 | foo = 'bar' # TODO: make this better # silence-me: ABC123 78 | foo = 'bar' # silence-me: ABC123,DEF456 79 | """ 80 | 81 | assert remove_error_silencing_comments( 82 | src, 'silence-me', 'ABC123', 83 | # use 'silence-me' to ensure we haven't hard-coded any real 84 | # linter-specific types 85 | ) == """\ 86 | foo = 'bar' 87 | 88 | def baz( 89 | a: int, b: int, 90 | ) -> str: 91 | ... 92 | 93 | s = ''' 94 | hello there 95 | ''' 96 | 97 | foo = 'bar' # TODO: make this better 98 | foo = 'bar' # silence-me: DEF456 99 | """ 100 | 101 | 102 | def test_add_noqa_comments(): 103 | src = """\ 104 | # a single-line statement on line 2 105 | foo = 'bar' 106 | 107 | # a function on line 5 108 | def baz( 109 | a: int, 110 | b: int, 111 | ) -> str: 112 | ... 113 | 114 | # a multi-line string on line 12 115 | s = ''' 116 | hello there 117 | ''' 118 | """ 119 | 120 | assert add_noqa_comments( 121 | src, {2, 5, 12}, 'ABC123', 122 | ) == """\ 123 | # a single-line statement on line 2 124 | foo = 'bar' # noqa: ABC123 125 | 126 | # a function on line 5 127 | def baz( # noqa: ABC123 128 | a: int, 129 | b: int, 130 | ) -> str: 131 | ... 132 | 133 | # a multi-line string on line 12 134 | s = ''' 135 | hello there 136 | ''' # noqa: ABC123 137 | """ 138 | 139 | 140 | @pytest.mark.parametrize( 141 | 'original, expected', ( 142 | ('something', 'something # noqa: ABC1'), 143 | ('noqa: XYZ0', 'noqa: ABC1,XYZ0'), 144 | ('something # noqa: XYZ0', 'something # noqa: ABC1,XYZ0'), 145 | ('noqa: XYZ0 # something', 'noqa: ABC1,XYZ0 # something'), 146 | ('noqa: XYZ0 # noqa: UVW3', 'noqa: ABC1,XYZ0 # noqa: UVW3'), 147 | ), 148 | ) 149 | def test_add_code_to_comment(original, expected): 150 | assert add_code_to_comment(original, 'noqa', 'ABC1') == expected 151 | 152 | 153 | @pytest.mark.parametrize( 154 | 'original, expected', ( 155 | ('# something # noqa: ABC1', '# something'), 156 | ('# noqa: ABC1 # something', '# something'), 157 | ('# noqa: ABC1,XYZ0', '# noqa: XYZ0'), 158 | ('# noqa: XYZ0,ABC1', '# noqa: XYZ0'), 159 | ('# noqa: XYZ0,ABC1,DEF3', '# noqa: XYZ0,DEF3'), 160 | ('# something # noqa: ABC1,XYZ0', '# something # noqa: XYZ0'), 161 | ('# something # noqa: XYZ0,ABC1', '# something # noqa: XYZ0'), 162 | ('# noqa: ABC1,XYZ0 # something', '# noqa: XYZ0 # something'), 163 | ('# noqa: XYZ0,ABC1 # something', '# noqa: XYZ0 # something'), 164 | ('# noqa: ABC1,XYZ0 # noqa: UVW3', '# noqa: XYZ0 # noqa: UVW3'), 165 | ('# noqa: XYZ0,ABC1 # noqa: UVW3', '# noqa: XYZ0 # noqa: UVW3'), 166 | ), 167 | ) 168 | def test_remove_code_from_comment(original, expected): 169 | assert remove_code_from_comment(original, 'noqa', 'ABC1') == expected 170 | -------------------------------------------------------------------------------- /tests/linters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samueljsb/silence-lint-error/e8aeba545dd77ea07152fe5328ce545817265af5/tests/linters/__init__.py -------------------------------------------------------------------------------- /tests/linters/fixit_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from silence_lint_error.linters.fixit import Fixit 8 | from silence_lint_error.silencing import Violation 9 | 10 | 11 | class TestFixit: 12 | @pytest.mark.parametrize( 13 | 'lines, expected_violations', ( 14 | pytest.param( 15 | ['t.py@1:2 MyRuleName: the error message'], 16 | [('t.py', Violation('MyRuleName', 1))], 17 | id='single-line', 18 | ), 19 | pytest.param( 20 | ['t.py@1:2 MyRuleName: '], 21 | [('t.py', Violation('MyRuleName', 1))], 22 | id='no-message', 23 | ), 24 | pytest.param( 25 | [ 26 | 't.py@1:2 MyRuleName: the error message', 27 | 'which continue over multiple lines', 28 | 'just like this one does.', 29 | ], 30 | [ 31 | ('t.py', Violation('MyRuleName', 1)), 32 | None, 33 | None, 34 | ], 35 | id='multi-line', 36 | ), 37 | pytest.param( 38 | [ 39 | 't.py@1:2 MyRuleName: ', 40 | 'the error message on a new line', 41 | 'which continue over multiple lines', 42 | 'just like this one does.', 43 | ], 44 | [ 45 | ('t.py', Violation('MyRuleName', 1)), 46 | None, 47 | None, 48 | None, 49 | ], 50 | id='multi-line-leading-ws', 51 | ), 52 | pytest.param( 53 | [ 54 | 't.py@1:2 MyRuleName: the error message', 55 | 'which continue over multiple lines', 56 | 'just like this one does.', 57 | '', 58 | ], 59 | [ 60 | ('t.py', Violation('MyRuleName', 1)), 61 | None, 62 | None, 63 | None, 64 | ], 65 | id='multi-line-trailing-ws', 66 | ), 67 | ), 68 | ) 69 | def test_parse_output_line( 70 | self, 71 | lines: list[str], 72 | expected_violations: list[tuple[str, Violation] | None], 73 | ): 74 | violations = [ 75 | Fixit()._parse_output_line(line) 76 | for line in lines 77 | ] 78 | 79 | assert violations == expected_violations 80 | 81 | def test_find_violations(self, tmp_path: Path): 82 | python_module = tmp_path / 't.py' 83 | python_module.write_text( 84 | """\ 85 | x = None 86 | isinstance(x, str) or isinstance(x, int) 87 | """, 88 | ) 89 | 90 | violations = Fixit().find_violations( 91 | 'fixit.rules:CollapseIsinstanceChecks', [str(python_module)], 92 | ) 93 | 94 | assert violations == { 95 | str(python_module): [ 96 | Violation('CollapseIsinstanceChecks', 2), 97 | ], 98 | } 99 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3{9,10,11,12,13},coverage 3 | 4 | [testenv] 5 | # Install wheels instead of source distributions for faster execution. 6 | package = wheel 7 | # Share the build environment between tox environments. 8 | wheel_build_env = .pkg 9 | 10 | deps = -rrequirements.txt 11 | commands = 12 | coverage run --parallel-mode -m pytest {posargs:tests} 13 | 14 | [testenv:clean] 15 | skip_install = true 16 | commands = 17 | coverage erase 18 | 19 | [testenv:coverage] 20 | skip_install = true 21 | depends = py3{9,10,11,12,13} 22 | commands = 23 | coverage combine 24 | coverage report 25 | --------------------------------------------------------------------------------