diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index 9f176ee5..00000000 --- a/.appveyor.yml +++ /dev/null @@ -1,46 +0,0 @@ -# NOTE: this file is auto-generated via ci/bootstrap.py (ci/templates/.appveyor.yml). -version: '{branch}-{build}' -build: off -image: - - Visual Studio 2015 - - Visual Studio 2019 -environment: - matrix: - - TOXENV: check - - TOXENV: 'py36-pytest46-xdist127-coverage55,py36-pytest46-xdist133-coverage55,py36-pytest54-xdist133-coverage55,py36-pytest62-xdist202-coverage55' - - TOXENV: 'py37-pytest46-xdist127-coverage55,py37-pytest46-xdist133-coverage55,py37-pytest54-xdist133-coverage55,py37-pytest62-xdist202-coverage55' - - TOXENV: 'py38-pytest46-xdist133-coverage55,py38-pytest54-xdist133-coverage55,py38-pytest62-xdist202-coverage55' - - TOXENV: 'py39-pytest62-xdist202-coverage55' - - TOXENV: 'pypy3-pytest46-xdist127-coverage55,pypy3-pytest46-xdist133-coverage55,pypy3-pytest54-xdist133-coverage55,pypy3-pytest62-xdist202-coverage55' -matrix: - exclude: - - image: Visual Studio 2015 - TOXENV: 'py36-pytest46-xdist127-coverage55,py36-pytest46-xdist133-coverage55,py36-pytest54-xdist133-coverage55,py36-pytest62-xdist202-coverage55' - - image: Visual Studio 2015 - TOXENV: 'py37-pytest46-xdist127-coverage55,py37-pytest46-xdist133-coverage55,py37-pytest54-xdist133-coverage55,py37-pytest62-xdist202-coverage55' - - image: Visual Studio 2015 - TOXENV: 'py38-pytest46-xdist133-coverage55,py38-pytest54-xdist133-coverage55,py38-pytest62-xdist202-coverage55' - - image: Visual Studio 2015 - TOXENV: 'py39-pytest62-xdist202-coverage55' - - image: Visual Studio 2015 - TOXENV: 'pypy3-pytest46-xdist127-coverage55,pypy3-pytest46-xdist133-coverage55,pypy3-pytest54-xdist133-coverage55,pypy3-pytest62-xdist202-coverage55' -init: - - ps: echo $env:TOXENV - - ps: ls C:\Python* -install: - - IF "%TOXENV:~0,6%" == "pypy3-" choco install --no-progress pypy3 - - SET PATH=C:\tools\pypy\pypy;%PATH% - - C:\Python37\python -m pip install --progress-bar=off tox -rci/requirements.txt - -test_script: - - cmd /E:ON /V:ON /C .\ci\appveyor-with-compiler.cmd C:\Python37\python -m tox - -on_failure: - - ps: dir "env:" - - ps: get-content .tox\*\log\* -artifacts: - - path: dist\* - -### To enable remote debugging uncomment this (also, see: http://www.appveyor.com/docs/how-to/rdp-to-build-worker): -# on_finish: -# - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 27a843fb..09bfcde0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.0 +current_version = 4.0.0 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 9cad1178..49e9880e 100644 --- a/.cookiecutterrc +++ b/.cookiecutterrc @@ -2,7 +2,7 @@ default_context: allow_tests_inside_package: no - appveyor: yes + appveyor: no c_extension_function: '-' c_extension_module: '-' c_extension_optional: no @@ -16,10 +16,10 @@ default_context: command_line_interface: no command_line_interface_bin_name: '-' coveralls: no - coveralls_token: '[Required for Appveyor, take it from https://coveralls.io/github/ionelmc/pytest-cov]' distribution_name: pytest-cov email: contact@ionelmc.ro full_name: Ionel Cristian Mărieș + github_actions: yes legacy_python: yes license: MIT license linter: flake8 @@ -29,9 +29,10 @@ default_context: 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. pypi_badge: yes pypi_disable_upload: no - release_date: '2020-06-12' + release_date: '2021-10-04' repo_hosting: github.com repo_hosting_domain: github.com + repo_main_branch: master repo_name: pytest-cov repo_username: pytest-dev requiresio: yes @@ -45,9 +46,10 @@ default_context: test_matrix_configurator: no test_matrix_separate_coverage: no test_runner: pytest - travis: yes + travis: no travis_osx: no - version: 2.10.1 + version: 3.0.0 + version_manager: bump2version website: http://blog.ionelmc.ro year_from: '2010' - year_to: '2020' + year_to: '2022' diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml deleted file mode 100644 index dbd62a6c..00000000 --- a/.github/workflows/examples.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Examples - -on: [push, pull_request, workflow_dispatch] - -env: - FORCE_COLOR: 1 - -jobs: - examples: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["pypy-3.7", "3.9"] - target: [ - "src-layout", - "adhoc-layout", - ] - include: - # Add new helper variables to existing jobs - - {python-version: "pypy-3.7", tox-python-version: "pypy3"} - - {python-version: "3.9", tox-python-version: "py39"} - steps: - - uses: actions/checkout@v2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: - examples-v1-${{ hashFiles('**/tox.ini') }} - restore-keys: | - examples-v1- - - - name: Install dependencies - run: | - python -m pip install -U pip - python -m pip install -U wheel - python -m pip install --progress-bar=off tox -rci/requirements.txt - - - name: Examples - run: | - cd examples/${{ matrix.target }} - tox -v -e ${{ matrix.tox-python-version }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 001ccfee..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Lint - -on: [push, pull_request, workflow_dispatch] - -env: - FORCE_COLOR: 1 - -jobs: - lint: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - toxenv: ["check", "docs"] - - steps: - - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - - name: Cache - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: - lint-v1-${{ hashFiles('**/tox.ini') }} - restore-keys: | - lint-v1- - - - name: Install dependencies - run: | - python -m pip install -U pip - python -m pip install -U wheel - python -m pip install --progress-bar=off tox -rci/requirements.txt - - - name: Lint ${{ matrix.toxenv }} - run: | - tox -v -e ${{ matrix.toxenv }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9f7c11c..a85a1e03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,42 +1,20 @@ -name: Test - +name: Tests on: [push, pull_request, workflow_dispatch] - -env: - FORCE_COLOR: 1 - jobs: - test: + examples: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["pypy-3.6", "pypy-3.7", "3.6", "3.7", "3.8", "3.9", "3.10-dev"] - tox-extra-versions: [ - "pytest46-xdist127", - "pytest46-xdist133", - "pytest54-xdist133", - "pytest62-xdist202", + python-version: ["pypy-3.7", "3.9"] + target: [ + "src-layout", + "adhoc-layout", ] include: # Add new helper variables to existing jobs - - {python-version: "pypy-3.6", tox-python-version: "pypy3"} - {python-version: "pypy-3.7", tox-python-version: "pypy3"} - - {python-version: "3.6", tox-python-version: "py36"} - - {python-version: "3.7", tox-python-version: "py37"} - - {python-version: "3.8", tox-python-version: "py38"} - {python-version: "3.9", tox-python-version: "py39"} - - {python-version: "3.10-dev", tox-python-version: "py310"} - exclude: - # Remove some jobs from the matrix - - {tox-extra-versions: "pytest46-xdist127", python-version: "3.8"} - - {tox-extra-versions: "pytest46-xdist127", python-version: "3.9"} - - {tox-extra-versions: "pytest46-xdist133", python-version: "3.9"} - - {tox-extra-versions: "pytest54-xdist133", python-version: "3.9"} - - {tox-extra-versions: "pytest46-xdist127", python-version: "3.10-dev"} - - {tox-extra-versions: "pytest46-xdist133", python-version: "3.10-dev"} - - {tox-extra-versions: "pytest54-xdist133", python-version: "3.10-dev"} - steps: - uses: actions/checkout@v2 @@ -45,37 +23,198 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Get pip cache dir - id: pip-cache - run: | - echo "::set-output name=dir::$(pip cache dir)" - - name: Cache uses: actions/cache@v2 with: - path: ${{ steps.pip-cache.outputs.dir }} + path: ~/.cache/pip key: - test-${{ matrix.python-version }}-v1-${{ hashFiles('**/requirements.txt') }} + examples-v1-${{ hashFiles('**/tox.ini') }} restore-keys: | - test-${{ matrix.python-version }}-v1- + examples-v1- - name: Install dependencies run: | python -m pip install -U pip python -m pip install -U wheel python -m pip install --progress-bar=off tox -rci/requirements.txt - virtualenv --version - pip --version - tox --version - - name: Tox tests + - name: Examples run: | - tox -v -e ${{ matrix.tox-python-version }}-${{ matrix.tox-extra-versions }}-coverage55 + cd examples/${{ matrix.target }} + tox -v -e ${{ matrix.tox-python-version }} + test: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: 'check' + python: '3.9' + toxpython: 'python3.9' + tox_env: 'check' + os: 'ubuntu-latest' + - name: 'docs' + python: '3.9' + toxpython: 'python3.9' + tox_env: 'docs' + os: 'ubuntu-latest' + - name: 'py36-pytest70-xdist250-coverage62 (ubuntu)' + python: '3.6' + toxpython: 'python3.6' + python_arch: 'x64' + tox_env: 'py36-pytest70-xdist250-coverage62' + os: 'ubuntu-latest' + - name: 'py36-pytest70-xdist250-coverage62 (windows)' + python: '3.6' + toxpython: 'python3.6' + python_arch: 'x64' + tox_env: 'py36-pytest70-xdist250-coverage62' + os: 'windows-latest' + - name: 'py36-pytest70-xdist250-coverage62 (macos)' + python: '3.6' + toxpython: 'python3.6' + python_arch: 'x64' + tox_env: 'py36-pytest70-xdist250-coverage62' + os: 'macos-latest' + - name: 'py37-pytest71-xdist250-coverage64 (ubuntu)' + python: '3.7' + toxpython: 'python3.7' + python_arch: 'x64' + tox_env: 'py37-pytest71-xdist250-coverage64' + os: 'ubuntu-latest' + - name: 'py37-pytest71-xdist250-coverage64 (windows)' + python: '3.7' + toxpython: 'python3.7' + python_arch: 'x64' + tox_env: 'py37-pytest71-xdist250-coverage64' + os: 'windows-latest' + - name: 'py37-pytest71-xdist250-coverage64 (macos)' + python: '3.7' + toxpython: 'python3.7' + python_arch: 'x64' + tox_env: 'py37-pytest71-xdist250-coverage64' + os: 'macos-latest' + - name: 'py38-pytest71-xdist250-coverage64 (ubuntu)' + python: '3.8' + toxpython: 'python3.8' + python_arch: 'x64' + tox_env: 'py38-pytest71-xdist250-coverage64' + os: 'ubuntu-latest' + - name: 'py38-pytest71-xdist250-coverage64 (windows)' + python: '3.8' + toxpython: 'python3.8' + python_arch: 'x64' + tox_env: 'py38-pytest71-xdist250-coverage64' + os: 'windows-latest' + - name: 'py38-pytest71-xdist250-coverage64 (macos)' + python: '3.8' + toxpython: 'python3.8' + python_arch: 'x64' + tox_env: 'py38-pytest71-xdist250-coverage64' + os: 'macos-latest' + - name: 'py39-pytest71-xdist250-coverage64 (ubuntu)' + python: '3.9' + toxpython: 'python3.9' + python_arch: 'x64' + tox_env: 'py39-pytest71-xdist250-coverage64' + os: 'ubuntu-latest' + - name: 'py39-pytest71-xdist250-coverage64 (windows)' + python: '3.9' + toxpython: 'python3.9' + python_arch: 'x64' + tox_env: 'py39-pytest71-xdist250-coverage64' + os: 'windows-latest' + - name: 'py39-pytest71-xdist250-coverage64 (macos)' + python: '3.9' + toxpython: 'python3.9' + python_arch: 'x64' + tox_env: 'py39-pytest71-xdist250-coverage64' + os: 'macos-latest' + - name: 'py310-pytest71-xdist250-coverage64 (ubuntu)' + python: '3.10' + toxpython: 'python3.10' + python_arch: 'x64' + tox_env: 'py310-pytest71-xdist250-coverage64' + os: 'ubuntu-latest' + - name: 'py310-pytest71-xdist250-coverage64 (windows)' + python: '3.10' + toxpython: 'python3.10' + python_arch: 'x64' + tox_env: 'py310-pytest71-xdist250-coverage64' + os: 'windows-latest' + - name: 'py310-pytest71-xdist250-coverage64 (macos)' + python: '3.10' + toxpython: 'python3.10' + python_arch: 'x64' + tox_env: 'py310-pytest71-xdist250-coverage64' + os: 'macos-latest' + - name: 'pypy37-pytest71-xdist250-coverage64 (ubuntu)' + python: 'pypy-3.7' + toxpython: 'pypy3.7' + python_arch: 'x64' + tox_env: 'pypy37-pytest71-xdist250-coverage64' + os: 'ubuntu-latest' + - name: 'pypy37-pytest71-xdist250-coverage64 (windows)' + python: 'pypy-3.7' + toxpython: 'pypy3.7' + python_arch: 'x64' + tox_env: 'pypy37-pytest71-xdist250-coverage64' + os: 'windows-latest' + - name: 'pypy37-pytest71-xdist250-coverage64 (macos)' + python: 'pypy-3.7' + toxpython: 'pypy3.7' + python_arch: 'x64' + tox_env: 'pypy37-pytest71-xdist250-coverage64' + os: 'macos-latest' + - name: 'pypy38-pytest71-xdist250-coverage64 (ubuntu)' + python: 'pypy-3.8' + toxpython: 'pypy3.8' + python_arch: 'x64' + tox_env: 'pypy38-pytest71-xdist250-coverage64' + os: 'ubuntu-latest' + - name: 'pypy38-pytest71-xdist250-coverage64 (windows)' + python: 'pypy-3.8' + toxpython: 'pypy3.8' + python_arch: 'x64' + tox_env: 'pypy38-pytest71-xdist250-coverage64' + os: 'windows-latest' + - name: 'pypy38-pytest71-xdist250-coverage64 (macos)' + python: 'pypy-3.8' + toxpython: 'pypy3.8' + python_arch: 'x64' + tox_env: 'pypy38-pytest71-xdist250-coverage64' + os: 'macos-latest' + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + architecture: ${{ matrix.python_arch }} + - name: install dependencies + run: | + python -mpip install --progress-bar=off -r ci/requirements.txt + virtualenv --version + pip --version + tox --version + pip list --format=freeze + - name: test + env: + TOXPYTHON: '${{ matrix.toxpython }}' + run: > + tox -e ${{ matrix.tox_env }} -v - allgood: - needs: test + check: + if: always() + needs: + - test + - examples runs-on: ubuntu-latest - name: Test successful steps: - - name: Success - run: echo Test successful + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc36fa2b..9bc7894f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,22 +4,22 @@ # pre-commit install-hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.2.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer exclude: '.*\.pth$' - id: debug-statements - repo: https://github.com/PyCQA/isort - rev: 5.9.3 + rev: 5.10.1 hooks: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 4.0.1 hooks: - id: flake8 - repo: https://github.com/asottile/pyupgrade - rev: v2.23.3 + rev: v2.32.1 hooks: - id: pyupgrade args: [--py36-plus] diff --git a/AUTHORS.rst b/AUTHORS.rst index 6aa916a1..2ad80a00 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -50,3 +50,10 @@ Authors * Brian Rutledge - https://github.com/bhrutledge * Danilo Šegan - https://github.com/dsegan * Michał Bielawski - https://github.com/D3X +* Zac Hatfield-Dodds - https://github.com/Zac-HD +* Ben Greiner - https://github.com/bnavigator +* Delgan - https://github.com/Delgan +* Andre Brisco - https://github.com/abrisco +* Colin O'Dell - https://github.com/colinodell +* Ronny Pfannschmidt - https://github.com/RonnyPfannschmidt +* Christian Fetzer - https://github.com/fetzerch diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e6e47d95..c49683f5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,38 @@ Changelog ========= +4.0.0 (2022-09-28) +------------------ + +**Note that this release drops support for multiprocessing.** + + +* `--cov-fail-under` no longer causes `pytest --collect-only` to fail + Contributed by Zac Hatfield-Dodds in `#511 `_. +* Dropped support for multiprocessing (mostly because `issue 82408 `_). This feature was + mostly working but very broken in certain scenarios and made the test suite very flaky and slow. + + There is builtin multiprocessing support in coverage and you can migrate to that. All you need is this in your + ``.coveragerc``:: + + [run] + concurrency = multiprocessing + parallel = true + sigterm = true +* Fixed deprecation in ``setup.py`` by trying to import setuptools before distutils. + Contributed by Ben Greiner in `#545 `_. +* Removed undesirable new lines that were displayed while reporting was disabled. + Contributed by Delgan in `#540 `_. +* Documentation fixes. + Contributed by Andre Brisco in `#543 `_ + and Colin O'Dell in `#525 `_. +* Added support for LCOV output format via `--cov-report=lcov`. Only works with coverage 6.3+. + Contributed by Christian Fetzer in `#536 `_. +* Modernized pytest hook implementation. + Contributed by Bruno Oliveira in `#549 `_ + and Ronny Pfannschmidt in `#550 `_. + + 3.0.0 (2021-10-04) ------------------- @@ -24,17 +56,9 @@ Changelog `#488 `_. * Updated trove classifiers. Contributed by Michał Bielawski in `#481 `_. - - -2.13.0 (2021-06-01) -------------------- - -* Changed the `toml` requirement to be always be directly required (instead of being required through a coverage extra). - This fixes issues with pip-compile (`pip-tools#1300 `_). - Contributed by Sorin Sbarnea in `#472 `_. -* Documented ``show_contexts``. - Contributed by Brian Rutledge in `#473 `_. - +* Reverted change for `toml` requirement. + Contributed by Thomas Grainger in + `#477 `_. 2.12.1 (2021-06-01) ------------------- @@ -185,8 +209,6 @@ Changelog `#272 `_, `#271 `_ and `#269 `_. -* Improved documentation regarding subprocess and multiprocessing. - Contributed in `#265 `_. * Improved ``pytest_cov.embed.cleanup_on_sigterm`` to be reentrant (signal deliveries while signal handling is running won't break stuff). * Added ``pytest_cov.embed.cleanup_on_signal`` for customized cleanup. diff --git a/MANIFEST.in b/MANIFEST.in index af1581be..cbb88f74 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -12,16 +12,17 @@ graft ci graft tests include .bumpversion.cfg -include .coveragerc include .cookiecutterrc +include .coveragerc include .editorconfig - +include tox.ini +include .readthedocs.yml +include .pre-commit-config.yaml include AUTHORS.rst include CHANGELOG.rst include CONTRIBUTING.rst include LICENSE include README.rst -include tox.ini .appveyor.yml .readthedocs.yml .pre-commit-config.yaml -global-exclude *.py[cod] __pycache__/* *.so *.dylib .coverage .coverage.* +global-exclude *.py[cod] __pycache__/* *.so *.dylib diff --git a/README.rst b/README.rst index 508aff84..84a85786 100644 --- a/README.rst +++ b/README.rst @@ -10,7 +10,8 @@ Overview * - docs - |docs| * - tests - - | |github-actions| |appveyor| |requires| + - | |github-actions| |requires| + | * - package - | |version| |conda-forge| |wheel| |supported-versions| |supported-implementations| | |commits-since| @@ -38,9 +39,9 @@ Overview .. |conda-forge| image:: https://img.shields.io/conda/vn/conda-forge/pytest-cov.svg :target: https://anaconda.org/conda-forge/pytest-cov -.. |commits-since| image:: https://img.shields.io/github/commits-since/pytest-dev/pytest-cov/v3.0.0.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/pytest-dev/pytest-cov/v4.0.0.svg :alt: Commits since latest release - :target: https://github.com/pytest-dev/pytest-cov/compare/v3.0.0...master + :target: https://github.com/pytest-dev/pytest-cov/compare/v4.0.0...master .. |wheel| image:: https://img.shields.io/pypi/wheel/pytest-cov.svg :alt: PyPI Wheel diff --git a/ci/appveyor-with-compiler.cmd b/ci/appveyor-with-compiler.cmd deleted file mode 100644 index 289585fc..00000000 --- a/ci/appveyor-with-compiler.cmd +++ /dev/null @@ -1,23 +0,0 @@ -:: Very simple setup: -:: - if WINDOWS_SDK_VERSION is set then activate the SDK. -:: - disable the WDK if it's around. - -SET COMMAND_TO_RUN=%* -SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows -SET WIN_WDK="c:\Program Files (x86)\Windows Kits\10\Include\wdf" -ECHO SDK: %WINDOWS_SDK_VERSION% ARCH: %PYTHON_ARCH% - -IF EXIST %WIN_WDK% ( - REM See: https://connect.microsoft.com/VisualStudio/feedback/details/1610302/ - REN %WIN_WDK% 0wdf -) -IF "%WINDOWS_SDK_VERSION%"=="" GOTO main - -SET DISTUTILS_USE_SDK=1 -SET MSSdk=1 -"%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% -CALL "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release - -:main -ECHO Executing: %COMMAND_TO_RUN% -CALL %COMMAND_TO_RUN% || EXIT 1 diff --git a/ci/bootstrap.py b/ci/bootstrap.py index 77daad5b..b0977495 100755 --- a/ci/bootstrap.py +++ b/ci/bootstrap.py @@ -3,13 +3,14 @@ import os import subprocess import sys -from collections import defaultdict from os.path import abspath from os.path import dirname from os.path import exists from os.path import join +from os.path import relpath base_path = dirname(dirname(abspath(__file__))) +templates_path = join(base_path, "ci", "templates") def check_call(args): @@ -51,7 +52,7 @@ def main(): print(f"Project path: {base_path}") jinja = jinja2.Environment( - loader=jinja2.FileSystemLoader(join(base_path, "ci", "templates")), + loader=jinja2.FileSystemLoader(templates_path), trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True @@ -59,22 +60,21 @@ def main(): tox_environments = [ line.strip() - # WARNING: 'tox' must be installed globally or in the project's virtualenv - for line in subprocess.check_output(['tox', '--listenvs'], universal_newlines=True).splitlines() + # 'tox' need not be installed globally, but must be importable + # by the Python that is running this script. + # This uses sys.executable the same way that the call in + # cookiecutter-pylibrary/hooks/post_gen_project.py + # invokes this bootstrap.py itself. + for line in subprocess.check_output([sys.executable, '-m', 'tox', '--listenvs'], universal_newlines=True).splitlines() ] - tox_environments = [line for line in tox_environments if line not in ['clean', 'report', 'docs', 'check']] - - template_vars = defaultdict(list) - template_vars['tox_environments'] = tox_environments - for env in tox_environments: - first, _ = env.split('-', 1) - template_vars['%s_environments' % first].append(env) - - for name in os.listdir(join("ci", "templates")): - with open(join(base_path, name), "w") as fh: - fh.write('# NOTE: this file is auto-generated via ci/bootstrap.py (ci/templates/%s).\n' % name) - fh.write(jinja.get_template(name).render(**template_vars)) - print(f"Wrote {name}") + tox_environments = [line for line in tox_environments if line.startswith('py')] + + for root, _, files in os.walk(templates_path): + for name in files: + relative = relpath(root, templates_path) + with open(join(base_path, relative, name), "w") as fh: + fh.write(jinja.get_template(join(relative, name)).render(tox_environments=tox_environments)) + print(f"Wrote {name}") print("DONE.") diff --git a/ci/requirements.txt b/ci/requirements.txt index d7f5177e..a0ef106f 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -2,3 +2,4 @@ virtualenv>=16.6.0 pip>=19.1.1 setuptools>=18.0.1 six>=1.14.0 +tox diff --git a/ci/templates/.appveyor.yml b/ci/templates/.appveyor.yml deleted file mode 100644 index 2b7e611c..00000000 --- a/ci/templates/.appveyor.yml +++ /dev/null @@ -1,53 +0,0 @@ -version: '{branch}-{build}' -build: off -image: - - Visual Studio 2015 - - Visual Studio 2019 -environment: - matrix: - - TOXENV: check - - TOXENV: '{{ py27_environments|join(",") }}' - - TOXENV: '{{ py35_environments|join(",") }}' - - TOXENV: '{{ py36_environments|join(",") }}' - - TOXENV: '{{ py37_environments|join(",") }}' - - TOXENV: '{{ py38_environments|join(",") }}' - - TOXENV: '{{ py39_environments|join(",") }}' - - TOXENV: '{{ pypy_environments|join(",") }}' - - TOXENV: '{{ pypy3_environments|join(",") }}' -matrix: - exclude: - - image: Visual Studio 2019 - TOXENV: '{{ py27_environments|join(",") }}' - - image: Visual Studio 2015 - TOXENV: '{{ py36_environments|join(",") }}' - - image: Visual Studio 2015 - TOXENV: '{{ py37_environments|join(",") }}' - - image: Visual Studio 2015 - TOXENV: '{{ py38_environments|join(",") }}' - - image: Visual Studio 2015 - TOXENV: '{{ py39_environments|join(",") }}' - - image: Visual Studio 2015 - TOXENV: '{{ pypy_environments|join(",") }}' - - image: Visual Studio 2015 - TOXENV: '{{ pypy3_environments|join(",") }}' -init: - - ps: echo $env:TOXENV - - ps: ls C:\Python* -install: - - IF "%TOXENV:~0,5%" == "pypy-" choco install --no-progress python.pypy - - IF "%TOXENV:~0,6%" == "pypy3-" choco install --no-progress pypy3 - - SET PATH=C:\tools\pypy\pypy;%PATH% - - C:\Python37\python -m pip install --progress-bar=off tox -rci/requirements.txt - -test_script: - - cmd /E:ON /V:ON /C .\ci\appveyor-with-compiler.cmd C:\Python37\python -m tox - -on_failure: - - ps: dir "env:" - - ps: get-content .tox\*\log\* -artifacts: - - path: dist\* - -### To enable remote debugging uncomment this (also, see: http://www.appveyor.com/docs/how-to/rdp-to-build-worker): -# on_finish: -# - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) diff --git a/ci/templates/.github/workflows/test.yml b/ci/templates/.github/workflows/test.yml new file mode 100644 index 00000000..3e99ef08 --- /dev/null +++ b/ci/templates/.github/workflows/test.yml @@ -0,0 +1,124 @@ +name: Tests +on: [push, pull_request, workflow_dispatch] +jobs: +{%- raw %} + examples: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["pypy-3.7", "3.9"] + target: [ + "src-layout", + "adhoc-layout", + ] + include: + # Add new helper variables to existing jobs + - {python-version: "pypy-3.7", tox-python-version: "pypy3"} + - {python-version: "3.9", tox-python-version: "py39"} + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: + examples-v1-${{ hashFiles('**/tox.ini') }} + restore-keys: | + examples-v1- + + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -U wheel + python -m pip install --progress-bar=off tox -rci/requirements.txt + + - name: Examples + run: | + cd examples/${{ matrix.target }} + tox -v -e ${{ matrix.tox-python-version }} +{%- endraw %} + + test: + name: {{ '${{ matrix.name }}' }} + runs-on: {{ '${{ matrix.os }}' }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: 'check' + python: '3.9' + toxpython: 'python3.9' + tox_env: 'check' + os: 'ubuntu-latest' + - name: 'docs' + python: '3.9' + toxpython: 'python3.9' + tox_env: 'docs' + os: 'ubuntu-latest' +{% for env in tox_environments %} +{% set prefix = env.split('-')[0] -%} +{% if prefix.startswith('pypy') %} +{% set python %}pypy-{{ prefix[4] }}.{{ prefix[5] }}{% endset %} +{% set cpython %}pp{{ prefix[4:5] }}{% endset %} +{% set toxpython %}pypy{{ prefix[4] }}.{{ prefix[5] }}{% endset %} +{% else %} +{% set python %}{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} +{% set cpython %}cp{{ prefix[2:] }}{% endset %} +{% set toxpython %}python{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} +{% endif %} +{% for os, python_arch in [ + ['ubuntu', 'x64'], + ['windows', 'x64'], + ['macos', 'x64'], +] %} + - name: '{{ env }} ({{ os }})' + python: '{{ python }}' + toxpython: '{{ toxpython }}' + python_arch: '{{ python_arch }}' + tox_env: '{{ env }}' + os: '{{ os }}-latest' +{% endfor %} +{% endfor %} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/setup-python@v2 + with: + python-version: {{ '${{ matrix.python }}' }} + architecture: {{ '${{ matrix.python_arch }}' }} + - name: install dependencies + run: | + python -mpip install --progress-bar=off -r ci/requirements.txt + virtualenv --version + pip --version + tox --version + pip list --format=freeze + - name: test + env: + TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' + run: > + tox -e {{ '${{ matrix.tox_env }}' }} -v +{% raw %} + successful: + # this provides a single status check for branch merge rules + # (use this in `Require status checks to pass before merging` in branch settings) + if: always() + needs: + - test + - examples + runs-on: ubuntu-latest + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} +{% endraw %} diff --git a/docs/conf.py b/docs/conf.py index 5dd4e74b..d417ed00 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ year = '2016' author = 'pytest-cov contributors' copyright = f'{year}, {author}' -version = release = '3.0.0' +version = release = '4.0.0' pygments_style = 'trac' templates_path = ['.'] diff --git a/docs/config.rst b/docs/config.rst index 0ead66e8..fa257037 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -23,14 +23,14 @@ For full details refer to the `coverage config file`_ documentation. .. note:: Important Note - This plugin overrides the ``data_file`` and ``parallel`` options of coverage. Unless you also run coverage without - pytest-cov it's pointless to set those options in your ``.coveragerc``. + This plugin overrides the ``parallel`` option of coverage. Unless you also run coverage without pytest-cov it's + pointless to set those options in your ``.coveragerc``. - If you use the ``--cov=something`` option (with a value) then coverage's ``source`` option will also get overriden. - If you have multiple sources it might be easier to set those in ``.coveragerc`` and always use ``--cov`` (wihout a value) + If you use the ``--cov=something`` option (with a value) then coverage's ``source`` option will also get overridden. + If you have multiple sources it might be easier to set those in ``.coveragerc`` and always use ``--cov`` (without a value) instead of having a long command line with ``--cov=pkg1 --cov=pkg2 --cov=pkg3 ...``. - If you use the ``--cov-branch`` option then coverage's ``branch`` option will also get overriden. + If you use the ``--cov-branch`` option then coverage's ``branch`` option will also get overridden. If you wish to always add pytest-cov with pytest, you can use ``addopts`` under ``pytest`` or ``tool:pytest`` section. For example: :: @@ -56,9 +56,9 @@ The complete list of command line options is: --cov=PATH Measure coverage for filesystem path. (multi-allowed) --cov-report=type Type of report to generate: term, term-missing, - annotate, html, xml (multi-allowed). term, term- + annotate, html, xml, lcov (multi-allowed). term, term- missing may be followed by ":skip-covered". annotate, - html and xml may be followed by ":DEST" where DEST + html, xml and lcov may be followed by ":DEST" where DEST specifies the output location. Use --cov-report= to not generate any output. --cov-config=path Config file for coverage. Default: .coveragerc diff --git a/docs/debuggers.rst b/docs/debuggers.rst index 15c83218..603119bb 100644 --- a/docs/debuggers.rst +++ b/docs/debuggers.rst @@ -7,7 +7,13 @@ Debuggers and PyCharm When it comes to TDD one obviously would like to debug tests. Debuggers in Python use mostly the sys.settrace function to gain access to context. Coverage uses the same technique to get access to the lines executed. Coverage does not play well with other tracers simultaneously running. This manifests itself in behaviour that PyCharm might not hit a -breakpoint no matter what the user does. Since it is common practice to have coverage configuration in the pytest.ini +breakpoint no matter what the user does, or encountering an error like this:: + + PYDEV DEBUGGER WARNING: + sys.settrace() should not be used when the debugger is being used. + This may cause the debugger to stop working correctly. + +Since it is common practice to have coverage configuration in the pytest.ini file and pytest does not support removeopts or similar the `--no-cov` flag can disable coverage completely. At the reporting part a warning message will show on screen:: diff --git a/docs/reporting.rst b/docs/reporting.rst index eaa99ad3..69191d48 100644 --- a/docs/reporting.rst +++ b/docs/reporting.rst @@ -3,7 +3,7 @@ Reporting It is possible to generate any combination of the reports for a single test run. -The available reports are terminal (with or without missing line numbers shown), HTML, XML and +The available reports are terminal (with or without missing line numbers shown), HTML, XML, LCOV and annotated source code. The terminal report without line numbers (default):: @@ -49,19 +49,21 @@ The terminal report with skip covered:: You can use ``skip-covered`` with ``term-missing`` as well. e.g. ``--cov-report term-missing:skip-covered`` -These three report options output to files without showing anything on the terminal:: +These four report options output to files without showing anything on the terminal:: pytest --cov-report html --cov-report xml + --cov-report lcov --cov-report annotate --cov=myproj tests/ -The output location for each of these reports can be specified. The output location for the XML +The output location for each of these reports can be specified. The output location for the XML and LCOV report is a file. Where as the output location for the HTML and annotated source code reports are directories:: pytest --cov-report html:cov_html --cov-report xml:cov.xml + --cov-report lcov:cov.info --cov-report annotate:cov_annotate --cov=myproj tests/ diff --git a/docs/requirements.txt b/docs/requirements.txt index ccec79fd..6fdf26f9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ sphinx==3.0.3 sphinx-py3doc-enhanced-theme==2.4.0 docutils==0.16 +jinja2<3.1 -e . diff --git a/setup.py b/setup.py index 503b9aac..799d4699 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ #!/usr/bin/env python import re -from distutils.command.build import build from glob import glob from itertools import chain from os.path import basename @@ -12,6 +11,13 @@ from setuptools import Command from setuptools import find_packages from setuptools import setup + +try: + # https://setuptools.pypa.io/en/latest/deprecated/distutils-legacy.html + from setuptools.command.build import build +except ImportError: + from distutils.command.build import build + from setuptools.command.develop import develop from setuptools.command.easy_install import easy_install from setuptools.command.install_lib import install_lib @@ -27,7 +33,7 @@ def read(*names, **kwargs): class BuildWithPTH(build): def run(self, *args, **kwargs): - build.run(self, *args, **kwargs) + super().run(*args, **kwargs) path = join(dirname(__file__), 'src', 'pytest-cov.pth') dest = join(self.build_lib, basename(path)) self.copy_file(path, dest) @@ -35,7 +41,7 @@ def run(self, *args, **kwargs): class EasyInstallWithPTH(easy_install): def run(self, *args, **kwargs): - easy_install.run(self, *args, **kwargs) + super().run(*args, **kwargs) path = join(dirname(__file__), 'src', 'pytest-cov.pth') dest = join(self.install_dir, basename(path)) self.copy_file(path, dest) @@ -43,19 +49,19 @@ def run(self, *args, **kwargs): class InstallLibWithPTH(install_lib): def run(self, *args, **kwargs): - install_lib.run(self, *args, **kwargs) + super().run(*args, **kwargs) path = join(dirname(__file__), 'src', 'pytest-cov.pth') dest = join(self.install_dir, basename(path)) self.copy_file(path, dest) self.outputs = [dest] def get_outputs(self): - return chain(install_lib.get_outputs(self), self.outputs) + return chain(super().get_outputs(), self.outputs) class DevelopWithPTH(develop): def run(self, *args, **kwargs): - develop.run(self, *args, **kwargs) + super().run(*args, **kwargs) path = join(dirname(__file__), 'src', 'pytest-cov.pth') dest = join(self.install_dir, basename(path)) self.copy_file(path, dest) @@ -81,7 +87,7 @@ def run(self): setup( name='pytest-cov', - version='3.0.0', + version='4.0.0', license='MIT', description='Pytest plugin for measuring coverage.', long_description='{}\n{}'.format(read('README.rst'), re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst'))), @@ -115,6 +121,11 @@ def run(self): 'Topic :: Software Development :: Testing', 'Topic :: Utilities', ], + project_urls={ + 'Documentation': 'https://pytest-cov.readthedocs.io/', + 'Changelog': 'https://pytest-cov.readthedocs.io/en/latest/changelog.html', + 'Issue Tracker': 'https://github.com/pytest-dev/pytest-cov/issues', + }, keywords=[ 'cover', 'coverage', 'pytest', 'py.test', 'distributed', 'parallel', ], diff --git a/src/pytest-cov.pth b/src/pytest-cov.pth index 91f2b7c7..8ed1a516 100644 --- a/src/pytest-cov.pth +++ b/src/pytest-cov.pth @@ -1 +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') \ No newline at end of file +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') diff --git a/src/pytest_cov/__init__.py b/src/pytest_cov/__init__.py index fba1d219..9dfd9823 100644 --- a/src/pytest_cov/__init__.py +++ b/src/pytest_cov/__init__.py @@ -1,2 +1,2 @@ """pytest-cov: avoid already-imported warning: PYTEST_DONT_REWRITE.""" -__version__ = '3.0.0' +__version__ = '4.0.0' diff --git a/src/pytest_cov/compat.py b/src/pytest_cov/compat.py index f422f25c..614419cb 100644 --- a/src/pytest_cov/compat.py +++ b/src/pytest_cov/compat.py @@ -3,17 +3,10 @@ except ImportError: from io import StringIO -import pytest StringIO # pyflakes, this is for re-export -if hasattr(pytest, 'hookimpl'): - hookwrapper = pytest.hookimpl(hookwrapper=True) -else: - hookwrapper = pytest.mark.hookwrapper - - class SessionWrapper: def __init__(self, session): self._session = session diff --git a/src/pytest_cov/embed.py b/src/pytest_cov/embed.py index 3adecdba..f8a2749f 100644 --- a/src/pytest_cov/embed.py +++ b/src/pytest_cov/embed.py @@ -20,22 +20,6 @@ _active_cov = None -def multiprocessing_start(_): - global _active_cov - cov = init() - if cov: - _active_cov = cov - multiprocessing.util.Finalize(None, cleanup, exitpriority=1000) - - -try: - import multiprocessing.util -except ImportError: - pass -else: - multiprocessing.util.register_after_fork(multiprocessing_start, multiprocessing_start) - - def init(): # Only continue if ancestor process has set everything needed in # the env. @@ -100,12 +84,10 @@ def cleanup(): _active_cov = None _cleanup_in_progress = False if _pending_signal: - pending_singal = _pending_signal + pending_signal = _pending_signal _pending_signal = None - _signal_cleanup_handler(*pending_singal) - + _signal_cleanup_handler(*pending_signal) -multiprocessing_finish = cleanup # in case someone dared to use this internal _previous_handlers = {} _pending_signal = None diff --git a/src/pytest_cov/engine.py b/src/pytest_cov/engine.py index 0303c2f1..bfede8c7 100644 --- a/src/pytest_cov/engine.py +++ b/src/pytest_cov/engine.py @@ -196,6 +196,18 @@ def summary(self, stream): total = self.cov.xml_report(ignore_errors=True, outfile=output) stream.write('Coverage XML written to file %s\n' % (self.cov.config.xml_output if output is None else output)) + # Produce lcov report if wanted. + if 'lcov' in self.cov_report: + output = self.cov_report['lcov'] + with _backup(self.cov, "config"): + self.cov.lcov_report(ignore_errors=True, outfile=output) + + # We need to call Coverage.report here, just to get the total + # Coverage.lcov_report doesn't return any total and we need it for --cov-fail-under. + total = self.cov.report(ignore_errors=True, file=_NullFile) + + stream.write('Coverage LCOV written to file %s\n' % (self.cov.config.lcov_output if output is None else output)) + return total diff --git a/src/pytest_cov/plugin.py b/src/pytest_cov/plugin.py index 94a1e494..dd7b8c4e 100644 --- a/src/pytest_cov/plugin.py +++ b/src/pytest_cov/plugin.py @@ -29,7 +29,7 @@ class CovReportWarning(PytestCovWarning): def validate_report(arg): - file_choices = ['annotate', 'html', 'xml'] + file_choices = ['annotate', 'html', 'xml', 'lcov'] term_choices = ['term', 'term-missing'] term_modifier_choices = ['skip-covered'] all_choices = term_choices + file_choices @@ -39,6 +39,9 @@ def validate_report(arg): msg = f'invalid choice: "{arg}" (choose from "{all_choices}")' raise argparse.ArgumentTypeError(msg) + if report_type == 'lcov' and coverage.version_info <= (6, 3): + raise argparse.ArgumentTypeError('LCOV output is only supported with coverage.py >= 6.3') + if len(values) == 1: return report_type, None @@ -96,9 +99,9 @@ def pytest_addoption(parser): group.addoption('--cov-report', action=StoreReport, default={}, metavar='TYPE', type=validate_report, help='Type of report to generate: term, term-missing, ' - 'annotate, html, xml (multi-allowed). ' + 'annotate, html, xml, lcov (multi-allowed). ' 'term, term-missing may be followed by ":skip-covered". ' - 'annotate, html and xml may be followed by ":DEST" ' + 'annotate, html, xml and lcov may be followed by ":DEST" ' 'where DEST specifies the output location. ' 'Use --cov-report= to not generate any output.') group.addoption('--cov-config', action='store', default='.coveragerc', @@ -133,7 +136,7 @@ def _prepare_cov_source(cov_source): return None if True in cov_source else [path for path in cov_source if path is not True] -@pytest.mark.tryfirst +@pytest.hookimpl(tryfirst=True) def pytest_load_initial_conftests(early_config, parser, args): options = early_config.known_args_namespace no_cov = options.no_cov_should_warn = False @@ -253,6 +256,7 @@ def pytest_sessionstart(self, session): if self.options.cov_context == 'test': session.config.pluginmanager.register(TestContextPlugin(self.cov_controller.cov), '_cov_contexts') + @pytest.hookimpl(optionalhook=True) def pytest_configure_node(self, node): """Delegate to our implementation. @@ -260,8 +264,8 @@ def pytest_configure_node(self, node): """ if not self._disabled: self.cov_controller.configure_node(node) - pytest_configure_node.optionalhook = True + @pytest.hookimpl(optionalhook=True) def pytest_testnodedown(self, node, error): """Delegate to our implementation. @@ -269,7 +273,6 @@ def pytest_testnodedown(self, node, error): """ if not self._disabled: self.cov_controller.testnodedown(node, error) - pytest_testnodedown.optionalhook = True def _should_report(self): return not (self.failed and self.options.no_cov_on_fail) @@ -280,7 +283,7 @@ def _failed_cov_total(self): # we need to wrap pytest_runtestloop. by the time pytest_sessionfinish # runs, it's too late to set testsfailed - @compat.hookwrapper + @pytest.hookimpl(hookwrapper=True) def pytest_runtestloop(self, session): yield @@ -308,7 +311,7 @@ def pytest_runtestloop(self, session): warnings.warn(CovReportWarning(message)) self.cov_total = 0 assert self.cov_total is not None, 'Test coverage should never be `None`' - if self._failed_cov_total(): + if self._failed_cov_total() and not self.options.collectonly: # make sure we get the EXIT_TESTSFAILED exit code compat_session.testsfailed += 1 @@ -326,7 +329,11 @@ def pytest_terminal_summary(self, terminalreporter): # we shouldn't report, or report generation failed (error raised above) return - terminalreporter.write('\n' + self.cov_report.getvalue() + '\n') + report = self.cov_report.getvalue() + + # Avoid undesirable new lines when output is disabled with "--cov-report=". + if report: + terminalreporter.write('\n' + report + '\n') if self.options.cov_fail_under is not None and self.options.cov_fail_under > 0: failed = self.cov_total < self.options.cov_fail_under @@ -352,7 +359,7 @@ def pytest_runtest_setup(self, item): def pytest_runtest_teardown(self, item): embed.cleanup() - @compat.hookwrapper + @pytest.hookimpl(hookwrapper=True) def pytest_runtest_call(self, item): if (item.get_closest_marker('no_cover') or 'no_cover' in getattr(item, 'fixturenames', ())): diff --git a/tests/test_pytest_cov.py b/tests/test_pytest_cov.py index e7f90668..84fe42ba 100644 --- a/tests/test_pytest_cov.py +++ b/tests/test_pytest_cov.py @@ -150,7 +150,8 @@ def test_foo(cov): CHILD_SCRIPT_RESULT = '[56] * 100%' PARENT_SCRIPT_RESULT = '9 * 100%' DEST_DIR = 'cov_dest' -REPORT_NAME = 'cov.xml' +XML_REPORT_NAME = 'cov.xml' +LCOV_REPORT_NAME = 'cov.info' xdist_params = pytest.mark.parametrize('opts', [ '', @@ -194,7 +195,7 @@ def prop(request): ) -def test_central(testdir, prop): +def test_central(pytester, testdir, prop): script = testdir.makepyfile(prop.code) testdir.tmpdir.join('.coveragerc').write(prop.fullconf) @@ -333,18 +334,50 @@ def test_xml_output_dir(testdir): result = testdir.runpytest('-v', '--cov=%s' % script.dirpath(), - '--cov-report=xml:' + REPORT_NAME, + '--cov-report=xml:' + XML_REPORT_NAME, script) result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'Coverage XML written to file ' + REPORT_NAME, + 'Coverage XML written to file ' + XML_REPORT_NAME, '*10 passed*', ]) - assert testdir.tmpdir.join(REPORT_NAME).check() + assert testdir.tmpdir.join(XML_REPORT_NAME).check() assert result.ret == 0 +@pytest.mark.skipif("coverage.version_info < (6, 3)") +def test_lcov_output_dir(testdir): + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + '--cov-report=lcov:' + LCOV_REPORT_NAME, + script) + + result.stdout.fnmatch_lines([ + '*- coverage: platform *, python * -*', + 'Coverage LCOV written to file ' + LCOV_REPORT_NAME, + '*10 passed*', + ]) + assert testdir.tmpdir.join(LCOV_REPORT_NAME).check() + assert result.ret == 0 + + +@pytest.mark.skipif("coverage.version_info >= (6, 3)") +def test_lcov_not_supported(testdir): + script = testdir.makepyfile("a = 1") + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + '--cov-report=lcov', + script, + ) + result.stderr.fnmatch_lines([ + '*argument --cov-report: LCOV output is only supported with coverage.py >= 6.3', + ]) + assert result.ret != 0 + + def test_term_output_dir(testdir): script = testdir.makepyfile(SCRIPT) @@ -389,6 +422,19 @@ def test_cov_min_100(testdir): ]) +def test_cov_min_100_passes_if_collectonly(testdir): + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + '--cov-report=term-missing', + '--cov-fail-under=100', + '--collect-only', + script) + + assert result.ret == 0 + + def test_cov_min_50(testdir): script = testdir.makepyfile(SCRIPT) @@ -448,7 +494,7 @@ def test_cov_min_no_report(testdir): ]) -def test_central_nonspecific(testdir, prop): +def test_central_nonspecific(pytester, testdir, prop): script = testdir.makepyfile(prop.code) testdir.tmpdir.join('.coveragerc').write(prop.fullconf) result = testdir.runpytest('-v', @@ -483,7 +529,7 @@ def test_cov_min_from_coveragerc(testdir): assert result.ret != 0 -def test_central_coveragerc(testdir, prop): +def test_central_coveragerc(pytester, testdir, prop): script = testdir.makepyfile(prop.code) testdir.tmpdir.join('.coveragerc').write(COVERAGERC_SOURCE + prop.conf) @@ -501,7 +547,7 @@ def test_central_coveragerc(testdir, prop): @xdist_params -def test_central_with_path_aliasing(testdir, monkeypatch, opts, prop): +def test_central_with_path_aliasing(pytester, testdir, monkeypatch, opts, prop): mod1 = testdir.mkdir('src').join('mod.py') mod1.write(SCRIPT) mod2 = testdir.mkdir('aliased').join('mod.py') @@ -535,7 +581,7 @@ def test_central_with_path_aliasing(testdir, monkeypatch, opts, prop): @xdist_params -def test_borken_cwd(testdir, monkeypatch, opts): +def test_borken_cwd(pytester, testdir, monkeypatch, opts): testdir.makepyfile(mod=''' def foobar(a, b): return a + b @@ -574,7 +620,7 @@ def test_foobar(bad): assert result.ret == 0 -def test_subprocess_with_path_aliasing(testdir, monkeypatch): +def test_subprocess_with_path_aliasing(pytester, testdir, monkeypatch): src = testdir.mkdir('src') src.join('parent_script.py').write(SCRIPT_PARENT) src.join('child_script.py').write(SCRIPT_CHILD) @@ -610,7 +656,7 @@ def test_subprocess_with_path_aliasing(testdir, monkeypatch): assert result.ret == 0 -def test_show_missing_coveragerc(testdir, prop): +def test_show_missing_coveragerc(pytester, testdir, prop): script = testdir.makepyfile(prop.code) testdir.tmpdir.join('.coveragerc').write(""" [run] @@ -653,7 +699,7 @@ def test_fail(): result.stdout.fnmatch_lines(['*1 failed*']) -def test_no_cov(testdir, monkeypatch): +def test_no_cov(pytester, testdir, monkeypatch): script = testdir.makepyfile(SCRIPT) testdir.makeini(""" [pytest] @@ -720,7 +766,7 @@ def test_foo(foo): @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') -def test_dist_collocated(testdir, prop): +def test_dist_collocated(pytester, testdir, prop): script = testdir.makepyfile(prop.code) testdir.tmpdir.join('.coveragerc').write(prop.fullconf) result = testdir.runpytest('-v', @@ -740,7 +786,7 @@ def test_dist_collocated(testdir, prop): @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') -def test_dist_not_collocated(testdir, prop): +def test_dist_not_collocated(pytester, testdir, prop): script = testdir.makepyfile(prop.code) dir1 = testdir.mkdir('dir1') dir2 = testdir.mkdir('dir2') @@ -773,7 +819,7 @@ def test_dist_not_collocated(testdir, prop): @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') -def test_dist_not_collocated_coveragerc_source(testdir, prop): +def test_dist_not_collocated_coveragerc_source(pytester, testdir, prop): script = testdir.makepyfile(prop.code) dir1 = testdir.mkdir('dir1') dir2 = testdir.mkdir('dir2') @@ -848,7 +894,7 @@ def test_central_subprocess_change_cwd(testdir): assert result.ret == 0 -def test_central_subprocess_change_cwd_with_pythonpath(testdir, monkeypatch): +def test_central_subprocess_change_cwd_with_pythonpath(pytester, testdir, monkeypatch): stuff = testdir.mkdir('stuff') parent_script = stuff.join('parent_script.py') parent_script.write(SCRIPT_PARENT_CHANGE_CWD_IMPORT_CHILD) @@ -919,7 +965,7 @@ def test_dist_subprocess_collocated(testdir): @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') -def test_dist_subprocess_not_collocated(testdir, tmpdir): +def test_dist_subprocess_not_collocated(pytester, testdir, tmpdir): scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT, child_script=SCRIPT_CHILD) parent_script = scripts.dirpath().join('parent_script.py') @@ -968,7 +1014,7 @@ def test_invalid_coverage_source(testdir): '*10 passed*' ]) result.stderr.fnmatch_lines([ - 'Coverage.py warning: No data was collected.*' + '*No data was collected.*' ]) result.stdout.fnmatch_lines([ '*Failed to generate report: No data to report.', @@ -981,6 +1027,8 @@ def test_invalid_coverage_source(testdir): @pytest.mark.skipif("'dev' in pytest.__version__") @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') +@pytest.mark.skipif('tuple(map(int, xdist.__version__.split("."))) >= (2, 3, 0)', + reason="Since pytest-xdist 2.3.0 the parent sys.path is copied in the child process") def test_dist_missing_data(testdir): """Test failure when using a worker without pytest-cov installed.""" venv_path = os.path.join(str(testdir.tmpdir), 'venv') @@ -1010,7 +1058,7 @@ def test_dist_missing_data(testdir): '--dist=load', '--tx=popen//python=%s' % exe, max_worker_restart_0, - script) + str(script)) result.stdout.fnmatch_lines([ 'The following workers failed to return coverage data, ensure that pytest-cov is installed on these workers.' ]) @@ -1044,231 +1092,8 @@ def test_funcarg_not_active(testdir): assert result.ret == 0 -@pytest.mark.skipif("sys.version_info[0] < 3", reason="no context manager api on Python 2") -@pytest.mark.skipif('sys.platform == "win32"', reason="multiprocessing support is broken on Windows") -@pytest.mark.skipif('platform.python_implementation() == "PyPy"', reason="often deadlocks on PyPy") -@pytest.mark.skipif('sys.version_info[:2] >= (3, 8)', reason="deadlocks on Python 3.8+, see: https://bugs.python.org/issue38227") -def test_multiprocessing_pool(testdir): - pytest.importorskip('multiprocessing.util') - - script = testdir.makepyfile(''' -import multiprocessing - -def target_fn(a): - %sse: # pragma: nocover - return None - -def test_run_target(): - from pytest_cov.embed import cleanup_on_sigterm - cleanup_on_sigterm() - - for i in range(33): - with multiprocessing.Pool(3) as p: - p.map(target_fn, [i * 3 + j for j in range(3)]) - p.join() -''' % ''.join('''if a == %r: - return a - el''' % i for i in range(99))) - - result = testdir.runpytest('-v', - '--cov=%s' % script.dirpath(), - '--cov-report=term-missing', - script) - - assert "Doesn't seem to be a coverage.py data file" not in result.stdout.str() - assert "Doesn't seem to be a coverage.py data file" not in result.stderr.str() - assert not testdir.tmpdir.listdir(".coverage.*") - result.stdout.fnmatch_lines([ - '*- coverage: platform *, python * -*', - 'test_multiprocessing_pool* 100%*', - '*1 passed*' - ]) - assert result.ret == 0 - - -@pytest.mark.skipif('sys.platform == "win32"', reason="multiprocessing support is broken on Windows") -@pytest.mark.skipif('platform.python_implementation() == "PyPy"', reason="often deadlocks on PyPy") -@pytest.mark.skipif('sys.version_info[:2] >= (3, 8)', reason="deadlocks on Python 3.8, see: https://bugs.python.org/issue38227") -def test_multiprocessing_pool_terminate(testdir): - pytest.importorskip('multiprocessing.util') - - script = testdir.makepyfile(''' -import multiprocessing - -def target_fn(a): - %sse: # pragma: nocover - return None - -def test_run_target(): - from pytest_cov.embed import cleanup_on_sigterm - cleanup_on_sigterm() - - for i in range(33): - p = multiprocessing.Pool(3) - try: - p.map(target_fn, [i * 3 + j for j in range(3)]) - finally: - p.terminate() - p.join() -''' % ''.join('''if a == %r: - return a - el''' % i for i in range(99))) - - result = testdir.runpytest('-v', - '--cov=%s' % script.dirpath(), - '--cov-report=term-missing', - script) - - assert "Doesn't seem to be a coverage.py data file" not in result.stdout.str() - assert "Doesn't seem to be a coverage.py data file" not in result.stderr.str() - assert not testdir.tmpdir.listdir(".coverage.*") - result.stdout.fnmatch_lines([ - '*- coverage: platform *, python * -*', - 'test_multiprocessing_pool* 100%*', - '*1 passed*' - ]) - assert result.ret == 0 - - -@pytest.mark.skipif('sys.platform == "win32"', reason="multiprocessing support is broken on Windows") -@pytest.mark.skipif('sys.version_info[0] > 2 and platform.python_implementation() == "PyPy"', reason="broken on PyPy3") -def test_multiprocessing_pool_close(testdir): - pytest.importorskip('multiprocessing.util') - - script = testdir.makepyfile(''' -import multiprocessing - -def target_fn(a): - %sse: # pragma: nocover - return None - -def test_run_target(): - for i in range(33): - p = multiprocessing.Pool(3) - try: - p.map(target_fn, [i * 3 + j for j in range(3)]) - finally: - p.close() - p.join() -''' % ''.join('''if a == %r: - return a - el''' % i for i in range(99))) - - result = testdir.runpytest('-v', - '--cov=%s' % script.dirpath(), - '--cov-report=term-missing', - script) - assert "Doesn't seem to be a coverage.py data file" not in result.stdout.str() - assert "Doesn't seem to be a coverage.py data file" not in result.stderr.str() - assert not testdir.tmpdir.listdir(".coverage.*") - result.stdout.fnmatch_lines([ - '*- coverage: platform *, python * -*', - 'test_multiprocessing_pool* 100%*', - '*1 passed*' - ]) - assert result.ret == 0 - - -@pytest.mark.skipif('sys.platform == "win32"', reason="multiprocessing support is broken on Windows") -def test_multiprocessing_process(testdir): - pytest.importorskip('multiprocessing.util') - - script = testdir.makepyfile(''' -import multiprocessing - -def target_fn(): - a = True - return a - -def test_run_target(): - p = multiprocessing.Process(target=target_fn) - p.start() - p.join() -''') - - result = testdir.runpytest('-v', - '--cov=%s' % script.dirpath(), - '--cov-report=term-missing', - script) - - result.stdout.fnmatch_lines([ - '*- coverage: platform *, python * -*', - 'test_multiprocessing_process* 8 * 100%*', - '*1 passed*' - ]) - assert result.ret == 0 - - -@pytest.mark.skipif('sys.platform == "win32"', reason="multiprocessing support is broken on Windows") -def test_multiprocessing_process_no_source(testdir): - pytest.importorskip('multiprocessing.util') - - script = testdir.makepyfile(''' -import multiprocessing - -def target_fn(): - a = True - return a - -def test_run_target(): - p = multiprocessing.Process(target=target_fn) - p.start() - p.join() -''') - - result = testdir.runpytest('-v', - '--cov', - '--cov-report=term-missing', - script) - - result.stdout.fnmatch_lines([ - '*- coverage: platform *, python * -*', - 'test_multiprocessing_process* 8 * 100%*', - '*1 passed*' - ]) - assert result.ret == 0 - - -@pytest.mark.skipif('sys.platform == "win32"', reason="multiprocessing support is broken on Windows") -def test_multiprocessing_process_with_terminate(testdir): - pytest.importorskip('multiprocessing.util') - - script = testdir.makepyfile(''' -import multiprocessing -import time -from pytest_cov.embed import cleanup_on_sigterm -cleanup_on_sigterm() - -event = multiprocessing.Event() - -def target_fn(): - a = True - event.set() - time.sleep(5) - -def test_run_target(): - p = multiprocessing.Process(target=target_fn) - p.start() - time.sleep(0.5) - event.wait(1) - p.terminate() - p.join() -''') - - result = testdir.runpytest('-v', - '--cov=%s' % script.dirpath(), - '--cov-report=term-missing', - script) - - result.stdout.fnmatch_lines([ - '*- coverage: platform *, python * -*', - 'test_multiprocessing_process* 16 * 100%*', - '*1 passed*' - ]) - assert result.ret == 0 - - @pytest.mark.skipif('sys.platform == "win32"', reason="SIGTERM isn't really supported on Windows") +@pytest.mark.xfail('platform.python_implementation() == "PyPy"', reason="Interpreter seems buggy") def test_cleanup_on_sigterm(testdir): script = testdir.makepyfile(''' import os, signal, subprocess, sys, time @@ -1319,7 +1144,7 @@ def test_run(): ('cleanup_on_signal(signal.SIGBREAK)', '87% 21-22'), ('cleanup()', '73% 19-22'), ]) -def test_cleanup_on_sigterm_sig_break(testdir, setup): +def test_cleanup_on_sigterm_sig_break(pytester, testdir, setup): # worth a read: https://stefan.sofa-rockers.org/2013/08/15/handling-sub-process-hierarchies-python-linux-os-x/ script = testdir.makepyfile(''' import os, signal, subprocess, sys, time @@ -1360,12 +1185,14 @@ def test_run(): @pytest.mark.skipif('sys.platform == "win32"', reason="SIGTERM isn't really supported on Windows") +@pytest.mark.xfail('sys.platform == "darwin"', reason="Something weird going on Macs...") +@pytest.mark.xfail('platform.python_implementation() == "PyPy"', reason="Interpreter seems buggy") @pytest.mark.parametrize('setup', [ ('signal.signal(signal.SIGTERM, signal.SIG_DFL); cleanup_on_sigterm()', '88% 18-19'), ('cleanup_on_sigterm()', '88% 18-19'), ('cleanup()', '75% 16-19'), ]) -def test_cleanup_on_sigterm_sig_dfl(testdir, setup): +def test_cleanup_on_sigterm_sig_dfl(pytester, testdir, setup): script = testdir.makepyfile(''' import os, signal, subprocess, sys, time @@ -1403,6 +1230,8 @@ def test_run(): @pytest.mark.skipif('sys.platform == "win32"', reason="SIGINT is subtly broken on Windows") +@pytest.mark.xfail('sys.platform == "darwin"', reason="Something weird going on Macs...") +@pytest.mark.xfail('platform.python_implementation() == "PyPy"', reason="Interpreter seems buggy") def test_cleanup_on_sigterm_sig_dfl_sigint(testdir): script = testdir.makepyfile(''' import os, signal, subprocess, sys, time @@ -1442,6 +1271,7 @@ def test_run(): @pytest.mark.skipif('sys.platform == "win32"', reason="fork not available on Windows") +@pytest.mark.xfail('platform.python_implementation() == "PyPy"', reason="Interpreter seems buggy") def test_cleanup_on_sigterm_sig_ign(testdir): script = testdir.makepyfile(''' import os, signal, subprocess, sys, time @@ -1607,7 +1437,6 @@ def test_basic(no_cover): # Regexes for lines to exclude from consideration exclude_lines = raise NotImplementedError - ''' EXCLUDED_TEST = ''' @@ -1674,7 +1503,7 @@ def test_basic(): @pytest.mark.parametrize('report_option', [ 'term-missing:skip-covered', 'term:skip-covered']) -def test_skip_covered_cli(testdir, report_option): +def test_skip_covered_cli(pytester, testdir, report_option): testdir.makefile('', coveragerc=SKIP_COVERED_COVERAGERC) script = testdir.makepyfile(SKIP_COVERED_TEST) result = testdir.runpytest('-v', @@ -1768,6 +1597,7 @@ def test_not_started_plugin_does_not_fail(testdir): class ns: cov_source = [True] cov_report = '' + plugin = pytest_cov.plugin.CovPlugin(ns, None, start=False) plugin.pytest_runtestloop(None) plugin.pytest_terminal_summary(None) @@ -1879,13 +1709,13 @@ def test_external_data_file_negative(testdir): @xdist_params -def test_append_coverage(testdir, opts, prop): +def test_append_coverage(pytester, testdir, opts, prop): script = testdir.makepyfile(test_1=prop.code) testdir.tmpdir.join('.coveragerc').write(prop.fullconf) result = testdir.runpytest('-v', '--cov=%s' % script.dirpath(), script, - *opts.split()+prop.args) + *opts.split() + prop.args) result.stdout.fnmatch_lines([ 'test_1* %s*' % prop.result, ]) @@ -1894,7 +1724,7 @@ def test_append_coverage(testdir, opts, prop): '--cov-append', '--cov=%s' % script2.dirpath(), script2, - *opts.split()+prop.args) + *opts.split() + prop.args) result.stdout.fnmatch_lines([ 'test_1* %s*' % prop.result, 'test_2* %s*' % prop.result2, @@ -1902,7 +1732,7 @@ def test_append_coverage(testdir, opts, prop): @xdist_params -def test_do_not_append_coverage(testdir, opts, prop): +def test_do_not_append_coverage(pytester, testdir, opts, prop): script = testdir.makepyfile(test_1=prop.code) testdir.tmpdir.join('.coveragerc').write(prop.fullconf) result = testdir.runpytest('-v', @@ -1964,8 +1794,11 @@ def bad_init(): monkeypatch.setattr(sys, 'stderr', buff) monkeypatch.setitem(os.environ, 'COV_CORE_SOURCE', 'foobar') exec(payload) - assert buff.getvalue() == '''pytest-cov: Failed to setup subprocess coverage. Environ: {'COV_CORE_SOURCE': 'foobar'} Exception: SpecificError() -''' + expected = ( + "pytest-cov: Failed to setup subprocess coverage. " + "Environ: {'COV_CORE_SOURCE': 'foobar'} Exception: SpecificError()\n" + ) + assert buff.getvalue() == expected def test_double_cov(testdir): @@ -2084,7 +1917,7 @@ def find_labels(text, pattern): @pytest.mark.skipif("coverage.version_info < (5, 0)") @xdist_params -def test_contexts(testdir, opts): +def test_contexts(pytester, testdir, opts): with open(os.path.join(os.path.dirname(__file__), "contextful.py")) as f: contextful_tests = f.read() script = testdir.makepyfile(contextful_tests) diff --git a/tox.ini b/tox.ini index 20a1561b..282cd244 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [testenv:bootstrap] deps = jinja2 - matrix tox skip_install = true commands = @@ -13,9 +12,8 @@ passenv = [tox] envlist = check - py{36,37,py,py3}-pytest46-xdist127-coverage{55} - py{36,37,38,py3}-pytest{46,54}-xdist133-coverage{55} - py{36,37,38,39,310,py3}-pytest{62}-xdist202-coverage{55} + py{36}-pytest{70}-xdist250-coverage{62} + py{37,38,39,310,py37,py38}-pytest{71}-xdist250-coverage{64} docs [testenv] @@ -30,6 +28,8 @@ setenv = pytest60: _DEP_PYTEST=pytest==6.0.2 pytest61: _DEP_PYTEST=pytest==6.1.2 pytest62: _DEP_PYTEST=pytest==6.2.5 + pytest70: _DEP_PYTEST=pytest==7.0.1 + pytest71: _DEP_PYTEST=pytest==7.1.2 xdist127: _DEP_PYTESTXDIST=pytest-xdist==1.27.0 xdist129: _DEP_PYTESTXDIST=pytest-xdist==1.29.0 @@ -40,6 +40,7 @@ setenv = xdist200: _DEP_PYTESTXDIST=pytest-xdist==2.0.0 xdist201: _DEP_PYTESTXDIST=pytest-xdist==2.1.0 xdist202: _DEP_PYTESTXDIST=pytest-xdist==2.2.0 + xdist250: _DEP_PYTESTXDIST=pytest-xdist==2.5.0 xdistdev: _DEP_PYTESTXDIST=git+https://github.com/pytest-dev/pytest-xdist.git#egg=pytest-xdist coverage45: _DEP_COVERAGE=coverage==4.5.4 @@ -49,6 +50,11 @@ setenv = coverage53: _DEP_COVERAGE=coverage==5.3.1 coverage54: _DEP_COVERAGE=coverage==5.4 coverage55: _DEP_COVERAGE=coverage==5.5 + coverage60: _DEP_COVERAGE=coverage==6.0.2 + coverage61: _DEP_COVERAGE=coverage==6.1.2 + coverage62: _DEP_COVERAGE=coverage==6.2 + coverage63: _DEP_COVERAGE=coverage==6.3.3 + coverage64: _DEP_COVERAGE=coverage==6.4.2 # For testing against a coverage.py working tree. coveragedev: _DEP_COVERAGE=-e{env:COVERAGE_HOME} passenv = @@ -59,7 +65,7 @@ deps = {env:_DEP_COVERAGE:coverage} pip_pre = true commands = - pytest {posargs:-vv} + {posargs:pytest -vv} [testenv:spell] setenv =