├── .bumpversion.cfg ├── .cookiecutterrc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── support_request.md ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── SECURITY.md ├── ci ├── bootstrap.py ├── requirements.txt └── templates │ └── .github │ └── workflows │ └── test.yml ├── docs ├── authors.rst ├── changelog.rst ├── conf.py ├── config.rst ├── contexts.rst ├── contributing.rst ├── debuggers.rst ├── index.rst ├── markers-fixtures.rst ├── plugins.rst ├── readme.rst ├── releasing.rst ├── reporting.rst ├── requirements.txt ├── spelling_wordlist.txt ├── subprocess-support.rst ├── tox.rst └── xdist.rst ├── examples ├── README.rst ├── adhoc-layout │ ├── .coveragerc │ ├── example │ │ └── __init__.py │ ├── setup.py │ ├── tests │ │ └── test_example.py │ └── tox.ini └── src-layout │ ├── .coveragerc │ ├── setup.py │ ├── src │ └── example │ │ └── __init__.py │ ├── tests │ └── test_example.py │ └── tox.ini ├── pyproject.toml ├── pytest.ini ├── setup.py ├── src ├── pytest-cov.embed ├── pytest-cov.pth └── pytest_cov │ ├── __init__.py │ ├── compat.py │ ├── embed.py │ ├── engine.py │ └── plugin.py ├── tests ├── conftest.py ├── contextful.py ├── helper.py └── test_pytest_cov.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 6.1.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file (badge):README.rst] 11 | search = /v{current_version}.svg 12 | replace = /v{new_version}.svg 13 | 14 | [bumpversion:file (link):README.rst] 15 | search = /v{current_version}...master 16 | replace = /v{new_version}...master 17 | 18 | [bumpversion:file:docs/conf.py] 19 | search = version = release = '{current_version}' 20 | replace = version = release = '{new_version}' 21 | 22 | [bumpversion:file:src/pytest_cov/__init__.py] 23 | search = __version__ = '{current_version}' 24 | replace = __version__ = '{new_version}' 25 | 26 | [bumpversion:file:.cookiecutterrc] 27 | search = version: {current_version} 28 | replace = version: {new_version} 29 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # Generated by cookiepatcher, a small shim around cookiecutter (pip install cookiepatcher) 2 | 3 | default_context: 4 | allow_tests_inside_package: 'no' 5 | c_extension_function: '-' 6 | c_extension_module: '-' 7 | c_extension_optional: 'no' 8 | c_extension_support: 'no' 9 | codacy: 'no' 10 | codacy_projectid: '[Get ID from https://app.codacy.com/app/ionelmc/pytest-cov/settings]' 11 | codeclimate: 'no' 12 | codecov: 'no' 13 | command_line_interface: 'no' 14 | command_line_interface_bin_name: '-' 15 | coveralls: 'no' 16 | distribution_name: pytest-cov 17 | email: contact@ionelmc.ro 18 | formatter_quote_style: single 19 | full_name: Ionel Cristian Mărieș 20 | github_actions: 'yes' 21 | github_actions_osx: 'yes' 22 | github_actions_windows: 'yes' 23 | license: MIT license 24 | package_name: pytest_cov 25 | pre_commit: 'yes' 26 | project_name: pytest-cov 27 | project_short_description: This plugin produces coverage reports. It supports centralised testing and distributed testing in both load and each modes. It also supports coverage of subprocesses. 28 | pypi_badge: 'yes' 29 | pypi_disable_upload: 'no' 30 | release_date: '2023-05-24' 31 | repo_hosting: github.com 32 | repo_hosting_domain: github.com 33 | repo_main_branch: master 34 | repo_name: pytest-cov 35 | repo_username: pytest-dev 36 | scrutinizer: 'no' 37 | setup_py_uses_setuptools_scm: 'no' 38 | sphinx_docs: 'yes' 39 | sphinx_docs_hosting: https://pytest-cov.readthedocs.io/ 40 | sphinx_doctest: 'no' 41 | sphinx_theme: sphinx-py3doc-enhanced-theme 42 | test_matrix_separate_coverage: 'no' 43 | version: 6.1.1 44 | version_manager: bump2version 45 | website: http://blog.ionelmc.ro 46 | year_from: '2010' 47 | year_to: '2024' 48 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | # Use Unix-style newlines for most files (except Windows files, see below). 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | insert_final_newline = true 10 | indent_size = 4 11 | charset = utf-8 12 | 13 | [*.{bat,cmd,ps1}] 14 | end_of_line = crlf 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | 19 | [*.tsv] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug report 3 | about: There a problem with how pytest-cov or coverage works 4 | --- 5 | 6 | # Summary 7 | 8 | ## Expected vs actual result 9 | 10 | # Reproducer 11 | 12 | ## Versions 13 | 14 | Output of relevant packages `pip list`, `python --version`, `pytest --version` etc. 15 | 16 | Make sure you include complete output of `tox` if you use it (it will show versions of various things). 17 | 18 | ## Config 19 | 20 | Include your `tox.ini`, `pytest.ini`, `.coveragerc`, `setup.cfg` or any relevant configuration. 21 | 22 | ## Code 23 | 24 | Link to your repository, gist, pastebin or just paste raw code that illustrates the issue. 25 | 26 | If you paste raw code make sure you quote it, eg: 27 | 28 | ```python 29 | def foobar(): 30 | pass 31 | ``` 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✈ Feature request 3 | about: Proposal for a new feature in pytest-cov 4 | --- 5 | 6 | Before proposing please consider: 7 | 8 | * the maintenance cost of the feature 9 | * implementing it externally (like a shell/python script, 10 | pytest plugin or something else) 11 | 12 | # Summary 13 | 14 | These questions should be answered: 15 | 16 | * why is the feature needed? 17 | * what problem does it solve? 18 | * how it is better compared to past solutions to the problem? 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤔 Support request 3 | about: Request help with setting up pytest-cov in your project 4 | --- 5 | 6 | Please go over all the sections and search 7 | https://pytest-cov.readthedocs.io/en/latest/ or 8 | https://coverage.readthedocs.io/en/latest/ 9 | before opening the issue. 10 | 11 | # Summary 12 | 13 | ## Expected vs actual result 14 | 15 | # Reproducer 16 | 17 | ## Versions 18 | 19 | Output of relevant packages `pip list`, `python --version`, `pytest --version` etc. 20 | 21 | Make sure you include complete output of `tox` if you use it (it will show versions of various things). 22 | 23 | ## Config 24 | 25 | Include your `tox.ini`, `pytest.ini`, `.coveragerc`, `setup.cfg` or any relevant configuration. 26 | 27 | ## Code 28 | 29 | Link to your repository, gist, pastebin or just paste raw code that illustrates the issue. 30 | 31 | If you paste raw code make sure you quote it, eg: 32 | 33 | ```python 34 | def foobar(): 35 | pass 36 | ``` 37 | 38 | # What has been tried to solve the problem 39 | 40 | You should outline the things you tried to solve the problem but didn't work. 41 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Keep GitHub Actions up to date with GitHub's Dependabot... 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" # Group all Actions updates into a single larger pull request 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | examples: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | python-version: ["pypy-3.9", "3.11"] 10 | target: [ 11 | "src-layout", 12 | "adhoc-layout", 13 | ] 14 | include: 15 | # Add new helper variables to existing jobs 16 | - {python-version: "pypy-3.9", tox-python-version: "pypy3"} 17 | - {python-version: "3.11", tox-python-version: "py311"} 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Cache 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.cache/pip 30 | key: 31 | examples-v1-${{ hashFiles('**/tox.ini') }} 32 | restore-keys: | 33 | examples-v1- 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | python -m pip install --upgrade wheel 39 | python -m pip install --progress-bar=off tox -rci/requirements.txt 40 | 41 | - name: Examples 42 | run: | 43 | cd examples/${{ matrix.target }} 44 | tox -v -e ${{ matrix.tox-python-version }} 45 | test: 46 | name: ${{ matrix.name }} 47 | runs-on: ${{ matrix.os }} 48 | timeout-minutes: 30 49 | strategy: 50 | fail-fast: false 51 | matrix: 52 | include: 53 | - name: 'check' 54 | python: '3.11' 55 | toxpython: 'python3.11' 56 | tox_env: 'check' 57 | os: 'ubuntu-latest' 58 | - name: 'docs' 59 | python: '3.11' 60 | toxpython: 'python3.11' 61 | tox_env: 'docs' 62 | os: 'ubuntu-latest' 63 | - name: 'py39-pytest83-xdist36-coverage77 (ubuntu)' 64 | python: '3.9' 65 | toxpython: 'python3.9' 66 | python_arch: 'x64' 67 | tox_env: 'py39-pytest83-xdist36-coverage77' 68 | os: 'ubuntu-latest' 69 | - name: 'py39-pytest83-xdist36-coverage77 (windows)' 70 | python: '3.9' 71 | toxpython: 'python3.9' 72 | python_arch: 'x64' 73 | tox_env: 'py39-pytest83-xdist36-coverage77' 74 | os: 'windows-latest' 75 | - name: 'py39-pytest83-xdist36-coverage77 (macos)' 76 | python: '3.9' 77 | toxpython: 'python3.9' 78 | python_arch: 'arm64' 79 | tox_env: 'py39-pytest83-xdist36-coverage77' 80 | os: 'macos-latest' 81 | - name: 'py310-pytest83-xdist36-coverage77 (ubuntu)' 82 | python: '3.10' 83 | toxpython: 'python3.10' 84 | python_arch: 'x64' 85 | tox_env: 'py310-pytest83-xdist36-coverage77' 86 | os: 'ubuntu-latest' 87 | - name: 'py310-pytest83-xdist36-coverage77 (windows)' 88 | python: '3.10' 89 | toxpython: 'python3.10' 90 | python_arch: 'x64' 91 | tox_env: 'py310-pytest83-xdist36-coverage77' 92 | os: 'windows-latest' 93 | - name: 'py310-pytest83-xdist36-coverage77 (macos)' 94 | python: '3.10' 95 | toxpython: 'python3.10' 96 | python_arch: 'arm64' 97 | tox_env: 'py310-pytest83-xdist36-coverage77' 98 | os: 'macos-latest' 99 | - name: 'py311-pytest83-xdist36-coverage77 (ubuntu)' 100 | python: '3.11' 101 | toxpython: 'python3.11' 102 | python_arch: 'x64' 103 | tox_env: 'py311-pytest83-xdist36-coverage77' 104 | os: 'ubuntu-latest' 105 | - name: 'py311-pytest83-xdist36-coverage77 (windows)' 106 | python: '3.11' 107 | toxpython: 'python3.11' 108 | python_arch: 'x64' 109 | tox_env: 'py311-pytest83-xdist36-coverage77' 110 | os: 'windows-latest' 111 | - name: 'py311-pytest83-xdist36-coverage77 (macos)' 112 | python: '3.11' 113 | toxpython: 'python3.11' 114 | python_arch: 'arm64' 115 | tox_env: 'py311-pytest83-xdist36-coverage77' 116 | os: 'macos-latest' 117 | - name: 'py312-pytest83-xdist36-coverage77 (ubuntu)' 118 | python: '3.12' 119 | toxpython: 'python3.12' 120 | python_arch: 'x64' 121 | tox_env: 'py312-pytest83-xdist36-coverage77' 122 | os: 'ubuntu-latest' 123 | - name: 'py312-pytest83-xdist36-coverage77 (windows)' 124 | python: '3.12' 125 | toxpython: 'python3.12' 126 | python_arch: 'x64' 127 | tox_env: 'py312-pytest83-xdist36-coverage77' 128 | os: 'windows-latest' 129 | - name: 'py312-pytest83-xdist36-coverage77 (macos)' 130 | python: '3.12' 131 | toxpython: 'python3.12' 132 | python_arch: 'arm64' 133 | tox_env: 'py312-pytest83-xdist36-coverage77' 134 | os: 'macos-latest' 135 | - name: 'py313-pytest83-xdist36-coverage77 (ubuntu)' 136 | python: '3.13' 137 | toxpython: 'python3.13' 138 | python_arch: 'x64' 139 | tox_env: 'py313-pytest83-xdist36-coverage77' 140 | os: 'ubuntu-latest' 141 | - name: 'py313-pytest83-xdist36-coverage77 (windows)' 142 | python: '3.13' 143 | toxpython: 'python3.13' 144 | python_arch: 'x64' 145 | tox_env: 'py313-pytest83-xdist36-coverage77' 146 | os: 'windows-latest' 147 | - name: 'py313-pytest83-xdist36-coverage77 (macos)' 148 | python: '3.13' 149 | toxpython: 'python3.13' 150 | python_arch: 'arm64' 151 | tox_env: 'py313-pytest83-xdist36-coverage77' 152 | os: 'macos-latest' 153 | - name: 'pypy39-pytest83-xdist36-coverage77 (ubuntu)' 154 | python: 'pypy-3.9' 155 | toxpython: 'pypy3.9' 156 | python_arch: 'x64' 157 | tox_env: 'pypy39-pytest83-xdist36-coverage77' 158 | os: 'ubuntu-latest' 159 | - name: 'pypy39-pytest83-xdist36-coverage77 (windows)' 160 | python: 'pypy-3.9' 161 | toxpython: 'pypy3.9' 162 | python_arch: 'x64' 163 | tox_env: 'pypy39-pytest83-xdist36-coverage77' 164 | os: 'windows-latest' 165 | - name: 'pypy39-pytest83-xdist36-coverage77 (macos)' 166 | python: 'pypy-3.9' 167 | toxpython: 'pypy3.9' 168 | python_arch: 'arm64' 169 | tox_env: 'pypy39-pytest83-xdist36-coverage77' 170 | os: 'macos-latest' 171 | - name: 'pypy310-pytest83-xdist36-coverage77 (ubuntu)' 172 | python: 'pypy-3.10' 173 | toxpython: 'pypy3.10' 174 | python_arch: 'x64' 175 | tox_env: 'pypy310-pytest83-xdist36-coverage77' 176 | os: 'ubuntu-latest' 177 | - name: 'pypy310-pytest83-xdist36-coverage77 (windows)' 178 | python: 'pypy-3.10' 179 | toxpython: 'pypy3.10' 180 | python_arch: 'x64' 181 | tox_env: 'pypy310-pytest83-xdist36-coverage77' 182 | os: 'windows-latest' 183 | - name: 'pypy310-pytest83-xdist36-coverage77 (macos)' 184 | python: 'pypy-3.10' 185 | toxpython: 'pypy3.10' 186 | python_arch: 'arm64' 187 | tox_env: 'pypy310-pytest83-xdist36-coverage77' 188 | os: 'macos-latest' 189 | steps: 190 | - uses: actions/checkout@v4 191 | with: 192 | fetch-depth: 0 193 | - uses: actions/setup-python@v5 194 | with: 195 | python-version: ${{ matrix.python }} 196 | architecture: ${{ matrix.python_arch }} 197 | - name: install dependencies 198 | run: | 199 | python -mpip install --progress-bar=off -r ci/requirements.txt 200 | virtualenv --version 201 | pip --version 202 | tox --version 203 | pip list --format=freeze 204 | - name: test 205 | env: 206 | TOXPYTHON: '${{ matrix.toxpython }}' 207 | run: > 208 | tox -e ${{ matrix.tox_env }} -v 209 | 210 | successful: 211 | # this provides a single status check for branch merge rules 212 | # (use this in `Require status checks to pass before merging` in branch settings) 213 | if: always() 214 | needs: 215 | - test 216 | - examples 217 | runs-on: ubuntu-latest 218 | steps: 219 | - name: Decide whether the needed jobs succeeded or failed 220 | uses: re-actors/alls-green@release/v1 221 | with: 222 | jobs: ${{ toJSON(needs) }} 223 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | .eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | wheelhouse 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | venv*/ 24 | pyvenv*/ 25 | pip-wheel-metadata/ 26 | 27 | # Installer logs 28 | pip-log.txt 29 | 30 | # Unit test / coverage reports 31 | .coverage 32 | .tox 33 | .coverage.* 34 | .pytest_cache/ 35 | nosetests.xml 36 | coverage.xml 37 | htmlcov 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Buildout 43 | .mr.developer.cfg 44 | 45 | # IDE project files 46 | .project 47 | .pydevproject 48 | .idea 49 | .vscode 50 | *.iml 51 | *.komodoproject 52 | 53 | # Complexity 54 | output/*.html 55 | output/*/index.html 56 | 57 | # Sphinx 58 | docs/_build 59 | 60 | .DS_Store 61 | *~ 62 | .*.sw[po] 63 | .build 64 | .ve 65 | .env 66 | .cache 67 | .pytest 68 | .benchmarks 69 | .bootstrap 70 | .appveyor.token 71 | *.bak 72 | 73 | # Mypy Cache 74 | .mypy_cache/ 75 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # To install the git pre-commit hooks run: 2 | # pre-commit install --install-hooks 3 | # To update the versions: 4 | # pre-commit autoupdate 5 | exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg)(/|$)' 6 | # Note the order is intentional to avoid multiple passes of the hooks 7 | repos: 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.11.2 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix, --show-fixes, --unsafe-fixes] 13 | - id: ruff-format 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v5.0.0 16 | hooks: 17 | - id: trailing-whitespace 18 | - id: end-of-file-fixer 19 | exclude: '.*\.pth$' 20 | - id: debug-statements 21 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | version: 2 3 | sphinx: 4 | configuration: docs/conf.py 5 | formats: all 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3" 10 | python: 11 | install: 12 | - requirements: docs/requirements.txt 13 | - method: pip 14 | path: . 15 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * Marc Schlaich - https://github.com/schlamar (\http://www.schlamar.org) 6 | * Rick van Hattem - http://wol.ph 7 | * Buck Evan - https://github.com/bukzor 8 | * Eric Larson - http://larsoner.com 9 | * Marc Abramowitz - \http://marc-abramowitz.com 10 | * Thomas Kluyver - https://github.com/takluyver 11 | * Guillaume Ayoub - http://www.yabz.fr 12 | * Federico Ceratto - \http://firelet.net 13 | * Josh Kalderimis - \http://blog.cookiestack.com 14 | * Ionel Cristian Mărieș - https://blog.ionelmc.ro 15 | * Christian Ledermann - https://github.com/cleder 16 | * Alec Nikolas Reiter - https://github.com/justanr 17 | * Patrick Lannigan - https://github.com/plannigan 18 | * David Szotten - https://github.com/davidszotten 19 | * Michael Elovskikh - https://github.com/wronglink 20 | * Saurabh Kumar - https://github.com/theskumar 21 | * Michael Elovskikh - https://github.com/wronglink 22 | * Daniel Hahler - https://github.com/blueyed (\https://daniel.hahler.de) 23 | * Florian Bruhin - http://www.the-compiler.org 24 | * Zoltan Kozma - https://github.com/kozmaz87 25 | * Francis Niu - https://flniu.github.io 26 | * Jannis Leidel - https://github.com/jezdez 27 | * Ryan Hiebert - http://ryanhiebert.com/ 28 | * Terence Honles - https://github.com/terencehonles 29 | * Jeremy Bowman - https://github.com/jmbowman 30 | * Samuel Giffard - https://github.com/Mulugruntz 31 | * Семён Марьясин - https://github.com/MarSoft 32 | * Alexander Shadchin - https://github.com/shadchin 33 | * Thomas Grainger - https://graingert.co.uk 34 | * Juanjo Bazán - https://github.com/xuanxu 35 | * Andrew Murray - https://github.com/radarhere 36 | * Ned Batchelder - https://nedbatchelder.com/ 37 | * Albert Tugushev - https://github.com/atugushev 38 | * Martín Gaitán - https://github.com/mgaitan 39 | * Hugo van Kemenade - https://github.com/hugovk 40 | * Michael Manganiello - https://github.com/adamantike 41 | * Anders Hovmöller - https://github.com/boxed 42 | * Zac Hatfield-Dodds - https://zhd.dev 43 | * Mateus Berardo de Souza Terra - https://github.com/MatTerra 44 | * Ganden Schaffner - https://github.com/gschaffner 45 | * Michał Górny - https://github.com/mgorny 46 | * Bernát Gábor - https://github.com/gaborbernat 47 | * Pamela McA'Nulty - https://github.com/PamelaM 48 | * Christian Riedel - https://github.com/Cielquan 49 | * Chris Sreesangkom - https://github.com/csreesan 50 | * Sorin Sbarnea - https://github.com/ssbarnea 51 | * Brian Rutledge - https://github.com/bhrutledge 52 | * Danilo Šegan - https://github.com/dsegan 53 | * Michał Bielawski - https://github.com/D3X 54 | * Zac Hatfield-Dodds - https://github.com/Zac-HD 55 | * Ben Greiner - https://github.com/bnavigator 56 | * Delgan - https://github.com/Delgan 57 | * Andre Brisco - https://github.com/abrisco 58 | * Colin O'Dell - https://github.com/colinodell 59 | * Ronny Pfannschmidt - https://github.com/RonnyPfannschmidt 60 | * Christian Fetzer - https://github.com/fetzerch 61 | * Jonathan Stewmon - https://github.com/jstewmon 62 | * Matthew Gamble - https://github.com/mwgamble 63 | * Christian Clauss - https://github.com/cclauss 64 | * Dawn James - https://github.com/dawngerpony 65 | * Tsvika Shapira - https://github.com/tsvikas 66 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 6.1.1 (2025-04-05) 6 | ------------------ 7 | 8 | * Fixed breakage that occurs when ``--cov-context`` and the ``no_cover`` marker are used together. 9 | 10 | 6.1.0 (2025-04-01) 11 | ------------------ 12 | 13 | * Change terminal output to use full width lines for the coverage header. 14 | Contributed by Tsvika Shapira in `#678 `_. 15 | * Removed unnecessary CovFailUnderWarning. Fixes `#675 `_. 16 | * Fixed the term report not using the precision specified via ``--cov-precision``. 17 | 18 | 19 | 6.0.0 (2024-10-29) 20 | ------------------ 21 | 22 | * Updated various documentation inaccuracies, especially on subprocess handling. 23 | * Changed fail under checks to use the precision set in the coverage configuration. 24 | Now it will perform the check just like ``coverage report`` would. 25 | * Added a ``--cov-precision`` cli option that can override the value set in your coverage configuration. 26 | * Dropped support for now EOL Python 3.8. 27 | 28 | 5.0.0 (2024-03-24) 29 | ------------------ 30 | 31 | * Removed support for xdist rsync (now deprecated). 32 | Contributed by Matthias Reichenbach in `#623 `_. 33 | * Switched docs theme to Furo. 34 | * Various legacy Python cleanup and CI improvements. 35 | Contributed by Christian Clauss and Hugo van Kemenade in 36 | `#630 `_, 37 | `#631 `_, 38 | `#632 `_ and 39 | `#633 `_. 40 | * Added a ``pyproject.toml`` example in the docs. 41 | Contributed by Dawn James in `#626 `_. 42 | * Modernized project's pre-commit hooks to use ruff. Initial POC contributed by 43 | Christian Clauss in `#584 `_. 44 | * Dropped support for Python 3.7. 45 | 46 | 4.1.0 (2023-05-24) 47 | ------------------ 48 | 49 | * Updated CI with new Pythons and dependencies. 50 | * Removed rsyncdir support. This makes pytest-cov compatible with xdist 3.0. 51 | Contributed by Sorin Sbarnea in `#558 `_. 52 | * Optimized summary generation to not be performed if no reporting is active (for example, 53 | when ``--cov-report=''`` is used without ``--cov-fail-under``). 54 | Contributed by Jonathan Stewmon in `#589 `_. 55 | * Added support for JSON reporting. 56 | Contributed by Matthew Gamble in `#582 `_. 57 | * Refactored code to use f-strings. 58 | Contributed by Mark Mayo in `#572 `_. 59 | * Fixed a skip in the test suite for some old xdist. 60 | Contributed by a bunch of people in `#565 `_. 61 | * Dropped support for Python 3.6. 62 | 63 | 64 | 4.0.0 (2022-09-28) 65 | ------------------ 66 | 67 | **Note that this release drops support for multiprocessing.** 68 | 69 | 70 | * `--cov-fail-under` no longer causes `pytest --collect-only` to fail 71 | Contributed by Zac Hatfield-Dodds in `#511 `_. 72 | * Dropped support for multiprocessing (mostly because `issue 82408 `_). This feature was 73 | mostly working but very broken in certain scenarios and made the test suite very flaky and slow. 74 | 75 | There is builtin multiprocessing support in coverage and you can migrate to that. All you need is this in your 76 | ``.coveragerc``:: 77 | 78 | [run] 79 | concurrency = multiprocessing 80 | parallel = true 81 | sigterm = true 82 | * Fixed deprecation in ``setup.py`` by trying to import setuptools before distutils. 83 | Contributed by Ben Greiner in `#545 `_. 84 | * Removed undesirable new lines that were displayed while reporting was disabled. 85 | Contributed by Delgan in `#540 `_. 86 | * Documentation fixes. 87 | Contributed by Andre Brisco in `#543 `_ 88 | and Colin O'Dell in `#525 `_. 89 | * Added support for LCOV output format via `--cov-report=lcov`. Only works with coverage 6.3+. 90 | Contributed by Christian Fetzer in `#536 `_. 91 | * Modernized pytest hook implementation. 92 | Contributed by Bruno Oliveira in `#549 `_ 93 | and Ronny Pfannschmidt in `#550 `_. 94 | 95 | 96 | 3.0.0 (2021-10-04) 97 | ------------------- 98 | 99 | **Note that this release drops support for Python 2.7 and Python 3.5.** 100 | 101 | * Added support for Python 3.10 and updated various test dependencies. 102 | Contributed by Hugo van Kemenade in 103 | `#500 `_. 104 | * Switched from Travis CI to GitHub Actions. Contributed by Hugo van Kemenade in 105 | `#494 `_ and 106 | `#495 `_. 107 | * Add a ``--cov-reset`` CLI option. 108 | Contributed by Danilo Šegan in 109 | `#459 `_. 110 | * Improved validation of ``--cov-fail-under`` CLI option. 111 | Contributed by ... Ronny Pfannschmidt's desire for skark in 112 | `#480 `_. 113 | * Dropped Python 2.7 support. 114 | Contributed by Thomas Grainger in 115 | `#488 `_. 116 | * Updated trove classifiers. Contributed by Michał Bielawski in 117 | `#481 `_. 118 | * Reverted change for `toml` requirement. 119 | Contributed by Thomas Grainger in 120 | `#477 `_. 121 | 122 | 2.12.1 (2021-06-01) 123 | ------------------- 124 | 125 | * Changed the `toml` requirement to be always be directly required (instead of being required through a coverage extra). 126 | This fixes issues with pip-compile (`pip-tools#1300 `_). 127 | Contributed by Sorin Sbarnea in `#472 `_. 128 | * Documented ``show_contexts``. 129 | Contributed by Brian Rutledge in `#473 `_. 130 | 131 | 2.12.0 (2021-05-14) 132 | ------------------- 133 | 134 | * Added coverage's `toml` extra to install requirements in setup.py. 135 | Contributed by Christian Riedel in `#410 `_. 136 | * Fixed ``pytest_cov.__version__`` to have the right value (string with version instead of a string 137 | including ``__version__ =``). 138 | * Fixed license classifier in ``setup.py``. 139 | Contributed by Chris Sreesangkom in `#467 `_. 140 | * Fixed *commits since* badge. 141 | Contributed by Terence Honles in `#470 `_. 142 | 143 | 2.11.1 (2021-01-20) 144 | ------------------- 145 | 146 | * Fixed support for newer setuptools (v42+). 147 | Contributed by Michał Górny in `#451 `_. 148 | 149 | 2.11.0 (2021-01-18) 150 | ------------------- 151 | 152 | * Bumped minimum coverage requirement to 5.2.1. This prevents reporting issues. 153 | Contributed by Mateus Berardo de Souza Terra in `#433 `_. 154 | * Improved sample projects (from the `examples `_ 155 | directory) to support running `tox -e pyXY`. Now the example configures a suffixed coverage data file, 156 | and that makes the cleanup environment unnecessary. 157 | Contributed by Ganden Schaffner in `#435 `_. 158 | * Removed the empty `console_scripts` entrypoint that confused some Gentoo build script. 159 | I didn't ask why it was so broken cause I didn't want to ruin my day. 160 | Contributed by Michał Górny in `#434 `_. 161 | * Fixed the missing `coverage context `_ 162 | when using subprocesses. 163 | Contributed by Bernát Gábor in `#443 `_. 164 | * Updated the config section in the docs. 165 | Contributed by Pamela McA'Nulty in `#429 `_. 166 | * Migrated CI to travis-ci.com (from .org). 167 | 168 | 2.10.1 (2020-08-14) 169 | ------------------- 170 | 171 | * Support for ``pytest-xdist`` 2.0, which breaks compatibility with ``pytest-xdist`` before 1.22.3 (from 2017). 172 | Contributed by Zac Hatfield-Dodds in `#412 `_. 173 | * Fixed the ``LocalPath has no attribute startswith`` failure that occurred when using the ``pytester`` plugin 174 | in inline mode. 175 | 176 | 2.10.0 (2020-06-12) 177 | ------------------- 178 | 179 | * Improved the ``--no-cov`` warning. Now it's only shown if ``--no-cov`` is present before ``--cov``. 180 | * Removed legacy pytest support. Changed ``setup.py`` so that ``pytest>=4.6`` is required. 181 | 182 | 2.9.0 (2020-05-22) 183 | ------------------ 184 | 185 | * Fixed ``RemovedInPytest4Warning`` when using Pytest 3.10. 186 | Contributed by Michael Manganiello in `#354 `_. 187 | * Made pytest startup faster when plugin not active by lazy-importing. 188 | Contributed by Anders Hovmöller in `#339 `_. 189 | * Various CI improvements. 190 | Contributed by Daniel Hahler in `#363 `_ and 191 | `#364 `_. 192 | * Various Python support updates (drop EOL 3.4, test against 3.8 final). 193 | Contributed by Hugo van Kemenade in 194 | `#336 `_ and 195 | `#367 `_. 196 | * Changed ``--cov-append`` to always enable ``data_suffix`` (a coverage setting). 197 | Contributed by Harm Geerts in 198 | `#387 `_. 199 | * Changed ``--cov-append`` to handle loading previous data better 200 | (fixes various path aliasing issues). 201 | * Various other testing improvements, github issue templates, example updates. 202 | * Fixed internal failures that are caused by tests that change the current working directory by 203 | ensuring a consistent working directory when coverage is called. 204 | See `#306 `_ and 205 | `coveragepy#881 `_ 206 | 207 | 2.8.1 (2019-10-05) 208 | ------------------ 209 | 210 | * Fixed `#348 `_ - 211 | regression when only certain reports (html or xml) are used then ``--cov-fail-under`` always fails. 212 | 213 | 2.8.0 (2019-10-04) 214 | ------------------ 215 | 216 | * Fixed ``RecursionError`` that can occur when using 217 | `cleanup_on_signal `__ or 218 | `cleanup_on_sigterm `__. 219 | See: `#294 `_. 220 | The 2.7.x releases of pytest-cov should be considered broken regarding aforementioned cleanup API. 221 | * Added compatibility with future xdist release that deprecates some internals 222 | (match pytest-xdist master/worker terminology). 223 | Contributed by Thomas Grainger in `#321 `_ 224 | * Fixed breakage that occurs when multiple reporting options are used. 225 | Contributed by Thomas Grainger in `#338 `_. 226 | * Changed internals to use a stub instead of ``os.devnull``. 227 | Contributed by Thomas Grainger in `#332 `_. 228 | * Added support for Coverage 5.0. 229 | Contributed by Ned Batchelder in `#319 `_. 230 | * Added support for float values in ``--cov-fail-under``. 231 | Contributed by Martín Gaitán in `#311 `_. 232 | * Various documentation fixes. Contributed by 233 | Juanjo Bazán, 234 | Andrew Murray and 235 | Albert Tugushev in 236 | `#298 `_, 237 | `#299 `_ and 238 | `#307 `_. 239 | * Various testing improvements. Contributed by 240 | Ned Batchelder, 241 | Daniel Hahler, 242 | Ionel Cristian Mărieș and 243 | Hugo van Kemenade in 244 | `#313 `_, 245 | `#314 `_, 246 | `#315 `_, 247 | `#316 `_, 248 | `#325 `_, 249 | `#326 `_, 250 | `#334 `_ and 251 | `#335 `_. 252 | * Added the ``--cov-context`` CLI options that enables coverage contexts. Only works with coverage 5.0+. 253 | Contributed by Ned Batchelder in `#345 `_. 254 | 255 | 2.7.1 (2019-05-03) 256 | ------------------ 257 | 258 | * Fixed source distribution manifest so that garbage ain't included in the tarball. 259 | 260 | 2.7.0 (2019-05-03) 261 | ------------------ 262 | 263 | * Fixed ``AttributeError: 'NoneType' object has no attribute 'configure_node'`` error when ``--no-cov`` is used. 264 | Contributed by Alexander Shadchin in `#263 `_. 265 | * Various testing and CI improvements. Contributed by Daniel Hahler in 266 | `#255 `_, 267 | `#266 `_, 268 | `#272 `_, 269 | `#271 `_ and 270 | `#269 `_. 271 | * Improved ``pytest_cov.embed.cleanup_on_sigterm`` to be reentrant (signal deliveries while signal handling is 272 | running won't break stuff). 273 | * Added ``pytest_cov.embed.cleanup_on_signal`` for customized cleanup. 274 | * Improved cleanup code and fixed various issues with leftover data files. All contributed in 275 | `#265 `_ or 276 | `#262 `_. 277 | * Improved examples. Now there are two examples for the common project layouts, complete with working coverage 278 | configuration. The examples have CI testing. Contributed in 279 | `#267 `_. 280 | * Improved help text for CLI options. 281 | 282 | 2.6.1 (2019-01-07) 283 | ------------------ 284 | 285 | * Added support for Pytest 4.1. Contributed by Daniel Hahler and Семён Марьясин in 286 | `#253 `_ and 287 | `#230 `_. 288 | * Various test and docs fixes. Contributed by Daniel Hahler in 289 | `#224 `_ and 290 | `#223 `_. 291 | * Fixed the "Module already imported" issue (`#211 `_). 292 | Contributed by Daniel Hahler in `#228 `_. 293 | 294 | 2.6.0 (2018-09-03) 295 | ------------------ 296 | 297 | * Dropped support for Python 3 < 3.4, Pytest < 3.5 and Coverage < 4.4. 298 | * Fixed some documentation formatting. Contributed by Jean Jordaan and Julian. 299 | * Added an example with ``addopts`` in documentation. Contributed by Samuel Giffard in 300 | `#195 `_. 301 | * Fixed ``TypeError: 'NoneType' object is not iterable`` in certain xdist configurations. Contributed by Jeremy Bowman in 302 | `#213 `_. 303 | * Added a ``no_cover`` marker and fixture. Fixes 304 | `#78 `_. 305 | * Fixed broken ``no_cover`` check when running doctests. Contributed by Terence Honles in 306 | `#200 `_. 307 | * Fixed various issues with path normalization in reports (when combining coverage data from parallel mode). Fixes 308 | `#130 `_. 309 | Contributed by Ryan Hiebert & Ionel Cristian Mărieș in 310 | `#178 `_. 311 | * Report generation failures don't raise exceptions anymore. A warning will be logged instead. Fixes 312 | `#161 `_. 313 | * Fixed multiprocessing issue on Windows (empty env vars are not passed). Fixes 314 | `#165 `_. 315 | 316 | 2.5.1 (2017-05-11) 317 | ------------------ 318 | 319 | * Fixed xdist breakage (regression in ``2.5.0``). 320 | Fixes `#157 `_. 321 | * Allow setting custom ``data_file`` name in ``.coveragerc``. 322 | Fixes `#145 `_. 323 | Contributed by Jannis Leidel & Ionel Cristian Mărieș in 324 | `#156 `_. 325 | 326 | 2.5.0 (2017-05-09) 327 | ------------------ 328 | 329 | * Always show a summary when ``--cov-fail-under`` is used. Contributed by Francis Niu in `PR#141 330 | `_. 331 | * Added ``--cov-branch`` option. Fixes `#85 `_. 332 | * Improve exception handling in subprocess setup. Fixes `#144 `_. 333 | * Fixed handling when ``--cov`` is used multiple times. Fixes `#151 `_. 334 | 335 | 2.4.0 (2016-10-10) 336 | ------------------ 337 | 338 | * Added a "disarm" option: ``--no-cov``. It will disable coverage measurements. Contributed by Zoltan Kozma in 339 | `PR#135 `_. 340 | 341 | **WARNING: Do not put this in your configuration files, it's meant to be an one-off for situations where you want to 342 | disable coverage from command line.** 343 | * Fixed broken exception handling on ``.pth`` file. See `#136 `_. 344 | 345 | 2.3.1 (2016-08-07) 346 | ------------------ 347 | 348 | * Fixed regression causing spurious errors when xdist was used. See `#124 349 | `_. 350 | * Fixed DeprecationWarning about incorrect `addoption` use. Contributed by Florian Bruhin in `PR#127 351 | `_. 352 | * Fixed deprecated use of funcarg fixture API. Contributed by Daniel Hahler in `PR#125 353 | `_. 354 | 355 | 2.3.0 (2016-07-05) 356 | ------------------ 357 | 358 | * Add support for specifying output location for html, xml, and annotate report. 359 | Contributed by Patrick Lannigan in `PR#113 `_. 360 | * Fix bug hiding test failure when cov-fail-under failed. 361 | * For coverage >= 4.0, match the default behaviour of `coverage report` and 362 | error if coverage fails to find the source instead of just printing a warning. 363 | Contributed by David Szotten in `PR#116 `_. 364 | * Fixed bug occurred when bare ``--cov`` parameter was used with xdist. 365 | Contributed by Michael Elovskikh in `PR#120 `_. 366 | * Add support for ``skip_covered`` and added ``--cov-report=term-skip-covered`` command 367 | line options. Contributed by Saurabh Kumar in `PR#115 `_. 368 | 369 | 2.2.1 (2016-01-30) 370 | ------------------ 371 | 372 | * Fixed incorrect merging of coverage data when xdist was used and coverage was ``>= 4.0``. 373 | 374 | 2.2.0 (2015-10-04) 375 | ------------------ 376 | 377 | * Added support for changing working directory in tests. Previously changing working 378 | directory would disable coverage measurements in suprocesses. 379 | * Fixed broken handling for ``--cov-report=annotate``. 380 | 381 | 2.1.0 (2015-08-23) 382 | ------------------ 383 | 384 | * Added support for `coverage 4.0b2`. 385 | * Added the ``--cov-append`` command line options. Contributed by Christian Ledermann 386 | in `PR#80 `_. 387 | 388 | 2.0.0 (2015-07-28) 389 | ------------------ 390 | 391 | * Added ``--cov-fail-under``, akin to the new ``fail_under`` option in `coverage-4.0` 392 | (automatically activated if there's a ``[report] fail_under = ...`` in ``.coveragerc``). 393 | * Changed ``--cov-report=term`` to automatically upgrade to ``--cov-report=term-missing`` 394 | if there's ``[run] show_missing = True`` in ``.coveragerc``. 395 | * Changed ``--cov`` so it can be used with no path argument (in which case the source 396 | settings from ``.coveragerc`` will be used instead). 397 | * Fixed `.pth` installation to work in all cases (install, easy_install, wheels, develop etc). 398 | * Fixed `.pth` uninstallation to work for wheel installs. 399 | * Support for coverage 4.0. 400 | * Data file suffixing changed to use coverage's ``data_suffix=True`` option (instead of the 401 | custom suffixing). 402 | * Avoid warning about missing coverage data (just like ``coverage.control.process_startup``). 403 | * Fixed a race condition when running with xdist (all the workers tried to combine the files). 404 | It's possible that this issue is not present in `pytest-cov 1.8.X`. 405 | 406 | 1.8.2 (2014-11-06) 407 | ------------------ 408 | 409 | * N/A 410 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | pytest-cov could always use more documentation, whether as part of the 21 | official pytest-cov docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/pytest-dev/pytest-cov/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `pytest-cov` for local development: 39 | 40 | 1. Fork `pytest-cov `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/pytest-cov.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes run all the checks and docs builder with one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``). 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | Tips 77 | ---- 78 | 79 | To run a subset of tests:: 80 | 81 | tox -e envname -- pytest -k test_myfeature 82 | 83 | To run all the test environments in *parallel*:: 84 | 85 | tox -p auto 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010 Meme Dough 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft examples 3 | prune examples/*/.tox 4 | prune examples/*/htmlcov 5 | prune examples/*/*/htmlcov 6 | prune examples/adhoc-layout/*.egg-info 7 | prune examples/src-layout/src/*.egg-info 8 | 9 | graft .github/workflows 10 | graft src 11 | graft ci 12 | graft tests 13 | 14 | include .bumpversion.cfg 15 | include .cookiecutterrc 16 | include .coveragerc 17 | include .editorconfig 18 | include .pre-commit-config.yaml 19 | include .readthedocs.yml 20 | include pytest.ini 21 | include tox.ini 22 | 23 | include AUTHORS.rst 24 | include CHANGELOG.rst 25 | include CONTRIBUTING.rst 26 | include LICENSE 27 | include README.rst 28 | include SECURITY.md 29 | 30 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 31 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | * - docs 11 | - |docs| 12 | * - tests 13 | - |github-actions| 14 | * - package 15 | - |version| |conda-forge| |wheel| |supported-versions| |supported-implementations| |commits-since| 16 | .. |docs| image:: https://readthedocs.org/projects/pytest-cov/badge/?style=flat 17 | :target: https://readthedocs.org/projects/pytest-cov/ 18 | :alt: Documentation Status 19 | 20 | .. |github-actions| image:: https://github.com/pytest-dev/pytest-cov/actions/workflows/test.yml/badge.svg 21 | :alt: GitHub Actions Status 22 | :target: https://github.com/pytest-dev/pytest-cov/actions 23 | 24 | .. |version| image:: https://img.shields.io/pypi/v/pytest-cov.svg 25 | :alt: PyPI Package latest release 26 | :target: https://pypi.org/project/pytest-cov 27 | 28 | .. |conda-forge| image:: https://img.shields.io/conda/vn/conda-forge/pytest-cov.svg 29 | :target: https://anaconda.org/conda-forge/pytest-cov 30 | .. |wheel| image:: https://img.shields.io/pypi/wheel/pytest-cov.svg 31 | :alt: PyPI Wheel 32 | :target: https://pypi.org/project/pytest-cov 33 | 34 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/pytest-cov.svg 35 | :alt: Supported versions 36 | :target: https://pypi.org/project/pytest-cov 37 | 38 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/pytest-cov.svg 39 | :alt: Supported implementations 40 | :target: https://pypi.org/project/pytest-cov 41 | 42 | .. |commits-since| image:: https://img.shields.io/github/commits-since/pytest-dev/pytest-cov/v6.1.1.svg 43 | :alt: Commits since latest release 44 | :target: https://github.com/pytest-dev/pytest-cov/compare/v6.1.1...master 45 | 46 | .. end-badges 47 | 48 | This plugin produces coverage reports. Compared to just using ``coverage run`` this plugin does some extras: 49 | 50 | * Subprocess support: you can fork or run stuff in a subprocess and will get covered without any fuss. 51 | * Xdist support: you can use all of pytest-xdist's features and still get coverage. 52 | * Consistent pytest behavior. If you run ``coverage run -m pytest`` you will have slightly different ``sys.path`` (CWD will be 53 | in it, unlike when running ``pytest``). 54 | 55 | All features offered by the coverage package should work, either through pytest-cov's command line options or 56 | through coverage's config file. 57 | 58 | * Free software: MIT license 59 | 60 | Installation 61 | ============ 62 | 63 | Install with pip:: 64 | 65 | pip install pytest-cov 66 | 67 | For distributed testing support install pytest-xdist:: 68 | 69 | pip install pytest-xdist 70 | 71 | Upgrading from ancient pytest-cov 72 | --------------------------------- 73 | 74 | `pytest-cov 2.0` is using a new ``.pth`` file (``pytest-cov.pth``). You may want to manually remove the older 75 | ``init_cov_core.pth`` from site-packages as it's not automatically removed. 76 | 77 | Uninstalling 78 | ------------ 79 | 80 | Uninstall with pip:: 81 | 82 | pip uninstall pytest-cov 83 | 84 | Under certain scenarios a stray ``.pth`` file may be left around in site-packages. 85 | 86 | * `pytest-cov 2.0` may leave a ``pytest-cov.pth`` if you installed without wheels 87 | (``easy_install``, ``setup.py install`` etc). 88 | * `pytest-cov 1.8 or older` will leave a ``init_cov_core.pth``. 89 | 90 | Usage 91 | ===== 92 | 93 | :: 94 | 95 | pytest --cov=myproj tests/ 96 | 97 | Would produce a report like:: 98 | 99 | -------------------- coverage: ... --------------------- 100 | Name Stmts Miss Cover 101 | ---------------------------------------- 102 | myproj/__init__ 2 0 100% 103 | myproj/myproj 257 13 94% 104 | myproj/feature4286 94 7 92% 105 | ---------------------------------------- 106 | TOTAL 353 20 94% 107 | 108 | Documentation 109 | ============= 110 | 111 | https://pytest-cov.readthedocs.io/en/latest/ 112 | 113 | 114 | 115 | 116 | 117 | 118 | Coverage Data File 119 | ================== 120 | 121 | The data file is erased at the beginning of testing to ensure clean data for each test run. If you 122 | need to combine the coverage of several test runs you can use the ``--cov-append`` option to append 123 | this coverage data to coverage data from previous test runs. 124 | 125 | The data file is left at the end of testing so that it is possible to use normal coverage tools to 126 | examine it. 127 | 128 | Limitations 129 | =========== 130 | 131 | For distributed testing the workers must have the pytest-cov package installed. This is needed since 132 | the plugin must be registered through setuptools for pytest to start the plugin on the 133 | worker. 134 | 135 | For subprocess measurement environment variables must make it from the main process to the 136 | subprocess. The python used by the subprocess must have pytest-cov installed. The subprocess must 137 | do normal site initialisation so that the environment variables can be detected and coverage 138 | started. See the `subprocess support docs `_ 139 | for more details of how this works. 140 | 141 | Security 142 | ======== 143 | 144 | To report a security vulnerability please use the `Tidelift security contact `_. 145 | Tidelift will coordinate the fix and disclosure. 146 | 147 | Acknowledgements 148 | ================ 149 | 150 | Whilst this plugin has been built fresh from the ground up it has been influenced by the work done 151 | on pytest-coverage (Ross Lawley, James Mills, Holger Krekel) and nose-cover (Jason Pellerin) which are 152 | other coverage plugins. 153 | 154 | Ned Batchelder for coverage and its ability to combine the coverage results of parallel runs. 155 | 156 | Holger Krekel for pytest with its distributed testing support. 157 | 158 | Jason Pellerin for nose. 159 | 160 | Michael Foord for unittest2. 161 | 162 | No doubt others have contributed to these tools as well. 163 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security contact information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import pathlib 4 | import subprocess 5 | import sys 6 | 7 | base_path: pathlib.Path = pathlib.Path(__file__).resolve().parent.parent 8 | templates_path = base_path / 'ci' / 'templates' 9 | 10 | 11 | def check_call(args): 12 | print('+', *args) 13 | subprocess.check_call(args) 14 | 15 | 16 | def exec_in_env(): 17 | env_path = base_path / '.tox' / 'bootstrap' 18 | if sys.platform == 'win32': 19 | bin_path = env_path / 'Scripts' 20 | else: 21 | bin_path = env_path / 'bin' 22 | if not env_path.exists(): 23 | import subprocess 24 | 25 | print(f'Making bootstrap env in: {env_path} ...') 26 | try: 27 | check_call([sys.executable, '-m', 'venv', env_path]) 28 | except subprocess.CalledProcessError: 29 | try: 30 | check_call([sys.executable, '-m', 'virtualenv', env_path]) 31 | except subprocess.CalledProcessError: 32 | check_call(['virtualenv', env_path]) 33 | print('Installing `jinja2` into bootstrap environment...') 34 | check_call([bin_path / 'pip', 'install', 'jinja2', 'tox']) 35 | python_executable = bin_path / 'python' 36 | if not python_executable.exists(): 37 | python_executable = python_executable.with_suffix('.exe') 38 | 39 | print(f'Re-executing with: {python_executable}') 40 | print('+ exec', python_executable, __file__, '--no-env') 41 | os.execv(python_executable, [python_executable, __file__, '--no-env']) 42 | 43 | 44 | def main(): 45 | import jinja2 46 | 47 | print(f'Project path: {base_path}') 48 | 49 | jinja = jinja2.Environment( 50 | loader=jinja2.FileSystemLoader(str(templates_path)), 51 | trim_blocks=True, 52 | lstrip_blocks=True, 53 | keep_trailing_newline=True, 54 | ) 55 | tox_environments = [ 56 | line.strip() 57 | # 'tox' need not be installed globally, but must be importable 58 | # by the Python that is running this script. 59 | # This uses sys.executable the same way that the call in 60 | # cookiecutter-pylibrary/hooks/post_gen_project.py 61 | # invokes this bootstrap.py itself. 62 | for line in subprocess.check_output([sys.executable, '-m', 'tox', '--listenvs'], text=True).splitlines() 63 | ] 64 | tox_environments = [line for line in tox_environments if line.startswith('py')] 65 | for template in templates_path.rglob('*'): 66 | if template.is_file(): 67 | template_path = template.relative_to(templates_path).as_posix() 68 | destination = base_path / template_path 69 | destination.parent.mkdir(parents=True, exist_ok=True) 70 | destination.write_text(jinja.get_template(template_path).render(tox_environments=tox_environments)) 71 | print(f'Wrote {template_path}') 72 | print('DONE.') 73 | 74 | 75 | if __name__ == '__main__': 76 | args = sys.argv[1:] 77 | if args == ['--no-env']: 78 | main() 79 | elif not args: 80 | exec_in_env() 81 | else: 82 | print(f'Unexpected arguments: {args}', file=sys.stderr) 83 | sys.exit(1) 84 | -------------------------------------------------------------------------------- /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | virtualenv>=16.6.0 2 | pip>=19.1.1 3 | setuptools>=18.0.1 4 | tox 5 | twine 6 | -------------------------------------------------------------------------------- /ci/templates/.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | {%- raw %} 5 | examples: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | python-version: ["pypy-3.9", "3.11"] 11 | target: [ 12 | "src-layout", 13 | "adhoc-layout", 14 | ] 15 | include: 16 | # Add new helper variables to existing jobs 17 | - {python-version: "pypy-3.9", tox-python-version: "pypy3"} 18 | - {python-version: "3.11", tox-python-version: "py311"} 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Cache 28 | uses: actions/cache@v4 29 | with: 30 | path: ~/.cache/pip 31 | key: 32 | examples-v1-${{ hashFiles('**/tox.ini') }} 33 | restore-keys: | 34 | examples-v1- 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install --upgrade wheel 40 | python -m pip install --progress-bar=off tox -rci/requirements.txt 41 | 42 | - name: Examples 43 | run: | 44 | cd examples/${{ matrix.target }} 45 | tox -v -e ${{ matrix.tox-python-version }} 46 | {%- endraw %} 47 | 48 | test: 49 | name: {{ '${{ matrix.name }}' }} 50 | runs-on: {{ '${{ matrix.os }}' }} 51 | timeout-minutes: 30 52 | strategy: 53 | fail-fast: false 54 | matrix: 55 | include: 56 | - name: 'check' 57 | python: '3.11' 58 | toxpython: 'python3.11' 59 | tox_env: 'check' 60 | os: 'ubuntu-latest' 61 | - name: 'docs' 62 | python: '3.11' 63 | toxpython: 'python3.11' 64 | tox_env: 'docs' 65 | os: 'ubuntu-latest' 66 | {% for env in tox_environments %} 67 | {% set prefix = env.split('-')[0] -%} 68 | {% if prefix.startswith('pypy') %} 69 | {% set python %}pypy-{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 70 | {% set cpython %}pp{{ prefix[4:5] }}{% endset %} 71 | {% set toxpython %}pypy{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 72 | {% else %} 73 | {% set python %}{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 74 | {% set cpython %}cp{{ prefix[2:] }}{% endset %} 75 | {% set toxpython %}python{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 76 | {% endif %} 77 | {% for os, python_arch in [ 78 | ['ubuntu', 'x64'], 79 | ['windows', 'x64'], 80 | ['macos', 'arm64'], 81 | ] %} 82 | - name: '{{ env }} ({{ os }})' 83 | python: '{{ python }}' 84 | toxpython: '{{ toxpython }}' 85 | python_arch: '{{ python_arch }}' 86 | tox_env: '{{ env }}' 87 | os: '{{ os }}-latest' 88 | {% endfor %} 89 | {% endfor %} 90 | steps: 91 | - uses: actions/checkout@v4 92 | with: 93 | fetch-depth: 0 94 | - uses: actions/setup-python@v5 95 | with: 96 | python-version: {{ '${{ matrix.python }}' }} 97 | architecture: {{ '${{ matrix.python_arch }}' }} 98 | - name: install dependencies 99 | run: | 100 | python -mpip install --progress-bar=off -r ci/requirements.txt 101 | virtualenv --version 102 | pip --version 103 | tox --version 104 | pip list --format=freeze 105 | - name: test 106 | env: 107 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 108 | run: > 109 | tox -e {{ '${{ matrix.tox_env }}' }} -v 110 | {% raw %} 111 | successful: 112 | # this provides a single status check for branch merge rules 113 | # (use this in `Require status checks to pass before merging` in branch settings) 114 | if: always() 115 | needs: 116 | - test 117 | - examples 118 | runs-on: ubuntu-latest 119 | steps: 120 | - name: Decide whether the needed jobs succeeded or failed 121 | uses: re-actors/alls-green@release/v1 122 | with: 123 | jobs: ${{ toJSON(needs) }} 124 | {% endraw %} 125 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'sphinx.ext.autosummary', 6 | 'sphinx.ext.todo', 7 | 'sphinx.ext.coverage', 8 | 'sphinx.ext.ifconfig', 9 | 'sphinx.ext.viewcode', 10 | 'sphinx.ext.napoleon', 11 | 'sphinx.ext.extlinks', 12 | ] 13 | if os.getenv('SPELLCHECK'): 14 | extensions += ('sphinxcontrib.spelling',) 15 | spelling_show_suggestions = True 16 | spelling_lang = 'en_US' 17 | 18 | source_suffix = '.rst' 19 | master_doc = 'index' 20 | project = 'pytest-cov' 21 | year = '2010-2024' 22 | author = 'pytest-cov contributors' 23 | copyright = f'{year}, {author}' 24 | version = release = '6.1.1' 25 | 26 | pygments_style = 'trac' 27 | templates_path = ['.'] 28 | extlinks = { 29 | 'issue': ('https://github.com/pytest-dev/pytest-cov/issues/%s', '#'), 30 | 'pr': ('https://github.com/pytest-dev/pytest-cov/pull/%s', 'PR #'), 31 | } 32 | html_theme = 'furo' 33 | html_theme_options = { 34 | 'githuburl': 'https://github.com/pytest-dev/pytest-cov/', 35 | } 36 | 37 | html_use_smartypants = True 38 | html_last_updated_fmt = '%b %d, %Y' 39 | html_split_index = False 40 | html_short_title = f'{project}-{version}' 41 | 42 | linkcheck_anchors_ignore_for_url = [ 43 | r'^https?://(www\.)?github\.com/.*', 44 | ] 45 | 46 | napoleon_use_ivar = True 47 | napoleon_use_rtype = False 48 | napoleon_use_param = False 49 | -------------------------------------------------------------------------------- /docs/config.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Configuration 3 | ============= 4 | 5 | This plugin provides a clean minimal set of command line options that are added to pytest. For 6 | further control of coverage use a coverage config file. 7 | 8 | For example if tests are contained within the directory tree being measured the tests may be 9 | excluded if desired by using a .coveragerc file with the omit option set:: 10 | 11 | pytest --cov-config=.coveragerc 12 | --cov=myproj 13 | myproj/tests/ 14 | 15 | Where the .coveragerc file contains file globs:: 16 | 17 | [run] 18 | omit = tests/* 19 | 20 | For full details refer to the `coverage config file`_ documentation. 21 | 22 | .. _`coverage config file`: https://coverage.readthedocs.io/en/latest/config.html 23 | 24 | .. note:: Important Note 25 | 26 | This plugin overrides the ``parallel`` option of coverage. Unless you also run coverage without pytest-cov it's 27 | pointless to set those options in your ``.coveragerc``. 28 | 29 | If you use the ``--cov=something`` option (with a value) then coverage's ``source`` option will also get overridden. 30 | If you have multiple sources it might be easier to set those in ``.coveragerc`` and always use ``--cov`` (without a value) 31 | instead of having a long command line with ``--cov=pkg1 --cov=pkg2 --cov=pkg3 ...``. 32 | 33 | If you use the ``--cov-branch`` option then coverage's ``branch`` option will also get overridden. 34 | 35 | If you wish to always add pytest-cov with pytest, you can use ``addopts`` under the ``pytest`` or ``tool:pytest`` section of 36 | your ``setup.cfg``, or the ``tool.pytest.ini_options`` section of your ``pyproject.toml`` file. 37 | 38 | For example, in ``setup.cfg``: :: 39 | 40 | [tool:pytest] 41 | addopts = --cov= --cov-report html 42 | 43 | Or for ``pyproject.toml``: :: 44 | 45 | [tool.pytest.ini_options] 46 | addopts = "--cov= --cov-report html" 47 | 48 | Caveats 49 | ======= 50 | 51 | A unfortunate consequence of coverage.py's history is that ``.coveragerc`` is a magic name: it's the default file but it also 52 | means "try to also lookup coverage configuration in ``tox.ini`` or ``setup.cfg``". 53 | 54 | In practical terms this means that if you have multiple configuration files around (``tox.ini``, ``pyproject.toml`` or ``setup.cfg``) you 55 | might need to use ``--cov-config`` to make coverage use the correct configuration file. 56 | 57 | Also, if you change the working directory and also use subprocesses in a test you might also need to use ``--cov-config`` to make pytest-cov 58 | will use the expected configuration file in the subprocess. 59 | 60 | Reference 61 | ========= 62 | 63 | The complete list of command line options is: 64 | 65 | --cov=PATH Measure coverage for filesystem path. (multi-allowed) 66 | --cov-report=type Type of report to generate: term, term-missing, 67 | annotate, html, xml, json, lcov (multi-allowed). term, term- 68 | missing may be followed by ":skip-covered". annotate, 69 | html, xml, json and lcov may be followed by ":DEST" where DEST 70 | specifies the output location. Use --cov-report= to 71 | not generate any output. 72 | --cov-config=path Config file for coverage. Default: .coveragerc 73 | --no-cov-on-fail Do not report coverage if test run fails. Default: 74 | False 75 | --no-cov Disable coverage report completely (useful for 76 | debuggers). Default: False 77 | --cov-reset Reset cov sources accumulated in options so far. 78 | Mostly useful for scripts and configuration files. 79 | --cov-fail-under=MIN Fail if the total coverage is less than MIN. 80 | --cov-append Do not delete coverage but append to current. Default: 81 | False 82 | --cov-branch Enable branch coverage. 83 | --cov-context Choose the method for setting the dynamic context. 84 | -------------------------------------------------------------------------------- /docs/contexts.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contexts 3 | ======== 4 | 5 | Coverage.py 5.0 can record separate coverage data for `different contexts`_ during 6 | one run of a test suite. Pytest-cov can use this feature to record coverage 7 | data for each test individually, with the ``--cov-context=test`` option. 8 | 9 | .. _different contexts: https://coverage.readthedocs.io/en/latest/contexts.html 10 | 11 | The context name recorded in the coverage.py database is the pytest test id, 12 | and the phase of execution, one of "setup", "run", or "teardown". These two 13 | are separated with a pipe symbol. You might see contexts like:: 14 | 15 | test_functions.py::test_addition|run 16 | test_fancy.py::test_parametrized[1-101]|setup 17 | test_oldschool.py::RegressionTests::test_error|run 18 | 19 | Note that parameterized tests include the values of the parameters in the test 20 | id, and each set of parameter values is recorded as a separate test. 21 | 22 | To view contexts when using ``--cov-report=html``, add this to your ``.coveragerc``:: 23 | 24 | [html] 25 | show_contexts = True 26 | 27 | The HTML report will include an annotation on each covered line, indicating the 28 | number of contexts that executed the line. Clicking the annotation displays a 29 | list of the contexts. 30 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/debuggers.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Debuggers and PyCharm 3 | ===================== 4 | 5 | (or other IDEs) 6 | 7 | When it comes to TDD one obviously would like to debug tests. Debuggers in Python use mostly the sys.settrace function 8 | to gain access to context. Coverage uses the same technique to get access to the lines executed. Coverage does not play 9 | well with other tracers simultaneously running. This manifests itself in behaviour that PyCharm might not hit a 10 | breakpoint no matter what the user does, or encountering an error like this:: 11 | 12 | PYDEV DEBUGGER WARNING: 13 | sys.settrace() should not be used when the debugger is being used. 14 | This may cause the debugger to stop working correctly. 15 | 16 | Since it is common practice to have coverage configuration in the pytest.ini 17 | file and pytest does not support removeopts or similar the `--no-cov` flag can disable coverage completely. 18 | 19 | At the reporting part a warning message will show on screen:: 20 | 21 | Coverage disabled via --no-cov switch! 22 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to pytest-cov's documentation! 2 | ====================================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | readme 10 | config 11 | reporting 12 | debuggers 13 | xdist 14 | subprocess-support 15 | contexts 16 | tox 17 | plugins 18 | markers-fixtures 19 | changelog 20 | authors 21 | releasing 22 | contributing 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | -------------------------------------------------------------------------------- /docs/markers-fixtures.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Markers and fixtures 3 | ==================== 4 | 5 | There are some builtin markers and fixtures in ``pytest-cov``. 6 | 7 | Markers 8 | ======= 9 | 10 | ``no_cover`` 11 | ------------ 12 | 13 | Eg: 14 | 15 | .. code-block:: python 16 | 17 | @pytest.mark.no_cover 18 | def test_foobar(): 19 | # do some stuff that needs coverage disabled 20 | 21 | .. warning:: Caveat 22 | 23 | Note that subprocess coverage will also be disabled. 24 | 25 | Fixtures 26 | ======== 27 | 28 | ``no_cover`` 29 | ------------ 30 | 31 | Eg: 32 | 33 | .. code-block:: python 34 | 35 | def test_foobar(no_cover): 36 | # same as the marker ... 37 | 38 | ``cov`` 39 | ------- 40 | 41 | For reasons that no one can remember there is a ``cov`` fixture that provides access to the underlying Coverage instance. 42 | Some say this is a disguised foot-gun and should be removed, and some think mysteries make life more interesting and it should 43 | be left alone. 44 | -------------------------------------------------------------------------------- /docs/plugins.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Plugin coverage 3 | =============== 4 | 5 | Getting coverage on pytest plugins is a very particular situation. Because of how pytest implements plugins (using setuptools 6 | entrypoints) it doesn't allow controlling the order in which the plugins load. 7 | See `pytest/issues/935 `_ for technical details. 8 | 9 | The current way of dealing with this problem is using the append feature and manually starting ``pytest-cov``'s engine, eg:: 10 | 11 | COV_CORE_SOURCE=src COV_CORE_CONFIG=.coveragerc COV_CORE_DATAFILE=.coverage.eager pytest --cov=src --cov-append 12 | 13 | Alternatively you can have this in ``tox.ini`` (if you're using `Tox `_ of course):: 14 | 15 | [testenv] 16 | setenv = 17 | COV_CORE_SOURCE= 18 | COV_CORE_CONFIG={toxinidir}/.coveragerc 19 | COV_CORE_DATAFILE={toxinidir}/.coverage 20 | 21 | And in ``pytest.ini`` / ``tox.ini`` / ``setup.cfg``:: 22 | 23 | [tool:pytest] 24 | addopts = --cov --cov-append 25 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/releasing.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Releasing 3 | ========= 4 | 5 | The process for releasing should follow these steps: 6 | 7 | #. Test that docs build and render properly by running ``tox -e docs``. 8 | 9 | If there are bogus spelling issues add the words in ``spelling_wordlist.txt``. 10 | #. Update ``CHANGELOG.rst`` and ``AUTHORS.rst`` to be up to date. 11 | #. Bump the version by running ``bumpversion [ major | minor | patch ]``. This will automatically add a tag. 12 | #. Push changes and tags with:: 13 | 14 | git push 15 | git push --tags 16 | #. Wait `GitHub Actions `_ to give the green builds. 17 | #. Check that the docs on `ReadTheDocs `_ are built. 18 | #. Make sure you have a clean checkout, run ``git status`` to verify. 19 | #. Manually clean temporary files (that are ignored and won't show up in ``git status``):: 20 | 21 | rm -rf dist build src/*.egg-info 22 | 23 | These files need to be removed to force distutils/setuptools to rebuild everything and recreate the egg-info metadata. 24 | #. Build the dists:: 25 | 26 | python3 setup.py clean --all sdist bdist_wheel 27 | 28 | #. Verify that the resulting archives (found in ``dist/``) are good. 29 | #. Upload the sdist and wheel with twine:: 30 | 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /docs/reporting.rst: -------------------------------------------------------------------------------- 1 | Reporting 2 | ========= 3 | 4 | It is possible to generate any combination of the reports for a single test run. 5 | 6 | The available reports are terminal (with or without missing line numbers shown), HTML, XML, JSON, LCOV and 7 | annotated source code. 8 | 9 | The terminal report without line numbers (default):: 10 | 11 | pytest --cov-report term --cov=myproj tests/ 12 | 13 | -------------------- coverage: platform linux2, python 2.6.4-final-0 --------------------- 14 | Name Stmts Miss Cover 15 | ---------------------------------------- 16 | myproj/__init__ 2 0 100% 17 | myproj/myproj 257 13 94% 18 | myproj/feature4286 94 7 92% 19 | ---------------------------------------- 20 | TOTAL 353 20 94% 21 | 22 | 23 | The terminal report with line numbers:: 24 | 25 | pytest --cov-report term-missing --cov=myproj tests/ 26 | 27 | -------------------- coverage: platform linux2, python 2.6.4-final-0 --------------------- 28 | Name Stmts Miss Cover Missing 29 | -------------------------------------------------- 30 | myproj/__init__ 2 0 100% 31 | myproj/myproj 257 13 94% 24-26, 99, 149, 233-236, 297-298, 369-370 32 | myproj/feature4286 94 7 92% 183-188, 197 33 | -------------------------------------------------- 34 | TOTAL 353 20 94% 35 | 36 | The terminal report with skip covered:: 37 | 38 | pytest --cov-report term:skip-covered --cov=myproj tests/ 39 | 40 | -------------------- coverage: platform linux2, python 2.6.4-final-0 --------------------- 41 | Name Stmts Miss Cover 42 | ---------------------------------------- 43 | myproj/myproj 257 13 94% 44 | myproj/feature4286 94 7 92% 45 | ---------------------------------------- 46 | TOTAL 353 20 94% 47 | 48 | 1 files skipped due to complete coverage. 49 | 50 | You can use ``skip-covered`` with ``term-missing`` as well. e.g. ``--cov-report term-missing:skip-covered`` 51 | 52 | These four report options output to files without showing anything on the terminal:: 53 | 54 | pytest --cov-report html 55 | --cov-report xml 56 | --cov-report json 57 | --cov-report lcov 58 | --cov-report annotate 59 | --cov=myproj tests/ 60 | 61 | The output location for each of these reports can be specified. The output location for the XML, JSON and LCOV 62 | report is a file. Where as the output location for the HTML and annotated source code reports are 63 | directories:: 64 | 65 | pytest --cov-report html:cov_html 66 | --cov-report xml:cov.xml 67 | --cov-report json:cov.json 68 | --cov-report lcov:cov.info 69 | --cov-report annotate:cov_annotate 70 | --cov=myproj tests/ 71 | 72 | The final report option can also suppress printing to the terminal:: 73 | 74 | pytest --cov-report= --cov=myproj tests/ 75 | 76 | This mode can be especially useful on continuous integration servers, where a coverage file 77 | is needed for subsequent processing, but no local report needs to be viewed. For example, 78 | tests run on GitHub Actions could produce a .coverage file for use with Coveralls. 79 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | furo 3 | -e . 4 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | builtins 3 | classmethod 4 | staticmethod 5 | classmethods 6 | staticmethods 7 | args 8 | kwargs 9 | callstack 10 | Changelog 11 | Indices 12 | -------------------------------------------------------------------------------- /docs/subprocess-support.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Subprocess support 3 | ================== 4 | 5 | Normally coverage writes the data via a pretty standard atexit handler. However, if the subprocess doesn't exit on its 6 | own then the atexit handler might not run. Why that happens is best left to the adventurous to discover by waddling 7 | through the Python bug tracker. 8 | 9 | pytest-cov supports subprocesses, and works around these atexit limitations. However, there are a few pitfalls that need to be explained. 10 | 11 | But first, how does pytest-cov's subprocess support works? 12 | 13 | pytest-cov packaging injects a pytest-cov.pth into the installation. This file effectively runs this at *every* python startup: 14 | 15 | .. code-block:: python 16 | 17 | if 'COV_CORE_SOURCE' in os.environ: 18 | try: 19 | from pytest_cov.embed import init 20 | init() 21 | except Exception as exc: 22 | sys.stderr.write( 23 | "pytest-cov: Failed to setup subprocess coverage. " 24 | "Environ: {0!r} " 25 | "Exception: {1!r}\n".format( 26 | dict((k, v) for k, v in os.environ.items() if k.startswith('COV_CORE')), 27 | exc 28 | ) 29 | ) 30 | 31 | The pytest plugin will set this ``COV_CORE_SOURCE`` environment variable thus any subprocess that inherits the environment variables 32 | (the default behavior) will run ``pytest_cov.embed.init`` which in turn sets up coverage according to these variables: 33 | 34 | * ``COV_CORE_SOURCE`` 35 | * ``COV_CORE_CONFIG`` 36 | * ``COV_CORE_DATAFILE`` 37 | * ``COV_CORE_BRANCH`` 38 | * ``COV_CORE_CONTEXT`` 39 | 40 | Why does it have the ``COV_CORE`` you wonder? Well, it's mostly historical reasons: long time ago pytest-cov depended on a cov-core package 41 | that implemented common functionality for pytest-cov, nose-cov and nose2-cov. The dependency is gone but the convention is kept. It could 42 | be changed but it would break all projects that manually set these intended-to-be-internal-but-sadly-not-in-reality environment variables. 43 | 44 | Coverage's subprocess support 45 | ============================= 46 | 47 | Now that you understand how pytest-cov works you can easily figure out that using 48 | `coverage's recommended `_ way of dealing with subprocesses, 49 | by either having this in a ``.pth`` file or ``sitecustomize.py`` will break everything: 50 | 51 | .. code-block:: 52 | 53 | import coverage; coverage.process_startup() # this will break pytest-cov 54 | 55 | Do not do that as that will restart coverage with the wrong options. 56 | 57 | If you use ``multiprocessing`` 58 | ============================== 59 | 60 | Builtin support for multiprocessing was dropped in pytest-cov 4.0. 61 | This support was mostly working but very broken in certain scenarios (see `issue 82408 `_) 62 | and made the test suite very flaky and slow. 63 | 64 | However, there is `builtin multiprocessing support in coverage `_ 65 | and you can migrate to that. All you need is this in your preferred configuration file (example: ``.coveragerc``): 66 | 67 | .. code-block:: ini 68 | 69 | [run] 70 | concurrency = multiprocessing 71 | parallel = true 72 | sigterm = true 73 | 74 | Now as a side-note, it's a good idea in general to properly close your Pool by using ``Pool.join()``: 75 | 76 | .. code-block:: python 77 | 78 | from multiprocessing import Pool 79 | 80 | def f(x): 81 | return x*x 82 | 83 | if __name__ == '__main__': 84 | p = Pool(5) 85 | try: 86 | print(p.map(f, [1, 2, 3])) 87 | finally: 88 | p.close() # Marks the pool as closed. 89 | p.join() # Waits for workers to exit. 90 | 91 | 92 | .. _cleanup_on_sigterm: 93 | 94 | Signal handlers 95 | =============== 96 | 97 | pytest-cov provides a signal handling routines, mostly for special situations where you'd have custom signal handling that doesn't 98 | allow atexit to properly run and the now-gone multiprocessing support: 99 | 100 | * ``pytest_cov.embed.cleanup_on_sigterm()`` 101 | * ``pytest_cov.embed.cleanup_on_signal(signum)`` (e.g.: ``cleanup_on_signal(signal.SIGHUP)``) 102 | 103 | If you use multiprocessing 104 | -------------------------- 105 | 106 | It is not recommanded to use these signal handlers with multiprocessing as registering signal handlers will cause deadlocks in the pool, 107 | see: https://bugs.python.org/issue38227). 108 | 109 | If you got custom signal handling 110 | --------------------------------- 111 | 112 | **pytest-cov 2.6** has a rudimentary ``pytest_cov.embed.cleanup_on_sigterm`` you can use to register a SIGTERM handler 113 | that flushes the coverage data. 114 | 115 | **pytest-cov 2.7** adds a ``pytest_cov.embed.cleanup_on_signal`` function and changes the implementation to be more 116 | robust: the handler will call the previous handler (if you had previously registered any), and is re-entrant (will 117 | defer extra signals if delivered while the handler runs). 118 | 119 | For example, if you reload on SIGHUP you should have something like this: 120 | 121 | .. code-block:: python 122 | 123 | import os 124 | import signal 125 | 126 | def restart_service(frame, signum): 127 | os.exec( ... ) # or whatever your custom signal would do 128 | signal.signal(signal.SIGHUP, restart_service) 129 | 130 | try: 131 | from pytest_cov.embed import cleanup_on_signal 132 | except ImportError: 133 | pass 134 | else: 135 | cleanup_on_signal(signal.SIGHUP) 136 | 137 | Note that both ``cleanup_on_signal`` and ``cleanup_on_sigterm`` will run the previous signal handler. 138 | 139 | Alternatively you can do this: 140 | 141 | .. code-block:: python 142 | 143 | import os 144 | import signal 145 | 146 | try: 147 | from pytest_cov.embed import cleanup 148 | except ImportError: 149 | cleanup = None 150 | 151 | def restart_service(frame, signum): 152 | if cleanup is not None: 153 | cleanup() 154 | 155 | os.exec( ... ) # or whatever your custom signal would do 156 | signal.signal(signal.SIGHUP, restart_service) 157 | 158 | If you use Windows 159 | ------------------ 160 | 161 | On Windows you can register a handler for SIGTERM but it doesn't actually work. It will work if you 162 | `os.kill(os.getpid(), signal.SIGTERM)` (send SIGTERM to the current process) but for most intents and purposes that's 163 | completely useless. 164 | 165 | Consequently this means that if you use multiprocessing you got no choice but to use the close/join pattern as described 166 | above. Using the context manager API or `terminate` won't work as it relies on SIGTERM. 167 | 168 | However you can have a working handler for SIGBREAK (with some caveats): 169 | 170 | .. code-block:: python 171 | 172 | import os 173 | import signal 174 | 175 | def shutdown(frame, signum): 176 | # your app's shutdown or whatever 177 | signal.signal(signal.SIGBREAK, shutdown) 178 | 179 | try: 180 | from pytest_cov.embed import cleanup_on_signal 181 | except ImportError: 182 | pass 183 | else: 184 | cleanup_on_signal(signal.SIGBREAK) 185 | 186 | The `caveats `_ being 187 | roughly: 188 | 189 | * you need to deliver ``signal.CTRL_BREAK_EVENT`` 190 | * it gets delivered to the whole process group, and that can have unforeseen consequences 191 | -------------------------------------------------------------------------------- /docs/tox.rst: -------------------------------------------------------------------------------- 1 | === 2 | Tox 3 | === 4 | 5 | When using `tox `_ you can have ultra-compact configuration - you can have all of it in 6 | ``tox.ini``:: 7 | 8 | [tox] 9 | envlist = ... 10 | 11 | [tool:pytest] 12 | ... 13 | 14 | [coverage:paths] 15 | ... 16 | 17 | [coverage:run] 18 | ... 19 | 20 | [coverage:report] 21 | .. 22 | 23 | [testenv] 24 | commands = ... 25 | 26 | An usual problem users have is that pytest-cov will erase the previous coverage data by default, thus if you run tox 27 | with multiple environments you'll get incomplete coverage at the end. 28 | 29 | To prevent this problem you need to use ``--cov-append``. It's still recommended to clean the previous coverage data to 30 | have consistent output. A ``tox.ini`` like this should be enough for sequential runs:: 31 | 32 | [tox] 33 | envlist = clean,py27,py36,... 34 | 35 | [testenv] 36 | commands = pytest --cov --cov-append --cov-report=term-missing ... 37 | deps = 38 | pytest 39 | pytest-cov 40 | 41 | [testenv:clean] 42 | deps = coverage 43 | skip_install = true 44 | commands = coverage erase 45 | 46 | For parallel runs we need to set some dependencies and have an extra report env like so:: 47 | 48 | [tox] 49 | envlist = clean,py27,py36,report 50 | 51 | [testenv] 52 | commands = pytest --cov --cov-append --cov-report=term-missing 53 | deps = 54 | pytest 55 | pytest-cov 56 | depends = 57 | {py27,py36}: clean 58 | report: py27,py36 59 | 60 | [testenv:report] 61 | deps = coverage 62 | skip_install = true 63 | commands = 64 | coverage report 65 | coverage html 66 | 67 | [testenv:clean] 68 | deps = coverage 69 | skip_install = true 70 | commands = coverage erase 71 | 72 | Depending on your project layout you might need extra configuration, see the working examples at 73 | https://github.com/pytest-dev/pytest-cov/tree/master/examples for two common layouts. 74 | -------------------------------------------------------------------------------- /docs/xdist.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Distributed testing (xdist) 3 | =========================== 4 | 5 | "load" mode 6 | =========== 7 | 8 | Distributed testing with dist mode set to "load" will report on the combined coverage of all workers. 9 | The workers may be spread out over any number of hosts and each worker may be located anywhere on the 10 | file system. Each worker will have its subprocesses measured. 11 | 12 | Running distributed testing with dist mode set to load:: 13 | 14 | pytest --cov=myproj -n 2 tests/ 15 | 16 | Shows a terminal report:: 17 | 18 | -------------------- coverage: platform linux2, python 2.6.4-final-0 --------------------- 19 | Name Stmts Miss Cover 20 | ---------------------------------------- 21 | myproj/__init__ 2 0 100% 22 | myproj/myproj 257 13 94% 23 | myproj/feature4286 94 7 92% 24 | ---------------------------------------- 25 | TOTAL 353 20 94% 26 | 27 | 28 | Again but spread over different hosts and different directories:: 29 | 30 | pytest --cov=myproj --dist load 31 | --tx ssh=memedough@host1//chdir=testenv1 32 | --tx ssh=memedough@host2//chdir=/tmp/testenv2//python=/tmp/env1/bin/python 33 | --rsyncdir myproj --rsyncdir tests --rsync examples 34 | tests/ 35 | 36 | Shows a terminal report:: 37 | 38 | -------------------- coverage: platform linux2, python 2.6.4-final-0 --------------------- 39 | Name Stmts Miss Cover 40 | ---------------------------------------- 41 | myproj/__init__ 2 0 100% 42 | myproj/myproj 257 13 94% 43 | myproj/feature4286 94 7 92% 44 | ---------------------------------------- 45 | TOTAL 353 20 94% 46 | 47 | 48 | "each" mode 49 | =========== 50 | 51 | Distributed testing with dist mode set to each will report on the combined coverage of all workers. 52 | Since each worker is running all tests this allows generating a combined coverage report for multiple 53 | environments. 54 | 55 | Running distributed testing with dist mode set to each:: 56 | 57 | pytest --cov=myproj --dist each 58 | --tx popen//chdir=/tmp/testenv3//python=/usr/local/python27/bin/python 59 | --tx ssh=memedough@host2//chdir=/tmp/testenv4//python=/tmp/env2/bin/python 60 | --rsyncdir myproj --rsyncdir tests --rsync examples 61 | tests/ 62 | 63 | Shows a terminal report:: 64 | 65 | ---------------------------------------- coverage ---------------------------------------- 66 | platform linux2, python 2.6.5-final-0 67 | platform linux2, python 2.7.0-final-0 68 | Name Stmts Miss Cover 69 | ---------------------------------------- 70 | myproj/__init__ 2 0 100% 71 | myproj/myproj 257 13 94% 72 | myproj/feature4286 94 7 92% 73 | ---------------------------------------- 74 | TOTAL 353 20 94% 75 | -------------------------------------------------------------------------------- /examples/README.rst: -------------------------------------------------------------------------------- 1 | Simple examples with ``tox.ini`` 2 | ================================ 3 | 4 | These examples provide necessary configuration to: 5 | 6 | * aggregate coverage from multiple interpreters 7 | * support tox parallel mode 8 | * run tests on installed code 9 | 10 | The `adhoc` layout is the old and problematic layout where you can mix up the installed code 11 | with the source code. However, these examples will provide correct configuration even for 12 | the `adhoc` layout. 13 | 14 | The `src` layout configuration is less complicated, have that in mind when picking a layout 15 | for your project. 16 | -------------------------------------------------------------------------------- /examples/adhoc-layout/.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | ../example 4 | */site-packages/example 5 | 6 | [run] 7 | branch = true 8 | parallel = true 9 | source = 10 | example 11 | . 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | -------------------------------------------------------------------------------- /examples/adhoc-layout/example/__init__.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | # test merging multiple tox runs with a platform 4 | # based branch 5 | if platform.python_implementation() == 'PyPy': 6 | 7 | def add(a, b): 8 | return a + b 9 | 10 | else: 11 | 12 | def add(a, b): 13 | return a + b 14 | -------------------------------------------------------------------------------- /examples/adhoc-layout/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | setup( 5 | name='example', 6 | packages=find_packages(include=['example']), 7 | ) 8 | -------------------------------------------------------------------------------- /examples/adhoc-layout/tests/test_example.py: -------------------------------------------------------------------------------- 1 | import example 2 | 3 | 4 | def test_add(): 5 | assert example.add(1, 1) == 2 6 | assert not example.add(0, 1) == 2 7 | -------------------------------------------------------------------------------- /examples/adhoc-layout/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = pypy3,py39,report 3 | 4 | [tool:pytest] 5 | addopts = 6 | --cov-report=term-missing 7 | 8 | [testenv] 9 | setenv = 10 | py{py3,39}: COVERAGE_FILE = .coverage.{envname} 11 | commands = pytest --cov --cov-config={toxinidir}/.coveragerc {posargs:-vv} 12 | deps = 13 | pytest 14 | coverage 15 | # Note: 16 | # This is here just to allow examples to be tested against 17 | # the current code of pytest-cov. If you copy this then 18 | # use "pytest-cov" instead of "../.." 19 | ../.. 20 | 21 | depends = 22 | report: pypy3,py39 23 | 24 | # note that this is necessary to prevent the tests importing the code from your badly laid project 25 | changedir = tests 26 | 27 | [testenv:report] 28 | skip_install = true 29 | deps = coverage 30 | commands = 31 | coverage combine 32 | coverage html 33 | coverage report --fail-under=100 34 | -------------------------------------------------------------------------------- /examples/src-layout/.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | parallel = true 9 | source = 10 | src/example 11 | tests 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | -------------------------------------------------------------------------------- /examples/src-layout/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | setup( 5 | name='example', 6 | packages=find_packages('src'), 7 | package_dir={'': 'src'}, 8 | ) 9 | -------------------------------------------------------------------------------- /examples/src-layout/src/example/__init__.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | # test merging multiple tox runs with a platform 4 | # based branch 5 | if platform.python_implementation() == 'PyPy': 6 | 7 | def add(a, b): 8 | return a + b 9 | 10 | else: 11 | 12 | def add(a, b): 13 | return a + b 14 | -------------------------------------------------------------------------------- /examples/src-layout/tests/test_example.py: -------------------------------------------------------------------------------- 1 | import example 2 | 3 | 4 | def test_add(): 5 | assert example.add(1, 1) == 2 6 | assert not example.add(0, 1) == 2 7 | -------------------------------------------------------------------------------- /examples/src-layout/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = clean,pypy310,py310,report 3 | 4 | [tool:pytest] 5 | testpaths = tests 6 | addopts = 7 | --cov-report=term-missing 8 | 9 | [testenv] 10 | commands = pytest --cov --cov-append {posargs:-vv} 11 | deps = 12 | pytest 13 | coverage 14 | # Note: 15 | # This is here just to allow examples to be tested against 16 | # the current code of pytest-cov. If you copy this then 17 | # use "pytest-cov" instead of "../.." 18 | ../.. 19 | 20 | depends = 21 | report: pypy310,py310 22 | {pypy310,py310}: clean 23 | 24 | [testenv:clean] 25 | skip_install = true 26 | deps = coverage 27 | commands = 28 | coverage erase 29 | 30 | [testenv:report] 31 | skip_install = true 32 | deps = coverage 33 | commands = 34 | coverage report --fail-under=100 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=30.3.0", 4 | ] 5 | 6 | [tool.ruff] 7 | extend-exclude = ["static", "ci/templates"] 8 | line-length = 140 9 | src = ["src", "tests"] 10 | target-version = "py39" 11 | 12 | [tool.ruff.lint.per-file-ignores] 13 | "ci/*" = ["S"] 14 | 15 | [tool.ruff.lint] 16 | ignore = [ 17 | "RUF001", # ruff-specific rules ambiguous-unicode-character-string 18 | "S101", # flake8-bandit assert 19 | "S308", # flake8-bandit suspicious-mark-safe-usage 20 | "E501", # pycodestyle line-too-long 21 | ] 22 | select = [ 23 | "B", # flake8-bugbear 24 | "C4", # flake8-comprehensions 25 | "DTZ", # flake8-datetimez 26 | "E", # pycodestyle errors 27 | "EXE", # flake8-executable 28 | "F", # pyflakes 29 | "I", # isort 30 | "INT", # flake8-gettext 31 | "PIE", # flake8-pie 32 | "PLC", # pylint convention 33 | "PLE", # pylint errors 34 | "PT", # flake8-pytest-style 35 | "PTH", # flake8-use-pathlib 36 | "RSE", # flake8-raise 37 | "RUF", # ruff-specific rules 38 | "S", # flake8-bandit 39 | "UP", # pyupgrade 40 | "W", # pycodestyle warnings 41 | ] 42 | 43 | [tool.ruff.lint.flake8-pytest-style] 44 | fixture-parentheses = false 45 | mark-parentheses = false 46 | 47 | [tool.ruff.lint.isort] 48 | forced-separate = ["conftest"] 49 | force-single-line = true 50 | 51 | [tool.ruff.format] 52 | quote-style = "single" 53 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # If a pytest section is found in one of the possible config files 3 | # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, 4 | # so if you add a pytest config section elsewhere, 5 | # you will need to delete this section from setup.cfg. 6 | norecursedirs = 7 | migrations 8 | 9 | python_files = 10 | test_*.py 11 | *_test.py 12 | tests.py 13 | addopts = 14 | -ra 15 | --strict-markers 16 | --doctest-modules 17 | --doctest-glob=\*.rst 18 | --tb=short 19 | -p pytester 20 | testpaths = 21 | tests 22 | 23 | # Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors 24 | filterwarnings = 25 | error 26 | # You can add exclusions, some examples: 27 | # ignore:'pytest_cov' defines default_app_config:PendingDeprecationWarning:: 28 | # ignore:The {{% if::: 29 | # ignore:Coverage disabled via --no-cov switch! 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import re 4 | from itertools import chain 5 | from pathlib import Path 6 | 7 | from setuptools import Command 8 | from setuptools import find_packages 9 | from setuptools import setup 10 | 11 | try: 12 | # https://setuptools.pypa.io/en/latest/deprecated/distutils-legacy.html 13 | from setuptools.command.build import build 14 | except ImportError: 15 | from distutils.command.build import build 16 | 17 | from setuptools.command.develop import develop 18 | from setuptools.command.easy_install import easy_install 19 | from setuptools.command.install_lib import install_lib 20 | 21 | 22 | def read(*names, **kwargs): 23 | with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get('encoding', 'utf8')) as fh: 24 | return fh.read() 25 | 26 | 27 | class BuildWithPTH(build): 28 | def run(self, *args, **kwargs): 29 | super().run(*args, **kwargs) 30 | path = str(Path(__file__).parent / 'src' / 'pytest-cov.pth') 31 | dest = str(Path(self.build_lib) / Path(path).name) 32 | self.copy_file(path, dest) 33 | 34 | 35 | class EasyInstallWithPTH(easy_install): 36 | def run(self, *args, **kwargs): 37 | super().run(*args, **kwargs) 38 | path = str(Path(__file__).parent / 'src' / 'pytest-cov.pth') 39 | dest = str(Path(self.install_dir) / Path(path).name) 40 | self.copy_file(path, dest) 41 | 42 | 43 | class InstallLibWithPTH(install_lib): 44 | def run(self, *args, **kwargs): 45 | super().run(*args, **kwargs) 46 | path = str(Path(__file__).parent / 'src' / 'pytest-cov.pth') 47 | dest = str(Path(self.install_dir) / Path(path).name) 48 | self.copy_file(path, dest) 49 | self.outputs = [dest] 50 | 51 | def get_outputs(self): 52 | return chain(super().get_outputs(), self.outputs) 53 | 54 | 55 | class DevelopWithPTH(develop): 56 | def run(self, *args, **kwargs): 57 | super().run(*args, **kwargs) 58 | path = str(Path(__file__).parent / 'src' / 'pytest-cov.pth') 59 | dest = str(Path(self.install_dir) / Path(path).name) 60 | self.copy_file(path, dest) 61 | 62 | 63 | class GeneratePTH(Command): 64 | user_options = () 65 | 66 | def initialize_options(self): 67 | pass 68 | 69 | def finalize_options(self): 70 | pass 71 | 72 | def run(self): 73 | with Path(__file__).parent.joinpath('src', 'pytest-cov.pth').open('w') as fh: 74 | with Path(__file__).parent.joinpath('src', 'pytest-cov.embed').open() as sh: 75 | fh.write(f'import os, sys;exec({sh.read().replace(" ", " ")!r})') 76 | 77 | 78 | setup( 79 | name='pytest-cov', 80 | version='6.1.1', 81 | license='MIT', 82 | description='Pytest plugin for measuring coverage.', 83 | long_description='{}\n{}'.format(read('README.rst'), re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst'))), 84 | author='Marc Schlaich', 85 | author_email='marc.schlaich@gmail.com', 86 | url='https://github.com/pytest-dev/pytest-cov', 87 | packages=find_packages('src'), 88 | package_dir={'': 'src'}, 89 | py_modules=[path.stem for path in Path('src').glob('*.py')], 90 | include_package_data=True, 91 | zip_safe=False, 92 | classifiers=[ 93 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 94 | 'Development Status :: 5 - Production/Stable', 95 | 'Framework :: Pytest', 96 | 'Intended Audience :: Developers', 97 | 'License :: OSI Approved :: MIT License', 98 | 'Operating System :: Microsoft :: Windows', 99 | 'Operating System :: POSIX', 100 | 'Operating System :: Unix', 101 | 'Programming Language :: Python', 102 | 'Programming Language :: Python :: 3', 103 | 'Programming Language :: Python :: 3 :: Only', 104 | 'Programming Language :: Python :: 3.9', 105 | 'Programming Language :: Python :: 3.10', 106 | 'Programming Language :: Python :: 3.11', 107 | 'Programming Language :: Python :: 3.12', 108 | 'Programming Language :: Python :: Implementation :: CPython', 109 | 'Programming Language :: Python :: Implementation :: PyPy', 110 | 'Topic :: Software Development :: Testing', 111 | 'Topic :: Utilities', 112 | ], 113 | project_urls={ 114 | 'Documentation': 'https://pytest-cov.readthedocs.io/', 115 | 'Changelog': 'https://pytest-cov.readthedocs.io/en/latest/changelog.html', 116 | 'Issue Tracker': 'https://github.com/pytest-dev/pytest-cov/issues', 117 | }, 118 | keywords=[ 119 | 'cover', 120 | 'coverage', 121 | 'pytest', 122 | 'py.test', 123 | 'distributed', 124 | 'parallel', 125 | ], 126 | python_requires='>=3.9', 127 | install_requires=[ 128 | 'pytest>=4.6', 129 | 'coverage[toml]>=7.5', 130 | ], 131 | extras_require={ 132 | 'testing': [ 133 | 'fields', 134 | 'hunter', 135 | 'process-tests', 136 | 'pytest-xdist', 137 | 'virtualenv', 138 | ] 139 | }, 140 | entry_points={ 141 | 'pytest11': [ 142 | 'pytest_cov = pytest_cov.plugin', 143 | ], 144 | }, 145 | cmdclass={ 146 | 'build': BuildWithPTH, 147 | 'easy_install': EasyInstallWithPTH, 148 | 'install_lib': InstallLibWithPTH, 149 | 'develop': DevelopWithPTH, 150 | 'genpth': GeneratePTH, 151 | }, 152 | ) 153 | -------------------------------------------------------------------------------- /src/pytest-cov.embed: -------------------------------------------------------------------------------- 1 | if 'COV_CORE_SOURCE' in os.environ: 2 | try: 3 | from pytest_cov.embed import init 4 | init() 5 | except Exception as exc: 6 | sys.stderr.write( 7 | "pytest-cov: Failed to setup subprocess coverage. " 8 | "Environ: {0!r} " 9 | "Exception: {1!r}\n".format( 10 | dict((k, v) for k, v in os.environ.items() if k.startswith('COV_CORE')), 11 | exc 12 | ) 13 | ) 14 | -------------------------------------------------------------------------------- /src/pytest-cov.pth: -------------------------------------------------------------------------------- 1 | import os, sys;exec('if \'COV_CORE_SOURCE\' in os.environ:\n try:\n from pytest_cov.embed import init\n init()\n except Exception as exc:\n sys.stderr.write(\n "pytest-cov: Failed to setup subprocess coverage. "\n "Environ: {0!r} "\n "Exception: {1!r}\\n".format(\n dict((k, v) for k, v in os.environ.items() if k.startswith(\'COV_CORE\')),\n exc\n )\n )\n') 2 | -------------------------------------------------------------------------------- /src/pytest_cov/__init__.py: -------------------------------------------------------------------------------- 1 | """pytest-cov: avoid already-imported warning: PYTEST_DONT_REWRITE.""" 2 | 3 | __version__ = '6.1.1' 4 | 5 | import pytest 6 | 7 | 8 | class CoverageError(Exception): 9 | """Indicates that our coverage is too low""" 10 | 11 | 12 | class PytestCovWarning(pytest.PytestWarning): 13 | """ 14 | The base for all pytest-cov warnings, never raised directly. 15 | """ 16 | 17 | 18 | class CovDisabledWarning(PytestCovWarning): 19 | """ 20 | Indicates that Coverage was manually disabled. 21 | """ 22 | 23 | 24 | class CovReportWarning(PytestCovWarning): 25 | """ 26 | Indicates that we failed to generate a report. 27 | """ 28 | 29 | 30 | class CentralCovContextWarning(PytestCovWarning): 31 | """ 32 | Indicates that dynamic_context was set to test_function instead of using the builtin --cov-context. 33 | """ 34 | 35 | 36 | class DistCovError(Exception): 37 | """ 38 | Raised when dynamic_context is set to test_function and xdist is also used. 39 | 40 | See: https://github.com/pytest-dev/pytest-cov/issues/604 41 | """ 42 | -------------------------------------------------------------------------------- /src/pytest_cov/compat.py: -------------------------------------------------------------------------------- 1 | class SessionWrapper: 2 | def __init__(self, session): 3 | self._session = session 4 | if hasattr(session, 'testsfailed'): 5 | self._attr = 'testsfailed' 6 | else: 7 | self._attr = '_testsfailed' 8 | 9 | @property 10 | def testsfailed(self): 11 | return getattr(self._session, self._attr) 12 | 13 | @testsfailed.setter 14 | def testsfailed(self, value): 15 | setattr(self._session, self._attr, value) 16 | -------------------------------------------------------------------------------- /src/pytest_cov/embed.py: -------------------------------------------------------------------------------- 1 | """Activate coverage at python startup if appropriate. 2 | 3 | The python site initialisation will ensure that anything we import 4 | will be removed and not visible at the end of python startup. However 5 | we minimise all work by putting these init actions in this separate 6 | module and only importing what is needed when needed. 7 | 8 | For normal python startup when coverage should not be activated the pth 9 | file checks a single env var and does not import or call the init fn 10 | here. 11 | 12 | For python startup when an ancestor process has set the env indicating 13 | that code coverage is being collected we activate coverage based on 14 | info passed via env vars. 15 | """ 16 | 17 | import atexit 18 | import os 19 | import signal 20 | 21 | _active_cov = None 22 | 23 | 24 | def init(): 25 | # Only continue if ancestor process has set everything needed in 26 | # the env. 27 | global _active_cov 28 | 29 | cov_source = os.environ.get('COV_CORE_SOURCE') 30 | cov_config = os.environ.get('COV_CORE_CONFIG') 31 | cov_datafile = os.environ.get('COV_CORE_DATAFILE') 32 | cov_branch = True if os.environ.get('COV_CORE_BRANCH') == 'enabled' else None 33 | cov_context = os.environ.get('COV_CORE_CONTEXT') 34 | 35 | if cov_datafile: 36 | if _active_cov: 37 | cleanup() 38 | # Import what we need to activate coverage. 39 | import coverage 40 | 41 | # Determine all source roots. 42 | if cov_source in os.pathsep: 43 | cov_source = None 44 | else: 45 | cov_source = cov_source.split(os.pathsep) 46 | if cov_config == os.pathsep: 47 | cov_config = True 48 | 49 | # Activate coverage for this process. 50 | cov = _active_cov = coverage.Coverage( 51 | source=cov_source, 52 | branch=cov_branch, 53 | data_suffix=True, 54 | config_file=cov_config, 55 | auto_data=True, 56 | data_file=cov_datafile, 57 | ) 58 | cov.load() 59 | cov.start() 60 | if cov_context: 61 | cov.switch_context(cov_context) 62 | cov._warn_no_data = False 63 | cov._warn_unimported_source = False 64 | return cov 65 | 66 | 67 | def _cleanup(cov): 68 | if cov is not None: 69 | cov.stop() 70 | cov.save() 71 | cov._auto_save = False # prevent autosaving from cov._atexit in case the interpreter lacks atexit.unregister 72 | try: 73 | atexit.unregister(cov._atexit) 74 | except Exception: # noqa: S110 75 | pass 76 | 77 | 78 | def cleanup(): 79 | global _active_cov 80 | global _cleanup_in_progress 81 | global _pending_signal 82 | 83 | _cleanup_in_progress = True 84 | _cleanup(_active_cov) 85 | _active_cov = None 86 | _cleanup_in_progress = False 87 | if _pending_signal: 88 | pending_signal = _pending_signal 89 | _pending_signal = None 90 | _signal_cleanup_handler(*pending_signal) 91 | 92 | 93 | _previous_handlers = {} 94 | _pending_signal = None 95 | _cleanup_in_progress = False 96 | 97 | 98 | def _signal_cleanup_handler(signum, frame): 99 | global _pending_signal 100 | if _cleanup_in_progress: 101 | _pending_signal = signum, frame 102 | return 103 | cleanup() 104 | _previous_handler = _previous_handlers.get(signum) 105 | if _previous_handler == signal.SIG_IGN: 106 | return 107 | elif _previous_handler and _previous_handler is not _signal_cleanup_handler: 108 | _previous_handler(signum, frame) 109 | elif signum == signal.SIGTERM: 110 | os._exit(128 + signum) 111 | elif signum == signal.SIGINT: 112 | raise KeyboardInterrupt 113 | 114 | 115 | def cleanup_on_signal(signum): 116 | previous = signal.getsignal(signum) 117 | if previous is not _signal_cleanup_handler: 118 | _previous_handlers[signum] = previous 119 | signal.signal(signum, _signal_cleanup_handler) 120 | 121 | 122 | def cleanup_on_sigterm(): 123 | cleanup_on_signal(signal.SIGTERM) 124 | -------------------------------------------------------------------------------- /src/pytest_cov/engine.py: -------------------------------------------------------------------------------- 1 | """Coverage controllers for use by pytest-cov and nose-cov.""" 2 | 3 | import argparse 4 | import contextlib 5 | import copy 6 | import functools 7 | import os 8 | import random 9 | import shutil 10 | import socket 11 | import sys 12 | import warnings 13 | from io import StringIO 14 | from pathlib import Path 15 | from typing import Union 16 | 17 | import coverage 18 | from coverage.data import CoverageData 19 | from coverage.sqldata import filename_suffix 20 | 21 | from . import CentralCovContextWarning 22 | from . import DistCovError 23 | from .embed import cleanup 24 | 25 | 26 | class BrokenCovConfigError(Exception): 27 | pass 28 | 29 | 30 | class _NullFile: 31 | @staticmethod 32 | def write(v): 33 | pass 34 | 35 | 36 | @contextlib.contextmanager 37 | def _backup(obj, attr): 38 | backup = getattr(obj, attr) 39 | try: 40 | setattr(obj, attr, copy.copy(backup)) 41 | yield 42 | finally: 43 | setattr(obj, attr, backup) 44 | 45 | 46 | def _ensure_topdir(meth): 47 | @functools.wraps(meth) 48 | def ensure_topdir_wrapper(self, *args, **kwargs): 49 | try: 50 | original_cwd = Path.cwd() 51 | except OSError: 52 | # Looks like it's gone, this is non-ideal because a side-effect will 53 | # be introduced in the tests here but we can't do anything about it. 54 | original_cwd = None 55 | os.chdir(self.topdir) 56 | try: 57 | return meth(self, *args, **kwargs) 58 | finally: 59 | if original_cwd is not None: 60 | os.chdir(original_cwd) 61 | 62 | return ensure_topdir_wrapper 63 | 64 | 65 | def _data_suffix(name): 66 | return f'{filename_suffix(True)}.{name}' 67 | 68 | 69 | class CovController: 70 | """Base class for different plugin implementations.""" 71 | 72 | def __init__(self, options: argparse.Namespace, config: Union[None, object], nodeid: Union[None, str]): 73 | """Get some common config used by multiple derived classes.""" 74 | self.cov_source = options.cov_source 75 | self.cov_report = options.cov_report 76 | self.cov_config = options.cov_config 77 | self.cov_append = options.cov_append 78 | self.cov_branch = options.cov_branch 79 | self.cov_precision = options.cov_precision 80 | self.config = config 81 | self.nodeid = nodeid 82 | 83 | self.cov = None 84 | self.combining_cov = None 85 | self.data_file = None 86 | self.node_descs = set() 87 | self.failed_workers = [] 88 | self.topdir = os.fspath(Path.cwd()) 89 | self.is_collocated = None 90 | self.started = False 91 | 92 | @contextlib.contextmanager 93 | def ensure_topdir(self): 94 | original_cwd = Path.cwd() 95 | os.chdir(self.topdir) 96 | yield 97 | os.chdir(original_cwd) 98 | 99 | @_ensure_topdir 100 | def pause(self): 101 | self.started = False 102 | self.cov.stop() 103 | self.unset_env() 104 | 105 | @_ensure_topdir 106 | def resume(self): 107 | self.cov.start() 108 | self.set_env() 109 | self.started = True 110 | 111 | def start(self): 112 | self.started = True 113 | 114 | def finish(self): 115 | self.started = False 116 | 117 | @_ensure_topdir 118 | def set_env(self): 119 | """Put info about coverage into the env so that subprocesses can activate coverage.""" 120 | if self.cov_source is None: 121 | os.environ['COV_CORE_SOURCE'] = os.pathsep 122 | else: 123 | os.environ['COV_CORE_SOURCE'] = os.pathsep.join(self.cov_source) 124 | config_file = Path(self.cov_config) 125 | if config_file.exists(): 126 | os.environ['COV_CORE_CONFIG'] = os.fspath(config_file.resolve()) 127 | else: 128 | os.environ['COV_CORE_CONFIG'] = os.pathsep 129 | # this still uses the old abspath cause apparently Python 3.9 on Windows has a buggy Path.resolve() 130 | os.environ['COV_CORE_DATAFILE'] = os.path.abspath(self.cov.config.data_file) # noqa: PTH100 131 | if self.cov_branch: 132 | os.environ['COV_CORE_BRANCH'] = 'enabled' 133 | 134 | @staticmethod 135 | def unset_env(): 136 | """Remove coverage info from env.""" 137 | os.environ.pop('COV_CORE_SOURCE', None) 138 | os.environ.pop('COV_CORE_CONFIG', None) 139 | os.environ.pop('COV_CORE_DATAFILE', None) 140 | os.environ.pop('COV_CORE_BRANCH', None) 141 | os.environ.pop('COV_CORE_CONTEXT', None) 142 | 143 | @staticmethod 144 | def get_node_desc(platform, version_info): 145 | """Return a description of this node.""" 146 | 147 | return 'platform {}, python {}'.format(platform, '{}.{}.{}-{}-{}'.format(*version_info[:5])) 148 | 149 | @staticmethod 150 | def get_width(): 151 | # taken from https://github.com/pytest-dev/pytest/blob/33c7b05a/src/_pytest/_io/terminalwriter.py#L26 152 | width, _ = shutil.get_terminal_size(fallback=(80, 24)) 153 | # The Windows get_terminal_size may be bogus, let's sanify a bit. 154 | if width < 40: 155 | width = 80 156 | return width 157 | 158 | def sep(self, stream, s, txt): 159 | if hasattr(stream, 'sep'): 160 | stream.sep(s, txt) 161 | else: 162 | fullwidth = self.get_width() 163 | # taken from https://github.com/pytest-dev/pytest/blob/33c7b05a/src/_pytest/_io/terminalwriter.py#L126 164 | # The goal is to have the line be as long as possible 165 | # under the condition that len(line) <= fullwidth. 166 | if sys.platform == 'win32': 167 | # If we print in the last column on windows we are on a 168 | # new line but there is no way to verify/neutralize this 169 | # (we may not know the exact line width). 170 | # So let's be defensive to avoid empty lines in the output. 171 | fullwidth -= 1 172 | N = max((fullwidth - len(txt) - 2) // (2 * len(s)), 1) 173 | fill = s * N 174 | line = f'{fill} {txt} {fill}' 175 | # In some situations there is room for an extra sepchar at the right, 176 | # in particular if we consider that with a sepchar like "_ " the 177 | # trailing space is not important at the end of the line. 178 | if len(line) + len(s.rstrip()) <= fullwidth: 179 | line += s.rstrip() 180 | # (end of terminalwriter borrowed code) 181 | line += '\n\n' 182 | stream.write(line) 183 | 184 | @_ensure_topdir 185 | def summary(self, stream): 186 | """Produce coverage reports.""" 187 | total = None 188 | 189 | if not self.cov_report: 190 | with _backup(self.cov, 'config'): 191 | return self.cov.report(show_missing=True, ignore_errors=True, file=_NullFile) 192 | 193 | # Output coverage section header. 194 | if len(self.node_descs) == 1: 195 | self.sep(stream, '_', f'coverage: {"".join(self.node_descs)}') 196 | else: 197 | self.sep(stream, '_', 'coverage') 198 | for node_desc in sorted(self.node_descs): 199 | self.sep(stream, ' ', f'{node_desc}') 200 | 201 | # Report on any failed workers. 202 | if self.failed_workers: 203 | self.sep(stream, '_', 'coverage: failed workers') 204 | stream.write('The following workers failed to return coverage data, ensure that pytest-cov is installed on these workers.\n') 205 | for node in self.failed_workers: 206 | stream.write(f'{node.gateway.id}\n') 207 | 208 | # Produce terminal report if wanted. 209 | if any(x in self.cov_report for x in ['term', 'term-missing']): 210 | options = { 211 | 'show_missing': ('term-missing' in self.cov_report) or None, 212 | 'ignore_errors': True, 213 | 'file': stream, 214 | 'precision': self.cov_precision, 215 | } 216 | skip_covered = isinstance(self.cov_report, dict) and 'skip-covered' in self.cov_report.values() 217 | options.update({'skip_covered': skip_covered or None}) 218 | with _backup(self.cov, 'config'): 219 | total = self.cov.report(**options) 220 | 221 | # Produce annotated source code report if wanted. 222 | if 'annotate' in self.cov_report: 223 | annotate_dir = self.cov_report['annotate'] 224 | 225 | with _backup(self.cov, 'config'): 226 | self.cov.annotate(ignore_errors=True, directory=annotate_dir) 227 | # We need to call Coverage.report here, just to get the total 228 | # Coverage.annotate don't return any total and we need it for --cov-fail-under. 229 | 230 | with _backup(self.cov, 'config'): 231 | total = self.cov.report(ignore_errors=True, file=_NullFile) 232 | if annotate_dir: 233 | stream.write(f'Coverage annotated source written to dir {annotate_dir}\n') 234 | else: 235 | stream.write('Coverage annotated source written next to source\n') 236 | 237 | # Produce html report if wanted. 238 | if 'html' in self.cov_report: 239 | output = self.cov_report['html'] 240 | with _backup(self.cov, 'config'): 241 | total = self.cov.html_report(ignore_errors=True, directory=output) 242 | stream.write(f'Coverage HTML written to dir {self.cov.config.html_dir if output is None else output}\n') 243 | 244 | # Produce xml report if wanted. 245 | if 'xml' in self.cov_report: 246 | output = self.cov_report['xml'] 247 | with _backup(self.cov, 'config'): 248 | total = self.cov.xml_report(ignore_errors=True, outfile=output) 249 | stream.write(f'Coverage XML written to file {self.cov.config.xml_output if output is None else output}\n') 250 | 251 | # Produce json report if wanted 252 | if 'json' in self.cov_report: 253 | output = self.cov_report['json'] 254 | with _backup(self.cov, 'config'): 255 | total = self.cov.json_report(ignore_errors=True, outfile=output) 256 | stream.write('Coverage JSON written to file %s\n' % (self.cov.config.json_output if output is None else output)) 257 | 258 | # Produce lcov report if wanted. 259 | if 'lcov' in self.cov_report: 260 | output = self.cov_report['lcov'] 261 | with _backup(self.cov, 'config'): 262 | self.cov.lcov_report(ignore_errors=True, outfile=output) 263 | 264 | # We need to call Coverage.report here, just to get the total 265 | # Coverage.lcov_report doesn't return any total and we need it for --cov-fail-under. 266 | total = self.cov.report(ignore_errors=True, file=_NullFile) 267 | 268 | stream.write(f'Coverage LCOV written to file {self.cov.config.lcov_output if output is None else output}\n') 269 | 270 | return total 271 | 272 | 273 | class Central(CovController): 274 | """Implementation for centralised operation.""" 275 | 276 | @_ensure_topdir 277 | def start(self): 278 | cleanup() 279 | 280 | self.cov = coverage.Coverage( 281 | source=self.cov_source, 282 | branch=self.cov_branch, 283 | data_suffix=_data_suffix('c'), 284 | config_file=self.cov_config, 285 | ) 286 | if self.cov.config.dynamic_context == 'test_function': 287 | message = ( 288 | 'Detected dynamic_context=test_function in coverage configuration. ' 289 | 'This is unnecessary as this plugin provides the more complete --cov-context option.' 290 | ) 291 | warnings.warn(CentralCovContextWarning(message), stacklevel=1) 292 | 293 | self.combining_cov = coverage.Coverage( 294 | source=self.cov_source, 295 | branch=self.cov_branch, 296 | data_suffix=_data_suffix('cc'), 297 | data_file=os.path.abspath(self.cov.config.data_file), # noqa: PTH100 298 | config_file=self.cov_config, 299 | ) 300 | 301 | # Erase or load any previous coverage data and start coverage. 302 | if not self.cov_append: 303 | self.cov.erase() 304 | self.cov.start() 305 | self.set_env() 306 | 307 | super().start() 308 | 309 | @_ensure_topdir 310 | def finish(self): 311 | """Stop coverage, save data to file and set the list of coverage objects to report on.""" 312 | super().finish() 313 | 314 | self.unset_env() 315 | self.cov.stop() 316 | self.cov.save() 317 | 318 | self.cov = self.combining_cov 319 | self.cov.load() 320 | self.cov.combine() 321 | self.cov.save() 322 | 323 | node_desc = self.get_node_desc(sys.platform, sys.version_info) 324 | self.node_descs.add(node_desc) 325 | 326 | 327 | class DistMaster(CovController): 328 | """Implementation for distributed master.""" 329 | 330 | @_ensure_topdir 331 | def start(self): 332 | cleanup() 333 | 334 | self.cov = coverage.Coverage( 335 | source=self.cov_source, 336 | branch=self.cov_branch, 337 | data_suffix=_data_suffix('m'), 338 | config_file=self.cov_config, 339 | ) 340 | if self.cov.config.dynamic_context == 'test_function': 341 | raise DistCovError( 342 | 'Detected dynamic_context=test_function in coverage configuration. ' 343 | 'This is known to cause issues when using xdist, see: https://github.com/pytest-dev/pytest-cov/issues/604\n' 344 | 'It is recommended to use --cov-context instead.' 345 | ) 346 | self.cov._warn_no_data = False 347 | self.cov._warn_unimported_source = False 348 | self.cov._warn_preimported_source = False 349 | self.combining_cov = coverage.Coverage( 350 | source=self.cov_source, 351 | branch=self.cov_branch, 352 | data_suffix=_data_suffix('mc'), 353 | data_file=os.path.abspath(self.cov.config.data_file), # noqa: PTH100 354 | config_file=self.cov_config, 355 | ) 356 | if not self.cov_append: 357 | self.cov.erase() 358 | self.cov.start() 359 | self.cov.config.paths['source'] = [self.topdir] 360 | 361 | def configure_node(self, node): 362 | """Workers need to know if they are collocated and what files have moved.""" 363 | 364 | node.workerinput.update( 365 | { 366 | 'cov_master_host': socket.gethostname(), 367 | 'cov_master_topdir': self.topdir, 368 | 'cov_master_rsync_roots': [str(root) for root in node.nodemanager.roots], 369 | } 370 | ) 371 | 372 | def testnodedown(self, node, error): 373 | """Collect data file name from worker.""" 374 | 375 | # If worker doesn't return any data then it is likely that this 376 | # plugin didn't get activated on the worker side. 377 | output = getattr(node, 'workeroutput', {}) 378 | if 'cov_worker_node_id' not in output: 379 | self.failed_workers.append(node) 380 | return 381 | 382 | # If worker is not collocated then we must save the data file 383 | # that it returns to us. 384 | if 'cov_worker_data' in output: 385 | data_suffix = '%s.%s.%06d.%s' % ( # noqa: UP031 386 | socket.gethostname(), 387 | os.getpid(), 388 | random.randint(0, 999999), # noqa: S311 389 | output['cov_worker_node_id'], 390 | ) 391 | 392 | cov = coverage.Coverage(source=self.cov_source, branch=self.cov_branch, data_suffix=data_suffix, config_file=self.cov_config) 393 | cov.start() 394 | if coverage.version_info < (5, 0): 395 | data = CoverageData() 396 | data.read_fileobj(StringIO(output['cov_worker_data'])) 397 | cov.data.update(data) 398 | else: 399 | data = CoverageData(no_disk=True, suffix='should-not-exist') 400 | data.loads(output['cov_worker_data']) 401 | cov.get_data().update(data) 402 | cov.stop() 403 | cov.save() 404 | path = output['cov_worker_path'] 405 | self.cov.config.paths['source'].append(path) 406 | 407 | # Record the worker types that contribute to the data file. 408 | rinfo = node.gateway._rinfo() 409 | node_desc = self.get_node_desc(rinfo.platform, rinfo.version_info) 410 | self.node_descs.add(node_desc) 411 | 412 | @_ensure_topdir 413 | def finish(self): 414 | """Combines coverage data and sets the list of coverage objects to report on.""" 415 | 416 | # Combine all the suffix files into the data file. 417 | self.cov.stop() 418 | self.cov.save() 419 | self.cov = self.combining_cov 420 | self.cov.load() 421 | self.cov.combine() 422 | self.cov.save() 423 | 424 | 425 | class DistWorker(CovController): 426 | """Implementation for distributed workers.""" 427 | 428 | @_ensure_topdir 429 | def start(self): 430 | cleanup() 431 | 432 | # Determine whether we are collocated with master. 433 | self.is_collocated = ( 434 | socket.gethostname() == self.config.workerinput['cov_master_host'] 435 | and self.topdir == self.config.workerinput['cov_master_topdir'] 436 | ) 437 | 438 | # If we are not collocated then rewrite master paths to worker paths. 439 | if not self.is_collocated: 440 | master_topdir = self.config.workerinput['cov_master_topdir'] 441 | worker_topdir = self.topdir 442 | if self.cov_source is not None: 443 | self.cov_source = [source.replace(master_topdir, worker_topdir) for source in self.cov_source] 444 | self.cov_config = self.cov_config.replace(master_topdir, worker_topdir) 445 | 446 | # Erase any previous data and start coverage. 447 | self.cov = coverage.Coverage( 448 | source=self.cov_source, 449 | branch=self.cov_branch, 450 | data_suffix=_data_suffix(f'w{self.nodeid}'), 451 | config_file=self.cov_config, 452 | ) 453 | self.cov.start() 454 | self.set_env() 455 | super().start() 456 | 457 | @_ensure_topdir 458 | def finish(self): 459 | """Stop coverage and send relevant info back to the master.""" 460 | super().finish() 461 | 462 | self.unset_env() 463 | self.cov.stop() 464 | 465 | if self.is_collocated: 466 | # We don't combine data if we're collocated - we can get 467 | # race conditions in the .combine() call (it's not atomic) 468 | # The data is going to be combined in the master. 469 | self.cov.save() 470 | 471 | # If we are collocated then just inform the master of our 472 | # data file to indicate that we have finished. 473 | self.config.workeroutput['cov_worker_node_id'] = self.nodeid 474 | else: 475 | self.cov.combine() 476 | self.cov.save() 477 | # If we are not collocated then add the current path 478 | # and coverage data to the output so we can combine 479 | # it on the master node. 480 | 481 | # Send all the data to the master over the channel. 482 | if coverage.version_info < (5, 0): 483 | buff = StringIO() 484 | self.cov.data.write_fileobj(buff) 485 | data = buff.getvalue() 486 | else: 487 | data = self.cov.get_data().dumps() 488 | 489 | self.config.workeroutput.update( 490 | { 491 | 'cov_worker_path': self.topdir, 492 | 'cov_worker_node_id': self.nodeid, 493 | 'cov_worker_data': data, 494 | } 495 | ) 496 | 497 | def summary(self, stream): 498 | """Only the master reports so do nothing.""" 499 | -------------------------------------------------------------------------------- /src/pytest_cov/plugin.py: -------------------------------------------------------------------------------- 1 | """Coverage plugin for pytest.""" 2 | 3 | import argparse 4 | import os 5 | import warnings 6 | from io import StringIO 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING 9 | 10 | import coverage 11 | import pytest 12 | from coverage.results import display_covered 13 | from coverage.results import should_fail_under 14 | 15 | from . import CovDisabledWarning 16 | from . import CovReportWarning 17 | from . import compat 18 | from . import embed 19 | 20 | if TYPE_CHECKING: 21 | from .engine import CovController 22 | 23 | 24 | def validate_report(arg): 25 | file_choices = ['annotate', 'html', 'xml', 'json', 'lcov'] 26 | term_choices = ['term', 'term-missing'] 27 | term_modifier_choices = ['skip-covered'] 28 | all_choices = term_choices + file_choices 29 | values = arg.split(':', 1) 30 | report_type = values[0] 31 | if report_type not in [*all_choices, '']: 32 | msg = f'invalid choice: "{arg}" (choose from "{all_choices}")' 33 | raise argparse.ArgumentTypeError(msg) 34 | 35 | if report_type == 'lcov' and coverage.version_info <= (6, 3): 36 | raise argparse.ArgumentTypeError('LCOV output is only supported with coverage.py >= 6.3') 37 | 38 | if len(values) == 1: 39 | return report_type, None 40 | 41 | report_modifier = values[1] 42 | if report_type in term_choices and report_modifier in term_modifier_choices: 43 | return report_type, report_modifier 44 | 45 | if report_type not in file_choices: 46 | msg = f'output specifier not supported for: "{arg}" (choose from "{file_choices}")' 47 | raise argparse.ArgumentTypeError(msg) 48 | 49 | return values 50 | 51 | 52 | def validate_fail_under(num_str): 53 | try: 54 | value = int(num_str) 55 | except ValueError: 56 | try: 57 | value = float(num_str) 58 | except ValueError: 59 | raise argparse.ArgumentTypeError('An integer or float value is required.') from None 60 | if value > 100: 61 | raise argparse.ArgumentTypeError( 62 | 'Your desire for over-achievement is admirable but misplaced. The maximum value is 100. Perhaps write more integration tests?' 63 | ) 64 | return value 65 | 66 | 67 | def validate_context(arg): 68 | if coverage.version_info <= (5, 0): 69 | raise argparse.ArgumentTypeError('Contexts are only supported with coverage.py >= 5.x') 70 | if arg != 'test': 71 | raise argparse.ArgumentTypeError('The only supported value is "test".') 72 | return arg 73 | 74 | 75 | class StoreReport(argparse.Action): 76 | def __call__(self, parser, namespace, values, option_string=None): 77 | report_type, file = values 78 | namespace.cov_report[report_type] = file 79 | 80 | 81 | def pytest_addoption(parser): 82 | """Add options to control coverage.""" 83 | 84 | group = parser.getgroup('cov', 'coverage reporting with distributed testing support') 85 | group.addoption( 86 | '--cov', 87 | action='append', 88 | default=[], 89 | metavar='SOURCE', 90 | nargs='?', 91 | const=True, 92 | dest='cov_source', 93 | help='Path or package name to measure during execution (multi-allowed). ' 94 | 'Use --cov= to not do any source filtering and record everything.', 95 | ) 96 | group.addoption( 97 | '--cov-reset', 98 | action='store_const', 99 | const=[], 100 | dest='cov_source', 101 | help='Reset cov sources accumulated in options so far. ', 102 | ) 103 | group.addoption( 104 | '--cov-report', 105 | action=StoreReport, 106 | default={}, 107 | metavar='TYPE', 108 | type=validate_report, 109 | help='Type of report to generate: term, term-missing, ' 110 | 'annotate, html, xml, json, lcov (multi-allowed). ' 111 | 'term, term-missing may be followed by ":skip-covered". ' 112 | 'annotate, html, xml, json and lcov may be followed by ":DEST" ' 113 | 'where DEST specifies the output location. ' 114 | 'Use --cov-report= to not generate any output.', 115 | ) 116 | group.addoption( 117 | '--cov-config', 118 | action='store', 119 | default='.coveragerc', 120 | metavar='PATH', 121 | help='Config file for coverage. Default: .coveragerc', 122 | ) 123 | group.addoption( 124 | '--no-cov-on-fail', 125 | action='store_true', 126 | default=False, 127 | help='Do not report coverage if test run fails. Default: False', 128 | ) 129 | group.addoption( 130 | '--no-cov', 131 | action='store_true', 132 | default=False, 133 | help='Disable coverage report completely (useful for debuggers). Default: False', 134 | ) 135 | group.addoption( 136 | '--cov-fail-under', 137 | action='store', 138 | metavar='MIN', 139 | type=validate_fail_under, 140 | help='Fail if the total coverage is less than MIN.', 141 | ) 142 | group.addoption( 143 | '--cov-append', 144 | action='store_true', 145 | default=False, 146 | help='Do not delete coverage but append to current. Default: False', 147 | ) 148 | group.addoption( 149 | '--cov-branch', 150 | action='store_true', 151 | default=None, 152 | help='Enable branch coverage.', 153 | ) 154 | group.addoption( 155 | '--cov-precision', 156 | type=int, 157 | default=None, 158 | help='Override the reporting precision.', 159 | ) 160 | group.addoption( 161 | '--cov-context', 162 | action='store', 163 | metavar='CONTEXT', 164 | type=validate_context, 165 | help='Dynamic contexts to use. "test" for now.', 166 | ) 167 | 168 | 169 | def _prepare_cov_source(cov_source): 170 | """ 171 | Prepare cov_source so that: 172 | 173 | --cov --cov=foobar is equivalent to --cov (cov_source=None) 174 | --cov=foo --cov=bar is equivalent to cov_source=['foo', 'bar'] 175 | """ 176 | return None if True in cov_source else [path for path in cov_source if path is not True] 177 | 178 | 179 | @pytest.hookimpl(tryfirst=True) 180 | def pytest_load_initial_conftests(early_config, parser, args): 181 | options = early_config.known_args_namespace 182 | no_cov = options.no_cov_should_warn = False 183 | for arg in args: 184 | arg = str(arg) 185 | if arg == '--no-cov': 186 | no_cov = True 187 | elif arg.startswith('--cov') and no_cov: 188 | options.no_cov_should_warn = True 189 | break 190 | 191 | if early_config.known_args_namespace.cov_source: 192 | plugin = CovPlugin(options, early_config.pluginmanager) 193 | early_config.pluginmanager.register(plugin, '_cov') 194 | 195 | 196 | class CovPlugin: 197 | """Use coverage package to produce code coverage reports. 198 | 199 | Delegates all work to a particular implementation based on whether 200 | this test process is centralised, a distributed master or a 201 | distributed worker. 202 | """ 203 | 204 | def __init__(self, options: argparse.Namespace, pluginmanager, start=True, no_cov_should_warn=False): 205 | """Creates a coverage pytest plugin. 206 | 207 | We read the rc file that coverage uses to get the data file 208 | name. This is needed since we give coverage through it's API 209 | the data file name. 210 | """ 211 | 212 | # Our implementation is unknown at this time. 213 | self.pid = None 214 | self.cov_controller = None 215 | self.cov_report = StringIO() 216 | self.cov_total = None 217 | self.failed = False 218 | self._started = False 219 | self._start_path = None 220 | self._disabled = False 221 | self.options = options 222 | self._wrote_heading = False 223 | 224 | is_dist = getattr(options, 'numprocesses', False) or getattr(options, 'distload', False) or getattr(options, 'dist', 'no') != 'no' 225 | if getattr(options, 'no_cov', False): 226 | self._disabled = True 227 | return 228 | 229 | if not self.options.cov_report: 230 | self.options.cov_report = ['term'] 231 | elif len(self.options.cov_report) == 1 and '' in self.options.cov_report: 232 | self.options.cov_report = {} 233 | self.options.cov_source = _prepare_cov_source(self.options.cov_source) 234 | 235 | # import engine lazily here to avoid importing 236 | # it for unit tests that don't need it 237 | from . import engine 238 | 239 | if is_dist and start: 240 | self.start(engine.DistMaster) 241 | elif start: 242 | self.start(engine.Central) 243 | 244 | # worker is started in pytest hook 245 | 246 | def start(self, controller_cls: type['CovController'], config=None, nodeid=None): 247 | if config is None: 248 | # fake config option for engine 249 | class Config: 250 | option = self.options 251 | 252 | config = Config() 253 | 254 | self.cov_controller = controller_cls(self.options, config, nodeid) 255 | self.cov_controller.start() 256 | self._started = True 257 | self._start_path = Path.cwd() 258 | cov_config = self.cov_controller.cov.config 259 | if self.options.cov_fail_under is None and hasattr(cov_config, 'fail_under'): 260 | self.options.cov_fail_under = cov_config.fail_under 261 | if self.options.cov_precision is None: 262 | self.options.cov_precision = getattr(cov_config, 'precision', 0) 263 | 264 | def _is_worker(self, session): 265 | return getattr(session.config, 'workerinput', None) is not None 266 | 267 | def pytest_sessionstart(self, session): 268 | """At session start determine our implementation and delegate to it.""" 269 | 270 | if self.options.no_cov: 271 | # Coverage can be disabled because it does not cooperate with debuggers well. 272 | self._disabled = True 273 | return 274 | 275 | # import engine lazily here to avoid importing 276 | # it for unit tests that don't need it 277 | from . import engine 278 | 279 | self.pid = os.getpid() 280 | if self._is_worker(session): 281 | nodeid = session.config.workerinput.get('workerid', session.nodeid) 282 | self.start(engine.DistWorker, session.config, nodeid) 283 | elif not self._started: 284 | self.start(engine.Central) 285 | 286 | if self.options.cov_context == 'test': 287 | session.config.pluginmanager.register(TestContextPlugin(self.cov_controller), '_cov_contexts') 288 | 289 | @pytest.hookimpl(optionalhook=True) 290 | def pytest_configure_node(self, node): 291 | """Delegate to our implementation. 292 | 293 | Mark this hook as optional in case xdist is not installed. 294 | """ 295 | if not self._disabled: 296 | self.cov_controller.configure_node(node) 297 | 298 | @pytest.hookimpl(optionalhook=True) 299 | def pytest_testnodedown(self, node, error): 300 | """Delegate to our implementation. 301 | 302 | Mark this hook as optional in case xdist is not installed. 303 | """ 304 | if not self._disabled: 305 | self.cov_controller.testnodedown(node, error) 306 | 307 | def _should_report(self): 308 | needed = self.options.cov_report or self.options.cov_fail_under 309 | return needed and not (self.failed and self.options.no_cov_on_fail) 310 | 311 | # we need to wrap pytest_runtestloop. by the time pytest_sessionfinish 312 | # runs, it's too late to set testsfailed 313 | @pytest.hookimpl(hookwrapper=True) 314 | def pytest_runtestloop(self, session): 315 | yield 316 | 317 | if self._disabled: 318 | return 319 | 320 | compat_session = compat.SessionWrapper(session) 321 | 322 | self.failed = bool(compat_session.testsfailed) 323 | if self.cov_controller is not None: 324 | self.cov_controller.finish() 325 | 326 | if not self._is_worker(session) and self._should_report(): 327 | # import coverage lazily here to avoid importing 328 | # it for unit tests that don't need it 329 | from coverage.misc import CoverageException 330 | 331 | try: 332 | self.cov_total = self.cov_controller.summary(self.cov_report) 333 | except CoverageException as exc: 334 | message = f'Failed to generate report: {exc}\n' 335 | session.config.pluginmanager.getplugin('terminalreporter').write(f'\nWARNING: {message}\n', red=True, bold=True) 336 | warnings.warn(CovReportWarning(message), stacklevel=1) 337 | self.cov_total = 0 338 | assert self.cov_total is not None, 'Test coverage should never be `None`' 339 | cov_fail_under = self.options.cov_fail_under 340 | cov_precision = self.options.cov_precision 341 | if cov_fail_under is None or self.options.collectonly: 342 | return 343 | if should_fail_under(self.cov_total, cov_fail_under, cov_precision): 344 | message = 'Coverage failure: total of {total} is less than fail-under={fail_under:.{p}f}'.format( 345 | total=display_covered(self.cov_total, cov_precision), 346 | fail_under=cov_fail_under, 347 | p=cov_precision, 348 | ) 349 | session.config.pluginmanager.getplugin('terminalreporter').write(f'\nERROR: {message}\n', red=True, bold=True) 350 | # make sure we get the EXIT_TESTSFAILED exit code 351 | compat_session.testsfailed += 1 352 | 353 | def write_heading(self, terminalreporter): 354 | if not self._wrote_heading: 355 | terminalreporter.write_sep('=', 'tests coverage') 356 | self._wrote_heading = True 357 | 358 | def pytest_terminal_summary(self, terminalreporter): 359 | if self._disabled: 360 | if self.options.no_cov_should_warn: 361 | self.write_heading(terminalreporter) 362 | message = 'Coverage disabled via --no-cov switch!' 363 | terminalreporter.write(f'WARNING: {message}\n', red=True, bold=True) 364 | warnings.warn(CovDisabledWarning(message), stacklevel=1) 365 | return 366 | if self.cov_controller is None: 367 | return 368 | 369 | if self.cov_total is None: 370 | # we shouldn't report, or report generation failed (error raised above) 371 | return 372 | 373 | report = self.cov_report.getvalue() 374 | 375 | if report: 376 | self.write_heading(terminalreporter) 377 | terminalreporter.write(report) 378 | 379 | if self.options.cov_fail_under is not None and self.options.cov_fail_under > 0: 380 | self.write_heading(terminalreporter) 381 | failed = self.cov_total < self.options.cov_fail_under 382 | markup = {'red': True, 'bold': True} if failed else {'green': True} 383 | message = '{fail}Required test coverage of {required}% {reached}. Total coverage: {actual:.2f}%\n'.format( 384 | required=self.options.cov_fail_under, 385 | actual=self.cov_total, 386 | fail='FAIL ' if failed else '', 387 | reached='not reached' if failed else 'reached', 388 | ) 389 | terminalreporter.write(message, **markup) 390 | 391 | def pytest_runtest_setup(self, item): 392 | if os.getpid() != self.pid: 393 | # test is run in another process than session, run 394 | # coverage manually 395 | embed.init() 396 | 397 | def pytest_runtest_teardown(self, item): 398 | embed.cleanup() 399 | 400 | @pytest.hookimpl(hookwrapper=True) 401 | def pytest_runtest_call(self, item): 402 | if item.get_closest_marker('no_cover') or 'no_cover' in getattr(item, 'fixturenames', ()): 403 | self.cov_controller.pause() 404 | yield 405 | self.cov_controller.resume() 406 | else: 407 | yield 408 | 409 | 410 | class TestContextPlugin: 411 | cov_controller: 'CovController' 412 | 413 | def __init__(self, cov_controller): 414 | self.cov_controller = cov_controller 415 | 416 | def pytest_runtest_setup(self, item): 417 | self.switch_context(item, 'setup') 418 | 419 | def pytest_runtest_teardown(self, item): 420 | self.switch_context(item, 'teardown') 421 | 422 | def pytest_runtest_call(self, item): 423 | self.switch_context(item, 'run') 424 | 425 | def switch_context(self, item, when): 426 | if self.cov_controller.started: 427 | context = f'{item.nodeid}|{when}' 428 | self.cov_controller.cov.switch_context(context) 429 | os.environ['COV_CORE_CONTEXT'] = context 430 | 431 | 432 | @pytest.fixture 433 | def no_cover(): 434 | """A pytest fixture to disable coverage.""" 435 | 436 | 437 | @pytest.fixture 438 | def cov(request): 439 | """A pytest fixture to provide access to the underlying coverage object.""" 440 | 441 | # Check with hasplugin to avoid getplugin exception in older pytest. 442 | if request.config.pluginmanager.hasplugin('_cov'): 443 | plugin = request.config.pluginmanager.getplugin('_cov') 444 | if plugin.cov_controller: 445 | return plugin.cov_controller.cov 446 | return None 447 | 448 | 449 | def pytest_configure(config): 450 | config.addinivalue_line('markers', 'no_cover: disable coverage for this test.') 451 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(config): 2 | config.option.runpytest = 'subprocess' 3 | -------------------------------------------------------------------------------- /tests/contextful.py: -------------------------------------------------------------------------------- 1 | # A test file for test_pytest_cov.py:test_contexts 2 | 3 | import unittest 4 | from typing import ClassVar 5 | 6 | import pytest 7 | 8 | 9 | def test_01(): 10 | assert 1 == 1 # r1 11 | 12 | 13 | def test_02(): 14 | assert 2 == 2 # r2 15 | 16 | 17 | class OldStyleTests(unittest.TestCase): 18 | items: ClassVar = [] 19 | 20 | @classmethod 21 | def setUpClass(cls): 22 | cls.items.append('hello') # s3 23 | 24 | @classmethod 25 | def tearDownClass(cls): 26 | cls.items.pop() # t4 27 | 28 | def setUp(self): 29 | self.number = 1 # r3 r4 30 | 31 | def tearDown(self): 32 | self.number = None # r3 r4 33 | 34 | def test_03(self): 35 | assert self.number == 1 # r3 36 | assert self.items[0] == 'hello' # r3 37 | 38 | def test_04(self): 39 | assert self.number == 1 # r4 40 | assert self.items[0] == 'hello' # r4 41 | 42 | 43 | @pytest.fixture 44 | def some_data(): 45 | return [1, 2, 3] # s5 s6 46 | 47 | 48 | def test_05(some_data): 49 | assert len(some_data) == 3 # r5 50 | 51 | 52 | @pytest.fixture 53 | def more_data(some_data): 54 | return [2 * x for x in some_data] # s6 55 | 56 | 57 | def test_06(some_data, more_data): 58 | assert len(some_data) == len(more_data) # r6 59 | 60 | 61 | @pytest.fixture(scope='session') 62 | def expensive_data(): 63 | return list(range(10)) # s7 64 | 65 | 66 | def test_07(expensive_data): 67 | assert len(expensive_data) == 10 # r7 68 | 69 | 70 | def test_08(expensive_data): 71 | assert len(expensive_data) == 10 # r8 72 | 73 | 74 | @pytest.fixture(params=[1, 2, 3]) 75 | def parametrized_number(request): 76 | return request.param # s9-1 s9-2 s9-3 77 | 78 | 79 | def test_09(parametrized_number): 80 | assert parametrized_number > 0 # r9-1 r9-2 r9-3 81 | 82 | 83 | def test_10(): 84 | assert 1 == 1 # r10 85 | 86 | 87 | @pytest.mark.parametrize( 88 | ('x', 'ans'), 89 | [ 90 | (1, 101), 91 | (2, 202), 92 | ], 93 | ) 94 | def test_11(x, ans): 95 | assert 100 * x + x == ans # r11-1 r11-2 96 | 97 | 98 | @pytest.mark.parametrize( 99 | ('x', 'ans'), 100 | [ 101 | (1, 101), 102 | (2, 202), 103 | ], 104 | ids=['one', 'two'], 105 | ) 106 | def test_12(x, ans): 107 | assert 100 * x + x == ans # r12-1 r12-2 108 | 109 | 110 | @pytest.mark.parametrize('x', [1, 2]) 111 | @pytest.mark.parametrize('y', [3, 4]) 112 | def test_13(x, y): 113 | assert x + y > 0 # r13-1 r13-2 r13-3 r13-4 114 | -------------------------------------------------------------------------------- /tests/helper.py: -------------------------------------------------------------------------------- 1 | def do_stuff(): 2 | a = 1 3 | return a 4 | -------------------------------------------------------------------------------- /tests/test_pytest_cov.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa 2 | import collections 3 | import glob 4 | import os 5 | import platform 6 | import re 7 | import subprocess 8 | import sys 9 | from io import StringIO 10 | from itertools import chain 11 | 12 | import coverage 13 | import py 14 | import pytest 15 | import virtualenv 16 | import xdist 17 | from fields import Namespace 18 | from process_tests import TestProcess as _TestProcess 19 | from process_tests import dump_on_error 20 | from process_tests import wait_for_strings 21 | 22 | import pytest_cov.plugin 23 | 24 | coverage, platform # required for skipif mark on test_cov_min_from_coveragerc 25 | 26 | max_worker_restart_0 = '--max-worker-restart=0' 27 | 28 | SCRIPT = """ 29 | import sys, helper 30 | 31 | def pytest_generate_tests(metafunc): 32 | for i in [10]: 33 | metafunc.parametrize('p', range(i)) 34 | 35 | def test_foo(p): 36 | x = True 37 | helper.do_stuff() # get some coverage in some other completely different location 38 | if sys.version_info[0] > 5: 39 | assert False 40 | """ 41 | 42 | SCRIPT2 = """ 43 | # 44 | 45 | def test_bar(): 46 | x = True 47 | assert x 48 | 49 | """ 50 | 51 | COVERAGERC_SOURCE = """\ 52 | [run] 53 | source = . 54 | """ 55 | 56 | SCRIPT_CHILD = """ 57 | import sys 58 | 59 | idx = int(sys.argv[1]) 60 | 61 | if idx == 0: 62 | foo = "a" # previously there was a "pass" here but Python 3.5 optimizes it away. 63 | if idx == 1: 64 | foo = "b" # previously there was a "pass" here but Python 3.5 optimizes it away. 65 | """ 66 | 67 | SCRIPT_PARENT = """ 68 | import os 69 | import subprocess 70 | import sys 71 | 72 | def pytest_generate_tests(metafunc): 73 | for i in [2]: 74 | metafunc.parametrize('idx', range(i)) 75 | 76 | def test_foo(idx): 77 | out, err = subprocess.Popen( 78 | [sys.executable, os.path.join(os.path.dirname(__file__), 'child_script.py'), str(idx)], 79 | stdout=subprocess.PIPE, 80 | stderr=subprocess.PIPE).communicate() 81 | 82 | # there is a issue in coverage.py with multiline statements at 83 | # end of file: https://bitbucket.org/ned/coveragepy/issue/293 84 | pass 85 | """ 86 | 87 | SCRIPT_PARENT_CHANGE_CWD = """ 88 | import subprocess 89 | import sys 90 | import os 91 | 92 | def pytest_generate_tests(metafunc): 93 | for i in [2]: 94 | metafunc.parametrize('idx', range(i)) 95 | 96 | def test_foo(idx): 97 | os.mkdir("foobar") 98 | os.chdir("foobar") 99 | 100 | subprocess.check_call([ 101 | sys.executable, 102 | os.path.join(os.path.dirname(__file__), 'child_script.py'), 103 | str(idx) 104 | ]) 105 | 106 | # there is a issue in coverage.py with multiline statements at 107 | # end of file: https://bitbucket.org/ned/coveragepy/issue/293 108 | pass 109 | """ 110 | 111 | SCRIPT_PARENT_CHANGE_CWD_IMPORT_CHILD = """ 112 | import subprocess 113 | import sys 114 | import os 115 | 116 | def pytest_generate_tests(metafunc): 117 | for i in [2]: 118 | if metafunc.function is test_foo: metafunc.parametrize('idx', range(i)) 119 | 120 | def test_foo(idx): 121 | os.mkdir("foobar") 122 | os.chdir("foobar") 123 | 124 | subprocess.check_call([ 125 | sys.executable, 126 | '-c', 'import sys; sys.argv = ["", str(%s)]; import child_script' % idx 127 | ]) 128 | 129 | # there is a issue in coverage.py with multiline statements at 130 | # end of file: https://bitbucket.org/ned/coveragepy/issue/293 131 | pass 132 | """ 133 | 134 | SCRIPT_FUNCARG = """ 135 | import coverage 136 | 137 | def test_foo(cov): 138 | assert isinstance(cov, coverage.Coverage) 139 | """ 140 | 141 | SCRIPT_FUNCARG_NOT_ACTIVE = """ 142 | def test_foo(cov): 143 | assert cov is None 144 | """ 145 | 146 | CHILD_SCRIPT_RESULT = '[56] * 100%' 147 | PARENT_SCRIPT_RESULT = '9 * 100%' 148 | DEST_DIR = 'cov_dest' 149 | XML_REPORT_NAME = 'cov.xml' 150 | JSON_REPORT_NAME = 'cov.json' 151 | LCOV_REPORT_NAME = 'cov.info' 152 | 153 | xdist_params = pytest.mark.parametrize( 154 | 'opts', 155 | [ 156 | '', 157 | pytest.param('-n 1', marks=pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"')), 158 | pytest.param('-n 2', marks=pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"')), 159 | pytest.param('-n 3', marks=pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"')), 160 | ], 161 | ids=['nodist', '1xdist', '2xdist', '3xdist'], 162 | ) 163 | 164 | 165 | @pytest.fixture(scope='session', autouse=True) 166 | def adjust_sys_path(): 167 | """Adjust PYTHONPATH during tests to make "helper" importable in SCRIPT.""" 168 | orig_path = os.environ.get('PYTHONPATH', None) 169 | new_path = os.path.dirname(__file__) 170 | if orig_path is not None: 171 | new_path = os.pathsep.join([new_path, orig_path]) 172 | os.environ['PYTHONPATH'] = new_path 173 | 174 | yield 175 | 176 | if orig_path is None: 177 | del os.environ['PYTHONPATH'] 178 | else: 179 | os.environ['PYTHONPATH'] = orig_path 180 | 181 | 182 | @pytest.fixture( 183 | params=[ 184 | ('branch=true', '--cov-branch', '9 * 85%', '3 * 100%'), 185 | ('branch=true', '', '9 * 85%', '3 * 100%'), 186 | ('', '--cov-branch', '9 * 85%', '3 * 100%'), 187 | ('', '', '9 * 89%', '3 * 100%'), 188 | ], 189 | ids=['branch2x', 'branch1c', 'branch1a', 'nobranch'], 190 | ) 191 | def prop(request): 192 | return Namespace( 193 | code=SCRIPT, 194 | code2=SCRIPT2, 195 | conf=request.param[0], 196 | fullconf=f'[run]\n{request.param[0]}\n', 197 | prefixedfullconf=f'[coverage:run]\n{request.param[0]}\n', 198 | args=request.param[1].split(), 199 | result=request.param[2], 200 | result2=request.param[3], 201 | ) 202 | 203 | 204 | def test_central(pytester, testdir, prop): 205 | script = testdir.makepyfile(prop.code) 206 | testdir.tmpdir.join('.coveragerc').write(prop.fullconf) 207 | 208 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', script, *prop.args) 209 | 210 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_central* {prop.result} *', '*10 passed*']) 211 | assert result.ret == 0 212 | 213 | 214 | def test_annotate(testdir): 215 | script = testdir.makepyfile(SCRIPT) 216 | 217 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=annotate', script) 218 | 219 | result.stdout.fnmatch_lines( 220 | [ 221 | '*_ coverage: platform *, python * _*', 222 | 'Coverage annotated source written next to source', 223 | '*10 passed*', 224 | ] 225 | ) 226 | assert result.ret == 0 227 | 228 | 229 | def test_annotate_output_dir(testdir): 230 | script = testdir.makepyfile(SCRIPT) 231 | 232 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=annotate:' + DEST_DIR, script) 233 | 234 | result.stdout.fnmatch_lines( 235 | [ 236 | '*_ coverage: platform *, python * _*', 237 | 'Coverage annotated source written to dir ' + DEST_DIR, 238 | '*10 passed*', 239 | ] 240 | ) 241 | dest_dir = testdir.tmpdir.join(DEST_DIR) 242 | assert dest_dir.check(dir=True) 243 | assert dest_dir.join(script.basename + ',cover').check() 244 | assert result.ret == 0 245 | 246 | 247 | def test_html(testdir): 248 | script = testdir.makepyfile(SCRIPT) 249 | 250 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=html', script) 251 | 252 | result.stdout.fnmatch_lines( 253 | [ 254 | '*_ coverage: platform *, python * _*', 255 | 'Coverage HTML written to dir htmlcov', 256 | '*10 passed*', 257 | ] 258 | ) 259 | dest_dir = testdir.tmpdir.join('htmlcov') 260 | assert dest_dir.check(dir=True) 261 | assert dest_dir.join('index.html').check() 262 | assert result.ret == 0 263 | 264 | 265 | def test_html_output_dir(testdir): 266 | script = testdir.makepyfile(SCRIPT) 267 | 268 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=html:' + DEST_DIR, script) 269 | 270 | result.stdout.fnmatch_lines( 271 | [ 272 | '*_ coverage: platform *, python * _*', 273 | 'Coverage HTML written to dir ' + DEST_DIR, 274 | '*10 passed*', 275 | ] 276 | ) 277 | dest_dir = testdir.tmpdir.join(DEST_DIR) 278 | assert dest_dir.check(dir=True) 279 | assert dest_dir.join('index.html').check() 280 | assert result.ret == 0 281 | 282 | 283 | def test_term_report_does_not_interact_with_html_output(testdir): 284 | script = testdir.makepyfile(test_funcarg=SCRIPT_FUNCARG) 285 | 286 | result = testdir.runpytest( 287 | '-v', f'--cov={script.dirpath()}', '--cov-report=term-missing:skip-covered', '--cov-report=html:' + DEST_DIR, script 288 | ) 289 | 290 | result.stdout.fnmatch_lines( 291 | [ 292 | '*_ coverage: platform *, python * _*', 293 | 'Coverage HTML written to dir ' + DEST_DIR, 294 | '*1 passed*', 295 | ] 296 | ) 297 | dest_dir = testdir.tmpdir.join(DEST_DIR) 298 | assert dest_dir.check(dir=True) 299 | expected = [dest_dir.join('index.html'), dest_dir.join('test_funcarg_py.html')] 300 | if coverage.version_info >= (7, 5): 301 | expected.insert(0, dest_dir.join('function_index.html')) 302 | expected.insert(0, dest_dir.join('class_index.html')) 303 | assert sorted(dest_dir.visit('**/*.html')) == expected 304 | assert dest_dir.join('index.html').check() 305 | assert result.ret == 0 306 | 307 | 308 | def test_html_configured_output_dir(testdir): 309 | script = testdir.makepyfile(SCRIPT) 310 | testdir.tmpdir.join('.coveragerc').write( 311 | """ 312 | [html] 313 | directory = somewhere 314 | """ 315 | ) 316 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=html', script) 317 | 318 | result.stdout.fnmatch_lines( 319 | [ 320 | '*_ coverage: platform *, python * _*', 321 | 'Coverage HTML written to dir somewhere', 322 | '*10 passed*', 323 | ] 324 | ) 325 | dest_dir = testdir.tmpdir.join('somewhere') 326 | assert dest_dir.check(dir=True) 327 | assert dest_dir.join('index.html').check() 328 | assert result.ret == 0 329 | 330 | 331 | def test_xml_output_dir(testdir): 332 | script = testdir.makepyfile(SCRIPT) 333 | 334 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=xml:' + XML_REPORT_NAME, script) 335 | 336 | result.stdout.fnmatch_lines( 337 | [ 338 | '*_ coverage: platform *, python * _*', 339 | 'Coverage XML written to file ' + XML_REPORT_NAME, 340 | '*10 passed*', 341 | ] 342 | ) 343 | assert testdir.tmpdir.join(XML_REPORT_NAME).check() 344 | assert result.ret == 0 345 | 346 | 347 | def test_json_output_dir(testdir): 348 | script = testdir.makepyfile(SCRIPT) 349 | 350 | result = testdir.runpytest('-v', '--cov=%s' % script.dirpath(), '--cov-report=json:' + JSON_REPORT_NAME, script) 351 | 352 | result.stdout.fnmatch_lines( 353 | [ 354 | '*_ coverage: platform *, python * _*', 355 | 'Coverage JSON written to file ' + JSON_REPORT_NAME, 356 | '*10 passed*', 357 | ] 358 | ) 359 | assert testdir.tmpdir.join(JSON_REPORT_NAME).check() 360 | assert result.ret == 0 361 | 362 | 363 | @pytest.mark.skipif('coverage.version_info < (6, 3)') 364 | def test_lcov_output_dir(testdir): 365 | script = testdir.makepyfile(SCRIPT) 366 | 367 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=lcov:' + LCOV_REPORT_NAME, script) 368 | 369 | result.stdout.fnmatch_lines( 370 | [ 371 | '*_ coverage: platform *, python * _*', 372 | 'Coverage LCOV written to file ' + LCOV_REPORT_NAME, 373 | '*10 passed*', 374 | ] 375 | ) 376 | assert testdir.tmpdir.join(LCOV_REPORT_NAME).check() 377 | assert result.ret == 0 378 | 379 | 380 | @pytest.mark.skipif('coverage.version_info >= (6, 3)') 381 | def test_lcov_not_supported(testdir): 382 | script = testdir.makepyfile('a = 1') 383 | result = testdir.runpytest( 384 | '-v', 385 | f'--cov={script.dirpath()}', 386 | '--cov-report=lcov', 387 | script, 388 | ) 389 | result.stderr.fnmatch_lines( 390 | [ 391 | '*argument --cov-report: LCOV output is only supported with coverage.py >= 6.3', 392 | ] 393 | ) 394 | assert result.ret != 0 395 | 396 | 397 | def test_term_output_dir(testdir): 398 | script = testdir.makepyfile(SCRIPT) 399 | 400 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term:' + DEST_DIR, script) 401 | 402 | result.stderr.fnmatch_lines( 403 | [ 404 | f'*argument --cov-report: output specifier not supported for: "term:{DEST_DIR}"*', 405 | ] 406 | ) 407 | assert result.ret != 0 408 | 409 | 410 | def test_term_missing_output_dir(testdir): 411 | script = testdir.makepyfile(SCRIPT) 412 | 413 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing:' + DEST_DIR, script) 414 | 415 | result.stderr.fnmatch_lines( 416 | [ 417 | '*argument --cov-report: output specifier not supported for: "term-missing:%s"*' % DEST_DIR, 418 | ] 419 | ) 420 | assert result.ret != 0 421 | 422 | 423 | def test_cov_min_100(testdir): 424 | script = testdir.makepyfile(SCRIPT) 425 | 426 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-fail-under=100', script) 427 | 428 | assert result.ret != 0 429 | result.stdout.fnmatch_lines(['FAIL Required test coverage of 100% not reached. Total coverage: *%']) 430 | 431 | 432 | def test_cov_min_100_passes_if_collectonly(testdir): 433 | script = testdir.makepyfile(SCRIPT) 434 | 435 | result = testdir.runpytest( 436 | '-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-fail-under=100', '--collect-only', script 437 | ) 438 | 439 | assert result.ret == 0 440 | 441 | 442 | def test_cov_min_50(testdir): 443 | script = testdir.makepyfile(SCRIPT) 444 | 445 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=html', '--cov-report=xml', '--cov-fail-under=50', script) 446 | 447 | assert result.ret == 0 448 | result.stdout.fnmatch_lines(['Required test coverage of 50% reached. Total coverage: *%']) 449 | 450 | 451 | def test_cov_min_float_value(testdir): 452 | script = testdir.makepyfile(SCRIPT) 453 | 454 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-fail-under=88.88', script) 455 | assert result.ret == 0 456 | result.stdout.fnmatch_lines(['Required test coverage of 88.88% reached. Total coverage: 88.89%']) 457 | 458 | 459 | def test_cov_min_float_value_not_reached(testdir): 460 | script = testdir.makepyfile(SCRIPT) 461 | testdir.tmpdir.join('.coveragerc').write(""" 462 | [report] 463 | precision = 3 464 | """) 465 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-fail-under=88.89', script) 466 | assert result.ret == 1 467 | result.stdout.fnmatch_lines(['FAIL Required test coverage of 88.89% not reached. Total coverage: 88.89%']) 468 | 469 | 470 | def test_cov_min_float_value_not_reached_cli(testdir): 471 | script = testdir.makepyfile(SCRIPT) 472 | result = testdir.runpytest( 473 | '-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-precision=3', '--cov-fail-under=88.89', script 474 | ) 475 | assert result.ret == 1 476 | result.stdout.fnmatch_lines(['FAIL Required test coverage of 88.89% not reached. Total coverage: 88.89%']) 477 | 478 | 479 | def test_cov_precision(testdir): 480 | script = testdir.makepyfile(SCRIPT) 481 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-precision=6', script) 482 | assert result.ret == 0 483 | result.stdout.fnmatch_lines( 484 | [ 485 | 'Name Stmts Miss Cover Missing', 486 | '----------------------------------------------------------', 487 | 'test_cov_precision.py 9 1 88.888889% 11', 488 | '----------------------------------------------------------', 489 | 'TOTAL 9 1 88.888889%', 490 | ] 491 | ) 492 | 493 | 494 | def test_cov_precision_from_config(testdir): 495 | script = testdir.makepyfile(SCRIPT) 496 | testdir.tmpdir.join('pyproject.toml').write(""" 497 | [tool.coverage.report] 498 | precision = 6""") 499 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 500 | assert result.ret == 0 501 | result.stdout.fnmatch_lines( 502 | [ 503 | 'Name Stmts Miss Cover Missing', 504 | '----------------------------------------------------------------------', 505 | 'test_cov_precision_from_config.py 9 1 88.888889% 11', 506 | '----------------------------------------------------------------------', 507 | 'TOTAL 9 1 88.888889%', 508 | ] 509 | ) 510 | 511 | 512 | def test_cov_min_no_report(testdir): 513 | script = testdir.makepyfile(SCRIPT) 514 | 515 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=', '--cov-fail-under=50', script) 516 | 517 | assert result.ret == 0 518 | result.stdout.fnmatch_lines(['Required test coverage of 50% reached. Total coverage: *%']) 519 | 520 | 521 | def test_central_nonspecific(pytester, testdir, prop): 522 | script = testdir.makepyfile(prop.code) 523 | testdir.tmpdir.join('.coveragerc').write(prop.fullconf) 524 | result = testdir.runpytest('-v', '--cov', '--cov-report=term-missing', script, *prop.args) 525 | 526 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_central_nonspecific* {prop.result} *', '*10 passed*']) 527 | 528 | # multi-module coverage report 529 | assert any(line.startswith('TOTAL ') for line in result.stdout.lines) 530 | 531 | assert result.ret == 0 532 | 533 | 534 | def test_cov_min_from_coveragerc(testdir): 535 | script = testdir.makepyfile(SCRIPT) 536 | testdir.tmpdir.join('.coveragerc').write( 537 | """ 538 | [report] 539 | fail_under = 100 540 | """ 541 | ) 542 | 543 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 544 | 545 | assert result.ret != 0 546 | 547 | 548 | def test_central_coveragerc(pytester, testdir, prop): 549 | script = testdir.makepyfile(prop.code) 550 | testdir.tmpdir.join('.coveragerc').write(COVERAGERC_SOURCE + prop.conf) 551 | 552 | result = testdir.runpytest('-v', '--cov', '--cov-report=term-missing', script, *prop.args) 553 | 554 | result.stdout.fnmatch_lines( 555 | [ 556 | '*_ coverage: platform *, python * _*', 557 | f'test_central_coveragerc* {prop.result} *', 558 | '*10 passed*', 559 | ] 560 | ) 561 | assert result.ret == 0 562 | 563 | 564 | @xdist_params 565 | def test_central_with_path_aliasing(pytester, testdir, monkeypatch, opts, prop): 566 | mod1 = testdir.mkdir('src').join('mod.py') 567 | mod1.write(SCRIPT) 568 | mod2 = testdir.mkdir('aliased').join('mod.py') 569 | mod2.write(SCRIPT) 570 | script = testdir.makepyfile( 571 | """ 572 | from mod import * 573 | """ 574 | ) 575 | testdir.tmpdir.join('setup.cfg').write( 576 | f""" 577 | [coverage:paths] 578 | source = 579 | src 580 | aliased 581 | [coverage:run] 582 | source = mod 583 | parallel = true 584 | {prop.conf} 585 | """ 586 | ) 587 | 588 | monkeypatch.setitem(os.environ, 'PYTHONPATH', os.pathsep.join([os.environ.get('PYTHONPATH', ''), 'aliased'])) 589 | result = testdir.runpytest('-v', '-s', '--cov', '--cov-report=term-missing', script, *opts.split() + prop.args) 590 | 591 | result.stdout.fnmatch_lines( 592 | [ 593 | '*_ coverage: platform *, python * _*', 594 | f'src[\\/]mod* {prop.result} *', 595 | '*10 passed*', 596 | ] 597 | ) 598 | assert result.ret == 0 599 | 600 | 601 | @xdist_params 602 | def test_borken_cwd(pytester, testdir, monkeypatch, opts): 603 | testdir.makepyfile( 604 | mod=""" 605 | def foobar(a, b): 606 | return a + b 607 | """ 608 | ) 609 | 610 | script = testdir.makepyfile( 611 | """ 612 | import os 613 | import tempfile 614 | import pytest 615 | import mod 616 | 617 | @pytest.fixture 618 | def bad(): 619 | path = tempfile.mkdtemp('test_borken_cwd') 620 | os.chdir(path) 621 | yield 622 | try: 623 | os.rmdir(path) 624 | except OSError: 625 | pass 626 | 627 | def test_foobar(bad): 628 | assert mod.foobar(1, 2) == 3 629 | """ 630 | ) 631 | result = testdir.runpytest('-v', '-s', '--cov=mod', '--cov-branch', script, *opts.split()) 632 | 633 | result.stdout.fnmatch_lines( 634 | [ 635 | '*_ coverage: platform *, python * _*', 636 | '*mod* 100%', 637 | '*1 passed*', 638 | ] 639 | ) 640 | 641 | assert result.ret == 0 642 | 643 | 644 | def test_subprocess_with_path_aliasing(pytester, testdir, monkeypatch): 645 | src = testdir.mkdir('src') 646 | src.join('parent_script.py').write(SCRIPT_PARENT) 647 | src.join('child_script.py').write(SCRIPT_CHILD) 648 | aliased = testdir.mkdir('aliased') 649 | parent_script = aliased.join('parent_script.py') 650 | parent_script.write(SCRIPT_PARENT) 651 | aliased.join('child_script.py').write(SCRIPT_CHILD) 652 | 653 | testdir.tmpdir.join('.coveragerc').write( 654 | """ 655 | [paths] 656 | source = 657 | src 658 | aliased 659 | [run] 660 | source = 661 | parent_script 662 | child_script 663 | parallel = true 664 | """ 665 | ) 666 | 667 | monkeypatch.setitem(os.environ, 'PYTHONPATH', os.pathsep.join([os.environ.get('PYTHONPATH', ''), 'aliased'])) 668 | result = testdir.runpytest('-v', '--cov', '--cov-report=term-missing', parent_script) 669 | 670 | result.stdout.fnmatch_lines( 671 | [ 672 | '*_ coverage: platform *, python * _*', 673 | f'src[\\/]child_script* {CHILD_SCRIPT_RESULT}*', 674 | f'src[\\/]parent_script* {PARENT_SCRIPT_RESULT}*', 675 | ] 676 | ) 677 | assert result.ret == 0 678 | 679 | 680 | def test_show_missing_coveragerc(pytester, testdir, prop): 681 | script = testdir.makepyfile(prop.code) 682 | testdir.tmpdir.join('.coveragerc').write( 683 | f""" 684 | [run] 685 | source = . 686 | {prop.conf} 687 | 688 | [report] 689 | show_missing = true 690 | """ 691 | ) 692 | 693 | result = testdir.runpytest('-v', '--cov', '--cov-report=term', script, *prop.args) 694 | 695 | result.stdout.fnmatch_lines( 696 | [ 697 | '*_ coverage: platform *, python * _*', 698 | 'Name * Stmts * Miss * Cover * Missing', 699 | f'test_show_missing_coveragerc* {prop.result} * 11*', 700 | '*10 passed*', 701 | ] 702 | ) 703 | 704 | assert result.ret == 0 705 | 706 | 707 | def test_no_cov_on_fail(testdir): 708 | script = testdir.makepyfile( 709 | """ 710 | def test_fail(): 711 | assert False 712 | 713 | """ 714 | ) 715 | 716 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--no-cov-on-fail', script) 717 | 718 | assert 'coverage: platform' not in result.stdout.str() 719 | result.stdout.fnmatch_lines(['*1 failed*']) 720 | 721 | 722 | def test_no_cov(pytester, testdir, monkeypatch): 723 | script = testdir.makepyfile(SCRIPT) 724 | testdir.makeini( 725 | """ 726 | [pytest] 727 | addopts=--no-cov 728 | """ 729 | ) 730 | result = testdir.runpytest('-vvv', f'--cov={script.dirpath()}', '--cov-report=term-missing', '-rw', script) 731 | result.stdout.fnmatch_lines_random( 732 | [ 733 | 'WARNING: Coverage disabled via --no-cov switch!', 734 | '*Coverage disabled via --no-cov switch!', 735 | ] 736 | ) 737 | 738 | 739 | def test_cov_and_failure_report_on_fail(testdir): 740 | script = testdir.makepyfile( 741 | SCRIPT 742 | + """ 743 | def test_fail(p): 744 | assert False 745 | 746 | """ 747 | ) 748 | 749 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-fail-under=100', '--cov-report=html', script) 750 | 751 | result.stdout.fnmatch_lines_random( 752 | [ 753 | '*10 failed*', 754 | '*coverage: platform*', 755 | '*FAIL Required test coverage of 100% not reached*', 756 | '*assert False*', 757 | ] 758 | ) 759 | 760 | 761 | @pytest.mark.skipif('sys.platform == "win32" or platform.python_implementation() == "PyPy"') 762 | def test_dist_combine_racecondition(testdir): 763 | script = testdir.makepyfile( 764 | """ 765 | import pytest 766 | 767 | @pytest.mark.parametrize("foo", range(1000)) 768 | def test_foo(foo): 769 | """ 770 | + '\n'.join( 771 | f""" 772 | if foo == {i}: 773 | assert True 774 | """ 775 | for i in range(1000) 776 | ) 777 | ) 778 | 779 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '-n', '5', '-s', script) 780 | result.stdout.fnmatch_lines(['test_dist_combine_racecondition* 0 * 100%*', '*1000 passed*']) 781 | 782 | for line in chain(result.stdout.lines, result.stderr.lines): 783 | assert 'The following workers failed to return coverage data' not in line 784 | assert 'INTERNALERROR' not in line 785 | assert result.ret == 0 786 | 787 | 788 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 789 | def test_dist_collocated(pytester, testdir, prop): 790 | script = testdir.makepyfile(prop.code) 791 | testdir.tmpdir.join('.coveragerc').write(prop.fullconf) 792 | result = testdir.runpytest( 793 | '-v', 794 | f'--cov={script.dirpath()}', 795 | '--cov-report=term-missing', 796 | '--dist=load', 797 | '--tx=2*popen', 798 | max_worker_restart_0, 799 | script, 800 | *prop.args, 801 | ) 802 | 803 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_dist_collocated* {prop.result} *', '*10 passed*']) 804 | assert result.ret == 0 805 | 806 | 807 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 808 | def test_dist_not_collocated(pytester, testdir, prop): 809 | script = testdir.makepyfile(prop.code) 810 | dir1 = testdir.mkdir('dir1') 811 | dir2 = testdir.mkdir('dir2') 812 | testdir.tmpdir.join('.coveragerc').write( 813 | f""" 814 | [run] 815 | {prop.conf} 816 | [paths] 817 | source = 818 | . 819 | dir1 820 | dir2""" 821 | ) 822 | 823 | result = testdir.runpytest( 824 | '-v', 825 | f'--cov={script.dirpath()}', 826 | '--cov-report=term-missing', 827 | '--dist=load', 828 | f'--tx=popen//chdir={dir1}', 829 | f'--tx=popen//chdir={dir2}', 830 | f'--rsyncdir={script.basename}', 831 | '--rsyncdir=.coveragerc', 832 | max_worker_restart_0, 833 | '-s', 834 | script, 835 | *prop.args, 836 | ) 837 | 838 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_dist_not_collocated* {prop.result} *', '*10 passed*']) 839 | assert result.ret == 0 840 | 841 | 842 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 843 | def test_dist_not_collocated_coveragerc_source(pytester, testdir, prop): 844 | script = testdir.makepyfile(prop.code) 845 | dir1 = testdir.mkdir('dir1') 846 | dir2 = testdir.mkdir('dir2') 847 | testdir.tmpdir.join('.coveragerc').write( 848 | f""" 849 | [run] 850 | {prop.conf} 851 | source = {script.dirpath()} 852 | [paths] 853 | source = 854 | . 855 | dir1 856 | dir2""" 857 | ) 858 | 859 | result = testdir.runpytest( 860 | '-v', 861 | '--cov', 862 | '--cov-report=term-missing', 863 | '--dist=load', 864 | f'--tx=popen//chdir={dir1}', 865 | f'--tx=popen//chdir={dir2}', 866 | f'--rsyncdir={script.basename}', 867 | '--rsyncdir=.coveragerc', 868 | max_worker_restart_0, 869 | '-s', 870 | script, 871 | *prop.args, 872 | ) 873 | 874 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_dist_not_collocated* {prop.result} *', '*10 passed*']) 875 | assert result.ret == 0 876 | 877 | 878 | def test_central_subprocess(testdir): 879 | scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT, child_script=SCRIPT_CHILD) 880 | parent_script = scripts.dirpath().join('parent_script.py') 881 | 882 | result = testdir.runpytest('-v', f'--cov={scripts.dirpath()}', '--cov-report=term-missing', parent_script) 883 | 884 | result.stdout.fnmatch_lines( 885 | [ 886 | '*_ coverage: platform *, python * _*', 887 | f'child_script* {CHILD_SCRIPT_RESULT}*', 888 | f'parent_script* {PARENT_SCRIPT_RESULT}*', 889 | ] 890 | ) 891 | assert result.ret == 0 892 | 893 | 894 | def test_central_subprocess_change_cwd(testdir): 895 | scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT_CHANGE_CWD, child_script=SCRIPT_CHILD) 896 | parent_script = scripts.dirpath().join('parent_script.py') 897 | testdir.makefile( 898 | '', 899 | coveragerc=""" 900 | [run] 901 | branch = true 902 | parallel = true 903 | """, 904 | ) 905 | 906 | result = testdir.runpytest( 907 | '-v', '-s', f'--cov={scripts.dirpath()}', '--cov-config=coveragerc', '--cov-report=term-missing', parent_script 908 | ) 909 | 910 | result.stdout.fnmatch_lines( 911 | [ 912 | '*_ coverage: platform *, python * _*', 913 | f'*child_script* {CHILD_SCRIPT_RESULT}*', 914 | '*parent_script* 100%*', 915 | ] 916 | ) 917 | assert result.ret == 0 918 | 919 | 920 | def test_central_subprocess_change_cwd_with_pythonpath(pytester, testdir, monkeypatch): 921 | stuff = testdir.mkdir('stuff') 922 | parent_script = stuff.join('parent_script.py') 923 | parent_script.write(SCRIPT_PARENT_CHANGE_CWD_IMPORT_CHILD) 924 | stuff.join('child_script.py').write(SCRIPT_CHILD) 925 | testdir.makefile( 926 | '', 927 | coveragerc=""" 928 | [run] 929 | parallel = true 930 | """, 931 | ) 932 | 933 | monkeypatch.setitem(os.environ, 'PYTHONPATH', str(stuff)) 934 | result = testdir.runpytest( 935 | '-vv', '-s', '--cov=child_script', '--cov-config=coveragerc', '--cov-report=term-missing', '--cov-branch', parent_script 936 | ) 937 | 938 | result.stdout.fnmatch_lines( 939 | [ 940 | '*_ coverage: platform *, python * _*', 941 | f'*child_script* {CHILD_SCRIPT_RESULT}*', 942 | ] 943 | ) 944 | assert result.ret == 0 945 | 946 | 947 | def test_central_subprocess_no_subscript(testdir): 948 | script = testdir.makepyfile( 949 | """ 950 | import subprocess, sys 951 | 952 | def test_foo(): 953 | subprocess.check_call([sys.executable, '-c', 'print("Hello World")']) 954 | """ 955 | ) 956 | testdir.makefile( 957 | '', 958 | coveragerc=""" 959 | [run] 960 | parallel = true 961 | """, 962 | ) 963 | result = testdir.runpytest('-v', '--cov-config=coveragerc', f'--cov={script.dirpath()}', '--cov-branch', script) 964 | result.stdout.fnmatch_lines( 965 | [ 966 | '*_ coverage: platform *, python * _*', 967 | 'test_central_subprocess_no_subscript* * 3 * 0 * 100%*', 968 | ] 969 | ) 970 | assert result.ret == 0 971 | 972 | 973 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 974 | def test_dist_subprocess_collocated(testdir): 975 | scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT, child_script=SCRIPT_CHILD) 976 | parent_script = scripts.dirpath().join('parent_script.py') 977 | 978 | result = testdir.runpytest( 979 | '-v', f'--cov={scripts.dirpath()}', '--cov-report=term-missing', '--dist=load', '--tx=2*popen', max_worker_restart_0, parent_script 980 | ) 981 | 982 | result.stdout.fnmatch_lines( 983 | [ 984 | '*_ coverage: platform *, python * _*', 985 | f'child_script* {CHILD_SCRIPT_RESULT}*', 986 | f'parent_script* {PARENT_SCRIPT_RESULT}*', 987 | ] 988 | ) 989 | assert result.ret == 0 990 | 991 | 992 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 993 | def test_dist_subprocess_not_collocated(pytester, testdir, tmpdir): 994 | scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT, child_script=SCRIPT_CHILD) 995 | parent_script = scripts.dirpath().join('parent_script.py') 996 | child_script = scripts.dirpath().join('child_script.py') 997 | 998 | dir1 = tmpdir.mkdir('dir1') 999 | dir2 = tmpdir.mkdir('dir2') 1000 | testdir.tmpdir.join('.coveragerc').write( 1001 | f""" 1002 | [paths] 1003 | source = 1004 | {scripts.dirpath()} 1005 | */dir1 1006 | */dir2 1007 | """ 1008 | ) 1009 | result = testdir.runpytest( 1010 | '-v', 1011 | f'--cov={scripts.dirpath()}', 1012 | '--dist=load', 1013 | f'--tx=popen//chdir={dir1}', 1014 | f'--tx=popen//chdir={dir2}', 1015 | f'--rsyncdir={child_script}', 1016 | f'--rsyncdir={parent_script}', 1017 | '--rsyncdir=.coveragerc', 1018 | max_worker_restart_0, 1019 | parent_script, 1020 | ) 1021 | 1022 | result.stdout.fnmatch_lines( 1023 | [ 1024 | '*_ coverage: platform *, python * _*', 1025 | f'child_script* {CHILD_SCRIPT_RESULT}*', 1026 | f'parent_script* {PARENT_SCRIPT_RESULT}*', 1027 | ] 1028 | ) 1029 | assert result.ret == 0 1030 | 1031 | 1032 | def test_invalid_coverage_source(testdir): 1033 | script = testdir.makepyfile(SCRIPT) 1034 | testdir.makeini( 1035 | """ 1036 | [pytest] 1037 | console_output_style=classic 1038 | """ 1039 | ) 1040 | result = testdir.runpytest('-v', '--cov=non_existent_module', '--cov-report=term-missing', script) 1041 | 1042 | result.stdout.fnmatch_lines(['*10 passed*']) 1043 | result.stderr.fnmatch_lines(['*No data was collected.*']) 1044 | result.stdout.fnmatch_lines( 1045 | [ 1046 | '*Failed to generate report: No data to report.', 1047 | ] 1048 | ) 1049 | assert result.ret == 0 1050 | 1051 | matching_lines = [line for line in result.outlines if '%' in line] 1052 | assert not matching_lines 1053 | 1054 | 1055 | @pytest.mark.skipif("'dev' in pytest.__version__") 1056 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 1057 | @pytest.mark.skipif( 1058 | 'tuple(map(int, xdist.__version__.split("."))) >= (2, 3, 0)', 1059 | reason='Since pytest-xdist 2.3.0 the parent sys.path is copied in the child process', 1060 | ) 1061 | def test_dist_missing_data(testdir): 1062 | """Test failure when using a worker without pytest-cov installed.""" 1063 | venv_path = os.path.join(str(testdir.tmpdir), 'venv') 1064 | virtualenv.cli_run([venv_path]) 1065 | if sys.platform == 'win32': 1066 | if platform.python_implementation() == 'PyPy': 1067 | exe = os.path.join(venv_path, 'bin', 'python.exe') 1068 | else: 1069 | exe = os.path.join(venv_path, 'Scripts', 'python.exe') 1070 | else: 1071 | exe = os.path.join(venv_path, 'bin', 'python') 1072 | subprocess.check_call( 1073 | [exe, '-mpip', 'install', f'py=={py.__version__}', f'pytest=={pytest.__version__}', f'pytest_xdist=={xdist.__version__}'] 1074 | ) 1075 | script = testdir.makepyfile(SCRIPT) 1076 | 1077 | result = testdir.runpytest( 1078 | '-v', 1079 | '--assert=plain', 1080 | f'--cov={script.dirpath()}', 1081 | '--cov-report=term-missing', 1082 | '--dist=load', 1083 | f'--tx=popen//python={exe}', 1084 | max_worker_restart_0, 1085 | str(script), 1086 | ) 1087 | result.stdout.fnmatch_lines( 1088 | ['The following workers failed to return coverage data, ensure that pytest-cov is installed on these workers.'] 1089 | ) 1090 | 1091 | 1092 | def test_funcarg(testdir): 1093 | script = testdir.makepyfile(SCRIPT_FUNCARG) 1094 | 1095 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1096 | 1097 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', 'test_funcarg* 3 * 100%*', '*1 passed*']) 1098 | assert result.ret == 0 1099 | 1100 | 1101 | def test_funcarg_not_active(testdir): 1102 | script = testdir.makepyfile(SCRIPT_FUNCARG_NOT_ACTIVE) 1103 | 1104 | result = testdir.runpytest('-v', script) 1105 | 1106 | result.stdout.fnmatch_lines(['*1 passed*']) 1107 | assert result.ret == 0 1108 | 1109 | 1110 | @pytest.mark.skipif('sys.platform == "win32"', reason="SIGTERM isn't really supported on Windows") 1111 | @pytest.mark.xfail('platform.python_implementation() == "PyPy"', reason='Interpreter seems buggy') 1112 | def test_cleanup_on_sigterm(testdir): 1113 | script = testdir.makepyfile( 1114 | ''' 1115 | import os, signal, subprocess, sys, time 1116 | 1117 | def cleanup(num, frame): 1118 | print("num == signal.SIGTERM => %s" % (num == signal.SIGTERM)) 1119 | raise Exception() 1120 | 1121 | def test_run(): 1122 | proc = subprocess.Popen([sys.executable, __file__], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 1123 | time.sleep(1) 1124 | proc.terminate() 1125 | stdout, stderr = proc.communicate() 1126 | assert not stderr 1127 | assert stdout == b"""num == signal.SIGTERM => True 1128 | captured Exception() 1129 | """ 1130 | assert proc.returncode == 0 1131 | 1132 | if __name__ == "__main__": 1133 | signal.signal(signal.SIGTERM, cleanup) 1134 | 1135 | from pytest_cov.embed import cleanup_on_sigterm 1136 | cleanup_on_sigterm() 1137 | 1138 | try: 1139 | time.sleep(10) 1140 | except BaseException as exc: 1141 | print("captured %r" % exc) 1142 | ''' 1143 | ) 1144 | 1145 | result = testdir.runpytest('-vv', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1146 | 1147 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', 'test_cleanup_on_sigterm* 26-27', '*1 passed*']) 1148 | assert result.ret == 0 1149 | 1150 | 1151 | @pytest.mark.skipif('sys.platform != "win32"') 1152 | @pytest.mark.parametrize( 1153 | 'setup', 1154 | [ 1155 | ('signal.signal(signal.SIGBREAK, signal.SIG_DFL); cleanup_on_signal(signal.SIGBREAK)', '87% 21-22'), 1156 | ('cleanup_on_signal(signal.SIGBREAK)', '87% 21-22'), 1157 | ('cleanup()', '73% 19-22'), 1158 | ], 1159 | ) 1160 | def test_cleanup_on_sigterm_sig_break(pytester, testdir, setup): 1161 | # worth a read: https://stefan.sofa-rockers.org/2013/08/15/handling-sub-process-hierarchies-python-linux-os-x/ 1162 | script = testdir.makepyfile( 1163 | """ 1164 | import os, signal, subprocess, sys, time 1165 | 1166 | def test_run(): 1167 | proc = subprocess.Popen( 1168 | [sys.executable, __file__], 1169 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, 1170 | creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, shell=True 1171 | ) 1172 | time.sleep(1) 1173 | proc.send_signal(signal.CTRL_BREAK_EVENT) 1174 | stdout, stderr = proc.communicate() 1175 | assert not stderr 1176 | assert stdout in [b"^C", b"", b"captured IOError(4, 'Interrupted function call')\\n"] 1177 | 1178 | if __name__ == "__main__": 1179 | from pytest_cov.embed import cleanup_on_signal, cleanup 1180 | """ 1181 | + setup[0] 1182 | + """ 1183 | 1184 | try: 1185 | time.sleep(10) 1186 | except BaseException as exc: 1187 | print("captured %r" % exc) 1188 | """ 1189 | ) 1190 | 1191 | result = testdir.runpytest('-vv', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1192 | 1193 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_cleanup_on_sigterm* {setup[1]}', '*1 passed*']) 1194 | assert result.ret == 0 1195 | 1196 | 1197 | @pytest.mark.skipif('sys.platform == "win32"', reason="SIGTERM isn't really supported on Windows") 1198 | @pytest.mark.xfail('sys.platform == "darwin"', reason='Something weird going on Macs...') 1199 | @pytest.mark.xfail('platform.python_implementation() == "PyPy"', reason='Interpreter seems buggy') 1200 | @pytest.mark.parametrize( 1201 | 'setup', 1202 | [ 1203 | ('signal.signal(signal.SIGTERM, signal.SIG_DFL); cleanup_on_sigterm()', '88% 18-19'), 1204 | ('cleanup_on_sigterm()', '88% 18-19'), 1205 | ('cleanup()', '75% 16-19'), 1206 | ], 1207 | ) 1208 | def test_cleanup_on_sigterm_sig_dfl(pytester, testdir, setup): 1209 | script = testdir.makepyfile( 1210 | """ 1211 | import os, signal, subprocess, sys, time 1212 | 1213 | def test_run(): 1214 | proc = subprocess.Popen([sys.executable, __file__], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 1215 | time.sleep(1) 1216 | proc.terminate() 1217 | stdout, stderr = proc.communicate() 1218 | assert not stderr 1219 | assert stdout == b"" 1220 | assert proc.returncode in [128 + signal.SIGTERM, -signal.SIGTERM] 1221 | 1222 | if __name__ == "__main__": 1223 | from pytest_cov.embed import cleanup_on_sigterm, cleanup 1224 | """ 1225 | + setup[0] 1226 | + """ 1227 | 1228 | try: 1229 | time.sleep(10) 1230 | except BaseException as exc: 1231 | print("captured %r" % exc) 1232 | """ 1233 | ) 1234 | 1235 | result = testdir.runpytest('-vv', '--assert=plain', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1236 | 1237 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_cleanup_on_sigterm* {setup[1]}', '*1 passed*']) 1238 | assert result.ret == 0 1239 | 1240 | 1241 | @pytest.mark.skipif('sys.platform == "win32"', reason='SIGINT is subtly broken on Windows') 1242 | @pytest.mark.xfail('sys.platform == "darwin"', reason='Something weird going on Macs...') 1243 | @pytest.mark.xfail('platform.python_implementation() == "PyPy"', reason='Interpreter seems buggy') 1244 | def test_cleanup_on_sigterm_sig_dfl_sigint(testdir): 1245 | script = testdir.makepyfile( 1246 | ''' 1247 | import os, signal, subprocess, sys, time 1248 | 1249 | def test_run(): 1250 | proc = subprocess.Popen([sys.executable, __file__], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 1251 | time.sleep(1) 1252 | proc.send_signal(signal.SIGINT) 1253 | stdout, stderr = proc.communicate() 1254 | assert not stderr 1255 | assert stdout == b"""captured KeyboardInterrupt() 1256 | """ 1257 | assert proc.returncode == 0 1258 | 1259 | if __name__ == "__main__": 1260 | from pytest_cov.embed import cleanup_on_signal 1261 | cleanup_on_signal(signal.SIGINT) 1262 | 1263 | try: 1264 | time.sleep(10) 1265 | except BaseException as exc: 1266 | print("captured %r" % exc) 1267 | ''' 1268 | ) 1269 | 1270 | result = testdir.runpytest('-vv', '--assert=plain', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1271 | 1272 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', 'test_cleanup_on_sigterm* 88% 19-20', '*1 passed*']) 1273 | assert result.ret == 0 1274 | 1275 | 1276 | @pytest.mark.skipif('sys.platform == "win32"', reason='fork not available on Windows') 1277 | @pytest.mark.xfail('platform.python_implementation() == "PyPy"', reason='Interpreter seems buggy') 1278 | def test_cleanup_on_sigterm_sig_ign(testdir): 1279 | script = testdir.makepyfile( 1280 | """ 1281 | import os, signal, subprocess, sys, time 1282 | 1283 | def test_run(): 1284 | proc = subprocess.Popen([sys.executable, __file__], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 1285 | time.sleep(1) 1286 | proc.send_signal(signal.SIGINT) 1287 | time.sleep(1) 1288 | proc.terminate() 1289 | stdout, stderr = proc.communicate() 1290 | assert not stderr 1291 | assert stdout == b"" 1292 | assert proc.returncode in [128 + signal.SIGTERM, -signal.SIGTERM] 1293 | 1294 | if __name__ == "__main__": 1295 | signal.signal(signal.SIGINT, signal.SIG_IGN) 1296 | 1297 | from pytest_cov.embed import cleanup_on_signal 1298 | cleanup_on_signal(signal.SIGINT) 1299 | 1300 | try: 1301 | time.sleep(10) 1302 | except BaseException as exc: 1303 | print("captured %r" % exc) 1304 | """ 1305 | ) 1306 | 1307 | result = testdir.runpytest('-vv', '--assert=plain', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1308 | 1309 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', 'test_cleanup_on_sigterm* 89% 22-23', '*1 passed*']) 1310 | assert result.ret == 0 1311 | 1312 | 1313 | MODULE = """ 1314 | def func(): 1315 | return 1 1316 | 1317 | """ 1318 | 1319 | CONFTEST = """ 1320 | 1321 | import mod 1322 | mod.func() 1323 | 1324 | """ 1325 | 1326 | BASIC_TEST = """ 1327 | 1328 | def test_basic(): 1329 | x = True 1330 | assert x 1331 | 1332 | """ 1333 | 1334 | CONF_RESULT = 'mod* 2 * 100%*' 1335 | 1336 | 1337 | def test_cover_conftest(testdir): 1338 | testdir.makepyfile(mod=MODULE) 1339 | testdir.makeconftest(CONFTEST) 1340 | script = testdir.makepyfile(BASIC_TEST) 1341 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1342 | assert result.ret == 0 1343 | result.stdout.fnmatch_lines([CONF_RESULT]) 1344 | 1345 | 1346 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 1347 | def test_cover_looponfail(testdir, monkeypatch): 1348 | testdir.makepyfile(mod=MODULE) 1349 | testdir.makeconftest(CONFTEST) 1350 | script = testdir.makepyfile(BASIC_TEST) 1351 | 1352 | def mock_run(*args, **kwargs): 1353 | return _TestProcess(*map(str, args)) 1354 | 1355 | monkeypatch.setattr(testdir, 'run', mock_run) 1356 | assert testdir.run is mock_run 1357 | if hasattr(testdir, '_pytester'): 1358 | monkeypatch.setattr(testdir._pytester, 'run', mock_run) 1359 | assert testdir._pytester.run is mock_run 1360 | with testdir.runpytest('-v', f'--cov={script.dirpath()}', '--looponfail', script) as process: 1361 | with dump_on_error(process.read): 1362 | wait_for_strings(process.read, 30, 'Stmts Miss Cover') # 30 seconds 1363 | 1364 | 1365 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 1366 | def test_cover_conftest_dist(testdir): 1367 | testdir.makepyfile(mod=MODULE) 1368 | testdir.makeconftest(CONFTEST) 1369 | script = testdir.makepyfile(BASIC_TEST) 1370 | result = testdir.runpytest( 1371 | '-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--dist=load', '--tx=2*popen', max_worker_restart_0, script 1372 | ) 1373 | assert result.ret == 0 1374 | result.stdout.fnmatch_lines([CONF_RESULT]) 1375 | 1376 | 1377 | def test_no_cover_marker(testdir): 1378 | testdir.makepyfile(mod=MODULE) 1379 | script = testdir.makepyfile( 1380 | """ 1381 | import pytest 1382 | import mod 1383 | import subprocess 1384 | import sys 1385 | 1386 | @pytest.mark.no_cover 1387 | def test_basic(): 1388 | mod.func() 1389 | subprocess.check_call([sys.executable, '-c', 'from mod import func; func()']) 1390 | """ 1391 | ) 1392 | result = testdir.runpytest('-v', '-ra', '--strict', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1393 | assert result.ret == 0 1394 | result.stdout.fnmatch_lines(['mod* 2 * 1 * 50% * 2']) 1395 | 1396 | 1397 | def test_no_cover_fixture(testdir): 1398 | testdir.makepyfile(mod=MODULE) 1399 | script = testdir.makepyfile( 1400 | """ 1401 | import mod 1402 | import subprocess 1403 | import sys 1404 | 1405 | def test_basic(no_cover): 1406 | mod.func() 1407 | subprocess.check_call([sys.executable, '-c', 'from mod import func; func()']) 1408 | """ 1409 | ) 1410 | result = testdir.runpytest('-v', '-ra', '--strict', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1411 | assert result.ret == 0 1412 | result.stdout.fnmatch_lines(['mod* 2 * 1 * 50% * 2']) 1413 | 1414 | 1415 | COVERAGERC = """ 1416 | [report] 1417 | # Regexes for lines to exclude from consideration 1418 | exclude_lines = 1419 | raise NotImplementedError 1420 | """ 1421 | PYPROJECTTOML = """ 1422 | [tool.coverage.report] 1423 | # Regexes for lines to exclude from consideration 1424 | exclude_lines = [ 1425 | 'raise NotImplementedError', 1426 | ] 1427 | """ 1428 | 1429 | EXCLUDED_TEST = """ 1430 | 1431 | def func(): 1432 | raise NotImplementedError 1433 | 1434 | def test_basic(): 1435 | x = True 1436 | assert x 1437 | 1438 | """ 1439 | 1440 | EXCLUDED_RESULT = '4 * 100%*' 1441 | 1442 | 1443 | def test_coveragerc(testdir): 1444 | testdir.makefile('', coveragerc=COVERAGERC) 1445 | script = testdir.makepyfile(EXCLUDED_TEST) 1446 | result = testdir.runpytest('-v', '--cov-config=coveragerc', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1447 | assert result.ret == 0 1448 | result.stdout.fnmatch_lines([f'test_coveragerc* {EXCLUDED_RESULT}']) 1449 | 1450 | 1451 | def test_pyproject_toml(testdir): 1452 | testdir.makefile('.toml', pyproject=PYPROJECTTOML) 1453 | script = testdir.makepyfile(EXCLUDED_TEST) 1454 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1455 | assert result.ret == 0 1456 | result.stdout.fnmatch_lines([f'test_pyproject_toml* {EXCLUDED_RESULT}']) 1457 | 1458 | 1459 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 1460 | def test_coveragerc_dist(testdir): 1461 | testdir.makefile('', coveragerc=COVERAGERC) 1462 | script = testdir.makepyfile(EXCLUDED_TEST) 1463 | result = testdir.runpytest( 1464 | '-v', '--cov-config=coveragerc', f'--cov={script.dirpath()}', '--cov-report=term-missing', '-n', '2', max_worker_restart_0, script 1465 | ) 1466 | assert result.ret == 0 1467 | result.stdout.fnmatch_lines([f'test_coveragerc_dist* {EXCLUDED_RESULT}']) 1468 | 1469 | 1470 | SKIP_COVERED_COVERAGERC = """ 1471 | [report] 1472 | skip_covered = True 1473 | 1474 | """ 1475 | 1476 | SKIP_COVERED_TEST = """ 1477 | 1478 | def func(): 1479 | return "full coverage" 1480 | 1481 | def test_basic(): 1482 | assert func() == "full coverage" 1483 | 1484 | """ 1485 | 1486 | SKIP_COVERED_RESULT = '1 file skipped due to complete coverage.' 1487 | 1488 | 1489 | @pytest.mark.parametrize('report_option', ['term-missing:skip-covered', 'term:skip-covered']) 1490 | def test_skip_covered_cli(pytester, testdir, report_option): 1491 | testdir.makefile('', coveragerc=SKIP_COVERED_COVERAGERC) 1492 | script = testdir.makepyfile(SKIP_COVERED_TEST) 1493 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', f'--cov-report={report_option}', script) 1494 | assert result.ret == 0 1495 | result.stdout.fnmatch_lines([SKIP_COVERED_RESULT]) 1496 | 1497 | 1498 | def test_skip_covered_coveragerc_config(testdir): 1499 | testdir.makefile('', coveragerc=SKIP_COVERED_COVERAGERC) 1500 | script = testdir.makepyfile(SKIP_COVERED_TEST) 1501 | result = testdir.runpytest('-v', '--cov-config=coveragerc', f'--cov={script.dirpath()}', script) 1502 | assert result.ret == 0 1503 | result.stdout.fnmatch_lines([SKIP_COVERED_RESULT]) 1504 | 1505 | 1506 | CLEAR_ENVIRON_TEST = """ 1507 | 1508 | import os 1509 | 1510 | def test_basic(): 1511 | os.environ.clear() 1512 | 1513 | """ 1514 | 1515 | 1516 | def test_clear_environ(testdir): 1517 | script = testdir.makepyfile(CLEAR_ENVIRON_TEST) 1518 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) 1519 | assert result.ret == 0 1520 | 1521 | 1522 | SCRIPT_SIMPLE = """ 1523 | 1524 | def test_foo(): 1525 | assert 1 == 1 1526 | x = True 1527 | assert x 1528 | 1529 | """ 1530 | 1531 | SCRIPT_SIMPLE_RESULT = '4 * 100%' 1532 | 1533 | 1534 | @pytest.mark.skipif('tuple(map(int, xdist.__version__.split("."))) >= (3, 0, 2)', reason='--boxed option was removed in version 3.0.2') 1535 | @pytest.mark.skipif('sys.platform == "win32"') 1536 | def test_dist_boxed(testdir): 1537 | script = testdir.makepyfile(SCRIPT_SIMPLE) 1538 | 1539 | result = testdir.runpytest('-v', '--assert=plain', f'--cov={script.dirpath()}', '--boxed', script) 1540 | 1541 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_dist_boxed* {SCRIPT_SIMPLE_RESULT}*', '*1 passed*']) 1542 | assert result.ret == 0 1543 | 1544 | 1545 | @pytest.mark.skipif('sys.platform == "win32"') 1546 | @pytest.mark.skipif('platform.python_implementation() == "PyPy"', reason='strange optimization on PyPy3') 1547 | def test_dist_bare_cov(testdir): 1548 | script = testdir.makepyfile(SCRIPT_SIMPLE) 1549 | 1550 | result = testdir.runpytest('-v', '--cov', '-n', '1', script) 1551 | 1552 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_dist_bare_cov* {SCRIPT_SIMPLE_RESULT}*', '*1 passed*']) 1553 | assert result.ret == 0 1554 | 1555 | 1556 | def test_not_started_plugin_does_not_fail(testdir): 1557 | class ns: 1558 | cov_source = [True] 1559 | cov_report = '' 1560 | 1561 | plugin = pytest_cov.plugin.CovPlugin(ns, None, start=False) 1562 | plugin.pytest_runtestloop(None) 1563 | plugin.pytest_terminal_summary(None) 1564 | 1565 | 1566 | def test_default_output_setting(testdir): 1567 | script = testdir.makepyfile(SCRIPT) 1568 | 1569 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script) 1570 | 1571 | result.stdout.fnmatch_lines(['*coverage*']) 1572 | assert result.ret == 0 1573 | 1574 | 1575 | def test_disabled_output(testdir): 1576 | script = testdir.makepyfile(SCRIPT) 1577 | 1578 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=', script) 1579 | 1580 | stdout = result.stdout.str() 1581 | # We don't want the path to the executable to fail the test if we happen 1582 | # to put the project in a directory with "coverage" in it. 1583 | stdout = stdout.replace(sys.executable, '') 1584 | assert 'coverage' not in stdout 1585 | assert result.ret == 0 1586 | 1587 | 1588 | def test_coverage_file(testdir): 1589 | script = testdir.makepyfile(SCRIPT) 1590 | data_file_name = 'covdata' 1591 | os.environ['COVERAGE_FILE'] = data_file_name 1592 | try: 1593 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script) 1594 | assert result.ret == 0 1595 | data_file = testdir.tmpdir.join(data_file_name) 1596 | assert data_file.check() 1597 | finally: 1598 | os.environ.pop('COVERAGE_FILE') 1599 | 1600 | 1601 | def test_external_data_file(testdir): 1602 | script = testdir.makepyfile(SCRIPT) 1603 | testdir.tmpdir.join('.coveragerc').write( 1604 | """ 1605 | [run] 1606 | data_file = %s 1607 | """ 1608 | % testdir.tmpdir.join('some/special/place/coverage-data').ensure() 1609 | ) 1610 | 1611 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script) 1612 | assert result.ret == 0 1613 | assert glob.glob(str(testdir.tmpdir.join('some/special/place/coverage-data*'))) 1614 | 1615 | 1616 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 1617 | def test_external_data_file_xdist(testdir): 1618 | script = testdir.makepyfile(SCRIPT) 1619 | testdir.tmpdir.join('.coveragerc').write( 1620 | """ 1621 | [run] 1622 | parallel = true 1623 | data_file = %s 1624 | """ 1625 | % testdir.tmpdir.join('some/special/place/coverage-data').ensure() 1626 | ) 1627 | 1628 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '-n', '1', max_worker_restart_0, script) 1629 | assert result.ret == 0 1630 | assert glob.glob(str(testdir.tmpdir.join('some/special/place/coverage-data*'))) 1631 | 1632 | 1633 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 1634 | def test_xdist_no_data_collected(testdir): 1635 | testdir.makepyfile(target='x = 123') 1636 | script = testdir.makepyfile( 1637 | """ 1638 | import target 1639 | def test_foobar(): 1640 | assert target.x == 123 1641 | """ 1642 | ) 1643 | result = testdir.runpytest('-v', '--cov=target', '-n', '1', script) 1644 | assert 'no-data-collected' not in result.stderr.str() 1645 | assert 'no-data-collected' not in result.stdout.str() 1646 | assert 'module-not-imported' not in result.stderr.str() 1647 | assert 'module-not-imported' not in result.stdout.str() 1648 | assert result.ret == 0 1649 | 1650 | 1651 | def test_external_data_file_negative(testdir): 1652 | script = testdir.makepyfile(SCRIPT) 1653 | testdir.tmpdir.join('.coveragerc').write('') 1654 | 1655 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script) 1656 | assert result.ret == 0 1657 | assert glob.glob(str(testdir.tmpdir.join('.coverage*'))) 1658 | 1659 | 1660 | @xdist_params 1661 | def test_append_coverage(pytester, testdir, opts, prop): 1662 | script = testdir.makepyfile(test_1=prop.code) 1663 | testdir.tmpdir.join('.coveragerc').write(prop.fullconf) 1664 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script, *opts.split() + prop.args) 1665 | result.stdout.fnmatch_lines( 1666 | [ 1667 | f'test_1* {prop.result}*', 1668 | ] 1669 | ) 1670 | script2 = testdir.makepyfile(test_2=prop.code2) 1671 | result = testdir.runpytest('-v', '--cov-append', f'--cov={script2.dirpath()}', script2, *opts.split() + prop.args) 1672 | result.stdout.fnmatch_lines( 1673 | [ 1674 | f'test_1* {prop.result}*', 1675 | f'test_2* {prop.result2}*', 1676 | ] 1677 | ) 1678 | 1679 | 1680 | @xdist_params 1681 | def test_coverage_plugin(pytester, testdir, opts, prop): 1682 | script = testdir.makepyfile(test_1=prop.code) 1683 | testdir.makepyfile( 1684 | coverageplugin=""" 1685 | import coverage 1686 | 1687 | class ExamplePlugin(coverage.CoveragePlugin): 1688 | pass 1689 | 1690 | def coverage_init(reg, options): 1691 | reg.add_file_tracer(ExamplePlugin()) 1692 | """ 1693 | ) 1694 | testdir.makepyprojecttoml(f""" 1695 | [tool.coverage.run] 1696 | plugins = ["coverageplugin"] 1697 | concurrency = ["thread", "multiprocessing"] 1698 | {prop.conf} 1699 | """) 1700 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script, *opts.split() + prop.args) 1701 | result.stdout.fnmatch_lines( 1702 | [ 1703 | f'test_1* {prop.result}*', 1704 | ] 1705 | ) 1706 | 1707 | 1708 | @xdist_params 1709 | def test_dynamic_context(pytester, testdir, opts, prop): 1710 | script = testdir.makepyfile(test_1=prop.code) 1711 | testdir.makepyprojecttoml(f""" 1712 | [tool.coverage.run] 1713 | dynamic_context = "test_function" 1714 | parallel = true 1715 | {prop.conf} 1716 | """) 1717 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script, *opts.split() + prop.args) 1718 | if opts: 1719 | result.stderr.fnmatch_lines(['pytest_cov.DistCovError: Detected dynamic_context=test_function*']) 1720 | else: 1721 | result.stdout.fnmatch_lines( 1722 | [ 1723 | '* CentralCovContextWarning: Detected dynamic_context=test_function*', 1724 | f'test_1* {prop.result}*', 1725 | ] 1726 | ) 1727 | 1728 | 1729 | @xdist_params 1730 | def test_simple(pytester, testdir, opts, prop): 1731 | script = testdir.makepyfile(test_1=prop.code) 1732 | testdir.makepyprojecttoml(f""" 1733 | [tool.coverage.run] 1734 | parallel = true 1735 | {prop.conf} 1736 | """) 1737 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script, *opts.split() + prop.args) 1738 | result.stdout.fnmatch_lines( 1739 | [ 1740 | f'test_1* {prop.result}*', 1741 | ] 1742 | ) 1743 | 1744 | 1745 | @xdist_params 1746 | def test_do_not_append_coverage(pytester, testdir, opts, prop): 1747 | script = testdir.makepyfile(test_1=prop.code) 1748 | testdir.tmpdir.join('.coveragerc').write(prop.fullconf) 1749 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script, *opts.split() + prop.args) 1750 | result.stdout.fnmatch_lines( 1751 | [ 1752 | f'test_1* {prop.result}*', 1753 | ] 1754 | ) 1755 | script2 = testdir.makepyfile(test_2=prop.code2) 1756 | result = testdir.runpytest('-v', f'--cov={script2.dirpath()}', script2, *opts.split() + prop.args) 1757 | result.stdout.fnmatch_lines( 1758 | [ 1759 | 'test_1* 0%', 1760 | f'test_2* {prop.result2}*', 1761 | ] 1762 | ) 1763 | 1764 | 1765 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 1766 | def test_append_coverage_subprocess(testdir): 1767 | scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT, child_script=SCRIPT_CHILD) 1768 | parent_script = scripts.dirpath().join('parent_script.py') 1769 | 1770 | result = testdir.runpytest( 1771 | '-v', 1772 | f'--cov={scripts.dirpath()}', 1773 | '--cov-append', 1774 | '--cov-report=term-missing', 1775 | '--dist=load', 1776 | '--tx=2*popen', 1777 | max_worker_restart_0, 1778 | parent_script, 1779 | ) 1780 | 1781 | result.stdout.fnmatch_lines( 1782 | [ 1783 | '*_ coverage: platform *, python * _*', 1784 | f'child_script* {CHILD_SCRIPT_RESULT}*', 1785 | f'parent_script* {PARENT_SCRIPT_RESULT}*', 1786 | ] 1787 | ) 1788 | assert result.ret == 0 1789 | 1790 | 1791 | def test_pth_failure(monkeypatch): 1792 | with open('src/pytest-cov.pth') as fh: 1793 | payload = fh.read() 1794 | 1795 | class SpecificError(Exception): 1796 | pass 1797 | 1798 | def bad_init(): 1799 | raise SpecificError 1800 | 1801 | buff = StringIO() 1802 | 1803 | from pytest_cov import embed 1804 | 1805 | monkeypatch.setattr(embed, 'init', bad_init) 1806 | monkeypatch.setattr(sys, 'stderr', buff) 1807 | monkeypatch.setitem(os.environ, 'COV_CORE_SOURCE', 'foobar') 1808 | exec(payload) 1809 | expected = "pytest-cov: Failed to setup subprocess coverage. Environ: {'COV_CORE_SOURCE': 'foobar'} Exception: SpecificError()\n" 1810 | assert buff.getvalue() == expected 1811 | 1812 | 1813 | def test_double_cov(testdir): 1814 | script = testdir.makepyfile(SCRIPT_SIMPLE) 1815 | result = testdir.runpytest('-v', '--assert=plain', '--cov', f'--cov={script.dirpath()}', script) 1816 | 1817 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_double_cov* {SCRIPT_SIMPLE_RESULT}*', '*1 passed*']) 1818 | assert result.ret == 0 1819 | 1820 | 1821 | def test_double_cov2(testdir): 1822 | script = testdir.makepyfile(SCRIPT_SIMPLE) 1823 | result = testdir.runpytest('-v', '--assert=plain', '--cov', '--cov', script) 1824 | 1825 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_double_cov2* {SCRIPT_SIMPLE_RESULT}*', '*1 passed*']) 1826 | assert result.ret == 0 1827 | 1828 | 1829 | def test_cov_reset(testdir): 1830 | script = testdir.makepyfile(SCRIPT_SIMPLE) 1831 | result = testdir.runpytest('-v', '--assert=plain', f'--cov={script.dirpath()}', '--cov-reset', script) 1832 | 1833 | assert 'coverage: platform' not in result.stdout.str() 1834 | 1835 | 1836 | def test_cov_reset_then_set(testdir): 1837 | script = testdir.makepyfile(SCRIPT_SIMPLE) 1838 | result = testdir.runpytest('-v', '--assert=plain', f'--cov={script.dirpath()}', '--cov-reset', f'--cov={script.dirpath()}', script) 1839 | 1840 | result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_cov_reset_then_set* {SCRIPT_SIMPLE_RESULT}*', '*1 passed*']) 1841 | 1842 | 1843 | @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') 1844 | def test_cov_and_no_cov(testdir): 1845 | script = testdir.makepyfile(SCRIPT_SIMPLE) 1846 | result = testdir.runpytest('-v', '--cov', '--no-cov', '-n', '1', '-s', script) 1847 | assert 'Coverage disabled via --no-cov switch!' not in result.stdout.str() 1848 | assert 'Coverage disabled via --no-cov switch!' not in result.stderr.str() 1849 | assert result.ret == 0 1850 | 1851 | 1852 | def find_labels(text, pattern): 1853 | all_labels = collections.defaultdict(set) 1854 | lines = text.splitlines() 1855 | for lineno, line in enumerate(lines, start=1): 1856 | labels = re.findall(pattern, line) 1857 | for label in labels: 1858 | all_labels[label].add(lineno) 1859 | return all_labels 1860 | 1861 | 1862 | # The contexts and their labels in contextful.py 1863 | EXPECTED_CONTEXTS = { 1864 | '': 'c0', 1865 | 'test_contexts.py::test_01|run': 'r1', 1866 | 'test_contexts.py::test_02|run': 'r2', 1867 | 'test_contexts.py::OldStyleTests::test_03|setup': 's3', 1868 | 'test_contexts.py::OldStyleTests::test_03|run': 'r3', 1869 | 'test_contexts.py::OldStyleTests::test_04|run': 'r4', 1870 | 'test_contexts.py::OldStyleTests::test_04|teardown': 't4', 1871 | 'test_contexts.py::test_05|setup': 's5', 1872 | 'test_contexts.py::test_05|run': 'r5', 1873 | 'test_contexts.py::test_06|setup': 's6', 1874 | 'test_contexts.py::test_06|run': 'r6', 1875 | 'test_contexts.py::test_07|setup': 's7', 1876 | 'test_contexts.py::test_07|run': 'r7', 1877 | 'test_contexts.py::test_08|run': 'r8', 1878 | 'test_contexts.py::test_09[1]|setup': 's9-1', 1879 | 'test_contexts.py::test_09[1]|run': 'r9-1', 1880 | 'test_contexts.py::test_09[2]|setup': 's9-2', 1881 | 'test_contexts.py::test_09[2]|run': 'r9-2', 1882 | 'test_contexts.py::test_09[3]|setup': 's9-3', 1883 | 'test_contexts.py::test_09[3]|run': 'r9-3', 1884 | 'test_contexts.py::test_10|run': 'r10', 1885 | 'test_contexts.py::test_11[1-101]|run': 'r11-1', 1886 | 'test_contexts.py::test_11[2-202]|run': 'r11-2', 1887 | 'test_contexts.py::test_12[one]|run': 'r12-1', 1888 | 'test_contexts.py::test_12[two]|run': 'r12-2', 1889 | 'test_contexts.py::test_13[3-1]|run': 'r13-1', 1890 | 'test_contexts.py::test_13[3-2]|run': 'r13-2', 1891 | 'test_contexts.py::test_13[4-1]|run': 'r13-3', 1892 | 'test_contexts.py::test_13[4-2]|run': 'r13-4', 1893 | } 1894 | 1895 | 1896 | @pytest.mark.skipif('coverage.version_info < (5, 0)') 1897 | @pytest.mark.skipif('coverage.version_info > (6, 4)') 1898 | @xdist_params 1899 | def test_contexts(pytester, testdir, opts): 1900 | with open(os.path.join(os.path.dirname(__file__), 'contextful.py')) as f: 1901 | contextful_tests = f.read() 1902 | script = testdir.makepyfile(contextful_tests) 1903 | result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-context=test', script, *opts.split()) 1904 | assert result.ret == 0 1905 | result.stdout.fnmatch_lines( 1906 | [ 1907 | 'test_contexts* 100%*', 1908 | ] 1909 | ) 1910 | 1911 | data = coverage.CoverageData('.coverage') 1912 | data.read() 1913 | assert data.measured_contexts() == set(EXPECTED_CONTEXTS) 1914 | measured = data.measured_files() 1915 | assert len(measured) == 1 1916 | test_context_path = next(iter(measured)) 1917 | assert test_context_path.lower() == os.path.abspath('test_contexts.py').lower() 1918 | 1919 | line_data = find_labels(contextful_tests, r'[crst]\d+(?:-\d+)?') 1920 | for context, label in EXPECTED_CONTEXTS.items(): 1921 | if context == '': 1922 | continue 1923 | data.set_query_context(context) 1924 | actual = set(data.lines(test_context_path)) 1925 | assert line_data[label] == actual, f'Wrong lines for context {context!r}' 1926 | 1927 | 1928 | @pytest.mark.skipif('coverage.version_info >= (5, 0)') 1929 | def test_contexts_not_supported(testdir): 1930 | script = testdir.makepyfile('a = 1') 1931 | result = testdir.runpytest( 1932 | '-v', 1933 | f'--cov={script.dirpath()}', 1934 | '--cov-context=test', 1935 | script, 1936 | ) 1937 | result.stderr.fnmatch_lines( 1938 | [ 1939 | '*argument --cov-context: Contexts are only supported with coverage.py >= 5.x', 1940 | ] 1941 | ) 1942 | assert result.ret != 0 1943 | 1944 | 1945 | def test_contexts_no_cover(testdir): 1946 | script = testdir.makepyfile(""" 1947 | import pytest 1948 | 1949 | def foobar(): 1950 | return 1 1951 | 1952 | def test_with_coverage(): 1953 | foobar() 1954 | 1955 | @pytest.mark.no_cover() 1956 | def test_without_coverage(): 1957 | foobar() 1958 | """) 1959 | result = testdir.runpytest( 1960 | '-v', 1961 | '--cov-context=test', 1962 | '--cov=test_contexts_no_cover', 1963 | script, 1964 | ) 1965 | result.stdout.fnmatch_lines( 1966 | [ 1967 | 'test_contexts_no_cover.py 8 1 88%', 1968 | 'TOTAL 8 1 88%', 1969 | ] 1970 | ) 1971 | assert result.stderr.lines == [] 1972 | assert result.ret == 0 1973 | 1974 | 1975 | def test_issue_417(testdir): 1976 | # https://github.com/pytest-dev/pytest-cov/issues/417 1977 | whatever = testdir.maketxtfile(whatever='') 1978 | testdir.inline_genitems(whatever) 1979 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv:bootstrap] 2 | deps = 3 | jinja2 4 | tox 5 | skip_install = true 6 | commands = 7 | python ci/bootstrap.py --no-env 8 | passenv = 9 | * 10 | 11 | ; a generative tox configuration, see: https://tox.wiki/en/latest/user_guide.html#generative-environments 12 | [tox] 13 | envlist = 14 | clean, 15 | check, 16 | docs, 17 | {py39,py310,py311,py312,py313,pypy39,pypy310}-{pytest83}-{xdist36}-{coverage77}, 18 | report 19 | ignore_basepython_conflict = true 20 | 21 | [testenv] 22 | basepython = 23 | pypy38: {env:TOXPYTHON:pypy3.8} 24 | pypy39: {env:TOXPYTHON:pypy3.9} 25 | pypy310: {env:TOXPYTHON:pypy3.10} 26 | py38: {env:TOXPYTHON:python3.8} 27 | py39: {env:TOXPYTHON:python3.9} 28 | py310: {env:TOXPYTHON:python3.10} 29 | py311: {env:TOXPYTHON:python3.11} 30 | py312: {env:TOXPYTHON:python3.12} 31 | py313: {env:TOXPYTHON:python3.13} 32 | {bootstrap,clean,check,report,docs}: {env:TOXPYTHON:python3} 33 | extras = testing 34 | setenv = 35 | PYTHONPATH={toxinidir}/tests 36 | PYTHONUNBUFFERED=yes 37 | 38 | # Use env vars for (optional) pinning of deps. 39 | pytest80: _DEP_PYTEST=pytest==8.0.2 40 | pytest81: _DEP_PYTEST=pytest==8.1.1 41 | pytest82: _DEP_PYTEST=pytest==8.2.2 42 | pytest83: _DEP_PYTEST=pytest==8.3.5 43 | 44 | xdist32: _DEP_PYTESTXDIST=pytest-xdist==3.2.0 45 | xdist33: _DEP_PYTESTXDIST=pytest-xdist==3.3.1 46 | xdist34: _DEP_PYTESTXDIST=pytest-xdist==3.4.0 47 | xdist35: _DEP_PYTESTXDIST=pytest-xdist==3.5.0 48 | xdist36: _DEP_PYTESTXDIST=pytest-xdist==3.6.1 49 | xdistdev: _DEP_PYTESTXDIST=git+https://github.com/pytest-dev/pytest-xdist.git#egg=pytest-xdist 50 | 51 | coverage72: _DEP_COVERAGE=coverage==7.2.7 52 | coverage73: _DEP_COVERAGE=coverage==7.3.4 53 | coverage74: _DEP_COVERAGE=coverage==7.4.4 54 | coverage75: _DEP_COVERAGE=coverage==7.5.4 55 | coverage76: _DEP_COVERAGE=coverage==7.6.12 56 | coverage77: _DEP_COVERAGE=coverage==7.7.1 57 | # For testing against a coverage.py working tree. 58 | coveragedev: _DEP_COVERAGE=-e{env:COVERAGE_HOME} 59 | passenv = 60 | * 61 | deps = 62 | {env:_DEP_PYTEST:pytest} 63 | {env:_DEP_PYTESTXDIST:pytest-xdist} 64 | {env:_DEP_COVERAGE:coverage} 65 | pip_pre = true 66 | commands = 67 | {posargs:pytest -vv} 68 | 69 | [testenv:check] 70 | deps = 71 | docutils 72 | check-manifest 73 | pre-commit 74 | readme-renderer 75 | pygments 76 | isort 77 | skip_install = true 78 | commands = 79 | python setup.py check --strict --metadata --restructuredtext 80 | check-manifest . 81 | pre-commit run --all-files --show-diff-on-failure 82 | 83 | [testenv:docs] 84 | usedevelop = true 85 | deps = 86 | -r{toxinidir}/docs/requirements.txt 87 | commands = 88 | sphinx-build {posargs:-E} -b html docs dist/docs 89 | sphinx-build -b linkcheck docs dist/docs 90 | 91 | [testenv:report] 92 | deps = 93 | coverage 94 | skip_install = true 95 | commands = 96 | coverage report 97 | coverage html 98 | 99 | [testenv:clean] 100 | commands = coverage erase 101 | skip_install = true 102 | deps = 103 | coverage 104 | --------------------------------------------------------------------------------