├── .bumpversion.cfg ├── .coveragerc ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── master.yml │ ├── pull_request.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── example_project ├── .coveragerc ├── pyproject.toml ├── src │ ├── __init__.py │ └── app.py └── tests │ ├── __init__.py │ └── test_app.py ├── mypy.ini ├── pyproject.toml ├── scripts ├── build.sh ├── bumpversion.sh ├── ci.sh ├── clean.sh ├── coverage.sh ├── lint-fix.sh ├── lint.sh ├── release.sh ├── static-analysis.sh ├── test.sh └── type-check.sh ├── src └── coverage_threshold │ ├── __init__.py │ ├── __main__.py │ ├── cli │ ├── __init__.py │ ├── args.py │ ├── colors.py │ ├── main.py │ ├── read_config.py │ └── read_report.py │ ├── lib │ ├── __init__.py │ ├── _all_checks.py │ ├── _common.py │ ├── _file.py │ ├── _totals.py │ ├── alternative.py │ └── check_result.py │ └── model │ ├── __init__.py │ ├── config.py │ ├── report.py │ └── util.py ├── tests ├── integration │ └── test_cli.py └── unit │ ├── config │ ├── complex.pyproject.toml │ ├── legacy.pyproject.toml │ ├── pyproject.toml │ └── test_read_config.py │ ├── lib │ ├── test_all_checks.py │ ├── test_check_result.py │ └── test_common.py │ └── model │ ├── test_config.py │ └── test_report.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.6.2 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | message = Bump version from {current_version} to {new_version} 7 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)((-rc(?P\d+))?) 8 | serialize = 9 | {major}.{minor}.{patch}-rc{rc} 10 | {major}.{minor}.{patch} 11 | 12 | [bumpversion:file:pyproject.toml] 13 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | src/coverage_threshold/ 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | if TYPE_CHECKING: 9 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | exclude = 5 | .git, 6 | __pycache__, 7 | build, 8 | dist, 9 | .mypy_cache, 10 | .pytest_cache, 11 | .hypothesis 12 | .tox 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: master build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/test.yml 11 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: pull request check 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | uses: ./.github/workflows/test.yml 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python: ["3.8", "3.9", "3.10", "3.11", "3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Setup Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python }} 19 | - name: Install tox and any other packages 20 | run: pip install tox 21 | - name: Run tox 22 | # Run tox using the version of Python in `PATH` 23 | run: tox -e py 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | MANIFEST 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | coverage.json 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # editor config 107 | .vscode/ 108 | .idea/ 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dean Way 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pytest = "*" 8 | pylint = "*" 9 | tox = "*" 10 | bumpversion = "*" 11 | coverage = "*" 12 | mypy = "*" 13 | black = "*" 14 | typing_extensions = "*" 15 | hypothesis = "*" 16 | flake8 = "*" 17 | types-toml = "*" 18 | 19 | [packages] 20 | toml = ">= 0.10.2" 21 | coverage-threshold = {editable = true,path = "."} 22 | 23 | [requires] 24 | python_version = "3.8" 25 | 26 | [pipenv] 27 | allow_prereleases = true 28 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "ba095b665a6ee91b4fd96ebf90b1cb4385ded32f16ce61c753ce984e21dde237" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "coverage-threshold": { 20 | "editable": true, 21 | "markers": "python_version >= '3.7'", 22 | "path": "." 23 | }, 24 | "toml": { 25 | "hashes": [ 26 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 27 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 28 | ], 29 | "index": "pypi", 30 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 31 | "version": "==0.10.2" 32 | } 33 | }, 34 | "develop": { 35 | "astroid": { 36 | "hashes": [ 37 | "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", 38 | "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25" 39 | ], 40 | "markers": "python_full_version >= '3.8.0'", 41 | "version": "==3.2.4" 42 | }, 43 | "attrs": { 44 | "hashes": [ 45 | "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", 46 | "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b" 47 | ], 48 | "markers": "python_version >= '3.8'", 49 | "version": "==25.3.0" 50 | }, 51 | "black": { 52 | "hashes": [ 53 | "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6", 54 | "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e", 55 | "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f", 56 | "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018", 57 | "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e", 58 | "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd", 59 | "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4", 60 | "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed", 61 | "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2", 62 | "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42", 63 | "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af", 64 | "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb", 65 | "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368", 66 | "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb", 67 | "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af", 68 | "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed", 69 | "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47", 70 | "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2", 71 | "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a", 72 | "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c", 73 | "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920", 74 | "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1" 75 | ], 76 | "index": "pypi", 77 | "markers": "python_version >= '3.8'", 78 | "version": "==24.8.0" 79 | }, 80 | "bump2version": { 81 | "hashes": [ 82 | "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", 83 | "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6" 84 | ], 85 | "markers": "python_version >= '3.5'", 86 | "version": "==1.0.1" 87 | }, 88 | "bumpversion": { 89 | "hashes": [ 90 | "sha256:4ba55e4080d373f80177b4dabef146c07ce73c7d1377aabf9d3c3ae1f94584a6", 91 | "sha256:4eb3267a38194d09f048a2179980bb4803701969bff2c85fa8f6d1ce050be15e" 92 | ], 93 | "index": "pypi", 94 | "version": "==0.6.0" 95 | }, 96 | "cachetools": { 97 | "hashes": [ 98 | "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", 99 | "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a" 100 | ], 101 | "markers": "python_version >= '3.7'", 102 | "version": "==5.5.2" 103 | }, 104 | "chardet": { 105 | "hashes": [ 106 | "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", 107 | "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" 108 | ], 109 | "markers": "python_version >= '3.7'", 110 | "version": "==5.2.0" 111 | }, 112 | "click": { 113 | "hashes": [ 114 | "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", 115 | "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" 116 | ], 117 | "markers": "python_version >= '3.7'", 118 | "version": "==8.1.8" 119 | }, 120 | "colorama": { 121 | "hashes": [ 122 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 123 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 124 | ], 125 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 126 | "version": "==0.4.6" 127 | }, 128 | "coverage": { 129 | "hashes": [ 130 | "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", 131 | "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", 132 | "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", 133 | "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", 134 | "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", 135 | "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", 136 | "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", 137 | "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", 138 | "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", 139 | "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", 140 | "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", 141 | "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", 142 | "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", 143 | "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", 144 | "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", 145 | "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", 146 | "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", 147 | "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", 148 | "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", 149 | "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", 150 | "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", 151 | "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", 152 | "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", 153 | "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", 154 | "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", 155 | "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", 156 | "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", 157 | "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", 158 | "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", 159 | "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", 160 | "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", 161 | "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", 162 | "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", 163 | "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", 164 | "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", 165 | "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", 166 | "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", 167 | "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", 168 | "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", 169 | "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", 170 | "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", 171 | "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", 172 | "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", 173 | "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", 174 | "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", 175 | "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", 176 | "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", 177 | "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", 178 | "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", 179 | "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", 180 | "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", 181 | "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", 182 | "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", 183 | "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", 184 | "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", 185 | "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", 186 | "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", 187 | "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", 188 | "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", 189 | "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", 190 | "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", 191 | "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", 192 | "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", 193 | "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", 194 | "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", 195 | "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", 196 | "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", 197 | "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", 198 | "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", 199 | "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", 200 | "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", 201 | "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc" 202 | ], 203 | "index": "pypi", 204 | "markers": "python_version >= '3.8'", 205 | "version": "==7.6.1" 206 | }, 207 | "dill": { 208 | "hashes": [ 209 | "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", 210 | "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" 211 | ], 212 | "markers": "python_version < '3.11'", 213 | "version": "==0.4.0" 214 | }, 215 | "distlib": { 216 | "hashes": [ 217 | "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", 218 | "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403" 219 | ], 220 | "version": "==0.3.9" 221 | }, 222 | "exceptiongroup": { 223 | "hashes": [ 224 | "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", 225 | "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88" 226 | ], 227 | "markers": "python_version < '3.11'", 228 | "version": "==1.3.0" 229 | }, 230 | "filelock": { 231 | "hashes": [ 232 | "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", 233 | "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435" 234 | ], 235 | "markers": "python_version >= '3.8'", 236 | "version": "==3.16.1" 237 | }, 238 | "flake8": { 239 | "hashes": [ 240 | "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", 241 | "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd" 242 | ], 243 | "index": "pypi", 244 | "markers": "python_full_version >= '3.8.1'", 245 | "version": "==7.1.2" 246 | }, 247 | "hypothesis": { 248 | "hashes": [ 249 | "sha256:5556ac66fdf72a4ccd5d237810f7cf6bdcd00534a4485015ef881af26e20f7c7", 250 | "sha256:d539180eb2bb71ed28a23dfe94e67c851f9b09f3ccc4125afad43f17e32e2bad" 251 | ], 252 | "index": "pypi", 253 | "markers": "python_version >= '3.8'", 254 | "version": "==6.113.0" 255 | }, 256 | "iniconfig": { 257 | "hashes": [ 258 | "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", 259 | "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" 260 | ], 261 | "markers": "python_version >= '3.8'", 262 | "version": "==2.1.0" 263 | }, 264 | "isort": { 265 | "hashes": [ 266 | "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", 267 | "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" 268 | ], 269 | "markers": "python_full_version >= '3.8.0'", 270 | "version": "==5.13.2" 271 | }, 272 | "mccabe": { 273 | "hashes": [ 274 | "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", 275 | "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" 276 | ], 277 | "markers": "python_version >= '3.6'", 278 | "version": "==0.7.0" 279 | }, 280 | "mypy": { 281 | "hashes": [ 282 | "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c", 283 | "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", 284 | "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", 285 | "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0", 286 | "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9", 287 | "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b", 288 | "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", 289 | "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35", 290 | "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319", 291 | "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc", 292 | "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb", 293 | "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb", 294 | "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", 295 | "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60", 296 | "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31", 297 | "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f", 298 | "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", 299 | "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", 300 | "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", 301 | "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", 302 | "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837", 303 | "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6", 304 | "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", 305 | "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d", 306 | "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", 307 | "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", 308 | "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", 309 | "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", 310 | "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b", 311 | "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac", 312 | "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", 313 | "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", 314 | "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", 315 | "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", 316 | "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427", 317 | "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", 318 | "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", 319 | "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89" 320 | ], 321 | "index": "pypi", 322 | "markers": "python_version >= '3.8'", 323 | "version": "==1.14.1" 324 | }, 325 | "mypy-extensions": { 326 | "hashes": [ 327 | "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", 328 | "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" 329 | ], 330 | "markers": "python_version >= '3.8'", 331 | "version": "==1.1.0" 332 | }, 333 | "packaging": { 334 | "hashes": [ 335 | "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", 336 | "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" 337 | ], 338 | "markers": "python_version >= '3.8'", 339 | "version": "==25.0" 340 | }, 341 | "pathspec": { 342 | "hashes": [ 343 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 344 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 345 | ], 346 | "markers": "python_version >= '3.8'", 347 | "version": "==0.12.1" 348 | }, 349 | "platformdirs": { 350 | "hashes": [ 351 | "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", 352 | "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" 353 | ], 354 | "markers": "python_version >= '3.8'", 355 | "version": "==4.3.6" 356 | }, 357 | "pluggy": { 358 | "hashes": [ 359 | "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", 360 | "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" 361 | ], 362 | "markers": "python_version >= '3.8'", 363 | "version": "==1.5.0" 364 | }, 365 | "pycodestyle": { 366 | "hashes": [ 367 | "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", 368 | "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521" 369 | ], 370 | "markers": "python_version >= '3.8'", 371 | "version": "==2.12.1" 372 | }, 373 | "pyflakes": { 374 | "hashes": [ 375 | "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", 376 | "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" 377 | ], 378 | "markers": "python_version >= '3.8'", 379 | "version": "==3.2.0" 380 | }, 381 | "pylint": { 382 | "hashes": [ 383 | "sha256:02f4aedeac91be69fb3b4bea997ce580a4ac68ce58b89eaefeaf06749df73f4b", 384 | "sha256:1b7a721b575eaeaa7d39db076b6e7743c993ea44f57979127c517c6c572c803e" 385 | ], 386 | "index": "pypi", 387 | "markers": "python_full_version >= '3.8.0'", 388 | "version": "==3.2.7" 389 | }, 390 | "pyproject-api": { 391 | "hashes": [ 392 | "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228", 393 | "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496" 394 | ], 395 | "markers": "python_version >= '3.8'", 396 | "version": "==1.8.0" 397 | }, 398 | "pytest": { 399 | "hashes": [ 400 | "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", 401 | "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845" 402 | ], 403 | "index": "pypi", 404 | "markers": "python_version >= '3.8'", 405 | "version": "==8.3.5" 406 | }, 407 | "sortedcontainers": { 408 | "hashes": [ 409 | "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", 410 | "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" 411 | ], 412 | "version": "==2.4.0" 413 | }, 414 | "tomli": { 415 | "hashes": [ 416 | "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", 417 | "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", 418 | "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", 419 | "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", 420 | "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", 421 | "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", 422 | "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", 423 | "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", 424 | "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", 425 | "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", 426 | "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", 427 | "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", 428 | "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", 429 | "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", 430 | "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", 431 | "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", 432 | "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", 433 | "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", 434 | "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", 435 | "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", 436 | "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", 437 | "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", 438 | "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", 439 | "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", 440 | "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", 441 | "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", 442 | "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", 443 | "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", 444 | "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", 445 | "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", 446 | "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", 447 | "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7" 448 | ], 449 | "markers": "python_version < '3.11'", 450 | "version": "==2.2.1" 451 | }, 452 | "tomlkit": { 453 | "hashes": [ 454 | "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", 455 | "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79" 456 | ], 457 | "markers": "python_version >= '3.8'", 458 | "version": "==0.13.2" 459 | }, 460 | "tox": { 461 | "hashes": [ 462 | "sha256:4dfdc7ba2cc6fdc6688dde1b21e7b46ff6c41795fb54586c91a3533317b5255c", 463 | "sha256:dd67f030317b80722cf52b246ff42aafd3ed27ddf331c415612d084304cf5e52" 464 | ], 465 | "index": "pypi", 466 | "markers": "python_version >= '3.8'", 467 | "version": "==4.25.0" 468 | }, 469 | "types-toml": { 470 | "hashes": [ 471 | "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331", 472 | "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d" 473 | ], 474 | "index": "pypi", 475 | "markers": "python_version >= '3.8'", 476 | "version": "==0.10.8.20240310" 477 | }, 478 | "typing-extensions": { 479 | "hashes": [ 480 | "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", 481 | "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" 482 | ], 483 | "index": "pypi", 484 | "markers": "python_version >= '3.8'", 485 | "version": "==4.13.2" 486 | }, 487 | "virtualenv": { 488 | "hashes": [ 489 | "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", 490 | "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af" 491 | ], 492 | "markers": "python_version >= '3.8'", 493 | "version": "==20.31.2" 494 | } 495 | } 496 | } 497 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # coverage threshold 2 | 3 | A command line tool for checking coverage reports against configurable coverage minimums. 4 | Currently built for use around python's [coverage](https://pypi.org/project/coverage/) 5 | 6 | ### Installation 7 | 8 | `pip install coverage-threshold` 9 | 10 | also recommended: 11 | 12 | `pip install coverage` 13 | 14 | ### Usage 15 | 16 | Typical execution: 17 | 18 | ```bash 19 | coverage run -m pytest tests/ # or any test runner here 20 | coverage json 21 | coverage-threshold 22 | ``` 23 | 24 | cli command options: 25 | 26 | ``` 27 | > coverage-threshold --help 28 | usage: coverage-threshold [-h] [--line-coverage-min LINE_COVERAGE_MIN] 29 | [--branch-coverage-min BRANCH_COVERAGE_MIN] 30 | [--combined-coverage-min COMBINED_COVERAGE_MIN] 31 | [--file-line-coverage-min FILE_LINE_COVERAGE_MIN] 32 | [--file-branch-coverage-min FILE_BRANCH_COVERAGE_MIN] 33 | [--file-combined-coverage-min FILE_COMBINED_COVERAGE_MIN] 34 | [--coverage-json COVERAGE_JSON] [--config CONFIG] 35 | 36 | A command line tool for checking coverage reports against configurable coverage minimums 37 | 38 | optional arguments: 39 | -h, --help show this help message and exit 40 | --line-coverage-min LINE_COVERAGE_MIN 41 | minimum global average line coverage threshold 42 | --branch-coverage-min BRANCH_COVERAGE_MIN 43 | minimum global average branch coverage threshold 44 | --combined-coverage-min COMBINED_COVERAGE_MIN 45 | minimum global average combined line and branch coverage threshold 46 | --file-line-coverage-min FILE_LINE_COVERAGE_MIN 47 | the line coverage threshold for each file 48 | --file-branch-coverage-min FILE_BRANCH_COVERAGE_MIN 49 | the branch coverage threshold for each file 50 | --file-combined-coverage-min FILE_COMBINED_COVERAGE_MIN 51 | the combined line and branch coverage threshold for each file 52 | --coverage-json COVERAGE_JSON 53 | path to coverage json (default: ./coverage.json) 54 | --config CONFIG path to config file (default: ./pyproject.toml) 55 | ``` 56 | 57 | ### Config 58 | 59 | the current expected config file format is [toml](https://toml.io/en/) 60 | the default config file used is `pyproject.toml` but and alternative path can be specified with `--config` 61 | 62 | example config: 63 | 64 | ```toml 65 | [tool.coverage-threshold] 66 | line_coverage_min = 95 67 | file_line_coverage_min = 95 68 | branch_coverage_min = 50 69 | 70 | [tool.coverage-threshold.modules."src/cli/"] 71 | file_line_coverage_min = 40 72 | 73 | [tool.coverage-threshold.modules."src/cli/my_command.py"] 74 | file_line_coverage_min = 100 75 | 76 | [tool.coverage-threshold.modules."src/lib/"] 77 | file_line_coverage_min = 100 78 | file_branch_coverage_min = 100 79 | 80 | [tool.coverage-threshold.modules."src/model/"] 81 | file_line_coverage_min = 100 82 | 83 | [tool.coverage-threshold.modules."src/__main__.py"] 84 | file_line_coverage_min = 0 85 | ``` 86 | 87 | Each string key in `config.modules` is treated as a path prefix, where the longest matching prefix is used to configure the coverage thresholds for each file 88 | -------------------------------------------------------------------------------- /example_project/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | src/ 4 | 5 | -------------------------------------------------------------------------------- /example_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.coverage-threshold] 2 | line_coverage_min = 75.0 3 | -------------------------------------------------------------------------------- /example_project/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/coverage-threshold/e056e32f3643eeb7515126955dc5a8e8325e020d/example_project/src/__init__.py -------------------------------------------------------------------------------- /example_project/src/app.py: -------------------------------------------------------------------------------- 1 | def is_big_number(x: int) -> str: 2 | if x > 9000: 3 | return "That's a big number!" 4 | else: 5 | return "That's ok I guess" 6 | -------------------------------------------------------------------------------- /example_project/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/coverage-threshold/e056e32f3643eeb7515126955dc5a8e8325e020d/example_project/tests/__init__.py -------------------------------------------------------------------------------- /example_project/tests/test_app.py: -------------------------------------------------------------------------------- 1 | from src.app import is_big_number 2 | 3 | 4 | def test_is_big_number() -> None: 5 | assert is_big_number(9001) == "That's a big number!" 6 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | mypy_path = $MYPY_CONFIG_FILE_DIR/src -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling >= 1.26"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "coverage_threshold" 7 | version = "0.6.2" 8 | authors = [{ name = "Dean Way", email = "deanwaydev@gmail.com" }] 9 | description = "Tools for coverage threshold limits" 10 | readme = "README.md" 11 | requires-python = ">=3.7" 12 | classifiers = [ 13 | "Programming Language :: Python :: 3.7", 14 | "Programming Language :: Python :: 3.8", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | ] 23 | license = "MIT" 24 | license-files = ["LICEN[CS]E*"] 25 | dependencies = ["toml >= 0.10.2"] 26 | 27 | [project.scripts] 28 | coverage-threshold = "coverage_threshold.cli:main" 29 | 30 | [project.urls] 31 | Homepage = "https://github.com/DeanWay/coverage-threshold" 32 | Issues = "https://github.com/DeanWay/coverage-threshold/issues" 33 | 34 | [tool.isort] 35 | profile = "black" 36 | src_paths = ["src", "tests"] 37 | 38 | [tool.pytest.ini_options] 39 | pythonpath = ["src"] 40 | 41 | [tool.coverage-threshold] 42 | line_coverage_min = 0 43 | file_line_coverage_min = 100 44 | file_branch_coverage_min = 100 45 | 46 | [tool.coverage-threshold.modules."src/coverage_threshold/cli/"] 47 | file_line_coverage_min = 0 48 | file_branch_coverage_min = 0 49 | 50 | [tool.coverage-threshold.modules."src/coverage_threshold/__main__.py"] 51 | file_line_coverage_min = 0 52 | file_branch_coverage_min = 0 53 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | python -m build 6 | -------------------------------------------------------------------------------- /scripts/bumpversion.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | git fetch origin 6 | git checkout -B master 7 | git reset --soft origin/master 8 | bumpversion "$@" 9 | git push 10 | git push --tags 11 | -------------------------------------------------------------------------------- /scripts/ci.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | 4 | ./static-analysis.sh 5 | ./coverage.sh 6 | -------------------------------------------------------------------------------- /scripts/clean.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | rm -rf dist/ build/ 6 | -------------------------------------------------------------------------------- /scripts/coverage.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | coverage run --branch -m pytest tests/ src/coverage_threshold/ --doctest-modules 6 | coverage report -m 7 | coverage json 8 | coverage-threshold 9 | -------------------------------------------------------------------------------- /scripts/lint-fix.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | isort . 6 | black . 7 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | isort --check . 6 | black --check . 7 | flake8 . 8 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | python -m twine upload dist/* 6 | -------------------------------------------------------------------------------- /scripts/static-analysis.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | 4 | ./lint.sh 5 | ./type-check.sh 6 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | pytest tests/ src/coverage-threshold/ --doctest-modules 6 | -------------------------------------------------------------------------------- /scripts/type-check.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | echo "type checking source" 6 | mypy src/coverage_threshold/ 7 | echo "type checking tests" 8 | mypy tests/ 9 | -------------------------------------------------------------------------------- /src/coverage_threshold/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | from .lib import check_all 3 | from .model import config, report 4 | 5 | __all__ = [ 6 | "check_all", 7 | "config", 8 | "main", 9 | "report", 10 | ] 11 | -------------------------------------------------------------------------------- /src/coverage_threshold/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from coverage_threshold.cli import main 4 | 5 | if __name__ == "__main__": 6 | sys.exit(main()) 7 | -------------------------------------------------------------------------------- /src/coverage_threshold/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import main 2 | 3 | __all__ = ["main"] 4 | -------------------------------------------------------------------------------- /src/coverage_threshold/cli/args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from decimal import Decimal 3 | from typing import Optional 4 | 5 | from coverage_threshold.lib.alternative import fallback 6 | from coverage_threshold.model.config import Config 7 | 8 | parser = argparse.ArgumentParser( 9 | description=( 10 | "A command line tool for checking coverage reports " 11 | + "against configurable coverage minimums" 12 | ) 13 | ) 14 | parser.add_argument( 15 | "--line-coverage-min", 16 | type=Decimal, 17 | required=False, 18 | help="minimum global average line coverage threshold", 19 | ) 20 | parser.add_argument( 21 | "--branch-coverage-min", 22 | type=Decimal, 23 | required=False, 24 | help="minimum global average branch coverage threshold", 25 | ) 26 | parser.add_argument( 27 | "--combined-coverage-min", 28 | type=Decimal, 29 | required=False, 30 | help="minimum global average combined line and branch coverage threshold", 31 | ) 32 | parser.add_argument( 33 | "--number-missing-lines-max", 34 | type=int, 35 | required=False, 36 | help="maximum global threshold for lines not covered", 37 | ) 38 | parser.add_argument( 39 | "--file-line-coverage-min", 40 | type=Decimal, 41 | required=False, 42 | help="the line coverage threshold for each file", 43 | ) 44 | parser.add_argument( 45 | "--file-branch-coverage-min", 46 | type=Decimal, 47 | required=False, 48 | help="the branch coverage threshold for each file", 49 | ) 50 | parser.add_argument( 51 | "--file-combined-coverage-min", 52 | type=Decimal, 53 | required=False, 54 | help="the combined line and branch coverage threshold for each file", 55 | ) 56 | parser.add_argument( 57 | "--coverage-json", 58 | type=str, 59 | default="./coverage.json", 60 | help="path to coverage json (default: ./coverage.json)", 61 | ) 62 | parser.add_argument( 63 | "--config", 64 | type=str, 65 | default=None, 66 | help="path to config file (default: ./pyproject.toml)", 67 | ) 68 | 69 | 70 | class ArgsNamespace(argparse.Namespace): 71 | line_coverage_min: Optional[Decimal] 72 | branch_coverage_min: Optional[Decimal] 73 | combined_coverage_min: Optional[Decimal] 74 | number_missing_lines_max: Optional[int] 75 | file_line_coverage_min: Optional[Decimal] 76 | file_branch_coverage_min: Optional[Decimal] 77 | file_combined_coverage_min: Optional[Decimal] 78 | coverage_json: str 79 | config: str 80 | 81 | 82 | def combine_config_with_args(args: ArgsNamespace, config: Config) -> Config: 83 | return Config( 84 | line_coverage_min=fallback(args.line_coverage_min, config.line_coverage_min), 85 | branch_coverage_min=fallback( 86 | args.branch_coverage_min, config.branch_coverage_min 87 | ), 88 | combined_coverage_min=fallback( 89 | args.combined_coverage_min, config.combined_coverage_min 90 | ), 91 | number_missing_lines_max=fallback( 92 | args.number_missing_lines_max, config.number_missing_lines_max 93 | ), 94 | file_line_coverage_min=fallback( 95 | args.file_line_coverage_min, config.file_line_coverage_min 96 | ), 97 | file_branch_coverage_min=fallback( 98 | args.file_branch_coverage_min, config.file_branch_coverage_min 99 | ), 100 | file_combined_coverage_min=fallback( 101 | args.file_combined_coverage_min, config.file_combined_coverage_min 102 | ), 103 | modules=config.modules, 104 | ) 105 | -------------------------------------------------------------------------------- /src/coverage_threshold/cli/colors.py: -------------------------------------------------------------------------------- 1 | HEADER = "\033[95m" 2 | OKBLUE = "\033[94m" 3 | OKCYAN = "\033[96m" 4 | OKGREEN = "\033[92m" 5 | WARNING = "\033[93m" 6 | FAIL = "\033[91m" 7 | ENDC = "\033[0m" 8 | BOLD = "\033[1m" 9 | UNDERLINE = "\033[4m" 10 | -------------------------------------------------------------------------------- /src/coverage_threshold/cli/main.py: -------------------------------------------------------------------------------- 1 | from coverage_threshold.cli import colors 2 | from coverage_threshold.cli.args import ArgsNamespace, combine_config_with_args, parser 3 | from coverage_threshold.cli.read_config import read_config 4 | from coverage_threshold.cli.read_report import read_report 5 | from coverage_threshold.lib import check_all 6 | 7 | 8 | def bool_to_return_status(x: bool) -> int: 9 | return 0 if x else 1 10 | 11 | 12 | def main() -> int: 13 | args = parser.parse_args(namespace=ArgsNamespace()) 14 | report = read_report(args.coverage_json) 15 | config_from_file = read_config(args.config) 16 | config = combine_config_with_args(args, config_from_file) 17 | all_checks = check_all(report, config) 18 | if all_checks.result: 19 | print(colors.OKGREEN + "Success!" + colors.ENDC) 20 | else: 21 | print(f"Failed with {len(all_checks.problems)} errors") 22 | for problem in all_checks.problems: 23 | print(colors.FAIL + problem + colors.ENDC) 24 | return bool_to_return_status(all_checks.result) 25 | -------------------------------------------------------------------------------- /src/coverage_threshold/cli/read_config.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from typing import Optional 3 | 4 | import toml 5 | 6 | from coverage_threshold.model.config import Config 7 | 8 | 9 | def read_config(config_file_name: Optional[str]) -> Config: 10 | DEFAULT_FILENAME = "./pyproject.toml" 11 | if config_file_name is not None: 12 | if not os.path.isfile(config_file_name): 13 | raise FileNotFoundError(f"Config file {config_file_name} not found") 14 | else: 15 | config_file_name = DEFAULT_FILENAME 16 | if os.path.isfile(config_file_name): 17 | toml_dict = toml.load(config_file_name) 18 | try: 19 | # PEP 518 compliant version 20 | config_dict = toml_dict["tool"]["coverage-threshold"] 21 | except KeyError: 22 | # Legacy version 23 | config_dict = toml_dict.get("coverage-threshold", {}) 24 | return Config.parse(config_dict) 25 | else: 26 | return Config() 27 | -------------------------------------------------------------------------------- /src/coverage_threshold/cli/read_report.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from coverage_threshold.model.report import ReportModel 4 | 5 | 6 | def read_report(coverage_json_filename: str) -> ReportModel: 7 | with open(coverage_json_filename) as coverage_json_file: 8 | return ReportModel.parse(json.loads(coverage_json_file.read())) 9 | -------------------------------------------------------------------------------- /src/coverage_threshold/lib/__init__.py: -------------------------------------------------------------------------------- 1 | from ._all_checks import check_all 2 | from ._file import check_all_files 3 | from ._totals import check_totals 4 | 5 | __all__ = [ 6 | "check_all_files", 7 | "check_all", 8 | "check_totals", 9 | ] 10 | -------------------------------------------------------------------------------- /src/coverage_threshold/lib/_all_checks.py: -------------------------------------------------------------------------------- 1 | from coverage_threshold.model.config import Config 2 | from coverage_threshold.model.report import ReportModel 3 | 4 | from ._file import check_all_files 5 | from ._totals import check_totals 6 | from .check_result import CheckResult, fold_check_results 7 | 8 | 9 | def check_all(report: ReportModel, config: Config) -> CheckResult: 10 | return fold_check_results( 11 | [ 12 | check_all_files(report, config), 13 | check_totals(report, config), 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /src/coverage_threshold/lib/_common.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from operator import ge as greater_or_eq 3 | from operator import le as less_or_eq 4 | from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar 5 | 6 | from coverage_threshold.model.report import CoverageSummaryModel 7 | 8 | from .check_result import CheckResult, Fail, Pass 9 | 10 | Num = TypeVar("Num", Decimal, int) 11 | if TYPE_CHECKING: 12 | from typing_extensions import Protocol 13 | 14 | T = TypeVar("T", contravariant=True) 15 | 16 | class CheckFunction(Protocol[T]): 17 | def __call__( 18 | self, 19 | summary: CoverageSummaryModel, 20 | threshold: Optional[T], 21 | failure_message_prefix: str, 22 | ) -> CheckResult: ... 23 | 24 | 25 | def _safe_percent(numerator: int, denomenator: int) -> Decimal: 26 | if denomenator == 0: 27 | return Decimal(100) 28 | else: 29 | return ((Decimal(numerator) / Decimal(denomenator)) * Decimal(100)).quantize( 30 | Decimal("0.0001") 31 | ) 32 | 33 | 34 | def percent_lines_covered(summary: CoverageSummaryModel) -> Decimal: 35 | return _safe_percent(summary.covered_lines, summary.num_statements) 36 | 37 | 38 | def percent_branches_covered(summary: CoverageSummaryModel) -> Decimal: 39 | if summary.covered_branches is not None and summary.num_branches is not None: 40 | return _safe_percent(summary.covered_branches, summary.num_branches) 41 | else: 42 | raise ValueError("missing number of branches or number of branches covered") 43 | 44 | 45 | def percent_combined_lines_and_branches_covered( 46 | summary: CoverageSummaryModel, 47 | ) -> Decimal: # pragma: no cover 48 | if summary.covered_branches is not None and summary.num_branches is not None: 49 | return _safe_percent( 50 | summary.covered_lines + summary.covered_branches, 51 | summary.num_statements + summary.num_branches, 52 | ) 53 | else: 54 | raise ValueError("missing number of branches or number of branches covered") 55 | 56 | 57 | def number_lines_not_covered(summary: CoverageSummaryModel) -> int: 58 | return summary.num_statements - summary.covered_lines 59 | 60 | 61 | def _check( 62 | comparison_function: Callable[[Any, Any], bool], 63 | coverage_value_from_summary: Callable[[CoverageSummaryModel], Num], 64 | ) -> "CheckFunction[Num]": 65 | def resulting_function( 66 | summary: CoverageSummaryModel, 67 | threshold: Optional[Num], 68 | failure_message_prefix: str, 69 | ) -> CheckResult: 70 | if threshold is None: 71 | return Pass() 72 | 73 | covered = coverage_value_from_summary(summary) 74 | if comparison_function(threshold, covered): 75 | return Pass() 76 | else: 77 | message = f"{failure_message_prefix}, expected {threshold}, was {covered}" 78 | return Fail([message]) 79 | 80 | return resulting_function 81 | 82 | 83 | check_line_coverage_min = _check(less_or_eq, percent_lines_covered) 84 | check_branch_coverage_min = _check(less_or_eq, percent_branches_covered) 85 | check_combined_coverage_min = _check( 86 | less_or_eq, percent_combined_lines_and_branches_covered 87 | ) 88 | check_number_missing_lines_max = _check(greater_or_eq, number_lines_not_covered) 89 | -------------------------------------------------------------------------------- /src/coverage_threshold/lib/_file.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from itertools import chain 3 | from typing import Callable, Optional, Union 4 | 5 | from coverage_threshold.model.config import Config, ModuleConfig 6 | from coverage_threshold.model.report import FileCoverageModel, ReportModel 7 | from coverage_threshold.model.util import normalize_path 8 | 9 | from ._common import ( 10 | check_branch_coverage_min, 11 | check_combined_coverage_min, 12 | check_line_coverage_min, 13 | ) 14 | from .check_result import CheckResult, fold_check_results 15 | 16 | 17 | def best_matching_module_config_for_file( 18 | filename: str, config: Config 19 | ) -> Optional[ModuleConfig]: 20 | # naive approach here, this can be improved if this turns out to be slow 21 | # but for most projects we're dealing with a few thousand files so shouldn't 22 | # need to optomize this for most cases 23 | if config.modules is None: 24 | return None 25 | matches = [ 26 | (prefix, module) 27 | for prefix, module in config.modules.items() 28 | if normalize_path(filename).startswith(prefix) 29 | ] 30 | if len(matches) > 0: 31 | return max(matches, key=lambda match: len(match[0]))[1] 32 | else: 33 | return None 34 | 35 | 36 | def threshold_from_config_and_module_config( 37 | config: Config, 38 | module_config: Optional[ModuleConfig], 39 | attribute: Callable[[Union[Config, ModuleConfig]], Optional[Decimal]], 40 | ) -> Optional[Decimal]: 41 | if module_config is not None and attribute(module_config) is not None: 42 | return attribute(module_config) 43 | else: 44 | return attribute(config) 45 | 46 | 47 | def check_file_line_coverage_min( 48 | filename: str, 49 | file_coverage: FileCoverageModel, 50 | config: Config, 51 | module_config: Optional[ModuleConfig], 52 | ) -> CheckResult: 53 | def file_line_coverage_min_from_config( 54 | config_obj: Union[Config, ModuleConfig] 55 | ) -> Optional[Decimal]: 56 | return config_obj.file_line_coverage_min 57 | 58 | return check_line_coverage_min( 59 | summary=file_coverage.summary, 60 | threshold=threshold_from_config_and_module_config( 61 | config, module_config, attribute=file_line_coverage_min_from_config 62 | ), 63 | failure_message_prefix=f'File: "{filename}" failed LINE coverage metric', 64 | ) 65 | 66 | 67 | def check_file_branch_coverage_min( 68 | filename: str, 69 | file_coverage: FileCoverageModel, 70 | config: Config, 71 | module_config: Optional[ModuleConfig], 72 | ) -> CheckResult: 73 | def file_branch_coverage_min_from_config( 74 | config_obj: Union[Config, ModuleConfig] 75 | ) -> Optional[Decimal]: 76 | return config_obj.file_branch_coverage_min 77 | 78 | return check_branch_coverage_min( 79 | summary=file_coverage.summary, 80 | threshold=threshold_from_config_and_module_config( 81 | config, module_config, attribute=file_branch_coverage_min_from_config 82 | ), 83 | failure_message_prefix=f'File: "{filename}" failed BRANCH coverage metric', 84 | ) 85 | 86 | 87 | def check_file_combined_coverage_min( 88 | filename: str, 89 | file_coverage: FileCoverageModel, 90 | config: Config, 91 | module_config: Optional[ModuleConfig], 92 | ) -> CheckResult: 93 | def file_branch_coverage_min_from_config( 94 | config_obj: Union[Config, ModuleConfig] 95 | ) -> Optional[Decimal]: 96 | return config_obj.file_combined_coverage_min 97 | 98 | return check_combined_coverage_min( 99 | summary=file_coverage.summary, 100 | threshold=threshold_from_config_and_module_config( 101 | config, module_config, attribute=file_branch_coverage_min_from_config 102 | ), 103 | failure_message_prefix=( 104 | f'File: "{filename}" failed COMBINED line plus branch coverage metric' 105 | ), 106 | ) 107 | 108 | 109 | def check_all_files(report: ReportModel, config: Config) -> CheckResult: 110 | files_with_module_config = ( 111 | ( 112 | filename, 113 | file_coverage, 114 | best_matching_module_config_for_file(filename, config), 115 | ) 116 | for filename, file_coverage in report.files.items() 117 | ) 118 | file_checks = ( 119 | [ 120 | check_file_line_coverage_min( 121 | filename=filename, 122 | file_coverage=file_coverage, 123 | config=config, 124 | module_config=module_config, 125 | ), 126 | check_file_branch_coverage_min( 127 | filename=filename, 128 | file_coverage=file_coverage, 129 | config=config, 130 | module_config=module_config, 131 | ), 132 | check_file_combined_coverage_min( 133 | filename=filename, 134 | file_coverage=file_coverage, 135 | config=config, 136 | module_config=module_config, 137 | ), 138 | ] 139 | for filename, file_coverage, module_config in files_with_module_config 140 | ) 141 | return fold_check_results(chain(*file_checks)) 142 | -------------------------------------------------------------------------------- /src/coverage_threshold/lib/_totals.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from coverage_threshold.model.config import Config 4 | from coverage_threshold.model.report import ReportModel 5 | 6 | from ._common import ( 7 | check_branch_coverage_min, 8 | check_combined_coverage_min, 9 | check_line_coverage_min, 10 | check_number_missing_lines_max, 11 | ) 12 | from .check_result import CheckResult, fold_check_results 13 | 14 | 15 | def check_total_line_coverage_min(report: ReportModel, config: Config) -> CheckResult: 16 | threshold = ( 17 | config.line_coverage_min 18 | if config.line_coverage_min is not None 19 | else Decimal("100.0") 20 | ) 21 | return check_line_coverage_min( 22 | summary=report.totals, 23 | threshold=threshold, 24 | failure_message_prefix="Total line coverage metric failed", 25 | ) 26 | 27 | 28 | def check_total_branch_coverage_min(report: ReportModel, config: Config) -> CheckResult: 29 | return check_branch_coverage_min( 30 | summary=report.totals, 31 | threshold=config.branch_coverage_min, 32 | failure_message_prefix="Total branch coverage metric failed", 33 | ) 34 | 35 | 36 | def check_total_combined_coverage_min( 37 | report: ReportModel, config: Config 38 | ) -> CheckResult: 39 | return check_combined_coverage_min( 40 | summary=report.totals, 41 | threshold=config.combined_coverage_min, 42 | failure_message_prefix="Total combined coverage metric failed", 43 | ) 44 | 45 | 46 | def check_total_number_missing_lines_max( 47 | report: ReportModel, config: Config 48 | ) -> CheckResult: 49 | return check_number_missing_lines_max( 50 | summary=report.totals, 51 | threshold=config.number_missing_lines_max, 52 | failure_message_prefix="Total number missing lines max failed", 53 | ) 54 | 55 | 56 | def check_totals(report: ReportModel, config: Config) -> CheckResult: 57 | return fold_check_results( 58 | [ 59 | check_total_line_coverage_min(report, config), 60 | check_total_branch_coverage_min(report, config), 61 | check_total_combined_coverage_min(report, config), 62 | check_total_number_missing_lines_max(report, config), 63 | ] 64 | ) 65 | -------------------------------------------------------------------------------- /src/coverage_threshold/lib/alternative.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TypeVar 2 | 3 | T = TypeVar("T") 4 | 5 | 6 | def fallback(left: Optional[T], right: Optional[T]) -> Optional[T]: 7 | """ 8 | >>> left = object() 9 | >>> right = object() 10 | 11 | >>> result = fallback(None, None) 12 | >>> result is None 13 | True 14 | 15 | >>> result = fallback(left, None) 16 | >>> result is left 17 | True 18 | 19 | >>> result = fallback(None, right) 20 | >>> result is right 21 | True 22 | 23 | >>> result = fallback(left, right) 24 | >>> result is left 25 | True 26 | """ 27 | return left if left is not None else right 28 | -------------------------------------------------------------------------------- /src/coverage_threshold/lib/check_result.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from functools import reduce 3 | from typing import TYPE_CHECKING, Iterable, List, Union 4 | 5 | if TYPE_CHECKING: 6 | from typing_extensions import Literal 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Pass: 11 | result: "Literal[True]" = True 12 | 13 | 14 | @dataclass(frozen=True) 15 | class Fail: 16 | problems: List[str] 17 | result: "Literal[False]" = False 18 | 19 | 20 | CheckResult = Union[Fail, Pass] 21 | 22 | 23 | def combine_check_results(first: CheckResult, second: CheckResult) -> CheckResult: 24 | """ 25 | >>> combine_check_results(Pass(), Pass()) 26 | Pass(result=True) 27 | 28 | >>> combine_check_results(Pass(), Fail(['no!'])) 29 | Fail(problems=['no!'], result=False) 30 | 31 | >>> combine_check_results(Fail(['oh!']), Pass()) 32 | Fail(problems=['oh!'], result=False) 33 | 34 | >>> combine_check_results(Fail(['oh!']), Fail(['no!'])) 35 | Fail(problems=['oh!', 'no!'], result=False) 36 | """ 37 | if isinstance(first, Pass): 38 | return second 39 | elif isinstance(second, Pass): 40 | return first 41 | else: 42 | return Fail(first.problems + second.problems) 43 | 44 | 45 | def fold_check_results(results: Iterable[CheckResult]) -> CheckResult: 46 | return reduce(combine_check_results, results) 47 | -------------------------------------------------------------------------------- /src/coverage_threshold/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/coverage-threshold/e056e32f3643eeb7515126955dc5a8e8325e020d/src/coverage_threshold/model/__init__.py -------------------------------------------------------------------------------- /src/coverage_threshold/model/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from decimal import Decimal 5 | from typing import Any, Mapping, Optional 6 | 7 | from .util import normalize_path, parse_option_field 8 | 9 | 10 | @dataclass(frozen=True) 11 | class Config: 12 | line_coverage_min: Optional[Decimal] = None 13 | branch_coverage_min: Optional[Decimal] = None 14 | combined_coverage_min: Optional[Decimal] = None 15 | number_missing_lines_max: Optional[int] = None 16 | file_line_coverage_min: Optional[Decimal] = None 17 | file_branch_coverage_min: Optional[Decimal] = None 18 | file_combined_coverage_min: Optional[Decimal] = None 19 | modules: Optional[Mapping[str, ModuleConfig]] = None 20 | 21 | @staticmethod 22 | def parse(obj: Any) -> Config: 23 | return Config( 24 | line_coverage_min=parse_option_field(obj, Decimal, "line_coverage_min"), 25 | branch_coverage_min=parse_option_field(obj, Decimal, "branch_coverage_min"), 26 | combined_coverage_min=parse_option_field( 27 | obj, Decimal, "combined_coverage_min" 28 | ), 29 | number_missing_lines_max=parse_option_field( 30 | obj, int, "number_missing_lines_max" 31 | ), 32 | file_line_coverage_min=parse_option_field( 33 | obj, Decimal, "file_line_coverage_min" 34 | ), 35 | file_branch_coverage_min=parse_option_field( 36 | obj, Decimal, "file_branch_coverage_min" 37 | ), 38 | file_combined_coverage_min=parse_option_field( 39 | obj, Decimal, "file_combined_coverage_min" 40 | ), 41 | modules=( 42 | { 43 | normalize_path(k): ModuleConfig.parse(v) 44 | for k, v in obj["modules"].items() 45 | } 46 | if "modules" in obj 47 | else None 48 | ), 49 | ) 50 | 51 | 52 | @dataclass(frozen=True) 53 | class ModuleConfig: 54 | file_line_coverage_min: Optional[Decimal] = None 55 | file_branch_coverage_min: Optional[Decimal] = None 56 | file_combined_coverage_min: Optional[Decimal] = None 57 | 58 | @staticmethod 59 | def parse(obj: Any) -> ModuleConfig: 60 | return ModuleConfig( 61 | file_line_coverage_min=parse_option_field( 62 | obj, Decimal, "file_line_coverage_min" 63 | ), 64 | file_branch_coverage_min=parse_option_field( 65 | obj, Decimal, "file_branch_coverage_min" 66 | ), 67 | file_combined_coverage_min=parse_option_field( 68 | obj, Decimal, "file_combined_coverage_min" 69 | ), 70 | ) 71 | -------------------------------------------------------------------------------- /src/coverage_threshold/model/report.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any, Mapping, Optional 5 | 6 | from .util import parse_option_field 7 | 8 | # replace these dataclass models with pydantic 9 | # if this gets too complex 10 | 11 | 12 | @dataclass(frozen=True) 13 | class CoverageSummaryModel: 14 | covered_lines: int 15 | num_statements: int 16 | num_branches: Optional[int] = None 17 | covered_branches: Optional[int] = None 18 | 19 | @staticmethod 20 | def parse(obj: Any) -> CoverageSummaryModel: 21 | return CoverageSummaryModel( 22 | covered_lines=int(obj["covered_lines"]), 23 | num_statements=int(obj["num_statements"]), 24 | num_branches=parse_option_field(obj, int, "num_branches"), 25 | covered_branches=parse_option_field(obj, int, "covered_branches"), 26 | ) 27 | 28 | 29 | @dataclass(frozen=True) 30 | class FileCoverageModel: 31 | summary: CoverageSummaryModel 32 | 33 | @staticmethod 34 | def parse(obj: Any) -> FileCoverageModel: 35 | return FileCoverageModel(summary=CoverageSummaryModel.parse(obj["summary"])) 36 | 37 | 38 | @dataclass(frozen=True) 39 | class ReportMetadata: 40 | branch_coverage: bool 41 | 42 | @staticmethod 43 | def parse(obj: Any) -> ReportMetadata: 44 | return ReportMetadata(branch_coverage=bool(obj["branch_coverage"])) 45 | 46 | 47 | @dataclass(frozen=True) 48 | class ReportModel: 49 | files: Mapping[str, FileCoverageModel] 50 | totals: CoverageSummaryModel 51 | meta: ReportMetadata 52 | 53 | @staticmethod 54 | def parse(obj: Any) -> ReportModel: 55 | return ReportModel( 56 | files={ 57 | filename: FileCoverageModel.parse(value) 58 | for filename, value in obj["files"].items() 59 | }, 60 | totals=CoverageSummaryModel.parse(obj["totals"]), 61 | meta=ReportMetadata.parse(obj["meta"]), 62 | ) 63 | -------------------------------------------------------------------------------- /src/coverage_threshold/model/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import posixpath 3 | from typing import Any, Optional, Type, TypeVar 4 | 5 | T = TypeVar("T", bound=Any) 6 | 7 | 8 | def parse_option_field( 9 | obj: Any, type_constructor: Type[T], field_name: str 10 | ) -> Optional[T]: 11 | return ( 12 | type_constructor(obj[field_name]) 13 | if field_name in obj and obj[field_name] is not None 14 | else None 15 | ) 16 | 17 | 18 | def normalize_path(path: str) -> str: 19 | return path.replace(os.sep, posixpath.sep) 20 | -------------------------------------------------------------------------------- /tests/integration/test_cli.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | import pytest 4 | 5 | from coverage_threshold.cli import colors 6 | 7 | EXAMPLE_PROJECT_PATH = "./example_project" 8 | SUCCESS_MESSAGE = f"{colors.OKGREEN}Success!{colors.ENDC}\n" 9 | 10 | 11 | @pytest.fixture(autouse=True, scope="module") 12 | def example_project_coverage_json() -> None: 13 | subprocess.run( 14 | ["coverage", "run", "-m", "pytest", "tests/"], 15 | cwd=EXAMPLE_PROJECT_PATH, 16 | ) 17 | subprocess.run(["coverage", "json"], cwd=EXAMPLE_PROJECT_PATH) 18 | 19 | 20 | def test_cli_runs_successfully_on_example_project() -> None: 21 | process = subprocess.run( 22 | ["coverage-threshold"], 23 | stdout=subprocess.PIPE, 24 | stderr=subprocess.PIPE, 25 | cwd=EXAMPLE_PROJECT_PATH, 26 | ) 27 | assert process.returncode == 0 28 | assert process.stderr == b"" 29 | assert process.stdout == SUCCESS_MESSAGE.encode("utf-8") 30 | 31 | 32 | def test_cli_fails() -> None: 33 | process = subprocess.run( 34 | ["coverage-threshold", "--line-coverage-min", "100.0"], 35 | stdout=subprocess.PIPE, 36 | stderr=subprocess.PIPE, 37 | cwd=EXAMPLE_PROJECT_PATH, 38 | ) 39 | assert process.returncode == 1 40 | assert process.stderr == b"" 41 | assert process.stdout == ( 42 | "Failed with 1 errors\n" 43 | + f"{colors.FAIL}Total line coverage metric failed" 44 | + f", expected 100.0, was 75.0000{colors.ENDC}\n" 45 | ).encode("utf-8") 46 | -------------------------------------------------------------------------------- /tests/unit/config/complex.pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.coverage-threshold] 2 | line_coverage_min = 95 3 | file_line_coverage_min = 95 4 | branch_coverage_min = 50 5 | 6 | [tool.coverage-threshold.modules."src/cli/"] 7 | file_line_coverage_min = 40 8 | 9 | [tool.coverage-threshold.modules."src/cli/my_command.py"] 10 | file_line_coverage_min = 100 11 | 12 | [tool.coverage-threshold.modules."src/lib/"] 13 | file_line_coverage_min = 100 14 | file_branch_coverage_min = 100 15 | 16 | [tool.coverage-threshold.modules."src/model/"] 17 | file_line_coverage_min = 100 18 | 19 | [tool.coverage-threshold.modules."src/__main__.py"] 20 | file_line_coverage_min = 0 21 | -------------------------------------------------------------------------------- /tests/unit/config/legacy.pyproject.toml: -------------------------------------------------------------------------------- 1 | [coverage-threshold] 2 | line_coverage_min = 75.0 3 | -------------------------------------------------------------------------------- /tests/unit/config/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.coverage-threshold] 2 | line_coverage_min = 75.0 3 | -------------------------------------------------------------------------------- /tests/unit/config/test_read_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from decimal import Decimal 3 | 4 | from coverage_threshold.cli.read_config import read_config 5 | 6 | 7 | def test_read_config_parses_default_pyproject_format() -> None: 8 | config = read_config(path_to_test_config("pyproject.toml")) 9 | assert config.line_coverage_min == Decimal("75.0") 10 | assert config.modules is None 11 | 12 | 13 | # backwards compatibilty for before this project became compliant with pep518 14 | def test_read_config_parses_legacy_pyproject_format() -> None: 15 | config = read_config(path_to_test_config("legacy.pyproject.toml")) 16 | assert config.line_coverage_min == Decimal("75.0") 17 | assert config.modules is None 18 | 19 | 20 | def test_read_config_parses_complex_pyproject_format() -> None: 21 | config = read_config(path_to_test_config("complex.pyproject.toml")) 22 | assert config.line_coverage_min == Decimal("95.0") 23 | assert config.file_line_coverage_min == Decimal("95.0") 24 | assert config.branch_coverage_min == Decimal("50.0") 25 | assert config.modules is not None 26 | assert config.modules["src/cli/"].file_line_coverage_min == Decimal("40.0") 27 | assert config.modules["src/cli/my_command.py"].file_line_coverage_min == Decimal( 28 | "100" 29 | ) 30 | assert config.modules["src/lib/"].file_line_coverage_min == Decimal("100") 31 | assert config.modules["src/lib/"].file_branch_coverage_min == Decimal("100") 32 | assert config.modules["src/model/"].file_line_coverage_min == Decimal("100") 33 | assert config.modules["src/__main__.py"].file_line_coverage_min == Decimal("0") 34 | 35 | 36 | def path_to_test_config(filename: str) -> str: 37 | return os.path.join(os.path.dirname(__file__), filename) 38 | -------------------------------------------------------------------------------- /tests/unit/lib/test_all_checks.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import Mapping, Optional 3 | 4 | import pytest 5 | 6 | from coverage_threshold.lib import check_all 7 | from coverage_threshold.lib.check_result import Fail, Pass 8 | from coverage_threshold.model.config import Config, ModuleConfig 9 | from coverage_threshold.model.report import ( 10 | CoverageSummaryModel, 11 | FileCoverageModel, 12 | ReportMetadata, 13 | ReportModel, 14 | ) 15 | 16 | 17 | def create_report( 18 | files: Optional[Mapping[str, FileCoverageModel]] = None, 19 | meta: ReportMetadata = ReportMetadata(branch_coverage=False), 20 | totals: CoverageSummaryModel = CoverageSummaryModel( 21 | covered_lines=4, 22 | num_statements=4, 23 | ), 24 | ) -> ReportModel: 25 | if files is None: 26 | files = {"src/main.py": FileCoverageModel(summary=totals)} 27 | return ReportModel( 28 | meta=meta, 29 | files=files, 30 | totals=totals, 31 | ) 32 | 33 | 34 | def test_check_totals() -> None: 35 | assert ( 36 | check_all( 37 | create_report( 38 | totals=CoverageSummaryModel(covered_lines=3, num_statements=4) 39 | ), 40 | Config(line_coverage_min=Decimal("75.0")), 41 | ) 42 | == Pass() 43 | ) 44 | assert check_all( 45 | create_report(totals=CoverageSummaryModel(covered_lines=2, num_statements=3)), 46 | Config(line_coverage_min=Decimal("67.0")), 47 | ) == Fail(["Total line coverage metric failed, expected 67.0, was 66.6667"]) 48 | 49 | 50 | def test_check_totals__with_number_missing_lines_max() -> None: 51 | report = create_report( 52 | totals=CoverageSummaryModel(covered_lines=3, num_statements=7) 53 | ) 54 | assert ( 55 | check_all( 56 | report, Config(line_coverage_min=Decimal(0), number_missing_lines_max=5) 57 | ) 58 | == Pass() 59 | ) 60 | assert ( 61 | check_all( 62 | report, Config(line_coverage_min=Decimal(0), number_missing_lines_max=4) 63 | ) 64 | == Pass() 65 | ) 66 | assert check_all( 67 | report, Config(line_coverage_min=Decimal(0), number_missing_lines_max=3) 68 | ) == Fail(["Total number missing lines max failed, expected 3, was 4"]) 69 | 70 | 71 | def test_check_all_files() -> None: 72 | report = create_report( 73 | files={ 74 | "a.py": FileCoverageModel( 75 | summary=CoverageSummaryModel(covered_lines=1, num_statements=2) 76 | ), 77 | "b.py": FileCoverageModel( 78 | summary=CoverageSummaryModel(covered_lines=3, num_statements=4) 79 | ), 80 | } 81 | ) 82 | 83 | assert check_all(report, Config(file_line_coverage_min=Decimal("50.0"))) == Pass() 84 | assert check_all(report, Config(file_line_coverage_min=Decimal("75.0"))) == Fail( 85 | ['File: "a.py" failed LINE coverage metric, expected 75.0, was 50.0000'] 86 | ) 87 | 88 | 89 | def test_checking_branch_coverage_fails_without_branch_report() -> None: 90 | report = create_report(meta=ReportMetadata(branch_coverage=False)) 91 | expected_error_message = "missing number of branches or number of branches covered" 92 | 93 | with pytest.raises(ValueError) as e: 94 | check_all(report, Config(branch_coverage_min=Decimal("50.0"))) 95 | assert str(e.value) == expected_error_message 96 | 97 | with pytest.raises(ValueError) as e: 98 | check_all(report, Config(combined_coverage_min=Decimal("50.0"))) 99 | assert str(e.value) == expected_error_message 100 | 101 | with pytest.raises(ValueError) as e: 102 | check_all(report, Config(file_branch_coverage_min=Decimal("75.0"))) 103 | assert str(e.value) == expected_error_message 104 | 105 | 106 | def test_check_totals_with_branch_coverage() -> None: 107 | report = create_report( 108 | meta=ReportMetadata(branch_coverage=True), 109 | totals=CoverageSummaryModel( 110 | covered_lines=5, 111 | num_statements=5, 112 | covered_branches=3, 113 | num_branches=4, 114 | ), 115 | ) 116 | assert ( 117 | check_all( 118 | report, 119 | Config(branch_coverage_min=Decimal("75.0")), 120 | ) 121 | == Pass() 122 | ) 123 | assert check_all( 124 | report, 125 | Config(branch_coverage_min=Decimal("75.001")), 126 | ) == Fail(["Total branch coverage metric failed, expected 75.001, was 75.0000"]) 127 | 128 | 129 | def test_check_totals_with_combined_coverage() -> None: 130 | report = create_report( 131 | meta=ReportMetadata(branch_coverage=True), 132 | totals=CoverageSummaryModel( 133 | covered_lines=5, 134 | num_statements=5, 135 | covered_branches=3, 136 | num_branches=5, 137 | ), 138 | ) 139 | assert ( 140 | check_all( 141 | report, 142 | Config(combined_coverage_min=Decimal("80.0")), 143 | ) 144 | == Pass() 145 | ) 146 | assert check_all( 147 | report, 148 | Config(combined_coverage_min=Decimal("80.001")), 149 | ) == Fail(["Total combined coverage metric failed, expected 80.001, was 80.0000"]) 150 | 151 | 152 | def test_check_all_files_with_branch_coverage() -> None: 153 | report = create_report( 154 | meta=ReportMetadata(branch_coverage=True), 155 | files={ 156 | "a.py": FileCoverageModel( 157 | summary=CoverageSummaryModel( 158 | covered_lines=5, 159 | num_statements=5, 160 | covered_branches=1, 161 | num_branches=2, 162 | ) 163 | ), 164 | "b.py": FileCoverageModel( 165 | summary=CoverageSummaryModel( 166 | covered_lines=5, 167 | num_statements=5, 168 | covered_branches=3, 169 | num_branches=4, 170 | ) 171 | ), 172 | }, 173 | ) 174 | 175 | assert check_all(report, Config(file_branch_coverage_min=Decimal("50.0"))) == Pass() 176 | assert check_all(report, Config(file_branch_coverage_min=Decimal("75.0"))) == Fail( 177 | ['File: "a.py" failed BRANCH coverage metric, expected 75.0, was 50.0000'] 178 | ) 179 | 180 | 181 | def test_check_all_files_with_combined_coverage() -> None: 182 | report = create_report( 183 | meta=ReportMetadata(branch_coverage=True), 184 | files={ 185 | "a.py": FileCoverageModel( 186 | summary=CoverageSummaryModel( 187 | covered_lines=5, 188 | num_statements=5, 189 | covered_branches=1, 190 | num_branches=5, 191 | ) 192 | ), 193 | "b.py": FileCoverageModel( 194 | summary=CoverageSummaryModel( 195 | covered_lines=5, 196 | num_statements=5, 197 | covered_branches=3, 198 | num_branches=5, 199 | ) 200 | ), 201 | }, 202 | ) 203 | 204 | assert ( 205 | check_all(report, Config(file_combined_coverage_min=Decimal("60.0"))) == Pass() 206 | ) 207 | assert check_all( 208 | report, Config(file_combined_coverage_min=Decimal("80.0")) 209 | ) == Fail( 210 | [ 211 | 'File: "a.py" failed COMBINED line plus branch coverage metric' 212 | + ", expected 80.0, was 60.0000" 213 | ] 214 | ) 215 | 216 | 217 | def test_module_level_config() -> None: 218 | report = create_report( 219 | meta=ReportMetadata(branch_coverage=True), 220 | files={ 221 | "src/model/a.py": FileCoverageModel( 222 | summary=CoverageSummaryModel( 223 | covered_lines=5, 224 | num_statements=5, 225 | covered_branches=1, 226 | num_branches=2, 227 | ) 228 | ), 229 | "src/model/b.py": FileCoverageModel( 230 | summary=CoverageSummaryModel( 231 | covered_lines=5, 232 | num_statements=5, 233 | covered_branches=3, 234 | num_branches=4, 235 | ) 236 | ), 237 | "src/cli/command.py": FileCoverageModel( 238 | summary=CoverageSummaryModel( 239 | covered_lines=5, 240 | num_statements=5, 241 | covered_branches=4, 242 | num_branches=4, 243 | ) 244 | ), 245 | }, 246 | ) 247 | 248 | assert ( 249 | check_all( 250 | report, 251 | Config( 252 | modules={ 253 | "src/model/": ModuleConfig(file_branch_coverage_min=Decimal("50.0")) 254 | } 255 | ), 256 | ) 257 | == Pass() 258 | ) 259 | assert ( 260 | check_all( 261 | report, 262 | Config( 263 | modules={ 264 | "src/model/": ModuleConfig( 265 | file_branch_coverage_min=Decimal("75.0") 266 | ), 267 | "src/model/a": ModuleConfig( 268 | file_branch_coverage_min=Decimal("50.0") 269 | ), 270 | } 271 | ), 272 | ) 273 | == Pass() 274 | ) 275 | assert check_all( 276 | report, 277 | Config( 278 | modules={ 279 | "src/model/": ModuleConfig(file_branch_coverage_min=Decimal("80.0")), 280 | "src/model/a": ModuleConfig(file_branch_coverage_min=Decimal("50.0")), 281 | } 282 | ), 283 | ) == Fail( 284 | [ 285 | 'File: "src/model/b.py" failed BRANCH coverage metric' 286 | + ", expected 80.0, was 75.0000" 287 | ] 288 | ) 289 | -------------------------------------------------------------------------------- /tests/unit/lib/test_check_result.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pytest 4 | from hypothesis import given 5 | from hypothesis.strategies import characters, integers, lists 6 | 7 | from coverage_threshold.lib.check_result import ( 8 | CheckResult, 9 | Fail, 10 | Pass, 11 | combine_check_results, 12 | fold_check_results, 13 | ) 14 | 15 | 16 | @given(integers(min_value=1, max_value=10)) 17 | def test_fold_check_results__all_pass(length: int) -> None: 18 | assert fold_check_results(Pass() for _ in range(length)) == Pass() 19 | 20 | 21 | @given(integers(min_value=1, max_value=10)) 22 | def test_fold_check_results__all_pass_except_one(length: int) -> None: 23 | assert fold_check_results( 24 | [*(Pass() for _ in range(length)), Fail(["D'oh!"])] 25 | ) == Fail(["D'oh!"]) 26 | 27 | 28 | @given(lists(lists(characters(), max_size=2), min_size=1, max_size=5)) 29 | def test_fold_check_results__all_fail(values: List[str]) -> None: 30 | assert fold_check_results(map(lambda string: Fail([string]), values)) == Fail( 31 | values 32 | ) 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "a, b, expected_result", 37 | [ 38 | (Pass(), Pass(), Pass()), 39 | (Pass(), Fail(["!"]), Fail(["!"])), 40 | (Fail(["!"]), Pass(), Fail(["!"])), 41 | (Fail(["one"]), Fail(["two"]), Fail(["one", "two"])), 42 | ], 43 | ) 44 | def test_combine_check_results( 45 | a: CheckResult, b: CheckResult, expected_result: CheckResult 46 | ) -> None: 47 | assert combine_check_results(a, b) == expected_result 48 | -------------------------------------------------------------------------------- /tests/unit/lib/test_common.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pytest 4 | from hypothesis import given 5 | from hypothesis.strategies import integers 6 | 7 | from coverage_threshold.lib._common import _safe_percent, percent_branches_covered 8 | from coverage_threshold.model.report import CoverageSummaryModel 9 | 10 | 11 | @given(integers()) 12 | def test_safe_percent__100_percent_when_denomenator_is_zero(numerator: int) -> None: 13 | assert _safe_percent(numerator, 0) == Decimal(100) 14 | 15 | 16 | @given(integers(min_value=0, max_value=10**10), integers(min_value=1, max_value=10**10)) 17 | def test_safe_percent__regular_fraction_otherwise( 18 | numerator: int, denomenator: int 19 | ) -> None: 20 | assert _safe_percent(numerator, denomenator) == ( 21 | Decimal(numerator) / Decimal(denomenator) * Decimal(100) 22 | ).quantize(Decimal("0.0001")) 23 | 24 | 25 | def test_percent_branches_covered__given_invalid_object() -> None: 26 | with pytest.raises(ValueError) as e: 27 | percent_branches_covered( 28 | CoverageSummaryModel(num_statements=1, covered_lines=1) 29 | ) 30 | assert str(e.value) == "missing number of branches or number of branches covered" 31 | -------------------------------------------------------------------------------- /tests/unit/model/test_config.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pytest 4 | 5 | from coverage_threshold.model.config import Config, ModuleConfig 6 | 7 | 8 | def test_config_parse__empty() -> None: 9 | assert Config.parse({}) == Config() 10 | 11 | 12 | def test_config_parse__ignores_extra_fields() -> None: 13 | assert Config.parse({"lol": 123}) == Config() 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "field_name", 18 | [ 19 | "line_coverage_min", 20 | "branch_coverage_min", 21 | "combined_coverage_min", 22 | "file_line_coverage_min", 23 | "file_branch_coverage_min", 24 | "file_combined_coverage_min", 25 | ], 26 | ) 27 | def test_config_parse__optional_decimals(field_name: str) -> None: 28 | assert Config.parse({field_name: 123}) == Config( 29 | **{field_name: Decimal("123")} # type: ignore 30 | ) 31 | assert Config.parse({field_name: None}) == Config() 32 | 33 | 34 | def test_config_parse__modules_emtpy() -> None: 35 | assert Config.parse({"modules": {"src/lib/": {}}}) == Config( 36 | modules={"src/lib/": ModuleConfig()} 37 | ) 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "field_name", 42 | [ 43 | "file_line_coverage_min", 44 | "file_branch_coverage_min", 45 | ], 46 | ) 47 | def test_config_parse__modules_optional_decimals(field_name: str) -> None: 48 | assert Config.parse({"modules": {"src/lib/": {field_name: 123}}}) == Config( 49 | modules={"src/lib/": ModuleConfig(**{field_name: Decimal("123")})} 50 | ) 51 | -------------------------------------------------------------------------------- /tests/unit/model/test_report.py: -------------------------------------------------------------------------------- 1 | from coverage_threshold.model.report import ( 2 | CoverageSummaryModel, 3 | FileCoverageModel, 4 | ReportMetadata, 5 | ReportModel, 6 | ) 7 | 8 | 9 | def test_report_parse__full() -> None: 10 | assert ReportModel.parse( 11 | { 12 | "meta": { 13 | "branch_coverage": True, 14 | }, 15 | "files": { 16 | "src/__init__.py": { 17 | "summary": { 18 | "covered_lines": 1, 19 | "num_statements": 2, 20 | "num_branches": 3, 21 | "covered_branches": 4, 22 | }, 23 | }, 24 | "src/main.py": { 25 | "summary": { 26 | "covered_lines": 5, 27 | "num_statements": 6, 28 | "num_branches": 7, 29 | "covered_branches": 8, 30 | }, 31 | }, 32 | }, 33 | "totals": { 34 | "covered_lines": 9, 35 | "num_statements": 10, 36 | "num_branches": 11, 37 | "covered_branches": 12, 38 | }, 39 | } 40 | ) == ReportModel( 41 | meta=ReportMetadata(branch_coverage=True), 42 | files={ 43 | "src/__init__.py": FileCoverageModel( 44 | summary=CoverageSummaryModel( 45 | covered_lines=1, 46 | num_statements=2, 47 | num_branches=3, 48 | covered_branches=4, 49 | ) 50 | ), 51 | "src/main.py": FileCoverageModel( 52 | summary=CoverageSummaryModel( 53 | covered_lines=5, 54 | num_statements=6, 55 | num_branches=7, 56 | covered_branches=8, 57 | ) 58 | ), 59 | }, 60 | totals=CoverageSummaryModel( 61 | covered_lines=9, 62 | num_statements=10, 63 | num_branches=11, 64 | covered_branches=12, 65 | ), 66 | ) 67 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38, py39, py310, py311, py312, py313 3 | 4 | [testenv] 5 | deps = pipenv 6 | commands= 7 | pipenv install --dev 8 | pip install -e . 9 | pipenv run ./scripts/ci.sh 10 | --------------------------------------------------------------------------------