├── .bumpversion.cfg ├── .bumpversion.cfg.license ├── .editorconfig ├── .flake8 ├── .gitattributes ├── .github └── workflows │ └── pythonpackage.yml ├── .gitignore ├── .isort.cfg ├── LICENSES ├── Apache-2.0.txt ├── CC0-1.0.txt └── MIT.txt ├── README.md ├── constraints.in ├── constraints.txt ├── lock-requirements.sh ├── mypy.ini ├── mypy_tests └── test_mypy_tests_in_test_file.py ├── pyproject.toml ├── pytest.ini ├── requirements.in ├── requirements.txt ├── requirements.txt.license ├── src └── pytest_mypy_testing │ ├── __init__.py │ ├── message.py │ ├── output_processing.py │ ├── parser.py │ ├── plugin.py │ ├── py.typed │ └── strutil.py ├── tasks.py ├── tests ├── conftest.py ├── test___init__.py ├── test_basics.mypy-testing ├── test_file_with_nonitem_messages.mypy-testing ├── test_message.py ├── test_output_processing.py ├── test_parser.py ├── test_plugin.py └── test_strutil.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = True 4 | sign_tags = True 5 | current_version = 0.1.3 6 | 7 | [bumpversion:file:src/pytest_mypy_testing/__init__.py] 8 | -------------------------------------------------------------------------------- /.bumpversion.cfg.license: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | # 4 | # See https://EditorConfig.org 5 | # 6 | # PyCharm support: 7 | # - https://www.jetbrains.com/help/pycharm/configuring-code-style.html#editorconfig 8 | # 9 | # Visual Studio Code: 10 | # - https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig 11 | # - https://github.com/editorconfig/editorconfig-vscode 12 | # 13 | # Emacs: 14 | # - https://github.com/editorconfig/editorconfig-emacs#readme 15 | 16 | # top-most EditorConfig file 17 | root = true 18 | 19 | [*.{cfg,ini,py,proto,sh}] 20 | charset = utf-8 21 | indent_style = space 22 | indent_size = 4 23 | end_of_line = lf 24 | insert_final_newline = true 25 | trim_trailing_whitespace = true 26 | 27 | [{.editorconfig,MANIFEST.in,Pipfile}] 28 | charset = utf-8 29 | indent_style = space 30 | indent_size = 4 31 | end_of_line = lf 32 | insert_final_newline = true 33 | trim_trailing_whitespace = true 34 | 35 | # No indent_size 36 | [*.{rst,txt,yml}] 37 | charset = utf-8 38 | indent_style = space 39 | end_of_line = lf 40 | insert_final_newline = true 41 | trim_trailing_whitespace = true 42 | 43 | # Do not trim trailing whitespace 44 | [*.md] 45 | charset = utf-8 46 | indent_style = space 47 | end_of_line = lf 48 | insert_final_newline = true 49 | trim_trailing_whitespace = false 50 | 51 | # CRLF line endings 52 | [*.bat] 53 | end_of_line = crlf 54 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # -*- mode: conf; -*- 2 | # SPDX-FileCopyrightText: David Fritzsche 3 | # SPDX-License-Identifier: CC0-1.0 4 | [flake8] 5 | exclude = 6 | *.egg-info 7 | .eggs 8 | .build 9 | .git 10 | .tox 11 | .venv 12 | build 13 | dist 14 | docs 15 | downloads 16 | generated 17 | venv 18 | src/prettypb/protobuf/*.py 19 | runtime/src/prettypb/protobuf/*.py 20 | 21 | ignore = 22 | # F811: redefinition of unused '...' from line ... 23 | F811 24 | # W503: line break before binary operator 25 | W503 26 | # E203: whitespace before ':' 27 | E203 28 | # E231: missing whitespace after ',' 29 | E231 30 | # E501:line too long 31 | E501 32 | # E731: do not assign a lambda expression, use a def 33 | E731 34 | 35 | builtins = reveal_type 36 | 37 | max-line-length = 88 38 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | # Git attributes 4 | # 5 | # See 6 | # - git help attributes 7 | # - https://git-scm.com/docs/gitattributes 8 | 9 | # Set the default behavior, in case people don't have core.autocrlf set. 10 | * text=auto 11 | 12 | # Pure text files: Use LF in repo and checkout 13 | *.c text eol=lf 14 | *.cfg text eol=lf 15 | *.in text eol=lf 16 | *.ini text eol=lf 17 | *.md text eol=lf 18 | *.proto text eol=lf 19 | *.puml text eol=lf 20 | *.py text eol=lf 21 | *.rst text eol=lf 22 | *.sh text eol=lf 23 | *.txt text eol=lf 24 | *.typed text eol=lf 25 | *.yml text eol=lf 26 | .editorconfig text eol=lf 27 | .gitattributes text eol=lf 28 | .gitignore text eol=lf 29 | .pylintrc text eol=lf 30 | Jenkinsfile* text eol=lf 31 | Pipfile* text eol=lf 32 | pylintrc text eol=lf 33 | 34 | # Binary/DOS files: Don't treat as text 35 | *.bat -text 36 | *.desc -text 37 | *.exe -text 38 | *.inv -text 39 | *.png -text 40 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | name: Python package 4 | 5 | on: [push] 6 | 7 | jobs: 8 | test-py37: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | max-parallel: 4 12 | matrix: 13 | os: [ubuntu-latest, macos-latest, windows-latest] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: | 21 | 3.7 22 | 3.11 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -c constraints.txt pip 26 | python -m pip install -c constraints.txt invoke tox 27 | - name: Run tox 28 | env: 29 | TOX_PARALLEL_NO_SPINNER: "1" 30 | run: | 31 | inv mkdir build/coverage-data 32 | inv tox -e "py37-*" 33 | 34 | test-py38-py39-py310-py311-py312: 35 | runs-on: ${{ matrix.os }} 36 | strategy: 37 | max-parallel: 8 38 | matrix: 39 | os: [ubuntu-latest, macos-latest, windows-latest] 40 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - name: Install dependencies 49 | run: | 50 | python -m pip install -c constraints.txt pip 51 | python -m pip install -c constraints.txt invoke tox 52 | - name: Run tox 53 | env: 54 | TOX_PARALLEL_NO_SPINNER: "1" 55 | run: | 56 | inv mkdir build/coverage-data 57 | inv tox -e "py-*" 58 | 59 | 60 | lint: 61 | runs-on: ubuntu-latest 62 | strategy: 63 | max-parallel: 4 64 | matrix: 65 | python-version: ["3.11"] 66 | 67 | steps: 68 | - uses: actions/checkout@v4 69 | - name: Set up Python ${{ matrix.python-version }} 70 | uses: actions/setup-python@v5 71 | with: 72 | python-version: ${{ matrix.python-version }} 73 | - name: Install dependencies 74 | run: | 75 | python -m pip install -c constraints.txt black flake8 flake8-isort mypy pytest 76 | pip list 77 | - name: Run black 78 | run: | 79 | black --check --diff . 80 | - name: Run mypy 81 | run: | 82 | mypy src tests 83 | - name: Run isort 84 | run: | 85 | isort . --check --diff 86 | - name: Run flake8 87 | run: | 88 | flake8 --help 89 | flake8 -v 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | !.bumpversion.cfg 5 | !.bumpversion.cfg.license 6 | !.editorconfig 7 | !.flake8 8 | !.gitattributes 9 | !.gitignore 10 | !.isort.cfg 11 | !/.github 12 | *.bak 13 | *.egg-info 14 | *.pyc 15 | *~ 16 | .*_cache 17 | .coverage 18 | .coverage.* 19 | .dir-locals.el 20 | .eggs 21 | .envrc 22 | .hypothesis 23 | .idea 24 | .mypy* 25 | .python-version 26 | .secrets 27 | .tox 28 | .vs 29 | .vscode 30 | /.build* 31 | /.venv 32 | /build* 33 | /dist 34 | /install* 35 | /old-* 36 | /pip-wheel-metadata 37 | /protox 38 | /public 39 | /target 40 | Pipfile.lock 41 | __pycache__ 42 | _version.py 43 | pip-wheel-metadata 44 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | [isort] 4 | # See https://github.com/timothycrosley/isort/wiki/isort-Settings 5 | line_length = 88 6 | combine_as_imports = true 7 | combine_star = true 8 | multi_line_output = 3 9 | include_trailing_comma = true 10 | order_by_type = true 11 | skip = .eggs,.git,.tox,build,dist,docs 12 | skip_gitignore = true 13 | lines_after_imports = 2 14 | known_first_party = pytest_mypy_testing 15 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 16 | -------------------------------------------------------------------------------- /LICENSES/Apache-2.0.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, 6 | AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | 11 | 12 | "License" shall mean the terms and conditions for use, reproduction, and distribution 13 | as defined by Sections 1 through 9 of this document. 14 | 15 | 16 | 17 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 18 | owner that is granting the License. 19 | 20 | 21 | 22 | "Legal Entity" shall mean the union of the acting entity and all other entities 23 | that control, are controlled by, or are under common control with that entity. 24 | For the purposes of this definition, "control" means (i) the power, direct 25 | or indirect, to cause the direction or management of such entity, whether 26 | by contract or otherwise, or (ii) ownership of fifty percent (50%) or more 27 | of the outstanding shares, or (iii) beneficial ownership of such entity. 28 | 29 | 30 | 31 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions 32 | granted by this License. 33 | 34 | 35 | 36 | "Source" form shall mean the preferred form for making modifications, including 37 | but not limited to software source code, documentation source, and configuration 38 | files. 39 | 40 | 41 | 42 | "Object" form shall mean any form resulting from mechanical transformation 43 | or translation of a Source form, including but not limited to compiled object 44 | code, generated documentation, and conversions to other media types. 45 | 46 | 47 | 48 | "Work" shall mean the work of authorship, whether in Source or Object form, 49 | made available under the License, as indicated by a copyright notice that 50 | is included in or attached to the work (an example is provided in the Appendix 51 | below). 52 | 53 | 54 | 55 | "Derivative Works" shall mean any work, whether in Source or Object form, 56 | that is based on (or derived from) the Work and for which the editorial revisions, 57 | annotations, elaborations, or other modifications represent, as a whole, an 58 | original work of authorship. For the purposes of this License, Derivative 59 | Works shall not include works that remain separable from, or merely link (or 60 | bind by name) to the interfaces of, the Work and Derivative Works thereof. 61 | 62 | 63 | 64 | "Contribution" shall mean any work of authorship, including the original version 65 | of the Work and any modifications or additions to that Work or Derivative 66 | Works thereof, that is intentionally submitted to Licensor for inclusion in 67 | the Work by the copyright owner or by an individual or Legal Entity authorized 68 | to submit on behalf of the copyright owner. For the purposes of this definition, 69 | "submitted" means any form of electronic, verbal, or written communication 70 | sent to the Licensor or its representatives, including but not limited to 71 | communication on electronic mailing lists, source code control systems, and 72 | issue tracking systems that are managed by, or on behalf of, the Licensor 73 | for the purpose of discussing and improving the Work, but excluding communication 74 | that is conspicuously marked or otherwise designated in writing by the copyright 75 | owner as "Not a Contribution." 76 | 77 | 78 | 79 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 80 | of whom a Contribution has been received by Licensor and subsequently incorporated 81 | within the Work. 82 | 83 | 2. Grant of Copyright License. Subject to the terms and conditions of this 84 | License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 85 | no-charge, royalty-free, irrevocable copyright license to reproduce, prepare 86 | Derivative Works of, publicly display, publicly perform, sublicense, and distribute 87 | the Work and such Derivative Works in Source or Object form. 88 | 89 | 3. Grant of Patent License. Subject to the terms and conditions of this License, 90 | each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 91 | no-charge, royalty-free, irrevocable (except as stated in this section) patent 92 | license to make, have made, use, offer to sell, sell, import, and otherwise 93 | transfer the Work, where such license applies only to those patent claims 94 | licensable by such Contributor that are necessarily infringed by their Contribution(s) 95 | alone or by combination of their Contribution(s) with the Work to which such 96 | Contribution(s) was submitted. If You institute patent litigation against 97 | any entity (including a cross-claim or counterclaim in a lawsuit) alleging 98 | that the Work or a Contribution incorporated within the Work constitutes direct 99 | or contributory patent infringement, then any patent licenses granted to You 100 | under this License for that Work shall terminate as of the date such litigation 101 | is filed. 102 | 103 | 4. Redistribution. You may reproduce and distribute copies of the Work or 104 | Derivative Works thereof in any medium, with or without modifications, and 105 | in Source or Object form, provided that You meet the following conditions: 106 | 107 | (a) You must give any other recipients of the Work or Derivative Works a copy 108 | of this License; and 109 | 110 | (b) You must cause any modified files to carry prominent notices stating that 111 | You changed the files; and 112 | 113 | (c) You must retain, in the Source form of any Derivative Works that You distribute, 114 | all copyright, patent, trademark, and attribution notices from the Source 115 | form of the Work, excluding those notices that do not pertain to any part 116 | of the Derivative Works; and 117 | 118 | (d) If the Work includes a "NOTICE" text file as part of its distribution, 119 | then any Derivative Works that You distribute must include a readable copy 120 | of the attribution notices contained within such NOTICE file, excluding those 121 | notices that do not pertain to any part of the Derivative Works, in at least 122 | one of the following places: within a NOTICE text file distributed as part 123 | of the Derivative Works; within the Source form or documentation, if provided 124 | along with the Derivative Works; or, within a display generated by the Derivative 125 | Works, if and wherever such third-party notices normally appear. The contents 126 | of the NOTICE file are for informational purposes only and do not modify the 127 | License. You may add Your own attribution notices within Derivative Works 128 | that You distribute, alongside or as an addendum to the NOTICE text from the 129 | Work, provided that such additional attribution notices cannot be construed 130 | as modifying the License. 131 | 132 | You may add Your own copyright statement to Your modifications and may provide 133 | additional or different license terms and conditions for use, reproduction, 134 | or distribution of Your modifications, or for any such Derivative Works as 135 | a whole, provided Your use, reproduction, and distribution of the Work otherwise 136 | complies with the conditions stated in this License. 137 | 138 | 5. Submission of Contributions. Unless You explicitly state otherwise, any 139 | Contribution intentionally submitted for inclusion in the Work by You to the 140 | Licensor shall be under the terms and conditions of this License, without 141 | any additional terms or conditions. Notwithstanding the above, nothing herein 142 | shall supersede or modify the terms of any separate license agreement you 143 | may have executed with Licensor regarding such Contributions. 144 | 145 | 6. Trademarks. This License does not grant permission to use the trade names, 146 | trademarks, service marks, or product names of the Licensor, except as required 147 | for reasonable and customary use in describing the origin of the Work and 148 | reproducing the content of the NOTICE file. 149 | 150 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to 151 | in writing, Licensor provides the Work (and each Contributor provides its 152 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 153 | KIND, either express or implied, including, without limitation, any warranties 154 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR 155 | A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness 156 | of using or redistributing the Work and assume any risks associated with Your 157 | exercise of permissions under this License. 158 | 159 | 8. Limitation of Liability. In no event and under no legal theory, whether 160 | in tort (including negligence), contract, or otherwise, unless required by 161 | applicable law (such as deliberate and grossly negligent acts) or agreed to 162 | in writing, shall any Contributor be liable to You for damages, including 163 | any direct, indirect, special, incidental, or consequential damages of any 164 | character arising as a result of this License or out of the use or inability 165 | to use the Work (including but not limited to damages for loss of goodwill, 166 | work stoppage, computer failure or malfunction, or any and all other commercial 167 | damages or losses), even if such Contributor has been advised of the possibility 168 | of such damages. 169 | 170 | 9. Accepting Warranty or Additional Liability. While redistributing the Work 171 | or Derivative Works thereof, You may choose to offer, and charge a fee for, 172 | acceptance of support, warranty, indemnity, or other liability obligations 173 | and/or rights consistent with this License. However, in accepting such obligations, 174 | You may act only on Your own behalf and on Your sole responsibility, not on 175 | behalf of any other Contributor, and only if You agree to indemnify, defend, 176 | and hold each Contributor harmless for any liability incurred by, or claims 177 | asserted against, such Contributor by reason of your accepting any such warranty 178 | or additional liability. 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 boilerplate 183 | notice, with the fields enclosed by brackets "[]" replaced with your own identifying 184 | information. (Don't include the brackets!) The text should be enclosed in 185 | the appropriate comment syntax for the file format. We also recommend that 186 | a file or class name and description of purpose be included on the same "printed 187 | page" as the copyright notice for easier identification within third-party 188 | archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | 194 | you may not use this file except in compliance with the License. 195 | 196 | You may obtain a copy of the License at 197 | 198 | http://www.apache.org/licenses/LICENSE-2.0 199 | 200 | Unless required by applicable law or agreed to in writing, software 201 | 202 | distributed under the License is distributed on an "AS IS" BASIS, 203 | 204 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 205 | 206 | See the License for the specific language governing permissions and 207 | 208 | limitations under the License. 209 | -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES 4 | NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE 5 | AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION 6 | ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE 7 | OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS 8 | LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION 9 | OR WORKS PROVIDED HEREUNDER. 10 | 11 | Statement of Purpose 12 | 13 | The laws of most jurisdictions throughout the world automatically confer exclusive 14 | Copyright and Related Rights (defined below) upon the creator and subsequent 15 | owner(s) (each and all, an "owner") of an original work of authorship and/or 16 | a database (each, a "Work"). 17 | 18 | Certain owners wish to permanently relinquish those rights to a Work for the 19 | purpose of contributing to a commons of creative, cultural and scientific 20 | works ("Commons") that the public can reliably and without fear of later claims 21 | of infringement build upon, modify, incorporate in other works, reuse and 22 | redistribute as freely as possible in any form whatsoever and for any purposes, 23 | including without limitation commercial purposes. These owners may contribute 24 | to the Commons to promote the ideal of a free culture and the further production 25 | of creative, cultural and scientific works, or to gain reputation or greater 26 | distribution for their Work in part through the use and efforts of others. 27 | 28 | For these and/or other purposes and motivations, and without any expectation 29 | of additional consideration or compensation, the person associating CC0 with 30 | a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 31 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 32 | and publicly distribute the Work under its terms, with knowledge of his or 33 | her Copyright and Related Rights in the Work and the meaning and intended 34 | legal effect of CC0 on those rights. 35 | 36 | 1. Copyright and Related Rights. A Work made available under CC0 may be protected 37 | by copyright and related or neighboring rights ("Copyright and Related Rights"). 38 | Copyright and Related Rights include, but are not limited to, the following: 39 | 40 | i. the right to reproduce, adapt, distribute, perform, display, communicate, 41 | and translate a Work; 42 | 43 | ii. moral rights retained by the original author(s) and/or performer(s); 44 | 45 | iii. publicity and privacy rights pertaining to a person's image or likeness 46 | depicted in a Work; 47 | 48 | iv. rights protecting against unfair competition in regards to a Work, subject 49 | to the limitations in paragraph 4(a), below; 50 | 51 | v. rights protecting the extraction, dissemination, use and reuse of data 52 | in a Work; 53 | 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal protection 56 | of databases, and under any national implementation thereof, including any 57 | amended or successor version of such directive); and 58 | 59 | vii. other similar, equivalent or corresponding rights throughout the world 60 | based on applicable law or treaty, and any national implementations thereof. 61 | 62 | 2. Waiver. To the greatest extent permitted by, but not in contravention of, 63 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 64 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 65 | and Related Rights and associated claims and causes of action, whether now 66 | known or unknown (including existing as well as future claims and causes of 67 | action), in the Work (i) in all territories worldwide, (ii) for the maximum 68 | duration provided by applicable law or treaty (including future time extensions), 69 | (iii) in any current or future medium and for any number of copies, and (iv) 70 | for any purpose whatsoever, including without limitation commercial, advertising 71 | or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the 72 | benefit of each member of the public at large and to the detriment of Affirmer's 73 | heirs and successors, fully intending that such Waiver shall not be subject 74 | to revocation, rescission, cancellation, termination, or any other legal or 75 | equitable action to disrupt the quiet enjoyment of the Work by the public 76 | as contemplated by Affirmer's express Statement of Purpose. 77 | 78 | 3. Public License Fallback. Should any part of the Waiver for any reason be 79 | judged legally invalid or ineffective under applicable law, then the Waiver 80 | shall be preserved to the maximum extent permitted taking into account Affirmer's 81 | express Statement of Purpose. In addition, to the extent the Waiver is so 82 | judged Affirmer hereby grants to each affected person a royalty-free, non 83 | transferable, non sublicensable, non exclusive, irrevocable and unconditional 84 | license to exercise Affirmer's Copyright and Related Rights in the Work (i) 85 | in all territories worldwide, (ii) for the maximum duration provided by applicable 86 | law or treaty (including future time extensions), (iii) in any current or 87 | future medium and for any number of copies, and (iv) for any purpose whatsoever, 88 | including without limitation commercial, advertising or promotional purposes 89 | (the "License"). The License shall be deemed effective as of the date CC0 90 | was applied by Affirmer to the Work. Should any part of the License for any 91 | reason be judged legally invalid or ineffective under applicable law, such 92 | partial invalidity or ineffectiveness shall not invalidate the remainder of 93 | the License, and in such case Affirmer hereby affirms that he or she will 94 | not (i) exercise any of his or her remaining Copyright and Related Rights 95 | in the Work or (ii) assert any associated claims and causes of action with 96 | respect to the Work, in either case contrary to Affirmer's express Statement 97 | of Purpose. 98 | 99 | 4. Limitations and Disclaimers. 100 | 101 | a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, 102 | licensed or otherwise affected by this document. 103 | 104 | b. Affirmer offers the Work as-is and makes no representations or warranties 105 | of any kind concerning the Work, express, implied, statutory or otherwise, 106 | including without limitation warranties of title, merchantability, fitness 107 | for a particular purpose, non infringement, or the absence of latent or other 108 | defects, accuracy, or the present or absence of errors, whether or not discoverable, 109 | all to the greatest extent permissible under applicable law. 110 | 111 | c. Affirmer disclaims responsibility for clearing rights of other persons 112 | that may apply to the Work or any use thereof, including without limitation 113 | any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims 114 | responsibility for obtaining any necessary consents, permissions or other 115 | rights required for any use of the Work. 116 | 117 | d. Affirmer understands and acknowledges that Creative Commons is not a party 118 | to this document and has no duty or obligation with respect to this CC0 or 119 | use of the Work. 120 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 19 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | [![PyPI](https://img.shields.io/pypi/v/pytest-mypy-testing.svg)](https://pypi.python.org/pypi/pytest-mypy-testing) 6 | [![GitHub Action Status](https://github.com/davidfritzsche/pytest-mypy-testing/workflows/Python%20package/badge.svg)](https://github.com/davidfritzsche/pytest-mypy-testing/actions) 7 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | 10 | 11 | # pytest-mypy-testing — Plugin to test mypy output with pytest 12 | 13 | `pytest-mypy-testing` provides a 14 | [pytest](https://pytest.readthedocs.io/en/latest/) plugin to test that 15 | [mypy](http://mypy-lang.org/) produces a given output. As mypy can be 16 | told to [display the type of an 17 | expression](https://mypy.readthedocs.io/en/latest/common_issues.html#displaying-the-type-of-an-expression) 18 | this allows us to check mypys type interference. 19 | 20 | 21 | # Installation 22 | 23 | ``` shell 24 | python -m pip install pytest-mypy-testing 25 | ``` 26 | 27 | The Python distribution package contains an [entry 28 | point](https://docs.pytest.org/en/latest/writing_plugins.html#making-your-plugin-installable-by-others) 29 | so that the plugin is automatically discovered by pytest. To disable 30 | the plugin when it is installed , you can use the pytest command line 31 | option `-p no:mypy-testing`. 32 | 33 | 34 | # Writing Mypy Output Test Cases 35 | 36 | A mypy test case is a top-level functions decorated with 37 | `@pytest.mark.mypy_testing` in a file named `*.mypy-testing` or in a 38 | pytest test module. `pytest-mypy-testing` follows the pytest logic in 39 | identifying test modules and respects the 40 | [`python_files`](https://docs.pytest.org/en/latest/reference.html#confval-python_files) 41 | config value. 42 | 43 | Note that ``pytest-mypy-testing`` uses the Python 44 | [ast](https://docs.python.org/3/library/ast.html) module to parse 45 | candidate files and does not import any file, i.e., the decorator must 46 | be exactly named `@pytest.mark.mypy_testing`. 47 | 48 | In a pytest test module file you may combine both regular pytest test 49 | functions and mypy test functions. A single function can be both. 50 | 51 | Example: A simple mypy test case could look like this: 52 | 53 | ``` python 54 | @pytest.mark.mypy_testing 55 | def mypy_test_invalid_assignment() -> None: 56 | foo = "abc" 57 | foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str") 58 | ``` 59 | 60 | The plugin runs mypy for every file containing at least one mypy test 61 | case. The mypy output is then compared to special Python comments in 62 | the file: 63 | 64 | * `# N: ` - we expect a mypy note message 65 | * `# W: ` - we expect a mypy warning message 66 | * `# E: ` - we expect a mypy error message 67 | * `# F: ` - we expect a mypy fatal error message 68 | * `# R: ` - we expect a mypy note message `Revealed type is 69 | ''`. This is useful to easily check `reveal_type` output: 70 | ```python 71 | @pytest.mark.mypy_testing 72 | def mypy_use_reveal_type(): 73 | reveal_type(123) # N: Revealed type is 'Literal[123]?' 74 | reveal_type(456) # R: Literal[456]? 75 | ``` 76 | 77 | ## mypy Error Code Matching 78 | 79 | The algorithm matching messages parses mypy error code both in the 80 | output generated by mypy and in the Python comments. 81 | 82 | If both the mypy output and the Python comment contain an error code 83 | and a full message, then the messages and the error codes must 84 | match. The following test case expects that mypy writes out an 85 | ``assignment`` error code and a specific error message: 86 | 87 | ``` python 88 | @pytest.mark.mypy_testing 89 | def mypy_test_invalid_assignment() -> None: 90 | foo = "abc" 91 | foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str") [assignment] 92 | ``` 93 | 94 | If the Python comment does not contain an error code, then the error 95 | code written out by mypy (if any) is ignored. The following test case 96 | expects a specific error message from mypy, but ignores the error code 97 | produced by mypy: 98 | 99 | ``` python 100 | @pytest.mark.mypy_testing 101 | def mypy_test_invalid_assignment() -> None: 102 | foo = "abc" 103 | foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str") 104 | ``` 105 | 106 | If the Python comment specifies only an error code, then the message 107 | written out by mypy is ignored, i.e., the following test case checks 108 | that mypy reports an `assignment` error: 109 | 110 | ``` python 111 | @pytest.mark.mypy_testing 112 | def mypy_test_invalid_assignment() -> None: 113 | foo = "abc" 114 | foo = 123 # E: [assignment] 115 | ``` 116 | 117 | 118 | ## Skipping and Expected Failures 119 | 120 | Mypy test case functions can be decorated with `@pytest.mark.skip` and 121 | `@pytest.mark.xfail` to mark them as to-be-skipped and as 122 | expected-to-fail, respectively. As with the 123 | `@pytest.mark.mypy_testing` mark, the names must match exactly as the 124 | decorators are extracted from the ast. 125 | 126 | 127 | # Development 128 | 129 | * Create and activate a Python virtual environment. 130 | * Install development dependencies by calling `python -m pip install 131 | -U -r requirements.txt`. 132 | * Start developing. 133 | * To run all tests with [tox](https://tox.readthedocs.io/en/latest/), 134 | Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12 must be available. You 135 | might want to look into using 136 | [pyenv](https://github.com/pyenv/pyenv). 137 | 138 | 139 | # Changelog 140 | 141 | ## Unreleased 142 | 143 | ## v0.1.3 (2024-03-05) 144 | 145 | * Replace usage of deprecated path argument to pytest hook 146 | ``pytest_collect_file()`` with usage of the file_path argument 147 | introduced in pytest 7 ([#51][i51], [#52][p52]) 148 | 149 | ## v0.1.2 (2024-02-26) 150 | 151 | * Add support for pytest 8 (no actual change, but declare support) 152 | ([#46][i46], [#47][p47]) 153 | * Declare support for Python 3.12 ([#50][p50]) 154 | * Update GitHub actions ([#48][p48]) 155 | * Update development dependencies ([#49][p49]) 156 | * In GitHub PRs run tests with Python 3.11 and 3.12 ([#50][p50]) 157 | 158 | ## v0.1.1 159 | 160 | * Compare just mypy error codes if given and no error message is given 161 | in the test case Python comment ([#36][i36], [#43][p43]) 162 | 163 | ## v0.1.0 164 | 165 | * Implement support for flexible matching of mypy error codes (towards 166 | [#36][i36], [#41][p41]) 167 | * Add support for pytest 7.2.x ([#42][p42]) 168 | * Add support for mypy 1.0.x ([#42][p42]) 169 | * Add support for Python 3.11 ([#42][p42]) 170 | * Drop support for pytest 6.x ([#42][p42]) 171 | * Drop support for mypy versions less than 0.931 ([#42][p42]) 172 | 173 | ## v0.0.12 174 | 175 | * Allow Windows drives in filename ([#17][i17], [#34][p34]) 176 | * Support async def tests ([#30][i30], [#31][p31]) 177 | * Add support for mypy 0.971 ([#35][i35], [#27][i27]) 178 | * Remove support for Python 3.6 ([#32][p32]) 179 | * Bump development dependencies ([#40][p40]) 180 | 181 | ## v0.0.11 182 | 183 | * Add support for mypy 0.960 ([#25][p25]) 184 | 185 | ## v0.0.10 186 | 187 | * Add support for pytest 7.0.x and require Python >= 3.7 ([#23][p23]) 188 | * Bump dependencies ([#24][p24]) 189 | 190 | ## v0.0.9 191 | 192 | * Disable soft error limit ([#21][p21]) 193 | 194 | ## v0.0.8 195 | 196 | * Normalize messages to enable support for mypy 0.902 and pytest 6.2.4 ([#20][p20]) 197 | 198 | ## v0.0.7 199 | 200 | * Fix `PYTEST_VERSION_INFO` - by [@blueyed](https://github.com/blueyed) ([#8][p8]) 201 | * Always pass `--check-untyped-defs` to mypy ([#11][p11]) 202 | * Respect pytest config `python_files` when identifying pytest test modules ([#12][p12]) 203 | 204 | ## v0.0.6 - add pytest 5.4 support 205 | 206 | * Update the plugin to work with pytest 5.4 ([#7][p7]) 207 | 208 | ## v0.0.5 - CI improvements 209 | 210 | * Make invoke tasks work (partially) on Windows ([#6][p6]) 211 | * Add an invoke task to run tox environments by selecting globs (e.g., 212 | `inv tox -e py-*`) ([#6][p6]) 213 | * Use coverage directly for code coverage to get more consistent 214 | parallel run results ([#6][p6]) 215 | * Use flit fork dflit to make packaging work with `LICENSES` directory 216 | ([#6][p6]) 217 | * Bump dependencies ([#6][p6]) 218 | 219 | 220 | [i17]: https://github.com/davidfritzsche/pytest-mypy-testing/issues/17 221 | [i27]: https://github.com/davidfritzsche/pytest-mypy-testing/issues/27 222 | [i30]: https://github.com/davidfritzsche/pytest-mypy-testing/issues/30 223 | [i35]: https://github.com/davidfritzsche/pytest-mypy-testing/issues/35 224 | [i36]: https://github.com/davidfritzsche/pytest-mypy-testing/issues/36 225 | [i46]: https://github.com/davidfritzsche/pytest-mypy-testing/issues/46 226 | [i51]: https://github.com/davidfritzsche/pytest-mypy-testing/issues/51 227 | 228 | [p6]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/6 229 | [p7]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/7 230 | [p8]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/8 231 | [p11]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/11 232 | [p12]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/12 233 | [p20]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/20 234 | [p21]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/21 235 | [p23]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/23 236 | [p24]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/24 237 | [p25]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/25 238 | [p31]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/31 239 | [p32]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/32 240 | [p34]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/34 241 | [p40]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/40 242 | [p41]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/41 243 | [p42]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/42 244 | [p43]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/43 245 | [p47]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/47 246 | [p48]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/48 247 | [p49]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/49 248 | [p50]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/50 249 | [p52]: https://github.com/davidfritzsche/pytest-mypy-testing/pull/52 250 | -------------------------------------------------------------------------------- /constraints.in: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | atomicwrites==1.4.0 4 | filelock <3.12.3; python_version < "3.8" 5 | importlib-metadata <6.8; python_version < "3.8" 6 | platformdirs <4.1; python_version < "3.8" 7 | pluggy <1.3; python_version < "3.8" 8 | typing-extensions <4.8; python_version < "3.8" 9 | zipp <3.16; python_version < "3.8" 10 | -------------------------------------------------------------------------------- /constraints.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | # 4 | # This file is autogenerated by lock-requirements.sh 5 | # To update, run: 6 | # 7 | # ./lock-requirements.sh 8 | # 9 | attrs==23.2.0 10 | binaryornot==0.4.4 11 | black==24.2.0 12 | boolean-py==4.0 13 | build==1.0.3 14 | bump2version==1.0.1 15 | certifi==2024.2.2 16 | chardet==5.2.0 17 | charset-normalizer==3.3.2 18 | click==8.1.7 19 | coverage==7.4.3 20 | dflit==2.3.0.1 21 | dflit-core==2.3.0.1 22 | distlib==0.3.8 23 | docutils==0.20.1 24 | exceptiongroup==1.2.0 25 | filelock==3.13.1 26 | flake8==7.0.0 27 | flake8-bugbear==24.2.6 28 | flake8-comprehensions==3.14.0 29 | flake8-html==0.4.3 30 | flake8-logging-format==0.9.0 31 | flake8-mutable==1.2.0 32 | flake8-pyi==24.1.0 33 | fsfe-reuse==1.0.0 34 | idna==3.6 35 | iniconfig==2.0.0 36 | invoke==2.2.0 37 | isort==5.13.2 38 | jinja2==3.1.3 39 | license-expression==30.2.0 40 | markupsafe==2.1.5 41 | mccabe==0.7.0 42 | mypy==1.8.0 43 | mypy-extensions==1.0.0 44 | packaging==23.2 45 | pathspec==0.12.1 46 | pip==24.0 47 | pip-tools==7.4.0 48 | platformdirs==4.2.0 49 | pluggy==1.4.0 50 | py==1.11.0 51 | pycodestyle==2.11.1 52 | pyflakes==3.2.0 53 | pygments==2.17.2 54 | pyproject-hooks==1.0.0 55 | pytest==8.0.2 56 | pytest-cov==4.1.0 57 | pytest-html==4.1.1 58 | pytest-metadata==3.1.1 59 | python-debian==0.1.49 60 | pytoml==0.1.21 61 | requests==2.31.0 62 | reuse==3.0.1 63 | setuptools==69.1.1 64 | six==1.16.0 65 | tomli==2.0.1 66 | tox==3.28.0 67 | tox-pyenv==1.1.0 68 | types-invoke==2.0.0.10 69 | typing-extensions==4.10.0 70 | urllib3==2.2.1 71 | virtualenv==20.25.1 72 | wheel==0.42.0 73 | atomicwrites==1.4.0 74 | filelock <3.12.3; python_version < "3.8" 75 | importlib-metadata <6.8; python_version < "3.8" 76 | platformdirs <4.1; python_version < "3.8" 77 | pluggy <1.3; python_version < "3.8" 78 | typing-extensions <4.8; python_version < "3.8" 79 | zipp <3.16; python_version < "3.8" 80 | -------------------------------------------------------------------------------- /lock-requirements.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # SPDX-FileCopyrightText: David Fritzsche 4 | # SPDX-License-Identifier: CC0-1.0 5 | 6 | export CUSTOM_COMPILE_COMMAND="./lock-requirements.sh" 7 | 8 | export PYTHONWARNINGS=ignore 9 | 10 | pip-compile \ 11 | --unsafe-package='' \ 12 | --no-emit-index-url \ 13 | --resolver=backtracking \ 14 | -o requirements.txt \ 15 | requirements.in \ 16 | "$@" 17 | 18 | cat >constraints.txt <>constraints.txt 29 | cat constraints.in | grep -v -E '^#' >>constraints.txt 30 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | [mypy] 4 | 5 | python_version = 3.8 6 | 7 | mypy_path = src 8 | 9 | verbosity = 0 10 | 11 | # Show some context in the error message 12 | show_error_context = True 13 | 14 | # Unfortunately, outputting the column number confuses Visual Studio Code 15 | show_column_numbers = True 16 | 17 | # follow_imports = (normal|silent|skip|error) 18 | # cf. https://mypy.readthedocs.io/en/latest/running_mypy.html#follow-imports 19 | # silent = Follow all imports and type check, but suppress any error messages 20 | # in imported modules 21 | follow_imports = silent 22 | 23 | # Do not complain about missing imports 24 | ignore_missing_imports = False 25 | 26 | # Enables PEP 420 style namespace packages. (default False) 27 | namespace_packages = False 28 | 29 | # explicit_package_bases = True 30 | 31 | # Type-checks the interior of functions without type annotations (default False) 32 | check_untyped_defs = True 33 | 34 | # Warn about unused per-module sections (default False) 35 | warn_unused_configs = True 36 | 37 | # Warns about casting an expression to its inferred type (default False) 38 | warn_redundant_casts = True 39 | 40 | # Warn about unused `# type: ignore` comments (default False) 41 | warn_unused_ignores = True 42 | 43 | # Shows a warning when returning a value with type Any from a function declared 44 | # with a non-Any return type (default False) 45 | warn_return_any = True 46 | 47 | # Strict Optional checks. 48 | # If False, mypy treats None as compatible with every type. (default True) 49 | strict_optional = True 50 | 51 | 52 | [mypy-py.*] 53 | ignore_missing_imports = True 54 | -------------------------------------------------------------------------------- /mypy_tests/test_mypy_tests_in_test_file.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | # flake8: noqa 5 | # ruff: noqa 6 | 7 | import pytest 8 | 9 | 10 | @pytest.mark.mypy_testing 11 | def err(): 12 | import foo # E: Cannot find implementation or library stub for module named 'foo' 13 | 14 | 15 | @pytest.mark.mypy_testing 16 | def test_invalid_assginment(): 17 | """An example test function to be both executed and mypy-tested""" 18 | foo = "abc" 19 | foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str") 20 | assert foo == 123 21 | reveal_type(123) # R: Literal[123]? 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | [build-system] 5 | requires = ["dflit_core >=2,<3"] 6 | build-backend = "flit_core.buildapi" 7 | 8 | [tool.flit.metadata] 9 | module = "pytest_mypy_testing" 10 | author = "David Fritzsche" 11 | author-email = "david.fritzsche@mvua.de" 12 | classifiers = [ 13 | "Framework :: Pytest", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: Apache Software License", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: MacOS", 18 | "Operating System :: Microsoft :: Windows", 19 | "Operating System :: OS Independent", 20 | "Operating System :: POSIX", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Typing :: Typed", 28 | ] 29 | description-file = "README.md" 30 | dist-name = "pytest-mypy-testing" 31 | home-page = "https://github.com/davidfritzsche/pytest-mypy-testing" 32 | license = "Apache-2.0 OR MIT" 33 | requires = [ 34 | "pytest>=7,<9", 35 | "mypy>=1.0", 36 | ] 37 | requires-python = ">=3.7" 38 | 39 | [tool.flit.entrypoints.pytest11] 40 | mypy-testing = "pytest_mypy_testing.plugin" 41 | 42 | [tool.flit.sdist] 43 | include = ["src/pytest_mypy_testing/_version.py"] 44 | 45 | 46 | [tool.black] 47 | line-length = 88 48 | target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] 49 | include = '\.pyi?$' 50 | extend-exclude = ''' 51 | ( 52 | /_version\.py 53 | | /dist/ 54 | ) 55 | ''' 56 | 57 | 58 | [tool.coverage.run] 59 | include = [ 60 | 'src/*', 61 | 'mypy_tests/*', 62 | 'tests/*', 63 | ] 64 | data_file = 'build/coverage-data/coverage' 65 | parallel = true 66 | 67 | 68 | [tool.ruff.lint.isort] 69 | combine-as-imports = true 70 | lines-after-imports = 2 71 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | [pytest] 4 | testpaths = 5 | tests mypy_tests pytest_mypy_testing 6 | addopts = 7 | --durations=20 8 | --doctest-continue-on-failure 9 | --doctest-modules 10 | --failed-first 11 | --pyargs 12 | --showlocals 13 | -p no:mypy-testing 14 | --verbose 15 | --verbose 16 | doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ELLIPSIS 17 | log_level = DEBUG 18 | junit_family = xunit2 19 | 20 | # By default report warnings as errors 21 | filterwarnings = 22 | error 23 | # Ignore some Python 3.12 related deprecations 24 | ignore:datetime.datetime.utc.* is deprecated 25 | ignore:ast.[A-Za-z]* is deprecated 26 | ignore:Attribute s is deprecated 27 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | black >=24,<25 4 | bump2version 5 | coverage[toml] 6 | dflit 7 | flake8-bugbear 8 | flake8-comprehensions 9 | flake8-html 10 | flake8-logging-format 11 | flake8-mutable 12 | flake8-pyi 13 | fsfe-reuse 14 | invoke 15 | isort 16 | mypy ~=1.8 17 | pip 18 | pip-tools 19 | pytest 20 | pytest-cov 21 | pytest-html 22 | setuptools >=69 23 | tox <4 24 | tox-pyenv 25 | types-invoke 26 | 27 | # Consider constraints constraints.in 28 | -c constraints.in 29 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # ./lock-requirements.sh 6 | # 7 | attrs==23.2.0 8 | # via flake8-bugbear 9 | binaryornot==0.4.4 10 | # via reuse 11 | black==24.2.0 12 | # via -r requirements.in 13 | boolean-py==4.0 14 | # via 15 | # license-expression 16 | # reuse 17 | build==1.0.3 18 | # via pip-tools 19 | bump2version==1.0.1 20 | # via -r requirements.in 21 | certifi==2024.2.2 22 | # via requests 23 | chardet==5.2.0 24 | # via 25 | # binaryornot 26 | # python-debian 27 | charset-normalizer==3.3.2 28 | # via requests 29 | click==8.1.7 30 | # via 31 | # black 32 | # pip-tools 33 | coverage[toml]==7.4.3 34 | # via 35 | # -r requirements.in 36 | # pytest-cov 37 | dflit==2.3.0.1 38 | # via -r requirements.in 39 | dflit-core==2.3.0.1 40 | # via dflit 41 | distlib==0.3.8 42 | # via virtualenv 43 | docutils==0.20.1 44 | # via dflit 45 | exceptiongroup==1.2.0 46 | # via pytest 47 | filelock==3.13.1 48 | # via 49 | # tox 50 | # virtualenv 51 | flake8==7.0.0 52 | # via 53 | # flake8-bugbear 54 | # flake8-comprehensions 55 | # flake8-html 56 | # flake8-mutable 57 | # flake8-pyi 58 | flake8-bugbear==24.2.6 59 | # via -r requirements.in 60 | flake8-comprehensions==3.14.0 61 | # via -r requirements.in 62 | flake8-html==0.4.3 63 | # via -r requirements.in 64 | flake8-logging-format==0.9.0 65 | # via -r requirements.in 66 | flake8-mutable==1.2.0 67 | # via -r requirements.in 68 | flake8-pyi==24.1.0 69 | # via -r requirements.in 70 | fsfe-reuse==1.0.0 71 | # via -r requirements.in 72 | idna==3.6 73 | # via requests 74 | iniconfig==2.0.0 75 | # via pytest 76 | invoke==2.2.0 77 | # via -r requirements.in 78 | isort==5.13.2 79 | # via -r requirements.in 80 | jinja2==3.1.3 81 | # via 82 | # flake8-html 83 | # pytest-html 84 | # reuse 85 | license-expression==30.2.0 86 | # via reuse 87 | markupsafe==2.1.5 88 | # via jinja2 89 | mccabe==0.7.0 90 | # via flake8 91 | mypy==1.8.0 92 | # via -r requirements.in 93 | mypy-extensions==1.0.0 94 | # via 95 | # black 96 | # mypy 97 | packaging==23.2 98 | # via 99 | # black 100 | # build 101 | # pytest 102 | # tox 103 | pathspec==0.12.1 104 | # via black 105 | pip==24.0 106 | # via 107 | # -r requirements.in 108 | # pip-tools 109 | pip-tools==7.4.0 110 | # via -r requirements.in 111 | platformdirs==4.2.0 112 | # via 113 | # black 114 | # virtualenv 115 | pluggy==1.4.0 116 | # via 117 | # pytest 118 | # tox 119 | py==1.11.0 120 | # via tox 121 | pycodestyle==2.11.1 122 | # via flake8 123 | pyflakes==3.2.0 124 | # via 125 | # flake8 126 | # flake8-pyi 127 | pygments==2.17.2 128 | # via flake8-html 129 | pyproject-hooks==1.0.0 130 | # via 131 | # build 132 | # pip-tools 133 | pytest==8.0.2 134 | # via 135 | # -r requirements.in 136 | # pytest-cov 137 | # pytest-html 138 | # pytest-metadata 139 | pytest-cov==4.1.0 140 | # via -r requirements.in 141 | pytest-html==4.1.1 142 | # via -r requirements.in 143 | pytest-metadata==3.1.1 144 | # via pytest-html 145 | python-debian==0.1.49 146 | # via reuse 147 | pytoml==0.1.21 148 | # via 149 | # dflit 150 | # dflit-core 151 | requests==2.31.0 152 | # via dflit 153 | reuse==3.0.1 154 | # via fsfe-reuse 155 | setuptools==69.1.1 156 | # via 157 | # -r requirements.in 158 | # pip-tools 159 | six==1.16.0 160 | # via tox 161 | tomli==2.0.1 162 | # via 163 | # black 164 | # build 165 | # coverage 166 | # mypy 167 | # pip-tools 168 | # pyproject-hooks 169 | # pytest 170 | # tox 171 | tox==3.28.0 172 | # via 173 | # -r requirements.in 174 | # tox-pyenv 175 | tox-pyenv==1.1.0 176 | # via -r requirements.in 177 | types-invoke==2.0.0.10 178 | # via -r requirements.in 179 | typing-extensions==4.10.0 180 | # via 181 | # black 182 | # mypy 183 | urllib3==2.2.1 184 | # via requests 185 | virtualenv==20.25.1 186 | # via tox 187 | wheel==0.42.0 188 | # via pip-tools 189 | -------------------------------------------------------------------------------- /requirements.txt.license: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /src/pytest_mypy_testing/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 David Fritzsche 2 | # SPDX-License-Identifier: Apache-2.0 OR MIT 3 | """Pytest plugin to check mypy output.""" 4 | 5 | __version__ = "0.1.3" 6 | -------------------------------------------------------------------------------- /src/pytest_mypy_testing/message.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 David Fritzsche 2 | # SPDX-License-Identifier: Apache-2.0 OR MIT 3 | """Severity and Message""" 4 | 5 | import dataclasses 6 | import enum 7 | import os 8 | import pathlib 9 | import re 10 | from typing import Optional, Tuple, Union 11 | 12 | 13 | __all__ = [ 14 | "Message", 15 | "Severity", 16 | ] 17 | 18 | 19 | class Severity(enum.Enum): 20 | """Severity of a mypy message.""" 21 | 22 | NOTE = 1 23 | WARNING = 2 24 | ERROR = 3 25 | FATAL = 4 26 | 27 | @classmethod 28 | def from_string(cls, string: str) -> "Severity": 29 | return _string_to_severity[string.upper()] 30 | 31 | def __str__(self) -> str: 32 | return self.name.lower() 33 | 34 | def __repr__(self) -> str: 35 | return f"{self.__class__.__qualname__}.{self.name}" 36 | 37 | 38 | _string_to_severity = { 39 | "R": Severity.NOTE, 40 | "N": Severity.NOTE, 41 | "W": Severity.WARNING, 42 | "E": Severity.ERROR, 43 | "F": Severity.FATAL, 44 | } 45 | 46 | _COMMENT_MESSAGES = frozenset( 47 | [ 48 | ( 49 | Severity.NOTE, 50 | "See https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports", 51 | ), 52 | ] 53 | ) 54 | 55 | 56 | @dataclasses.dataclass 57 | class Message: 58 | """Mypy message""" 59 | 60 | filename: str = "" 61 | lineno: int = 0 62 | colno: Optional[int] = None 63 | severity: Severity = Severity.ERROR 64 | message: str = "" 65 | revealed_type: Optional[str] = None 66 | error_code: Optional[str] = None 67 | 68 | TupleType = Tuple[ 69 | str, int, Optional[int], Severity, str, Optional[str], Optional[str] 70 | ] 71 | 72 | _prefix: str = dataclasses.field(init=False, repr=False, default="") 73 | 74 | COMMENT_RE = re.compile( 75 | r"^(?:# *type: *ignore *)?(?:# *)?" 76 | r"(?P[RENW]):" 77 | r"((?P\d+):)?" 78 | r" *" 79 | r"(?P[^#]*)" 80 | r"(?:#.*?)?$" 81 | ) 82 | 83 | MESSAGE_AND_ERROR_CODE = re.compile( 84 | r"(?P[^\[][^#]*?)" r" +" r"\[(?P[^\]]*)\]" 85 | ) 86 | 87 | OUTPUT_RE = re.compile( 88 | r"^(?P([a-zA-Z]:)?[^:]+):" 89 | r"(?P[0-9]+):" 90 | r"((?P[0-9]+):)?" 91 | r" *(?P(error|note|warning)):" 92 | r"(?P.*?)" 93 | r"$" 94 | ) 95 | 96 | _OUTPUT_REVEALED_RE = re.compile( 97 | "^Revealed type is (?P'[^']+'|\"[^\"]+\")$" 98 | ) 99 | 100 | _INFERRED_TYPE_ASTERISK_RE = re.compile("(?<=[A-Za-z])[*]") 101 | 102 | def __post_init__(self): 103 | parts = [self.filename, str(self.lineno)] 104 | if self.colno: 105 | parts.append(str(self.colno)) 106 | self._prefix = ":".join(parts) + ":" 107 | if not self.revealed_type: 108 | revealed_m = self._OUTPUT_REVEALED_RE.match(self.message) 109 | if revealed_m: 110 | self.revealed_type = revealed_m.group("quoted_type")[1:-1] 111 | if self.revealed_type: 112 | # Remove the '*' for inferred types from reveal_type output. 113 | # This matches the behavior of mypy 0.950 and newer. 114 | self.revealed_type = self._INFERRED_TYPE_ASTERISK_RE.sub( 115 | "", self.revealed_type 116 | ) 117 | 118 | @property 119 | def normalized_message(self) -> str: 120 | """Normalized message. 121 | 122 | >>> m = Message("foo.py", 1, 1, Severity.NOTE, 'Revealed type is "float"') 123 | >>> m.normalized_message 124 | "Revealed type is 'float'" 125 | """ 126 | if self.revealed_type: 127 | return "Revealed type is {!r}".format(self.revealed_type) 128 | else: 129 | return self.message.replace("'", '"') 130 | 131 | def astuple(self, *, normalized: bool = False) -> "Message.TupleType": 132 | """Return a tuple representing this message. 133 | 134 | >>> m = Message("foo.py", 1, 1, Severity.NOTE, 'Revealed type is "float"') 135 | >>> m.astuple() 136 | ('foo.py', 1, 1, Severity.NOTE, 'Revealed type is "float"', 'float', None) 137 | """ 138 | return ( 139 | self.filename, 140 | self.lineno, 141 | self.colno, 142 | self.severity, 143 | self.normalized_message if normalized else self.message, 144 | self.revealed_type, 145 | self.error_code, 146 | ) 147 | 148 | def is_comment(self) -> bool: 149 | return (self.severity, self.message) in _COMMENT_MESSAGES 150 | 151 | def _as_short_tuple( 152 | self, 153 | *, 154 | normalized: bool = False, 155 | default_message: str = "", 156 | default_error_code: Optional[str] = None, 157 | ) -> "Message.TupleType": 158 | if normalized: 159 | message = self.normalized_message 160 | else: 161 | message = self.message 162 | return ( 163 | self.filename, 164 | self.lineno, 165 | None, 166 | self.severity, 167 | message or default_message, 168 | self.revealed_type, 169 | self.error_code or default_error_code, 170 | ) 171 | 172 | def __hash__(self) -> int: 173 | t = (self.filename, self.lineno, self.severity, self.revealed_type) 174 | return hash(t) 175 | 176 | def __eq__(self, other): 177 | """Compare if *self* and *other* are equal. 178 | 179 | Returns `True` if *other* is a :obj:`Message:` object 180 | considered to be equal to *self*. 181 | 182 | >>> Message() == Message() 183 | True 184 | >>> Message(error_code="baz") == Message(message="some text", error_code="baz") 185 | True 186 | >>> Message(message="some text") == Message(message="some text", error_code="baz") 187 | True 188 | 189 | >>> Message() == Message(message="some text", error_code="baz") 190 | False 191 | >>> Message(error_code="baz") == Message(error_code="bax") 192 | False 193 | """ 194 | if isinstance(other, Message): 195 | default_error_code = self.error_code or other.error_code 196 | if self.error_code and other.error_code: 197 | default_message = self.normalized_message or other.normalized_message 198 | else: 199 | default_message = "" 200 | 201 | def to_tuple(m: Message): 202 | return m._as_short_tuple( 203 | normalized=True, 204 | default_message=default_message, 205 | default_error_code=default_error_code, 206 | ) 207 | 208 | if self.colno is None or other.colno is None: 209 | return to_tuple(self) == to_tuple(other) 210 | else: 211 | return self.astuple(normalized=True) == other.astuple(normalized=True) 212 | else: 213 | return NotImplemented 214 | 215 | def __str__(self) -> str: 216 | return self.to_string(prefix=f"{self._prefix} ") 217 | 218 | def to_string(self, prefix: Optional[str] = None) -> str: 219 | prefix = prefix or f"{self._prefix} " 220 | error_code = f" [{self.error_code}]" if self.error_code else "" 221 | return f"{prefix}{self.severity.name.lower()}: {self.message}{error_code}" 222 | 223 | @classmethod 224 | def __split_message_and_error_code(cls, msg: str) -> Tuple[str, Optional[str]]: 225 | msg = msg.strip() 226 | if msg.startswith("[") and msg.endswith("]"): 227 | return "", msg[1:-1] 228 | else: 229 | m = cls.MESSAGE_AND_ERROR_CODE.fullmatch(msg) 230 | if m: 231 | return m.group("message"), m.group("error_code") 232 | else: 233 | return msg, None 234 | 235 | @classmethod 236 | def from_comment( 237 | cls, filename: Union[pathlib.Path, str], lineno: int, comment: str 238 | ) -> "Message": 239 | """Create message object from Python *comment*. 240 | 241 | >>> Message.from_comment("foo.py", 1, "R: foo") 242 | Message(filename='foo.py', lineno=1, colno=None, severity=Severity.NOTE, message="Revealed type is 'foo'", revealed_type='foo', error_code=None) 243 | >>> Message.from_comment("foo.py", 1, "E: [assignment]") 244 | Message(filename='foo.py', lineno=1, colno=None, severity=Severity.ERROR, message='', revealed_type=None, error_code='assignment') 245 | """ 246 | m = cls.COMMENT_RE.match(comment.strip()) 247 | if not m: 248 | raise ValueError("Not a valid mypy message comment") 249 | colno = int(m.group("colno")) if m.group("colno") else None 250 | message, error_code = cls.__split_message_and_error_code( 251 | m.group("message_and_error_code") 252 | ) 253 | if m.group("severity") == "R": 254 | revealed_type = message 255 | message = "Revealed type is {!r}".format(message) 256 | else: 257 | revealed_type = None 258 | return Message( 259 | str(filename), 260 | lineno=lineno, 261 | colno=colno, 262 | severity=Severity.from_string(m.group("severity")), 263 | message=message, 264 | revealed_type=revealed_type, 265 | error_code=error_code, 266 | ) 267 | 268 | @classmethod 269 | def from_output(cls, line: str) -> "Message": 270 | """Create message object from mypy output line. 271 | 272 | >>> m = Message.from_output("z.py:1: note: bar") 273 | >>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code) 274 | (1, None, Severity.NOTE, 'bar', None, None) 275 | 276 | >>> m = Message.from_output("z.py:1:13: note: bar") 277 | >>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code) 278 | (1, 13, Severity.NOTE, 'bar', None, None) 279 | 280 | >>> m = Message.from_output("z.py:1: note: Revealed type is 'bar'") 281 | >>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code) 282 | (1, None, Severity.NOTE, "Revealed type is 'bar'", 'bar', None) 283 | 284 | >>> m = Message.from_output('z.py:1: note: Revealed type is "bar"') 285 | >>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code) 286 | (1, None, Severity.NOTE, 'Revealed type is "bar"', 'bar', None) 287 | 288 | >>> m = Message.from_output("z.py:1:13: error: bar [baz]") 289 | >>> (m.lineno, m.colno, m.severity, m.message, m.revealed_type, m.error_code) 290 | (1, 13, Severity.ERROR, 'bar', None, 'baz') 291 | 292 | """ 293 | m = cls.OUTPUT_RE.match(line) 294 | if not m: 295 | raise ValueError("Not a valid mypy message") 296 | message, error_code = cls.__split_message_and_error_code( 297 | m.group("message_and_error_code") 298 | ) 299 | return cls( 300 | os.path.abspath(m.group("fname")), 301 | lineno=int(m.group("lineno")), 302 | colno=int(m.group("colno")) if m.group("colno") else None, 303 | severity=Severity[m.group("severity").upper()], 304 | message=message, 305 | error_code=error_code, 306 | ) 307 | -------------------------------------------------------------------------------- /src/pytest_mypy_testing/output_processing.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 David Fritzsche 2 | # SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | import dataclasses 5 | import difflib 6 | import itertools 7 | from typing import Dict, Iterator, List, Sequence, Tuple 8 | 9 | from .message import Message, Severity 10 | from .strutil import common_prefix 11 | 12 | 13 | @dataclasses.dataclass 14 | class OutputMismatch: 15 | actual: List[Message] = dataclasses.field(default_factory=lambda: []) 16 | expected: List[Message] = dataclasses.field(default_factory=lambda: []) 17 | lineno: int = dataclasses.field(init=False, default=0) 18 | lines: List[str] = dataclasses.field(init=False, default_factory=lambda: []) 19 | error_message: str = dataclasses.field(init=False, default="") 20 | 21 | @property 22 | def actual_lineno(self) -> int: 23 | if self.actual: 24 | return self.actual[0].lineno 25 | raise RuntimeError("No actual messages") 26 | 27 | @property 28 | def expected_lineno(self) -> int: 29 | if self.expected: 30 | return self.expected[0].lineno 31 | raise RuntimeError("No expected messages") 32 | 33 | @property 34 | def actual_severity(self) -> Severity: 35 | if not self.actual: 36 | raise RuntimeError("No actual messages") 37 | return Severity(max(msg.severity.value for msg in self.actual)) 38 | 39 | @property 40 | def expected_severity(self) -> Severity: 41 | if not self.expected: 42 | raise RuntimeError("No expected messages") 43 | return Severity(max(msg.severity.value for msg in self.expected)) 44 | 45 | def __post_init__(self) -> None: 46 | def _fmt(msg: Message, actual_expected: str = "", *, indent: str = " ") -> str: 47 | if actual_expected: 48 | actual_expected += ": " 49 | return msg.to_string(prefix=f"{indent}{actual_expected}") 50 | 51 | if not any([self.actual, self.expected]): 52 | raise ValueError("At least one of actual and expected must be given") 53 | 54 | if self.actual: 55 | self.lineno = self.actual_lineno 56 | elif self.expected: 57 | self.lineno = self.expected_lineno 58 | 59 | assert self.lines == [] 60 | 61 | if self.actual and self.expected: 62 | if self.actual_lineno != self.expected_lineno: 63 | raise ValueError("line numbers do not match") 64 | self.error_message = f"{self.actual[0].severity} (mismatch):" 65 | if len(self.actual) == len(self.expected) == 1: 66 | sp = " " * len( 67 | common_prefix(self.actual[0].message, self.expected[0].message) 68 | ) 69 | sp_lines = [f" {sp}^"] 70 | else: 71 | sp_lines = [] 72 | self.lines = ( 73 | [_fmt(msg, "A") for msg in self.actual] 74 | + [_fmt(msg, "E") for msg in self.expected] 75 | + sp_lines 76 | ) 77 | elif self.actual: 78 | if len(self.actual) == 1: 79 | self.error_message = ( 80 | f"{self.actual_severity} (unexpected): {self.actual[0].message}" 81 | ) 82 | else: 83 | self.error_message = f"{self.actual_severity} (unexpected):" 84 | self.lines = [_fmt(msg, "A") for msg in self.actual] 85 | else: 86 | assert self.expected 87 | if len(self.expected) == 1: 88 | self.error_message = ( 89 | f"{self.expected_severity} (missing): {self.expected[0].message}" 90 | ) 91 | else: 92 | self.error_message = f"{self.expected_severity} (missing):" 93 | self.lines = [_fmt(msg, "E") for msg in self.expected] 94 | 95 | 96 | def diff_message_sequences( 97 | actual_messages: Sequence[Message], expected_messages: Sequence[Message] 98 | ) -> List[OutputMismatch]: 99 | """Diff lists of messages""" 100 | 101 | def _chunk_to_dict(chunk: Sequence[Message]) -> Dict[int, List[Message]]: 102 | d: Dict[int, List[Message]] = {} 103 | for msg in chunk: 104 | d.setdefault(msg.lineno, []).append(msg) 105 | return d 106 | 107 | errors: List[OutputMismatch] = [] 108 | 109 | for a_chunk, b_chunk in iter_msg_seq_diff_chunks( 110 | actual_messages, expected_messages 111 | ): 112 | a_dict = _chunk_to_dict(a_chunk) 113 | b_dict = _chunk_to_dict(b_chunk) 114 | 115 | linenos_set = set(a_dict.keys()) | set(b_dict.keys()) 116 | 117 | linenos = sorted(linenos_set) 118 | 119 | for lineno in linenos: 120 | actual = a_dict.get(lineno, []) 121 | expected = b_dict.get(lineno, []) 122 | 123 | if any((not msg.is_comment()) for msg in itertools.chain(actual, expected)): 124 | errors.append(OutputMismatch(actual=actual, expected=expected)) 125 | 126 | return errors 127 | 128 | 129 | def iter_msg_seq_diff_chunks( 130 | a: Sequence[Message], b: Sequence[Message] 131 | ) -> Iterator[Tuple[Sequence[Message], Sequence[Message]]]: 132 | """Iterate over sequences of not matching messages""" 133 | seq_matcher = difflib.SequenceMatcher(isjunk=None, a=a, b=b, autojunk=False) 134 | for tag, i1, i2, j1, j2 in seq_matcher.get_opcodes(): 135 | if tag == "equal": 136 | continue 137 | actual = a[i1:i2] 138 | expected = b[j1:j2] 139 | if actual or expected: 140 | yield actual, expected 141 | -------------------------------------------------------------------------------- /src/pytest_mypy_testing/parser.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 David Fritzsche 2 | # SPDX-License-Identifier: Apache-2.0 OR MIT 3 | """Parse a Python file to determine the mypy test cases.""" 4 | 5 | import ast 6 | import dataclasses 7 | import io 8 | import itertools 9 | import os 10 | import pathlib 11 | import sys 12 | import tokenize 13 | from typing import Iterable, Iterator, List, Optional, Set, Tuple, Union 14 | 15 | from .message import Message 16 | 17 | 18 | __all__ = ["parse_file"] 19 | 20 | 21 | @dataclasses.dataclass 22 | class MypyTestItem: 23 | name: str 24 | lineno: int 25 | end_lineno: int 26 | expected_messages: List[Message] 27 | func_node: Optional[Union[ast.FunctionDef, ast.AsyncFunctionDef]] = None 28 | marks: Set[str] = dataclasses.field(default_factory=lambda: set()) 29 | actual_messages: List[Message] = dataclasses.field(default_factory=lambda: []) 30 | 31 | @classmethod 32 | def from_ast_node( 33 | cls, 34 | func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], 35 | marks: Optional[Set[str]] = None, 36 | unfiltered_messages: Optional[Iterable[Message]] = None, 37 | ) -> "MypyTestItem": 38 | if not isinstance(func_node, (ast.FunctionDef, ast.AsyncFunctionDef)): 39 | raise ValueError( 40 | f"Invalid func_node type: Got {type(func_node)}, " 41 | f"expected {ast.FunctionDef} or {ast.AsyncFunctionDef}" 42 | ) 43 | lineno = func_node.lineno 44 | end_lineno = getattr(func_node, "end_lineno", 0) 45 | 46 | for node in func_node.decorator_list: 47 | lineno = min(lineno, node.lineno) 48 | 49 | if unfiltered_messages is not None: 50 | expected_messages = [ 51 | msg for msg in unfiltered_messages if lineno <= msg.lineno <= end_lineno 52 | ] 53 | else: 54 | expected_messages = [] 55 | 56 | return cls( 57 | name=func_node.name, 58 | lineno=lineno, 59 | end_lineno=end_lineno, 60 | expected_messages=expected_messages, 61 | func_node=func_node, 62 | marks=(marks or set()), 63 | ) 64 | 65 | 66 | @dataclasses.dataclass 67 | class MypyTestFile: 68 | filename: str 69 | source_lines: List[str] = dataclasses.field(default_factory=lambda: []) 70 | items: List[MypyTestItem] = dataclasses.field(default_factory=lambda: []) 71 | messages: List[Message] = dataclasses.field(default_factory=lambda: []) 72 | 73 | 74 | def iter_comments( 75 | filename: Union[pathlib.Path, str], token_lists: List[List[tokenize.TokenInfo]] 76 | ) -> Iterator[tokenize.TokenInfo]: 77 | for toks in token_lists: 78 | for tok in toks: 79 | if tok.type == tokenize.COMMENT: 80 | yield tok 81 | 82 | 83 | def iter_mypy_comments( 84 | filename: Union[pathlib.Path, str], tokens: List[List[tokenize.TokenInfo]] 85 | ) -> Iterator[Message]: 86 | for tok in iter_comments(filename, tokens): 87 | try: 88 | yield Message.from_comment(filename, tok.start[0], tok.string) 89 | except ValueError: 90 | pass 91 | 92 | 93 | def generate_per_line_token_lists(source: str) -> Iterator[List[tokenize.TokenInfo]]: 94 | i = 0 95 | for lineno, group in itertools.groupby( 96 | tokenize.generate_tokens(io.StringIO(source).readline), 97 | lambda tok: tok.start[0], 98 | ): 99 | assert 0 <= lineno <= 10000000 100 | while i < lineno: 101 | yield [] 102 | i += 1 103 | yield list(group) 104 | i += 1 105 | 106 | 107 | def parse_file(filename: Union[os.PathLike, str, pathlib.Path], config) -> MypyTestFile: 108 | """Parse *filename* and return information about mypy test cases.""" 109 | filename = pathlib.Path(filename).resolve() 110 | with open(filename, "r", encoding="utf-8") as f: 111 | source_text = f.read() 112 | 113 | source_lines = source_text.splitlines() 114 | token_lists = list(generate_per_line_token_lists(source_text)) 115 | messages = list(iter_mypy_comments(filename, token_lists)) 116 | 117 | tree = ast.parse(source_text, filename=str(filename)) 118 | if sys.version_info < (3, 8): 119 | _add_end_lineno_if_missing(tree, len(source_lines)) 120 | 121 | items: List[MypyTestItem] = [] 122 | 123 | for node in ast.iter_child_nodes(tree): 124 | if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): 125 | continue 126 | marks = _find_marks(node) 127 | if "mypy_testing" in marks: 128 | items.append( 129 | MypyTestItem.from_ast_node( 130 | node, marks=marks, unfiltered_messages=messages 131 | ) 132 | ) 133 | 134 | return MypyTestFile( 135 | filename=str(filename), 136 | source_lines=source_lines, 137 | items=items, 138 | messages=messages, 139 | ) 140 | 141 | 142 | def _add_end_lineno_if_missing(tree, line_count: int): 143 | """Add end_lineno attribute to top-level nodes if missing""" 144 | prev_node: Optional[ast.AST] = None 145 | for node in ast.iter_child_nodes(tree): 146 | if prev_node is not None: 147 | setattr(prev_node, "end_lineno", node.lineno) # noqa: B010 148 | prev_node = node 149 | if prev_node: 150 | setattr(prev_node, "end_lineno", line_count) # noqa: B010 151 | 152 | 153 | def _find_marks(func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> Set[str]: 154 | return { 155 | name.split(".", 2)[2] 156 | for name, _ in _iter_func_decorators(func_node) 157 | if name.startswith("pytest.mark.") 158 | } 159 | 160 | 161 | def _iter_func_decorators( 162 | func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef] 163 | ) -> Iterator[Tuple[str, ast.AST]]: 164 | def dotted(*nodes): 165 | return ".".join(_get_node_name(node) for node in reversed(nodes)) 166 | 167 | for decorator_node in func_node.decorator_list: 168 | if isinstance(decorator_node, (ast.Name, ast.Attribute)): 169 | node, attrs = _unwrap_ast_attributes(decorator_node) 170 | if isinstance(node, ast.Name): 171 | yield dotted(*attrs, node), decorator_node 172 | 173 | elif isinstance(decorator_node, ast.Call): 174 | node, attrs = _unwrap_ast_attributes(decorator_node.func) 175 | if isinstance(node, ast.Name): 176 | yield dotted(*attrs, node), decorator_node 177 | 178 | 179 | def _get_node_name(node) -> str: 180 | if isinstance(node, ast.Attribute): 181 | return node.attr 182 | elif isinstance(node, ast.Name): 183 | return node.id 184 | else: 185 | raise RuntimeError(f"Unsupported node type: {type(node)}") # pragma: no cover 186 | 187 | 188 | def _unwrap_ast_attributes(node) -> Tuple[ast.AST, List[ast.Attribute]]: 189 | attrs: List[ast.Attribute] = [] 190 | while isinstance(node, ast.Attribute): 191 | attrs.append(node) 192 | node = node.value 193 | return node, attrs 194 | -------------------------------------------------------------------------------- /src/pytest_mypy_testing/plugin.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 David Fritzsche 2 | # SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | import os 5 | import pathlib 6 | import tempfile 7 | from typing import Iterable, Iterator, List, NamedTuple, Optional, Tuple, Union 8 | 9 | import mypy.api 10 | import pytest 11 | from _pytest._code.code import ReprEntry, ReprFileLocation 12 | from _pytest.config import Config 13 | from _pytest.python import path_matches_patterns 14 | 15 | from .message import Message, Severity 16 | from .output_processing import OutputMismatch, diff_message_sequences 17 | from .parser import MypyTestItem, parse_file 18 | 19 | 20 | PYTEST_VERSION = pytest.__version__ 21 | PYTEST_VERSION_INFO = tuple(int(part) for part in PYTEST_VERSION.split(".")[:3]) 22 | 23 | 24 | class MypyResult(NamedTuple): 25 | mypy_args: List[str] 26 | returncode: int 27 | output_lines: List[str] 28 | file_messages: List[Message] 29 | non_item_messages: List[Message] 30 | 31 | 32 | class MypyAssertionError(AssertionError): 33 | def __init__(self, item, errors: Iterable[OutputMismatch]): 34 | super().__init__(item, errors) 35 | self.item = item 36 | self.errors = errors 37 | 38 | 39 | class PytestMypyTestItem(pytest.Item): 40 | parent: "PytestMypyFile" 41 | 42 | def __init__( 43 | self, 44 | name: str, 45 | parent: "PytestMypyFile", 46 | *, 47 | mypy_item: MypyTestItem, 48 | config: Optional[Config] = None, 49 | **kwargs, 50 | ) -> None: 51 | if config is None: 52 | config = parent.config 53 | super().__init__(name, parent=parent, config=config, **kwargs) 54 | self.add_marker("mypy") 55 | self.mypy_item = mypy_item 56 | for mark in self.mypy_item.marks: 57 | self.add_marker(mark) 58 | 59 | @classmethod 60 | def from_parent(cls, parent, name, mypy_item): 61 | return super().from_parent(parent=parent, name=name, mypy_item=mypy_item) 62 | 63 | def runtest(self) -> None: 64 | returncode, actual_messages = self.parent.run_mypy(self.mypy_item) 65 | 66 | errors = diff_message_sequences( 67 | actual_messages, self.mypy_item.expected_messages 68 | ) 69 | 70 | if errors: 71 | raise MypyAssertionError(item=self, errors=errors) 72 | 73 | def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]: 74 | return self.parent.path, self.mypy_item.lineno, self.name 75 | 76 | def repr_failure(self, excinfo, style=None): 77 | if not excinfo.errisinstance(MypyAssertionError): 78 | return super().repr_failure(excinfo, style=style) # pragma: no cover 79 | reprfileloc_key = "reprfileloc" 80 | exception_repr = excinfo.getrepr(style="short") 81 | exception_repr.reprcrash.message = "" 82 | exception_repr.reprtraceback.reprentries = [ 83 | ReprEntry( 84 | lines=mismatch.lines, 85 | style="short", 86 | reprlocals=None, 87 | reprfuncargs=None, 88 | **{ 89 | reprfileloc_key: ReprFileLocation( 90 | path=str(self.parent.path), 91 | lineno=mismatch.lineno, 92 | message=mismatch.error_message, 93 | ) 94 | }, 95 | ) 96 | for mismatch in excinfo.value.errors 97 | ] 98 | return exception_repr 99 | 100 | 101 | class PytestMypyFile(pytest.File): 102 | def __init__( 103 | self, 104 | *, 105 | parent=None, 106 | config=None, 107 | session=None, 108 | nodeid=None, 109 | **kwargs, 110 | ) -> None: 111 | if config is None: 112 | config = getattr(parent, "config", None) 113 | super().__init__( 114 | parent=parent, 115 | config=config, 116 | session=session, 117 | nodeid=nodeid, 118 | **kwargs, 119 | ) 120 | self.add_marker("mypy") 121 | self.mypy_file = parse_file(self.path, config=config) 122 | self._mypy_result: Optional[MypyResult] = None 123 | 124 | @classmethod 125 | def from_parent(cls, parent, **kwargs): 126 | return super().from_parent(parent=parent, **kwargs) 127 | 128 | def collect(self) -> Iterator[PytestMypyTestItem]: 129 | for item in self.mypy_file.items: 130 | yield PytestMypyTestItem.from_parent( 131 | parent=self, name="[mypy]" + item.name, mypy_item=item 132 | ) 133 | 134 | def run_mypy(self, item: MypyTestItem) -> Tuple[int, List[Message]]: 135 | if self._mypy_result is None: 136 | self._mypy_result = self._run_mypy(self.path) 137 | return ( 138 | self._mypy_result.returncode, 139 | sorted( 140 | item.actual_messages + self._mypy_result.non_item_messages, 141 | key=lambda msg: msg.lineno, 142 | ), 143 | ) 144 | 145 | def _run_mypy(self, filename: Union[pathlib.Path, os.PathLike, str]) -> MypyResult: 146 | filename = pathlib.Path(filename) 147 | with tempfile.TemporaryDirectory(prefix="pytest-mypy-testing-") as tmp_dir_name: 148 | mypy_cache_dir = os.path.join(tmp_dir_name, "mypy_cache") 149 | os.makedirs(mypy_cache_dir) 150 | 151 | mypy_args = [ 152 | "--cache-dir={}".format(mypy_cache_dir), 153 | "--check-untyped-defs", 154 | "--hide-error-context", 155 | "--no-color-output", 156 | "--no-error-summary", 157 | "--no-pretty", 158 | "--soft-error-limit=-1", 159 | "--no-silence-site-packages", 160 | "--no-warn-unused-configs", 161 | "--show-column-numbers", 162 | "--show-error-codes", 163 | "--show-traceback", 164 | str(filename), 165 | ] 166 | 167 | out, err, returncode = mypy.api.run(mypy_args) 168 | 169 | lines = (out + err).splitlines() 170 | 171 | file_messages = [ 172 | msg 173 | for msg in map(Message.from_output, lines) 174 | if (msg.filename == self.mypy_file.filename) 175 | and not ( 176 | msg.severity is Severity.NOTE 177 | and msg.message 178 | == "See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports" 179 | ) 180 | ] 181 | 182 | non_item_messages = [] 183 | 184 | for msg in file_messages: 185 | for item in self.mypy_file.items: 186 | if item.lineno <= msg.lineno <= item.end_lineno: 187 | item.actual_messages.append(msg) 188 | break 189 | else: 190 | non_item_messages.append(msg) 191 | 192 | return MypyResult( 193 | mypy_args=mypy_args, 194 | returncode=returncode, 195 | output_lines=lines, 196 | file_messages=file_messages, 197 | non_item_messages=non_item_messages, 198 | ) 199 | 200 | 201 | def pytest_collect_file(file_path: pathlib.Path, parent): 202 | if file_path.suffix == ".mypy-testing" or _is_pytest_test_file(file_path, parent): 203 | file = PytestMypyFile.from_parent(parent=parent, path=file_path) 204 | if file.mypy_file.items: 205 | return file 206 | return None 207 | 208 | 209 | def _is_pytest_test_file(file_path: pathlib.Path, parent): 210 | """Return `True` if *path* is considered to be a pytest test file.""" 211 | # Based on _pytest/python.py::pytest_collect_file 212 | fn_patterns = parent.config.getini("python_files") + ["__init__.py"] 213 | return file_path.suffix == ".py" and ( 214 | parent.session.isinitpath(file_path) 215 | or path_matches_patterns(file_path, fn_patterns) 216 | ) 217 | 218 | 219 | def pytest_configure(config): 220 | """ 221 | Register a custom marker for MypyItems, 222 | and configure the plugin based on the CLI. 223 | """ 224 | _add_reveal_type_to_builtins() 225 | 226 | config.addinivalue_line( 227 | "markers", "mypy_testing: mark functions to be used for mypy testing." 228 | ) 229 | config.addinivalue_line( 230 | "markers", "mypy: mark mypy tests. Do not add this marker manually!" 231 | ) 232 | 233 | 234 | def _add_reveal_type_to_builtins(): 235 | # Add a reveal_type function to the builtins module 236 | import builtins 237 | 238 | if not hasattr(builtins, "reveal_type"): 239 | setattr(builtins, "reveal_type", lambda x: x) # noqa: B010 240 | -------------------------------------------------------------------------------- /src/pytest_mypy_testing/py.typed: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | -------------------------------------------------------------------------------- /src/pytest_mypy_testing/strutil.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 David Fritzsche 2 | # SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | import textwrap 5 | 6 | 7 | def common_prefix(a: str, b: str) -> str: 8 | """Determine the common prefix of *a* and *b*.""" 9 | if len(a) > len(b): 10 | a, b = b, a 11 | for i in range(len(a)): 12 | if a[i] != b[i]: 13 | return a[:i] 14 | return a 15 | 16 | 17 | def dedent(a: str) -> str: 18 | return textwrap.dedent(a).lstrip("\n") 19 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | import os 5 | import sys 6 | 7 | from invoke import task 8 | 9 | 10 | MAYBE_PTY = sys.platform != "win32" 11 | 12 | 13 | @task 14 | def mkdir(ctx, dirname): 15 | os.makedirs(dirname, exist_ok=True) 16 | 17 | 18 | @task 19 | def pth(ctx): 20 | import sysconfig 21 | 22 | site_packages_dir = sysconfig.get_path("purelib") 23 | pth_filename = os.path.join(site_packages_dir, "subprojects.pth") 24 | with open(pth_filename, "w", encoding="utf-8") as f: 25 | print(os.path.abspath("src"), file=f) 26 | 27 | 28 | @task(pre=[pth]) 29 | def tox(ctx, parallel="auto", e="ALL"): 30 | import fnmatch 31 | import itertools 32 | 33 | env_patterns = list(filter(None, e.split(","))) 34 | result = ctx.run("tox --listenvs-all", hide=True, pty=False) 35 | all_envs = result.stdout.splitlines() 36 | 37 | if any(pat == "ALL" for pat in env_patterns): 38 | envs = set(all_envs) 39 | else: 40 | envs = set( 41 | itertools.chain.from_iterable( 42 | fnmatch.filter(all_envs, pat) for pat in env_patterns 43 | ) 44 | ) 45 | envlist = ",".join(sorted(envs)) 46 | ctx.run(f"tox --parallel={parallel} -e {envlist}", echo=True, pty=MAYBE_PTY) 47 | 48 | 49 | @task 50 | def mypy(ctx): 51 | ctx.run("mypy src tests", echo=True, pty=MAYBE_PTY) 52 | 53 | 54 | @task 55 | def flake8(ctx): 56 | ctx.run("flake8", echo=True, pty=MAYBE_PTY) 57 | 58 | 59 | @task(pre=[pth]) 60 | def pytest(ctx): 61 | cmd = [ 62 | "pytest", 63 | # "-s", 64 | # "--log-cli-level=DEBUG", 65 | "--cov=pytest_mypy_testing", 66 | "--cov-report=html:build/cov_html", 67 | "--cov-report=term:skip-covered", 68 | ] 69 | ctx.run(" ".join(cmd), echo=True, pty=MAYBE_PTY) 70 | 71 | 72 | @task 73 | def black(ctx): 74 | ctx.run("black --check --diff .", echo=True, pty=MAYBE_PTY) 75 | 76 | 77 | @task 78 | def reuse_lint(ctx): 79 | ctx.run("reuse lint", echo=True, pty=MAYBE_PTY) 80 | 81 | 82 | @task 83 | def black_reformat(ctx): 84 | ctx.run("black .", echo=True, pty=MAYBE_PTY) 85 | 86 | 87 | @task 88 | def lock_requirements(ctx, upgrade=False): 89 | cmd = "pip-compile --allow-unsafe --no-index" 90 | if upgrade: 91 | cmd += " --upgrade" 92 | ctx.run(cmd, env={"CUSTOM_COMPILE_COMMAND": cmd}, echo=True, pty=MAYBE_PTY) 93 | 94 | 95 | @task 96 | def build(ctx): 97 | result = ctx.run("git show -s --format=%ct HEAD") 98 | timestamp = result.stdout.strip() 99 | cmd = "flit build" 100 | ctx.run(cmd, env={"SOURCE_DATE_EPOCH": timestamp}, echo=True, pty=MAYBE_PTY) 101 | 102 | 103 | @task 104 | def publish(ctx, repository="testpypi"): 105 | cmd = "flit publish --repository=%s" % (repository,) 106 | ctx.run(cmd, echo=True, pty=MAYBE_PTY) 107 | 108 | 109 | @task(pre=[mypy, pytest, flake8]) 110 | def check(ctx): 111 | pass 112 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | 5 | def pytest_cmdline_main(config): 6 | """Load pytest_mypy_testing if not already present.""" 7 | if not config.pluginmanager.get_plugin("mypy-testing"): 8 | from pytest_mypy_testing import plugin 9 | 10 | config.pluginmanager.register(plugin) 11 | -------------------------------------------------------------------------------- /tests/test___init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | import re 5 | 6 | from pytest_mypy_testing import __version__ 7 | 8 | 9 | def test_version(): 10 | assert re.match("^[0-9]*([.][0-9]*)*$", __version__) 11 | -------------------------------------------------------------------------------- /tests/test_basics.mypy-testing: -------------------------------------------------------------------------------- 1 | # -*- mode: python; -*- 2 | # SPDX-FileCopyrightText: David Fritzsche 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | import pytest 6 | 7 | 8 | @pytest.mark.mypy_testing 9 | def mypy_test_invalid_assignment(): 10 | foo = "abc" 11 | foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str") 12 | 13 | 14 | @pytest.mark.mypy_testing 15 | def mypy_test_invalid_assignment_with_error_code(): 16 | foo = "abc" 17 | foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str") [assignment] 18 | 19 | 20 | @pytest.mark.xfail 21 | @pytest.mark.mypy_testing 22 | def mypy_test_invalid_assignment_with_error_code__message_does_not_match(): 23 | foo = "abc" 24 | foo = 123 # E: Invalid assignment [assignment] 25 | 26 | 27 | @pytest.mark.mypy_testing 28 | def mypy_test_invalid_assignment_only_error_code(): 29 | foo = "abc" 30 | foo = 123 # E: [assignment] 31 | 32 | 33 | @pytest.mark.xfail 34 | @pytest.mark.mypy_testing 35 | def mypy_test_invalid_assignment_only_error_code__error_code_does_not_match(): 36 | foo = "abc" 37 | foo = 123 # E: [baz] 38 | 39 | 40 | @pytest.mark.xfail 41 | @pytest.mark.mypy_testing 42 | def mypy_test_invalid_assignment_no_message_and_no_error_code(): 43 | foo = "abc" 44 | foo = 123 # E: 45 | 46 | 47 | @pytest.mark.mypy_testing 48 | def mypy_test_use_reveal_type(): 49 | reveal_type(123) # N: Revealed type is 'Literal[123]?' 50 | reveal_type(456) # R: Literal[456]? 51 | 52 | 53 | @pytest.mark.mypy_testing 54 | def mypy_test_use_reveal_type__float_var(): 55 | some_float = 123.03 56 | reveal_type(some_float) # R: builtins.float 57 | 58 | 59 | @pytest.mark.mypy_testing 60 | def mypy_test_use_reveal_type__int_var(): 61 | some_int = 123 62 | reveal_type(some_int) # R: builtins.int 63 | 64 | 65 | @pytest.mark.mypy_testing 66 | def mypy_test_use_reveal_type__int_list_var(): 67 | some_list = [123] 68 | reveal_type(some_list) # R: builtins.list[builtins.int] 69 | 70 | 71 | @pytest.mark.mypy_testing 72 | def mypy_test_use_reveal_type__int_list_var__with__inferred_asterisk(): 73 | some_list = [123] 74 | reveal_type(some_list) # R: builtins.list[builtins.int*] 75 | 76 | 77 | @pytest.mark.mypy_testing 78 | @pytest.mark.skip("foo") 79 | def mypy_test_use_skip_marker(): 80 | reveal_type(123) # N: Revealed type is 'Literal[123]?' 81 | reveal_type(456) # R: Literal[456]? 82 | 83 | 84 | @pytest.mark.mypy_testing 85 | @pytest.mark.xfail 86 | def mypy_test_xfail_wrong_reveal_type(): 87 | reveal_type(456) # R: float 88 | 89 | 90 | @pytest.mark.mypy_testing 91 | @pytest.mark.xfail 92 | def mypy_test_xfail_missing_note(): 93 | "nothing" # N: missing 94 | 95 | 96 | @pytest.mark.mypy_testing 97 | @pytest.mark.xfail 98 | def mypy_test_xfail_unexpected_note(): 99 | reveal_type([]) # unexpected message 100 | -------------------------------------------------------------------------------- /tests/test_file_with_nonitem_messages.mypy-testing: -------------------------------------------------------------------------------- 1 | # -*- mode: python; -*- 2 | # SPDX-FileCopyrightText: David Fritzsche 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | import pytest 6 | 7 | 8 | a = 123 9 | a = "abc" # mypy error not covered by the test case below 10 | 11 | 12 | @pytest.mark.mypy_testing 13 | @pytest.mark.xfail 14 | def mypy_test_xfail_unexpected_note(): 15 | """Test case that fails due to not covered non-item error above.""" 16 | -------------------------------------------------------------------------------- /tests/test_message.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | from typing import Optional 5 | 6 | import pytest 7 | 8 | from pytest_mypy_testing.message import Message, Severity 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "string,expected", [("r", Severity.NOTE), ("N", Severity.NOTE)] 13 | ) 14 | def test_init_severity(string: str, expected: Severity): 15 | assert Severity.from_string(string) == expected 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "filename,comment,severity,message,error_code", 20 | [ 21 | ("z.py", "# E: bar", Severity.ERROR, "bar", None), 22 | ("z.py", "# E: bar", Severity.ERROR, "bar", "foo"), 23 | ("z.py", "# E: bar [foo]", Severity.ERROR, "bar", "foo"), 24 | ("z.py", "# E: bar [foo]", Severity.ERROR, "bar", ""), 25 | ("z.py", "#type:ignore# W: bar", Severity.WARNING, "bar", None), 26 | ("z.py", "# type: ignore # W: bar", Severity.WARNING, "bar", None), 27 | ("z.py", "# R: bar", Severity.NOTE, "Revealed type is 'bar'", None), 28 | ], 29 | ) 30 | def test_message_from_comment( 31 | filename: str, 32 | comment: str, 33 | severity: Severity, 34 | message: str, 35 | error_code: Optional[str], 36 | ): 37 | lineno = 123 38 | actual = Message.from_comment(filename, lineno, comment) 39 | expected = Message( 40 | filename=filename, 41 | lineno=lineno, 42 | colno=None, 43 | severity=severity, 44 | message=message, 45 | error_code=error_code, 46 | ) 47 | assert actual == expected 48 | 49 | 50 | def test_message_from_invalid_comment(): 51 | with pytest.raises(ValueError): 52 | Message.from_comment("foo.py", 1, "# fubar") 53 | 54 | 55 | @pytest.mark.parametrize( 56 | "line,severity,message", 57 | [ 58 | ("z.py:1: note: bar", Severity.NOTE, "bar"), 59 | ("z.py:1:2: note: bar", Severity.NOTE, "bar"), 60 | ("z.py:1:2: error: fubar", Severity.ERROR, "fubar"), 61 | ], 62 | ) 63 | def test_message_from_output(line: str, severity: Severity, message: str): 64 | msg = Message.from_output(line) 65 | assert msg.message == message 66 | assert msg.severity == severity 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "output", 71 | [ 72 | "foo.py:a: fubar", 73 | "fubar", 74 | "foo.py:1: fubar", 75 | "foo.py:1:1: fubar", 76 | "foo.py:1:1: not: fubar", 77 | ], 78 | ) 79 | def test_message_from_invalid_output(output): 80 | with pytest.raises(ValueError): 81 | Message.from_output(output) 82 | 83 | 84 | MSG_WITHOUT_COL = Message("z.py", 13, None, Severity.NOTE, "foo") 85 | MSG_WITH_COL = Message("z.py", 13, 23, Severity.NOTE, "foo") 86 | 87 | 88 | @pytest.mark.parametrize( 89 | "a,b", 90 | [ 91 | (MSG_WITH_COL, MSG_WITH_COL), 92 | (MSG_WITH_COL, MSG_WITHOUT_COL), 93 | (MSG_WITHOUT_COL, MSG_WITHOUT_COL), 94 | (MSG_WITHOUT_COL, MSG_WITH_COL), 95 | ], 96 | ) 97 | def test_message_eq(a: Message, b: Message): 98 | assert a == b 99 | assert not (a != b) 100 | 101 | 102 | def test_message_neq_with_not_message(): 103 | assert MSG_WITH_COL != 23 104 | assert MSG_WITH_COL != "abc" 105 | 106 | 107 | def test_message_hash(): 108 | assert hash(MSG_WITH_COL) == hash(MSG_WITHOUT_COL) 109 | 110 | 111 | def test_message_str(): 112 | assert str(MSG_WITHOUT_COL) == "z.py:13: note: foo" 113 | assert str(MSG_WITH_COL) == "z.py:13:23: note: foo" 114 | -------------------------------------------------------------------------------- /tests/test_output_processing.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | from typing import List, Tuple 5 | 6 | import pytest 7 | 8 | from pytest_mypy_testing.message import Message, Severity 9 | from pytest_mypy_testing.output_processing import ( 10 | OutputMismatch, 11 | diff_message_sequences, 12 | iter_msg_seq_diff_chunks, 13 | ) 14 | 15 | 16 | ERROR = Severity.ERROR 17 | NOTE = Severity.NOTE 18 | WARNING = Severity.WARNING 19 | 20 | 21 | MSGS_DIFF_A_SINGLE = [Message("z.py", 15, 1, NOTE, "diff-a")] 22 | MSGS_DIFF_B_SINGLE = [Message("z.py", 15, 1, NOTE, "diff-b")] 23 | 24 | MSGS_DIFF_A_MULTI = [ 25 | Message("z.py", 25, 1, NOTE, "diff-a"), 26 | Message("z.py", 25, 1, NOTE, "diff-a error"), 27 | ] 28 | MSGS_DIFF_B_MULTI = [Message("z.py", 25, 1, NOTE, "diff-b")] 29 | 30 | MSGS_UNEXPECTED_A_SINGLE = [ 31 | Message("z.py", 35, 1, NOTE, "unexpected"), 32 | ] 33 | 34 | MSGS_UNEXPECTED_A_MULTI = [ 35 | Message("z.py", 45, 1, NOTE, "unexpected"), 36 | Message("z.py", 45, 1, ERROR, "unexpected error"), 37 | ] 38 | 39 | MSGS_MISSING_B_SINGLE = [ 40 | Message("z.py", 55, 1, NOTE, "missing"), 41 | ] 42 | 43 | MSGS_MISSING_B_MULTI = [ 44 | Message("z.py", 65, 1, ERROR, "missing error"), 45 | Message("z.py", 65, 1, NOTE, "missing note"), 46 | ] 47 | 48 | A = [ 49 | Message("z.py", 10, 3, ERROR, "equal error"), 50 | Message("z.py", 10, 3, NOTE, "equal"), 51 | *MSGS_DIFF_A_SINGLE, 52 | Message("z.py", 20, 1, NOTE, "equal"), 53 | *MSGS_DIFF_A_MULTI, 54 | Message("z.py", 30, 1, NOTE, "equal"), 55 | *MSGS_UNEXPECTED_A_SINGLE, 56 | Message("z.py", 40, 1, NOTE, "equal"), 57 | *MSGS_UNEXPECTED_A_MULTI, 58 | Message("z.py", 50, 1, NOTE, "equal"), 59 | Message("z.py", 60, 1, NOTE, "equal"), 60 | Message("z.py", 70, 1, NOTE, "equal"), 61 | ] 62 | B = [ 63 | Message("z.py", 10, 3, ERROR, "equal error"), 64 | Message("z.py", 10, 3, NOTE, "equal"), 65 | *MSGS_DIFF_B_SINGLE, 66 | Message("z.py", 20, 1, NOTE, "equal"), 67 | *MSGS_DIFF_B_MULTI, 68 | Message("z.py", 30, 1, NOTE, "equal"), 69 | Message("z.py", 40, 1, NOTE, "equal"), 70 | Message("z.py", 50, 1, NOTE, "equal"), 71 | *MSGS_MISSING_B_SINGLE, 72 | Message("z.py", 60, 1, NOTE, "equal"), 73 | *MSGS_MISSING_B_MULTI, 74 | Message("z.py", 70, 1, NOTE, "equal"), 75 | ] 76 | 77 | EXPECTED_DIFF_SEQUENCE: List[Tuple[List[Message], List[Message]]] = [ 78 | (MSGS_DIFF_A_SINGLE, MSGS_DIFF_B_SINGLE), 79 | (MSGS_DIFF_A_MULTI, MSGS_DIFF_B_MULTI), 80 | (MSGS_UNEXPECTED_A_SINGLE, []), 81 | (MSGS_UNEXPECTED_A_MULTI, []), 82 | ([], MSGS_MISSING_B_SINGLE), 83 | ([], MSGS_MISSING_B_MULTI), 84 | ] 85 | 86 | 87 | def test_output_mismatch_neither_actual_nor_expected(): 88 | with pytest.raises(ValueError): 89 | OutputMismatch() 90 | 91 | 92 | def test_output_mismatch_actual_lineno_or_severity_without_actual(): 93 | msg = Message("z.py", 1, 0, NOTE, "foo") 94 | om = OutputMismatch(expected=[msg]) 95 | 96 | with pytest.raises(RuntimeError): 97 | om.actual_lineno 98 | with pytest.raises(RuntimeError): 99 | om.actual_severity 100 | 101 | 102 | def test_output_mismatch_expected_lineno_or_severity_without_expected(): 103 | msg = Message("z.py", 1, 0, NOTE, "foo") 104 | om = OutputMismatch(actual=[msg]) 105 | 106 | with pytest.raises(RuntimeError): 107 | om.expected_lineno 108 | with pytest.raises(RuntimeError): 109 | om.expected_severity 110 | 111 | 112 | def test_output_mismatch_line_number_mismatch(): 113 | msg_a = Message("z.py", 1, 0, NOTE, "foo") 114 | msg_b = Message("z.py", 2, 0, NOTE, "foo") 115 | 116 | with pytest.raises(ValueError): 117 | OutputMismatch([msg_a], [msg_b]) 118 | 119 | 120 | def test_output_mismatch_with_actual_and_expected(): 121 | actual = Message("z.py", 17, 3, NOTE, "bar") 122 | expected = Message("z.py", 17, 3, NOTE, "foo") 123 | 124 | om = OutputMismatch(actual=[actual], expected=[expected]) 125 | 126 | assert om.lineno == actual.lineno == expected.lineno 127 | assert "note" in om.error_message 128 | assert om.lines 129 | 130 | 131 | def test_output_mismatch_only_expected(): 132 | expected = Message("z.py", 17, 3, NOTE, "foo") 133 | 134 | om = OutputMismatch(expected=[expected]) 135 | 136 | assert om.lineno == expected.lineno 137 | assert "note" in om.error_message 138 | assert "missing" in om.error_message 139 | assert not om.lines 140 | 141 | 142 | def test_output_mismatch_only_actual(): 143 | actual = Message("z.py", 17, 3, NOTE, "foo") 144 | 145 | om = OutputMismatch(actual=[actual]) 146 | 147 | assert om.lineno == actual.lineno 148 | assert "note" in om.error_message 149 | assert "unexpected" in om.error_message 150 | assert not om.lines 151 | 152 | 153 | def test_iter_diff_sequences(): 154 | diff = list(iter_msg_seq_diff_chunks(A, B)) 155 | 156 | assert diff == EXPECTED_DIFF_SEQUENCE 157 | 158 | 159 | def test_diff_message_sequences(): 160 | expected = [OutputMismatch(a, e) for a, e in EXPECTED_DIFF_SEQUENCE] 161 | 162 | actual = diff_message_sequences(A, B) 163 | 164 | assert actual == expected 165 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | import ast 5 | import sys 6 | import typing as _typing 7 | from tokenize import COMMENT, ENDMARKER, NAME, NEWLINE, NL, TokenInfo 8 | from unittest.mock import Mock 9 | 10 | import pytest 11 | from _pytest.config import Config 12 | 13 | from pytest_mypy_testing.parser import ( 14 | MypyTestItem, 15 | generate_per_line_token_lists, 16 | parse_file, 17 | ) 18 | from pytest_mypy_testing.strutil import dedent 19 | 20 | 21 | @pytest.mark.parametrize("node", [None, 123, "abc"]) 22 | def test_cannot_create_mypy_test_case_from_ast_node_without_valid_node(node): 23 | with pytest.raises(ValueError): 24 | MypyTestItem.from_ast_node(node) 25 | 26 | 27 | def test_create_mypy_test_case(): 28 | func = dedent( 29 | r""" 30 | @pytest.mark.mypy_testing 31 | @pytest.mark.skip() 32 | @foo.bar 33 | def mypy_foo(): 34 | pass 35 | """ 36 | ) 37 | tree = ast.parse(func, "func.py") 38 | 39 | func_nodes = [ 40 | node for node in ast.iter_child_nodes(tree) if isinstance(node, ast.FunctionDef) 41 | ] 42 | 43 | assert func_nodes 44 | 45 | tc = MypyTestItem.from_ast_node(func_nodes[0]) 46 | 47 | assert tc.lineno == 1 48 | 49 | 50 | def test_iter_comments(): 51 | source = "\n".join(["# foo", "assert True # E: bar"]) 52 | 53 | actual = list(generate_per_line_token_lists(source)) 54 | 55 | # fmt: off 56 | expected:_typing.List[_typing.List[TokenInfo]] = [ 57 | [], # line 0 58 | [ 59 | TokenInfo(type=COMMENT, string="# foo", start=(1, 0), end=(1, 5), line="# foo\n",), 60 | TokenInfo(type=NL, string="\n", start=(1, 5), end=(1, 6), line="# foo\n"), 61 | ], 62 | [ 63 | TokenInfo(type=NAME, string="assert", start=(2, 0), end=(2, 6), line="assert True # E: bar",), 64 | TokenInfo(type=NAME, string="True", start=(2, 7), end=(2, 11), line="assert True # E: bar",), 65 | TokenInfo(type=COMMENT, string="# E: bar", start=(2, 12), end=(2, 20), line="assert True # E: bar",), 66 | TokenInfo(type=NEWLINE, string="", start=(2, 20), end=(2, 21), line=""), 67 | ], 68 | [ 69 | TokenInfo(type=ENDMARKER, string="", start=(3, 0), end=(3, 0), line="") 70 | ], 71 | ] 72 | # fmt: on 73 | 74 | # some patching due to differences between Python versions... 75 | for lineno, line_toks in enumerate(actual): 76 | for i, tok in enumerate(line_toks): 77 | if tok.type == NEWLINE: 78 | try: 79 | expected_tok = expected[lineno][i] 80 | if expected_tok.type == NEWLINE: 81 | expected[lineno][i] = TokenInfo( 82 | type=expected_tok.type, 83 | string=expected_tok.string, 84 | start=expected_tok.start, 85 | end=expected_tok.end, 86 | line=tok.line, 87 | ) 88 | except IndexError: 89 | pass 90 | 91 | assert actual == expected 92 | 93 | 94 | def test_parse_file_basic_call_works_with_py37(monkeypatch, tmp_path): 95 | path = tmp_path / "parse_file_test.py" 96 | path.write_text( 97 | dedent( 98 | r""" 99 | # foo 100 | def test_mypy_foo(): 101 | pass 102 | @pytest.mark.mypy_testing 103 | def test_mypy_bar(): 104 | pass 105 | """ 106 | ) 107 | ) 108 | 109 | monkeypatch.setattr(sys, "version_info", (3, 7, 5)) 110 | config = Mock(spec=Config) 111 | parse_file(str(path), config) 112 | 113 | 114 | def test_parse_async(tmp_path): 115 | path = tmp_path / "test_async.mypy-testing" 116 | path.write_text( 117 | dedent( 118 | r""" 119 | import pytest 120 | 121 | @pytest.mark.mypy_testing 122 | async def mypy_test_invalid_assginment(): 123 | foo = "abc" 124 | foo = 123 # E: Incompatible types in assignment (expression has type "int", variable has type "str") 125 | """ 126 | ) 127 | ) 128 | config = Mock(spec=Config) 129 | result = parse_file(str(path), config) 130 | assert len(result.items) == 1 131 | item = result.items[0] 132 | assert item.name == "mypy_test_invalid_assginment" 133 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | import pathlib 5 | from types import SimpleNamespace 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | from _pytest.config import Config 10 | 11 | from pytest_mypy_testing.message import Severity 12 | from pytest_mypy_testing.parser import MypyTestFile 13 | from pytest_mypy_testing.plugin import ( 14 | MypyAssertionError, 15 | PytestMypyFile, 16 | pytest_collect_file, 17 | ) 18 | from pytest_mypy_testing.strutil import dedent 19 | 20 | 21 | PYTEST_VERSION = pytest.__version__ 22 | PYTEST_VERSION_INFO = tuple(int(part) for part in PYTEST_VERSION.split(".")[:3]) 23 | 24 | 25 | ERROR = Severity.ERROR 26 | NOTE = Severity.NOTE 27 | WARNING = Severity.WARNING 28 | 29 | 30 | def call_pytest_collect_file(file_path: pathlib.Path, parent): 31 | return pytest_collect_file(file_path, parent) 32 | 33 | 34 | def test_create_mypy_assertion_error(): 35 | MypyAssertionError(None, []) 36 | 37 | 38 | def mk_dummy_parent(tmp_path: pathlib.Path, filename, content=""): 39 | path = tmp_path / filename 40 | path.write_text(content) 41 | 42 | config = Mock(spec=Config) 43 | config.rootdir = str(tmp_path) 44 | config.rootpath = str(tmp_path) 45 | config.getini.return_value = ["test_*.py", "*_test.py"] 46 | session = SimpleNamespace( 47 | config=config, isinitpath=lambda p: True, _initialpaths=[] 48 | ) 49 | parent = SimpleNamespace( 50 | config=config, 51 | session=session, 52 | nodeid="dummy", 53 | path=path, 54 | ) 55 | 56 | return parent 57 | 58 | 59 | @pytest.mark.parametrize("filename", ["z.py", "test_z.mypy-testing"]) 60 | def test_pytest_collect_file_not_test_file_name(tmp_path, filename: str): 61 | parent = mk_dummy_parent(tmp_path, filename) 62 | file_path = parent.path 63 | actual = call_pytest_collect_file(file_path, parent) 64 | assert actual is None 65 | 66 | 67 | @pytest.mark.parametrize("filename", ["test_z.py", "test_z.mypy-testing"]) 68 | def test_pytest_collect_file(tmp_path, filename): 69 | content = dedent( 70 | """ 71 | @pytest.mark.mypy_testing 72 | def foo(): 73 | pass 74 | """ 75 | ) 76 | 77 | parent = mk_dummy_parent(tmp_path, filename, content) 78 | expected = MypyTestFile( 79 | filename=str(parent.path), source_lines=content.splitlines() 80 | ) 81 | 82 | file_path = parent.path 83 | actual = call_pytest_collect_file(file_path, parent) 84 | assert isinstance(actual, PytestMypyFile) 85 | 86 | assert len(actual.mypy_file.items) == 1 87 | actual.mypy_file.items = [] 88 | 89 | assert actual.mypy_file == expected 90 | -------------------------------------------------------------------------------- /tests/test_strutil.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | import pytest 5 | 6 | from pytest_mypy_testing.strutil import common_prefix, dedent 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "a,b,expected", 11 | [ 12 | ("", "", ""), 13 | ("a", "a", "a"), 14 | ("abc", "abcd", "abc"), 15 | ("abcd", "abc", "abc"), 16 | ("abc", "xyz", ""), 17 | ], 18 | ) 19 | def test_common_prefix(a: str, b: str, expected: str): 20 | actual = common_prefix(a, b) 21 | assert actual == expected 22 | 23 | 24 | def test_dedent(): 25 | input = """ 26 | foo 27 | bar 28 | baz 29 | """ 30 | 31 | expected = "foo\nbar\n baz\n" 32 | actual = dedent(input) 33 | assert actual == expected 34 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: David Fritzsche 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | [tox] 5 | isolated_build = True 6 | envlist = 7 | py37-pytest{70,74}-mypy{10,14} 8 | {py38,py39,py310,py311,py312}-pytest{70,81}-mypy{10,18} 9 | py-pytest{70,81}-mypy{10,18} 10 | linting 11 | minversion = 3.28 12 | 13 | 14 | [testenv] 15 | deps = 16 | -c constraints.in 17 | coverage[toml] 18 | pytest70: pytest~=7.0.1 19 | pytest71: pytest~=7.1.3 20 | pytest72: pytest~=7.2.2 21 | pytest74: pytest~=7.4.4 22 | pytest80: pytest~=8.0.2 23 | pytest80: pytest~=8.0.2 24 | pytest81: pytest==8.1.0 # to test with the yanked version 25 | mypy10: mypy==1.0.1 26 | mypy14: mypy==1.4.1 # last version to support Python 3.7 27 | mypy17: mypy==1.7.1 28 | mypy18: mypy==1.8.0 29 | setenv = 30 | COVERAGE_FILE={toxinidir}/build/{envname}/coverage 31 | commands = 32 | python -m coverage run --context "{envname}" -m pytest {posargs} --junitxml={toxinidir}/build/{envname}/junit.xml 33 | 34 | [testenv:black] 35 | basepython = python3.10 36 | skip_install = True 37 | deps = 38 | -c constraints.txt 39 | black 40 | commands = 41 | python -m black --check --fast --diff {posargs} . 42 | 43 | [testenv:flake8] 44 | basepython = python3.10 45 | skip_install = True 46 | deps = 47 | -c constraints.txt 48 | flake8 49 | flake8-bugbear 50 | flake8-comprehensions 51 | flake8-html 52 | flake8-mutable 53 | flake8-pyi 54 | flake8-logging-format 55 | commands = 56 | python -m flake8 {posargs} 57 | 58 | [testenv:isort] 59 | basepython = python3.10 60 | skip_install = True 61 | deps = 62 | -c constraints.txt 63 | isort 64 | commands = 65 | python -m isort . --check {posargs} 66 | 67 | [testenv:mypy] 68 | basepython = python3.10 69 | skip_install = True 70 | deps = 71 | -cconstraints.txt 72 | mypy 73 | pytest 74 | commands = 75 | mypy src tests 76 | 77 | [testenv:linting] 78 | basepython = python3.10 79 | skip_install = True 80 | deps = 81 | -cconstraints.txt 82 | {[testenv:black]deps} 83 | {[testenv:flake8]deps} 84 | {[testenv:isort]deps} 85 | {[testenv:mypy]deps} 86 | commands = 87 | {[testenv:black]commands} 88 | {[testenv:flake8]commands} 89 | {[testenv:isort]commands} 90 | {[testenv:mypy]commands} 91 | 92 | [testenv:lock-requirements] 93 | basepython = python3.10 94 | skip_install = True 95 | deps = 96 | -cconstraints.txt 97 | pip-tools 98 | allowlist_externals = 99 | sh 100 | commands = 101 | sh ./lock-requirements.sh {posargs} 102 | 103 | [tox:.package] 104 | # note tox will use the same python version as under what tox is 105 | # installed to package so unless this is python 3 you can require a 106 | # given python version for the packaging environment via the 107 | # basepython key 108 | basepython = python3 109 | --------------------------------------------------------------------------------