├── .github ├── CONTRIBUTING.md ├── dependabot.yml ├── release.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── noxfile.py ├── pyproject.toml ├── src └── flake8_errmsg │ ├── __init__.py │ ├── __main__.py │ └── py.typed └── tests ├── example1.py └── test_package.py /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | See the [Scikit-HEP Developer introduction][skhep-dev-intro] for a detailed 2 | description of best practices for developing Scikit-HEP packages. 3 | 4 | [skhep-dev-intro]: https://scikit-hep.org/developer/intro 5 | 6 | # Quick development 7 | 8 | The fastest way to start with development is to use nox. If you don't have nox, 9 | you can use `pipx run nox` to run it without installing, or `pipx install nox`. 10 | If you don't have pipx (pip for applications), then you can install with with 11 | `pip install pipx` (the only case were installing an application with regular 12 | pip is reasonable). If you use macOS, then pipx and nox are both in brew, use 13 | `brew install pipx nox`. 14 | 15 | To use, run `nox`. This will lint and test using every installed version of 16 | Python on your system, skipping ones that are not installed. You can also run 17 | specific jobs: 18 | 19 | ```console 20 | $ nox -s lint # Lint only 21 | $ nox -s tests-3.9 # Python 3.9 tests only 22 | $ nox -s docs -- serve # Build and serve the docs 23 | $ nox -s build # Make an SDist and wheel 24 | ``` 25 | 26 | Nox handles everything for you, including setting up an temporary virtual 27 | environment for each run. 28 | 29 | # Setting up a development environment manually 30 | 31 | You can set up a development environment by running: 32 | 33 | ```bash 34 | python3 -m venv .venv 35 | source ./.venv/bin/activate 36 | pip install -v -e .[dev] 37 | ``` 38 | 39 | If you have the 40 | [Python Launcher for Unix](https://github.com/brettcannon/python-launcher), you 41 | can instead do: 42 | 43 | ```bash 44 | py -m venv .venv 45 | py -m install -v -e .[dev] 46 | ``` 47 | 48 | # Post setup 49 | 50 | You should prepare pre-commit, which will help you by checking that commits pass 51 | required checks: 52 | 53 | ```bash 54 | pip install pre-commit # or brew install pre-commit on macOS 55 | pre-commit install # Will install a pre-commit hook into the git repo 56 | ``` 57 | 58 | You can also/alternatively run `pre-commit run` (changes only) or 59 | `pre-commit run --all-files` to check even without installing the hook. 60 | 61 | # Testing 62 | 63 | Use pytest to run the unit checks: 64 | 65 | ```bash 66 | pytest 67 | ``` 68 | 69 | # Coverage 70 | 71 | Use pytest-cov to generate coverage reports: 72 | 73 | ```bash 74 | pytest --cov=flake8-errmsg 75 | ``` 76 | 77 | # Building docs 78 | 79 | You can build the docs using: 80 | 81 | ```bash 82 | nox -s docs 83 | ``` 84 | 85 | You can see a preview with: 86 | 87 | ```bash 88 | nox -s docs -- serve 89 | ``` 90 | 91 | # Pre-commit 92 | 93 | This project uses pre-commit for all style checking. While you can run it with 94 | nox, this is such an important tool that it deserves to be installed on its own. 95 | Install pre-commit and run: 96 | 97 | ```bash 98 | pre-commit run -a 99 | ``` 100 | 101 | to check all files. 102 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | groups: 9 | actions: 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | dist: 11 | name: Distribution build 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: hynek/build-and-inspect-python-package@v2 20 | 21 | publish: 22 | name: Publish 23 | needs: [dist] 24 | environment: 25 | name: pypi 26 | url: https://pypi.org/p/flake8-errmsg 27 | permissions: 28 | id-token: write 29 | attestations: write 30 | runs-on: ubuntu-latest 31 | if: github.event_name == 'release' && github.event.action == 'published' 32 | steps: 33 | - uses: actions/download-artifact@v4 34 | with: 35 | name: Packages 36 | path: dist 37 | 38 | - name: Generate artifact attestation for sdist and wheel 39 | uses: actions/attest-build-provenance@v2 40 | with: 41 | subject-path: "dist/*" 42 | 43 | - uses: pypa/gh-action-pypi-publish@release/v1 44 | with: 45 | attestations: true 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | pylint: 16 | name: PyLint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: astral-sh/setup-uv@v6 21 | - name: Run PyLint 22 | run: uvx nox -s pylint -- --output-format=github 23 | 24 | checks: 25 | name: Check Python ${{ matrix.python-version }} on ${{ matrix.runs-on }} 26 | runs-on: ${{ matrix.runs-on }} 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | python-version: ["3.10", "3.13", "3.14"] 31 | runs-on: [ubuntu-latest, macos-latest, windows-latest] 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | with: 36 | fetch-depth: 0 37 | 38 | - uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | allow-prereleases: true 42 | 43 | - uses: astral-sh/setup-uv@v6 44 | 45 | - name: Test package 46 | run: | 47 | uvx nox -s tests 48 | uvx nox -s tests_flake8 49 | 50 | dist: 51 | name: Distribution build 52 | runs-on: ubuntu-latest 53 | 54 | steps: 55 | - uses: actions/checkout@v4 56 | with: 57 | fetch-depth: 0 58 | 59 | - uses: hynek/build-and-inspect-python-package@v2 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # setuptools_scm 141 | src/*/_version.py 142 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_commit_msg: "chore: update pre-commit hooks" 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: check-added-large-files 9 | - id: check-case-conflict 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: check-yaml 13 | - id: debug-statements 14 | - id: end-of-file-fixer 15 | - id: mixed-line-ending 16 | - id: requirements-txt-fixer 17 | - id: trailing-whitespace 18 | 19 | - repo: https://github.com/pre-commit/pygrep-hooks 20 | rev: v1.10.0 21 | hooks: 22 | - id: python-check-blanket-noqa 23 | - id: python-check-blanket-type-ignore 24 | - id: python-no-eval 25 | - id: python-use-type-annotations 26 | - id: rst-backticks 27 | - id: rst-directive-colons 28 | - id: rst-inline-touching-normal 29 | 30 | - repo: https://github.com/rbubley/mirrors-prettier 31 | rev: "v3.5.3" 32 | hooks: 33 | - id: prettier 34 | types_or: [yaml, markdown, html, css, scss, javascript, json] 35 | args: [--prose-wrap=always] 36 | 37 | - repo: https://github.com/adamchainz/blacken-docs 38 | rev: 1.19.1 39 | hooks: 40 | - id: blacken-docs 41 | additional_dependencies: [black==24.*] 42 | 43 | - repo: https://github.com/astral-sh/ruff-pre-commit 44 | rev: "v0.11.9" 45 | hooks: 46 | - id: ruff 47 | args: ["--fix", "--show-fixes"] 48 | - id: ruff-format 49 | 50 | - repo: https://github.com/pre-commit/mirrors-mypy 51 | rev: v1.15.0 52 | hooks: 53 | - id: mypy 54 | files: src 55 | args: [] 56 | 57 | - repo: https://github.com/codespell-project/codespell 58 | rev: v2.4.1 59 | hooks: 60 | - id: codespell 61 | args: ["-w"] 62 | 63 | - repo: https://github.com/shellcheck-py/shellcheck-py 64 | rev: v0.10.0.1 65 | hooks: 66 | - id: shellcheck 67 | 68 | - repo: local 69 | hooks: 70 | - id: disallow-caps 71 | name: Disallow improper capitalization 72 | language: pygrep 73 | entry: PyBind|Numpy|Cmake|CCache|Github|PyTest 74 | exclude: .pre-commit-config.yaml 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | 180 | APPENDIX: How to apply the Apache License to your work. 181 | 182 | To apply the Apache License to your work, attach the following 183 | boilerplate notice, with the fields enclosed by brackets "[]" 184 | replaced with your own identifying information. (Don't include 185 | the brackets!) The text should be enclosed in the appropriate 186 | comment syntax for the file format. We also recommend that a 187 | file or class name and description of purpose be included on the 188 | same "printed page" as the copyright notice for easier 189 | identification within third-party archives. 190 | 191 | Copyright 2022 Henry Schreiner 192 | 193 | Licensed under the Apache License, Version 2.0 (the "License"); 194 | you may not use this file except in compliance with the License. 195 | You may obtain a copy of the License at 196 | 197 | http://www.apache.org/licenses/LICENSE-2.0 198 | 199 | Unless required by applicable law or agreed to in writing, software 200 | distributed under the License is distributed on an "AS IS" BASIS, 201 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 202 | See the License for the specific language governing permissions and 203 | limitations under the License. 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flake8-errmsg 2 | 3 | [![Actions Status][actions-badge]][actions-link] 4 | [![PyPI version][pypi-version]][pypi-link] 5 | [![PyPI platforms][pypi-platforms]][pypi-link] 6 | 7 | ## Intro 8 | 9 | A checker for Flake8 that helps format nice error messages. The checks are: 10 | 11 | - **EM101**: Check for raw usage of a string literal in Exception raising. 12 | - **EM102**: Check for raw usage of an f-string literal in Exception raising. 13 | - **EM103**: Check for raw usage of `.format` on a string literal in Exception 14 | raising. 15 | - **EM104**: Check for missing parentheses for built-in exceptions. 16 | - **EM105**: Check for missing message for built-in exceptions. 17 | 18 | The issue is that Python includes the line with the raise in the default 19 | traceback (and most other formatters, like Rich and IPython to too). That means 20 | a user gets a message like this: 21 | 22 | ```python 23 | sub = "Some value" 24 | raise RuntimeError(f"{sub!r} is incorrect") 25 | ``` 26 | 27 | ```pytb 28 | Traceback (most recent call last): 29 | File "tmp.py", line 2, in 30 | raise RuntimeError(f"{sub!r} is incorrect") 31 | RuntimeError: 'Some value' is incorrect 32 | ``` 33 | 34 | If this is longer or more complex, the duplication can be quite confusing for a 35 | user unaccustomed to reading tracebacks. 36 | 37 | While if you always assign to something like `msg`, then you get: 38 | 39 | ```python 40 | sub = "Some value" 41 | msg = f"{sub!r} is incorrect" 42 | raise RuntimeError(msg) 43 | ``` 44 | 45 | ```pytb 46 | Traceback (most recent call last): 47 | File "tmp.py", line 3, in 48 | raise RuntimeError(msg) 49 | RuntimeError: 'Some value' is incorrect 50 | ``` 51 | 52 | Now there's a simpler traceback and no double message. If you have a long 53 | message, this also often formats better when using Black, too. 54 | 55 | Reminder: Libraries should produce tracebacks with custom error classes, and 56 | applications should print nice errors, usually _without_ a traceback, unless 57 | something _unexpected_ occurred. An app should not print a traceback for an 58 | error that is known to be triggerable by a user. 59 | 60 | ## Options 61 | 62 | There is one option, `--errmsg-max-string-length`, which defaults to 0 but can 63 | be set to a larger value. The check will ignore string literals shorter than 64 | this length. This option is supported in configuration mode as well. This will 65 | only affect string literals and not f-strings. This option is also supported 66 | when running directly, without Flake8. 67 | 68 | ## Usage 69 | 70 | Just add this to your `.pre-commit-config.yaml` `flake8` check under 71 | `additional_dependencies`. If you use `extend-select`, you should need no other 72 | config. 73 | 74 | You can also manually run this check (without Flake8's `noqa` filtering) via 75 | script entry-point (`pipx run flake8-errmsg `) or module entry-point 76 | (`python -m flake8_errmsg ` when installed). 77 | 78 | ## FAQ 79 | 80 | Q: Why Python 3.10+ only?
A: This is a static checker and for developers. 81 | Developers and static checks should be on 3.10 already. And I was lazy and match 82 | statements are fantastic for this sort of thing. And the AST module changed in 83 | 3.8 anyway. Use [Ruff][] (which contains the checks from this plugin) if you 84 | need to run on older versions. 85 | 86 | Q: What other sorts of checks are acceptable?
A: Things that help with 87 | nice errors. For example, maybe requiring `raise SystemExit(n)` over `sys.exit`, 88 | `exit`, etc. Possibly adding a check for `warnings.warn` without setting 89 | `stacklevel` to something (usually 2). 90 | 91 | 92 | [actions-badge]: https://github.com/henryiii/flake8-errmsg/workflows/CI/badge.svg 93 | [actions-link]: https://github.com/henryiii/flake8-errmsg/actions 94 | [pypi-link]: https://pypi.org/project/flake8-errmsg/ 95 | [pypi-platforms]: https://img.shields.io/pypi/pyversions/flake8-errmsg 96 | [pypi-version]: https://img.shields.io/pypi/v/flake8-errmsg 97 | [ruff]: https://github.com/astral-sh/ruff 98 | 99 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import nox 4 | 5 | nox.needs_version = ">=2024.4.15" 6 | nox.options.default_venv_backend = "uv|virtualenv" 7 | 8 | 9 | @nox.session 10 | def lint(session: nox.Session) -> None: 11 | """ 12 | Run the linter. 13 | """ 14 | session.install("pre-commit") 15 | session.run("pre-commit", "run", "--all-files", *session.posargs) 16 | 17 | 18 | @nox.session 19 | def pylint(session: nox.Session) -> None: 20 | """ 21 | Run PyLint. 22 | """ 23 | # This needs to be installed into the package environment, and is slower 24 | # than a pre-commit check 25 | session.install("-e.", "pylint") 26 | session.run("pylint", "src", *session.posargs) 27 | 28 | 29 | @nox.session 30 | def tests(session: nox.Session) -> None: 31 | """ 32 | Run the unit and regular tests. 33 | """ 34 | session.install("-e.[test]") 35 | session.run("pytest", *session.posargs) 36 | 37 | 38 | @nox.session(default=False) 39 | def build(session: nox.Session) -> None: 40 | """ 41 | Build an SDist and wheel. 42 | """ 43 | session.install("build") 44 | session.run("python", "-m", "build") 45 | 46 | 47 | @nox.session 48 | def tests_flake8(session: nox.Session) -> None: 49 | """ 50 | Run the flake8 tests. 51 | """ 52 | session.install("-e.", "flake8") 53 | result = session.run("flake8", "tests/example1.py", silent=True, success_codes=[1]) 54 | if len(result.splitlines()) != 2: 55 | session.error(f"Expected 2 errors from flake8\n{result}") 56 | 57 | result = session.run( 58 | "flake8", 59 | "--errmsg-max-string-length=30", 60 | "tests/example1.py", 61 | silent=True, 62 | success_codes=[1], 63 | ) 64 | if len(result.splitlines()) != 1: 65 | session.error(f"Expected 1 errors from flake8\n{result}") 66 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | 6 | [project] 7 | name = "flake8_errmsg" 8 | authors = [ 9 | { name = "Henry Schreiner", email = "henryschreineriii@gmail.com" }, 10 | ] 11 | 12 | description = "Flake8 checker for raw literals inside raises." 13 | readme = "README.md" 14 | 15 | requires-python = ">=3.10" 16 | 17 | classifiers = [ 18 | "Framework :: Flake8", 19 | "Topic :: Software Development :: Libraries :: Python Modules", 20 | "Topic :: Software Development :: Quality Assurance", 21 | "Intended Audience :: Developers", 22 | "Operating System :: OS Independent", 23 | "License :: OSI Approved :: Apache Software License", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3.14", 31 | "Development Status :: 5 - Production/Stable", 32 | "Typing :: Typed", 33 | ] 34 | dynamic = ["version"] 35 | 36 | [project.optional-dependencies] 37 | test = [ 38 | "pytest >=7", 39 | ] 40 | dev = [ 41 | "pytest >=7", 42 | "flake8", 43 | ] 44 | 45 | [project.urls] 46 | homepage = "https://github.com/henryiii/flake8-errmsg" 47 | 48 | [project.scripts] 49 | flake8-errmsg = "flake8_errmsg:main" 50 | 51 | [project.entry-points."flake8.extension"] 52 | EM = "flake8_errmsg:ErrMsgASTPlugin" 53 | 54 | 55 | [tool.hatch] 56 | version.path = "src/flake8_errmsg/__init__.py" 57 | envs.default.dependencies = [ 58 | "pytest", 59 | ] 60 | 61 | 62 | [tool.pytest.ini_options] 63 | minversion = "7.0" 64 | addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] 65 | xfail_strict = true 66 | filterwarnings = ["error"] 67 | log_cli_level = "INFO" 68 | testpaths = [ 69 | "tests", 70 | ] 71 | 72 | 73 | [tool.mypy] 74 | files = "src" 75 | python_version = "3.10" 76 | warn_unused_configs = true 77 | strict = true 78 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 79 | warn_unreachable = true 80 | 81 | 82 | [tool.ruff.lint] 83 | extend-select = [ 84 | "B", # flake8-bugbear 85 | "I", # isort 86 | "ARG", # flake8-unused-arguments 87 | "C4", # flake8-comprehensions 88 | "ICN", # flake8-import-conventions 89 | "ISC", # flake8-implicit-str-concat 90 | "G", # flake8-logging-format 91 | "PGH", # pygrep-hooks 92 | "PIE", # flake8-pie 93 | "PL", # pylint 94 | "PT", # flake8-pytest-style 95 | "PTH", # flake8-use-pathlib 96 | "RET", # flake8-return 97 | "RUF", # Ruff-specific 98 | "SIM", # flake8-simplify 99 | "T20", # flake8-print 100 | "UP", # pyupgrade 101 | "YTT", # flake8-2020 102 | "EXE", # flake8-executable 103 | "NPY", # NumPy specific rules 104 | "PD", # pandas-vet 105 | ] 106 | ignore = [ 107 | "PLR2004", # Magic value used in comparison 108 | ] 109 | isort.required-imports = ["from __future__ import annotations"] 110 | 111 | [tool.ruff.lint.per-file-ignores] 112 | "tests/**" = ["T20"] 113 | 114 | 115 | [tool.pylint] 116 | master.py-version = "3.10" 117 | master.ignore-paths= ["src/flake8_errmsg/_version.py"] 118 | reports.output-format = "colorized" 119 | similarities.ignore-imports = "yes" 120 | messages_control.disable = [ 121 | "design", 122 | "fixme", 123 | "line-too-long", 124 | "wrong-import-position", 125 | "missing-class-docstring", 126 | "missing-function-docstring", 127 | "missing-module-docstring", 128 | "invalid-name", 129 | ] 130 | 131 | [tool.repo-review] 132 | ignore = ["PY004", "PC111", "RTD"] 133 | -------------------------------------------------------------------------------- /src/flake8_errmsg/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2022 Henry Schreiner. All rights reserved. 3 | 4 | flake8-errmsg: Flake8 checker for raw string literals inside raises. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import argparse 10 | import ast 11 | import builtins 12 | import dataclasses 13 | import functools 14 | import inspect 15 | import sys 16 | import traceback 17 | from collections.abc import Iterator 18 | from pathlib import Path 19 | from typing import Any, ClassVar, NamedTuple 20 | 21 | __all__ = ("ErrMsgASTPlugin", "__version__", "main", "run_on_file") 22 | 23 | __version__ = "0.5.1" 24 | 25 | BUILTIN_EXCEPTION_LIST = { 26 | name 27 | for name in dir(builtins) 28 | if inspect.isclass(_cls := getattr(builtins, name)) 29 | and issubclass(_cls, BaseException) 30 | } 31 | 32 | 33 | class Flake8ASTErrorInfo(NamedTuple): 34 | line_number: int 35 | offset: int 36 | msg: str 37 | cls: type # unused 38 | 39 | 40 | class Visitor(ast.NodeVisitor): 41 | def __init__(self, max_string_len: int) -> None: 42 | self.errors: list[Flake8ASTErrorInfo] = [] 43 | self.max_string_len = max_string_len 44 | 45 | def visit_Raise(self, node: ast.Raise) -> None: 46 | self.generic_visit(node) 47 | match node.exc: 48 | case ast.Call(args=[ast.Constant(value=str(value)), *_]): 49 | if len(value) >= self.max_string_len: 50 | self.errors.append(EM101(node)) 51 | case ast.Call(args=[ast.JoinedStr(), *_]): 52 | self.errors.append(EM102(node)) 53 | case ast.Call( 54 | args=[ 55 | ast.Call(func=ast.Attribute(attr="format", value=ast.Constant())), 56 | *_, 57 | ] 58 | ): 59 | self.errors.append(EM103(node)) 60 | case ast.Name(id=name) if name in BUILTIN_EXCEPTION_LIST: 61 | self.errors.append(EM104(node)) 62 | case ast.Call(func=ast.Name(id=name), args=[]) if ( 63 | name in BUILTIN_EXCEPTION_LIST 64 | ): 65 | self.errors.append(EM105(node)) 66 | case _: 67 | pass 68 | 69 | 70 | def EM101(node: ast.stmt) -> Flake8ASTErrorInfo: 71 | msg = "EM101 Exception must not use a string literal, assign to variable first" 72 | return Flake8ASTErrorInfo(node.lineno, node.col_offset, msg, Visitor) 73 | 74 | 75 | def EM102(node: ast.stmt) -> Flake8ASTErrorInfo: 76 | msg = "EM102 Exception must not use an f-string literal, assign to variable first" 77 | return Flake8ASTErrorInfo(node.lineno, node.col_offset, msg, Visitor) 78 | 79 | 80 | def EM103(node: ast.stmt) -> Flake8ASTErrorInfo: 81 | msg = "EM103 Exception must not use a .format() string directly, assign to variable first" 82 | return Flake8ASTErrorInfo(node.lineno, node.col_offset, msg, Visitor) 83 | 84 | 85 | def EM104(node: ast.stmt) -> Flake8ASTErrorInfo: 86 | msg = "EM104 Built-in Exceptions must not be thrown without being called" 87 | return Flake8ASTErrorInfo(node.lineno, node.col_offset, msg, Visitor) 88 | 89 | 90 | def EM105(node: ast.stmt) -> Flake8ASTErrorInfo: 91 | msg = "EM105 Built-in Exceptions must have a useful message" 92 | return Flake8ASTErrorInfo(node.lineno, node.col_offset, msg, Visitor) 93 | 94 | 95 | MAX_STRING_LENGTH = 0 96 | 97 | 98 | @dataclasses.dataclass 99 | class ErrMsgASTPlugin: 100 | # Options have to be class variables in flake8 plugins 101 | max_string_length: ClassVar[int] = 0 102 | 103 | tree: ast.AST 104 | 105 | _: dataclasses.KW_ONLY 106 | name: str = "flake8_errmsg" 107 | version: str = "0.1.0" 108 | 109 | def run(self) -> Iterator[Flake8ASTErrorInfo]: 110 | visitor = Visitor(self.max_string_length) 111 | visitor.visit(self.tree) 112 | yield from visitor.errors 113 | 114 | @staticmethod 115 | def add_options(optmanager: Any) -> None: 116 | optmanager.add_option( 117 | "--errmsg-max-string-length", 118 | parse_from_config=True, 119 | default=0, 120 | type=int, 121 | help="Set a maximum string length to allow inline strings. Default 0 (always disallow).", 122 | ) 123 | 124 | @classmethod 125 | def parse_options(cls, options: argparse.Namespace) -> None: 126 | cls.max_string_length = options.errmsg_max_string_length 127 | 128 | 129 | def run_on_file(path: str, max_string_length: int = 0) -> None: 130 | code = Path(path).read_text(encoding="utf-8") 131 | 132 | try: 133 | node = ast.parse(code) 134 | except SyntaxError as e: 135 | e.filename = path 136 | print("Traceback:") # noqa: T201 137 | traceback.print_exception(e, limit=0) 138 | raise SystemExit(1) from None 139 | 140 | plugin = ErrMsgASTPlugin(node) 141 | ErrMsgASTPlugin.max_string_length = max_string_length 142 | 143 | for err in plugin.run(): 144 | print(f"{path}:{err.line_number}:{err.offset} {err.msg}") # noqa: T201 145 | 146 | 147 | def main() -> None: 148 | make_parser = functools.partial(argparse.ArgumentParser, allow_abbrev=False) 149 | if sys.version_info >= (3, 14): 150 | make_parser = functools.partial(make_parser, color=True, suggest_on_error=True) 151 | parser = make_parser() 152 | parser.add_argument("--errmsg-max-string-length", type=int, default=0) 153 | parser.add_argument("files", nargs="+") 154 | namespace = parser.parse_args() 155 | 156 | for item in namespace.files: 157 | run_on_file(item, namespace.errmsg_max_string_length) 158 | 159 | 160 | if __name__ == "__main__": 161 | main() 162 | -------------------------------------------------------------------------------- /src/flake8_errmsg/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import main 4 | 5 | main() 6 | -------------------------------------------------------------------------------- /src/flake8_errmsg/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henryiii/flake8-errmsg/d245eb531b69ee9bc0956fc136098214b767c896/src/flake8_errmsg/py.typed -------------------------------------------------------------------------------- /tests/example1.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def f_a(): 5 | raise RuntimeError("This is an example exception") 6 | 7 | 8 | def f_b(): 9 | example = "example" 10 | raise RuntimeError(f"This is an {example} exception") 11 | 12 | 13 | def f_c(): 14 | msg = "hello" 15 | raise RuntimeError(msg) 16 | -------------------------------------------------------------------------------- /tests/test_package.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | 5 | import flake8_errmsg as m 6 | 7 | ERR1 = """\ 8 | raise Error("that") 9 | raise Error(f"this {that}") 10 | raise Error(1) 11 | raise Error(msg) 12 | """ 13 | 14 | 15 | def test_err1(): 16 | node = ast.parse(ERR1) 17 | 18 | results = list(m.ErrMsgASTPlugin(node).run()) 19 | assert len(results) == 2 20 | assert results[0].line_number == 1 21 | assert results[1].line_number == 2 22 | 23 | assert results[0].offset == 0 24 | assert results[1].offset == 0 25 | 26 | assert ( 27 | results[0].msg 28 | == "EM101 Exception must not use a string literal, assign to variable first" 29 | ) 30 | assert ( 31 | results[1].msg 32 | == "EM102 Exception must not use an f-string literal, assign to variable first" 33 | ) 34 | 35 | 36 | def test_string_length(): 37 | node = ast.parse(ERR1) 38 | plugin = m.ErrMsgASTPlugin(node) 39 | m.ErrMsgASTPlugin.max_string_length = 10 40 | results = list(plugin.run()) 41 | assert len(results) == 1 42 | assert results[0].line_number == 2 43 | 44 | assert ( 45 | results[0].msg 46 | == "EM102 Exception must not use an f-string literal, assign to variable first" 47 | ) 48 | 49 | 50 | ERR2 = """\ 51 | raise RuntimeError("this {} is".format("that")) 52 | """ 53 | 54 | 55 | def test_err2(): 56 | node = ast.parse(ERR2) 57 | results = list(m.ErrMsgASTPlugin(node).run()) 58 | assert len(results) == 1 59 | assert results[0].line_number == 1 60 | assert ( 61 | results[0].msg 62 | == "EM103 Exception must not use a .format() string directly, assign to variable first" 63 | ) 64 | 65 | 66 | ERR3 = """\ 67 | raise RuntimeError 68 | """ 69 | 70 | 71 | def test_err3(): 72 | node = ast.parse(ERR3) 73 | results = list(m.ErrMsgASTPlugin(node).run()) 74 | assert len(results) == 1 75 | assert results[0].line_number == 1 76 | assert ( 77 | results[0].msg 78 | == "EM104 Built-in Exceptions must not be thrown without being called" 79 | ) 80 | 81 | 82 | ERR4 = """\ 83 | raise RuntimeError() 84 | """ 85 | 86 | 87 | def test_err4(): 88 | node = ast.parse(ERR4) 89 | results = list(m.ErrMsgASTPlugin(node).run()) 90 | assert len(results) == 1 91 | assert results[0].line_number == 1 92 | assert results[0].msg == "EM105 Built-in Exceptions must have a useful message" 93 | --------------------------------------------------------------------------------