diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 631c2b31..24e75193 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,512 +60,152 @@ jobs: toxpython: 'python3.11' tox_env: 'docs' os: 'ubuntu-latest' - - name: 'py39-pytest83-xdist36-coverage78 (ubuntu)' + - name: 'py39-pytest84-xdist38-coverage710 (ubuntu)' python: '3.9' toxpython: 'python3.9' python_arch: 'x64' - tox_env: 'py39-pytest83-xdist36-coverage78' + tox_env: 'py39-pytest84-xdist38-coverage710' os: 'ubuntu-latest' - - name: 'py39-pytest83-xdist36-coverage78 (windows)' + - name: 'py39-pytest84-xdist38-coverage710 (windows)' python: '3.9' toxpython: 'python3.9' python_arch: 'x64' - tox_env: 'py39-pytest83-xdist36-coverage78' + tox_env: 'py39-pytest84-xdist38-coverage710' os: 'windows-latest' - - name: 'py39-pytest83-xdist36-coverage78 (macos)' + - name: 'py39-pytest84-xdist38-coverage710 (macos)' python: '3.9' toxpython: 'python3.9' python_arch: 'arm64' - tox_env: 'py39-pytest83-xdist36-coverage78' + tox_env: 'py39-pytest84-xdist38-coverage710' os: 'macos-latest' - - name: 'py39-pytest83-xdist37-coverage78 (ubuntu)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'x64' - tox_env: 'py39-pytest83-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py39-pytest83-xdist37-coverage78 (windows)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'x64' - tox_env: 'py39-pytest83-xdist37-coverage78' - os: 'windows-latest' - - name: 'py39-pytest83-xdist37-coverage78 (macos)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'arm64' - tox_env: 'py39-pytest83-xdist37-coverage78' - os: 'macos-latest' - - name: 'py39-pytest84-xdist36-coverage78 (ubuntu)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'x64' - tox_env: 'py39-pytest84-xdist36-coverage78' - os: 'ubuntu-latest' - - name: 'py39-pytest84-xdist36-coverage78 (windows)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'x64' - tox_env: 'py39-pytest84-xdist36-coverage78' - os: 'windows-latest' - - name: 'py39-pytest84-xdist36-coverage78 (macos)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'arm64' - tox_env: 'py39-pytest84-xdist36-coverage78' - os: 'macos-latest' - - name: 'py39-pytest84-xdist37-coverage78 (ubuntu)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'x64' - tox_env: 'py39-pytest84-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py39-pytest84-xdist37-coverage78 (windows)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'x64' - tox_env: 'py39-pytest84-xdist37-coverage78' - os: 'windows-latest' - - name: 'py39-pytest84-xdist37-coverage78 (macos)' - python: '3.9' - toxpython: 'python3.9' - python_arch: 'arm64' - tox_env: 'py39-pytest84-xdist37-coverage78' - os: 'macos-latest' - - name: 'py310-pytest83-xdist36-coverage78 (ubuntu)' + - name: 'py310-pytest84-xdist38-coverage710 (ubuntu)' python: '3.10' toxpython: 'python3.10' python_arch: 'x64' - tox_env: 'py310-pytest83-xdist36-coverage78' + tox_env: 'py310-pytest84-xdist38-coverage710' os: 'ubuntu-latest' - - name: 'py310-pytest83-xdist36-coverage78 (windows)' + - name: 'py310-pytest84-xdist38-coverage710 (windows)' python: '3.10' toxpython: 'python3.10' python_arch: 'x64' - tox_env: 'py310-pytest83-xdist36-coverage78' + tox_env: 'py310-pytest84-xdist38-coverage710' os: 'windows-latest' - - name: 'py310-pytest83-xdist36-coverage78 (macos)' + - name: 'py310-pytest84-xdist38-coverage710 (macos)' python: '3.10' toxpython: 'python3.10' python_arch: 'arm64' - tox_env: 'py310-pytest83-xdist36-coverage78' + tox_env: 'py310-pytest84-xdist38-coverage710' os: 'macos-latest' - - name: 'py310-pytest83-xdist37-coverage78 (ubuntu)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'x64' - tox_env: 'py310-pytest83-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py310-pytest83-xdist37-coverage78 (windows)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'x64' - tox_env: 'py310-pytest83-xdist37-coverage78' - os: 'windows-latest' - - name: 'py310-pytest83-xdist37-coverage78 (macos)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'arm64' - tox_env: 'py310-pytest83-xdist37-coverage78' - os: 'macos-latest' - - name: 'py310-pytest84-xdist36-coverage78 (ubuntu)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'x64' - tox_env: 'py310-pytest84-xdist36-coverage78' - os: 'ubuntu-latest' - - name: 'py310-pytest84-xdist36-coverage78 (windows)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'x64' - tox_env: 'py310-pytest84-xdist36-coverage78' - os: 'windows-latest' - - name: 'py310-pytest84-xdist36-coverage78 (macos)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'arm64' - tox_env: 'py310-pytest84-xdist36-coverage78' - os: 'macos-latest' - - name: 'py310-pytest84-xdist37-coverage78 (ubuntu)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'x64' - tox_env: 'py310-pytest84-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py310-pytest84-xdist37-coverage78 (windows)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'x64' - tox_env: 'py310-pytest84-xdist37-coverage78' - os: 'windows-latest' - - name: 'py310-pytest84-xdist37-coverage78 (macos)' - python: '3.10' - toxpython: 'python3.10' - python_arch: 'arm64' - tox_env: 'py310-pytest84-xdist37-coverage78' - os: 'macos-latest' - - name: 'py311-pytest83-xdist36-coverage78 (ubuntu)' + - name: 'py311-pytest84-xdist38-coverage710 (ubuntu)' python: '3.11' toxpython: 'python3.11' python_arch: 'x64' - tox_env: 'py311-pytest83-xdist36-coverage78' + tox_env: 'py311-pytest84-xdist38-coverage710' os: 'ubuntu-latest' - - name: 'py311-pytest83-xdist36-coverage78 (windows)' + - name: 'py311-pytest84-xdist38-coverage710 (windows)' python: '3.11' toxpython: 'python3.11' python_arch: 'x64' - tox_env: 'py311-pytest83-xdist36-coverage78' + tox_env: 'py311-pytest84-xdist38-coverage710' os: 'windows-latest' - - name: 'py311-pytest83-xdist36-coverage78 (macos)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'arm64' - tox_env: 'py311-pytest83-xdist36-coverage78' - os: 'macos-latest' - - name: 'py311-pytest83-xdist37-coverage78 (ubuntu)' + - name: 'py311-pytest84-xdist38-coverage710 (macos)' python: '3.11' toxpython: 'python3.11' - python_arch: 'x64' - tox_env: 'py311-pytest83-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py311-pytest83-xdist37-coverage78 (windows)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'x64' - tox_env: 'py311-pytest83-xdist37-coverage78' - os: 'windows-latest' - - name: 'py311-pytest83-xdist37-coverage78 (macos)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'arm64' - tox_env: 'py311-pytest83-xdist37-coverage78' - os: 'macos-latest' - - name: 'py311-pytest84-xdist36-coverage78 (ubuntu)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'x64' - tox_env: 'py311-pytest84-xdist36-coverage78' - os: 'ubuntu-latest' - - name: 'py311-pytest84-xdist36-coverage78 (windows)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'x64' - tox_env: 'py311-pytest84-xdist36-coverage78' - os: 'windows-latest' - - name: 'py311-pytest84-xdist36-coverage78 (macos)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'arm64' - tox_env: 'py311-pytest84-xdist36-coverage78' - os: 'macos-latest' - - name: 'py311-pytest84-xdist37-coverage78 (ubuntu)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'x64' - tox_env: 'py311-pytest84-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py311-pytest84-xdist37-coverage78 (windows)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'x64' - tox_env: 'py311-pytest84-xdist37-coverage78' - os: 'windows-latest' - - name: 'py311-pytest84-xdist37-coverage78 (macos)' - python: '3.11' - toxpython: 'python3.11' - python_arch: 'arm64' - tox_env: 'py311-pytest84-xdist37-coverage78' - os: 'macos-latest' - - name: 'py312-pytest83-xdist36-coverage78 (ubuntu)' - python: '3.12' - toxpython: 'python3.12' - python_arch: 'x64' - tox_env: 'py312-pytest83-xdist36-coverage78' - os: 'ubuntu-latest' - - name: 'py312-pytest83-xdist36-coverage78 (windows)' - python: '3.12' - toxpython: 'python3.12' - python_arch: 'x64' - tox_env: 'py312-pytest83-xdist36-coverage78' - os: 'windows-latest' - - name: 'py312-pytest83-xdist36-coverage78 (macos)' - python: '3.12' - toxpython: 'python3.12' - python_arch: 'arm64' - tox_env: 'py312-pytest83-xdist36-coverage78' - os: 'macos-latest' - - name: 'py312-pytest83-xdist37-coverage78 (ubuntu)' - python: '3.12' - toxpython: 'python3.12' - python_arch: 'x64' - tox_env: 'py312-pytest83-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py312-pytest83-xdist37-coverage78 (windows)' - python: '3.12' - toxpython: 'python3.12' - python_arch: 'x64' - tox_env: 'py312-pytest83-xdist37-coverage78' - os: 'windows-latest' - - name: 'py312-pytest83-xdist37-coverage78 (macos)' - python: '3.12' - toxpython: 'python3.12' python_arch: 'arm64' - tox_env: 'py312-pytest83-xdist37-coverage78' + tox_env: 'py311-pytest84-xdist38-coverage710' os: 'macos-latest' - - name: 'py312-pytest84-xdist36-coverage78 (ubuntu)' + - name: 'py312-pytest84-xdist38-coverage710 (ubuntu)' python: '3.12' toxpython: 'python3.12' python_arch: 'x64' - tox_env: 'py312-pytest84-xdist36-coverage78' + tox_env: 'py312-pytest84-xdist38-coverage710' os: 'ubuntu-latest' - - name: 'py312-pytest84-xdist36-coverage78 (windows)' + - name: 'py312-pytest84-xdist38-coverage710 (windows)' python: '3.12' toxpython: 'python3.12' python_arch: 'x64' - tox_env: 'py312-pytest84-xdist36-coverage78' + tox_env: 'py312-pytest84-xdist38-coverage710' os: 'windows-latest' - - name: 'py312-pytest84-xdist36-coverage78 (macos)' - python: '3.12' - toxpython: 'python3.12' - python_arch: 'arm64' - tox_env: 'py312-pytest84-xdist36-coverage78' - os: 'macos-latest' - - name: 'py312-pytest84-xdist37-coverage78 (ubuntu)' + - name: 'py312-pytest84-xdist38-coverage710 (macos)' python: '3.12' toxpython: 'python3.12' - python_arch: 'x64' - tox_env: 'py312-pytest84-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py312-pytest84-xdist37-coverage78 (windows)' - python: '3.12' - toxpython: 'python3.12' - python_arch: 'x64' - tox_env: 'py312-pytest84-xdist37-coverage78' - os: 'windows-latest' - - name: 'py312-pytest84-xdist37-coverage78 (macos)' - python: '3.12' - toxpython: 'python3.12' - python_arch: 'arm64' - tox_env: 'py312-pytest84-xdist37-coverage78' - os: 'macos-latest' - - name: 'py313-pytest83-xdist36-coverage78 (ubuntu)' - python: '3.13' - toxpython: 'python3.13' - python_arch: 'x64' - tox_env: 'py313-pytest83-xdist36-coverage78' - os: 'ubuntu-latest' - - name: 'py313-pytest83-xdist36-coverage78 (windows)' - python: '3.13' - toxpython: 'python3.13' - python_arch: 'x64' - tox_env: 'py313-pytest83-xdist36-coverage78' - os: 'windows-latest' - - name: 'py313-pytest83-xdist36-coverage78 (macos)' - python: '3.13' - toxpython: 'python3.13' - python_arch: 'arm64' - tox_env: 'py313-pytest83-xdist36-coverage78' - os: 'macos-latest' - - name: 'py313-pytest83-xdist37-coverage78 (ubuntu)' - python: '3.13' - toxpython: 'python3.13' - python_arch: 'x64' - tox_env: 'py313-pytest83-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py313-pytest83-xdist37-coverage78 (windows)' - python: '3.13' - toxpython: 'python3.13' - python_arch: 'x64' - tox_env: 'py313-pytest83-xdist37-coverage78' - os: 'windows-latest' - - name: 'py313-pytest83-xdist37-coverage78 (macos)' - python: '3.13' - toxpython: 'python3.13' python_arch: 'arm64' - tox_env: 'py313-pytest83-xdist37-coverage78' + tox_env: 'py312-pytest84-xdist38-coverage710' os: 'macos-latest' - - name: 'py313-pytest84-xdist36-coverage78 (ubuntu)' + - name: 'py313-pytest84-xdist38-coverage710 (ubuntu)' python: '3.13' toxpython: 'python3.13' python_arch: 'x64' - tox_env: 'py313-pytest84-xdist36-coverage78' + tox_env: 'py313-pytest84-xdist38-coverage710' os: 'ubuntu-latest' - - name: 'py313-pytest84-xdist36-coverage78 (windows)' + - name: 'py313-pytest84-xdist38-coverage710 (windows)' python: '3.13' toxpython: 'python3.13' python_arch: 'x64' - tox_env: 'py313-pytest84-xdist36-coverage78' + tox_env: 'py313-pytest84-xdist38-coverage710' os: 'windows-latest' - - name: 'py313-pytest84-xdist36-coverage78 (macos)' + - name: 'py313-pytest84-xdist38-coverage710 (macos)' python: '3.13' toxpython: 'python3.13' python_arch: 'arm64' - tox_env: 'py313-pytest84-xdist36-coverage78' + tox_env: 'py313-pytest84-xdist38-coverage710' os: 'macos-latest' - - name: 'py313-pytest84-xdist37-coverage78 (ubuntu)' - python: '3.13' - toxpython: 'python3.13' - python_arch: 'x64' - tox_env: 'py313-pytest84-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'py313-pytest84-xdist37-coverage78 (windows)' - python: '3.13' - toxpython: 'python3.13' - python_arch: 'x64' - tox_env: 'py313-pytest84-xdist37-coverage78' - os: 'windows-latest' - - name: 'py313-pytest84-xdist37-coverage78 (macos)' - python: '3.13' - toxpython: 'python3.13' - python_arch: 'arm64' - tox_env: 'py313-pytest84-xdist37-coverage78' - os: 'macos-latest' - - name: 'pypy39-pytest83-xdist36-coverage78 (ubuntu)' + - name: 'pypy39-pytest84-xdist38-coverage710 (ubuntu)' python: 'pypy-3.9' toxpython: 'pypy3.9' python_arch: 'x64' - tox_env: 'pypy39-pytest83-xdist36-coverage78' + tox_env: 'pypy39-pytest84-xdist38-coverage710' os: 'ubuntu-latest' - - name: 'pypy39-pytest83-xdist36-coverage78 (windows)' + - name: 'pypy39-pytest84-xdist38-coverage710 (windows)' python: 'pypy-3.9' toxpython: 'pypy3.9' python_arch: 'x64' - tox_env: 'pypy39-pytest83-xdist36-coverage78' + tox_env: 'pypy39-pytest84-xdist38-coverage710' os: 'windows-latest' - - name: 'pypy39-pytest83-xdist36-coverage78 (macos)' + - name: 'pypy39-pytest84-xdist38-coverage710 (macos)' python: 'pypy-3.9' toxpython: 'pypy3.9' python_arch: 'arm64' - tox_env: 'pypy39-pytest83-xdist36-coverage78' + tox_env: 'pypy39-pytest84-xdist38-coverage710' os: 'macos-latest' - - name: 'pypy39-pytest83-xdist37-coverage78 (ubuntu)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'x64' - tox_env: 'pypy39-pytest83-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'pypy39-pytest83-xdist37-coverage78 (windows)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'x64' - tox_env: 'pypy39-pytest83-xdist37-coverage78' - os: 'windows-latest' - - name: 'pypy39-pytest83-xdist37-coverage78 (macos)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'arm64' - tox_env: 'pypy39-pytest83-xdist37-coverage78' - os: 'macos-latest' - - name: 'pypy39-pytest84-xdist36-coverage78 (ubuntu)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'x64' - tox_env: 'pypy39-pytest84-xdist36-coverage78' - os: 'ubuntu-latest' - - name: 'pypy39-pytest84-xdist36-coverage78 (windows)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'x64' - tox_env: 'pypy39-pytest84-xdist36-coverage78' - os: 'windows-latest' - - name: 'pypy39-pytest84-xdist36-coverage78 (macos)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'arm64' - tox_env: 'pypy39-pytest84-xdist36-coverage78' - os: 'macos-latest' - - name: 'pypy39-pytest84-xdist37-coverage78 (ubuntu)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'x64' - tox_env: 'pypy39-pytest84-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'pypy39-pytest84-xdist37-coverage78 (windows)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'x64' - tox_env: 'pypy39-pytest84-xdist37-coverage78' - os: 'windows-latest' - - name: 'pypy39-pytest84-xdist37-coverage78 (macos)' - python: 'pypy-3.9' - toxpython: 'pypy3.9' - python_arch: 'arm64' - tox_env: 'pypy39-pytest84-xdist37-coverage78' - os: 'macos-latest' - - name: 'pypy310-pytest83-xdist36-coverage78 (ubuntu)' + - name: 'pypy310-pytest84-xdist38-coverage710 (ubuntu)' python: 'pypy-3.10' toxpython: 'pypy3.10' python_arch: 'x64' - tox_env: 'pypy310-pytest83-xdist36-coverage78' + tox_env: 'pypy310-pytest84-xdist38-coverage710' os: 'ubuntu-latest' - - name: 'pypy310-pytest83-xdist36-coverage78 (windows)' + - name: 'pypy310-pytest84-xdist38-coverage710 (windows)' python: 'pypy-3.10' toxpython: 'pypy3.10' python_arch: 'x64' - tox_env: 'pypy310-pytest83-xdist36-coverage78' + tox_env: 'pypy310-pytest84-xdist38-coverage710' os: 'windows-latest' - - name: 'pypy310-pytest83-xdist36-coverage78 (macos)' + - name: 'pypy310-pytest84-xdist38-coverage710 (macos)' python: 'pypy-3.10' toxpython: 'pypy3.10' python_arch: 'arm64' - tox_env: 'pypy310-pytest83-xdist36-coverage78' + tox_env: 'pypy310-pytest84-xdist38-coverage710' os: 'macos-latest' - - name: 'pypy310-pytest83-xdist37-coverage78 (ubuntu)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' - python_arch: 'x64' - tox_env: 'pypy310-pytest83-xdist37-coverage78' - os: 'ubuntu-latest' - - name: 'pypy310-pytest83-xdist37-coverage78 (windows)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' - python_arch: 'x64' - tox_env: 'pypy310-pytest83-xdist37-coverage78' - os: 'windows-latest' - - name: 'pypy310-pytest83-xdist37-coverage78 (macos)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' - python_arch: 'arm64' - tox_env: 'pypy310-pytest83-xdist37-coverage78' - os: 'macos-latest' - - name: 'pypy310-pytest84-xdist36-coverage78 (ubuntu)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' - python_arch: 'x64' - tox_env: 'pypy310-pytest84-xdist36-coverage78' - os: 'ubuntu-latest' - - name: 'pypy310-pytest84-xdist36-coverage78 (windows)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' + - name: 'pypy311-pytest84-xdist38-coverage710 (ubuntu)' + python: 'pypy-3.11' + toxpython: 'pypy3.11' python_arch: 'x64' - tox_env: 'pypy310-pytest84-xdist36-coverage78' - os: 'windows-latest' - - name: 'pypy310-pytest84-xdist36-coverage78 (macos)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' - python_arch: 'arm64' - tox_env: 'pypy310-pytest84-xdist36-coverage78' - os: 'macos-latest' - - name: 'pypy310-pytest84-xdist37-coverage78 (ubuntu)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' - python_arch: 'x64' - tox_env: 'pypy310-pytest84-xdist37-coverage78' + tox_env: 'pypy311-pytest84-xdist38-coverage710' os: 'ubuntu-latest' - - name: 'pypy310-pytest84-xdist37-coverage78 (windows)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' + - name: 'pypy311-pytest84-xdist38-coverage710 (windows)' + python: 'pypy-3.11' + toxpython: 'pypy3.11' python_arch: 'x64' - tox_env: 'pypy310-pytest84-xdist37-coverage78' + tox_env: 'pypy311-pytest84-xdist38-coverage710' os: 'windows-latest' - - name: 'pypy310-pytest84-xdist37-coverage78 (macos)' - python: 'pypy-3.10' - toxpython: 'pypy3.10' + - name: 'pypy311-pytest84-xdist38-coverage710 (macos)' + python: 'pypy-3.11' + toxpython: 'pypy3.11' python_arch: 'arm64' - tox_env: 'pypy310-pytest84-xdist37-coverage78' + tox_env: 'pypy311-pytest84-xdist38-coverage710' os: 'macos-latest' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-python@v5 diff --git a/ci/templates/.github/workflows/test.yml b/ci/templates/.github/workflows/test.yml index 22fec036..b1e9f361 100644 --- a/ci/templates/.github/workflows/test.yml +++ b/ci/templates/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: - {python-version: "pypy-3.9", tox-python-version: "pypy3"} - {python-version: "3.11", tox-python-version: "py311"} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 diff --git a/docs/plugins.rst b/docs/plugins.rst index 577870de..6c6b1c13 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -6,19 +6,5 @@ Getting coverage on pytest plugins is a very particular situation. Because of ho entrypoints) it doesn't allow controlling the order in which the plugins load. See `pytest/issues/935 `_ for technical details. -The current way of dealing with this problem is using the append feature and manually starting ``pytest-cov``'s engine, eg:: - - COV_CORE_SOURCE=src COV_CORE_CONFIG=.coveragerc COV_CORE_DATAFILE=.coverage.eager pytest --cov=src --cov-append - -Alternatively you can have this in ``tox.ini`` (if you're using `Tox `_ of course):: - - [testenv] - setenv = - COV_CORE_SOURCE= - COV_CORE_CONFIG={toxinidir}/.coveragerc - COV_CORE_DATAFILE={toxinidir}/.coverage - -And in ``pytest.ini`` / ``tox.ini`` / ``setup.cfg``:: - - [tool:pytest] - addopts = --cov --cov-append +**Currently there is no way to measure your pytest plugin if you use pytest-cov**. +You should change your test invocations to use ``coverage run -m pytest ...`` instead. diff --git a/docs/subprocess-support.rst b/docs/subprocess-support.rst index 56044392..7e552c45 100644 --- a/docs/subprocess-support.rst +++ b/docs/subprocess-support.rst @@ -2,189 +2,21 @@ Subprocess support ================== -Normally coverage writes the data via a pretty standard atexit handler. However, if the subprocess doesn't exit on its -own then the atexit handler might not run. Why that happens is best left to the adventurous to discover by waddling -through the Python bug tracker. - -pytest-cov supports subprocesses, and works around these atexit limitations. However, there are a few pitfalls that need to be explained. - -But first, how does pytest-cov's subprocess support works? - -pytest-cov packaging injects a pytest-cov.pth into the installation. This file effectively runs this at *every* python startup: - -.. code-block:: python - - if 'COV_CORE_SOURCE' in os.environ: - try: - from pytest_cov.embed import init - init() - except Exception as exc: - sys.stderr.write( - "pytest-cov: Failed to setup subprocess coverage. " - "Environ: {0!r} " - "Exception: {1!r}\n".format( - dict((k, v) for k, v in os.environ.items() if k.startswith('COV_CORE')), - exc - ) - ) - -The pytest plugin will set this ``COV_CORE_SOURCE`` environment variable thus any subprocess that inherits the environment variables -(the default behavior) will run ``pytest_cov.embed.init`` which in turn sets up coverage according to these variables: - -* ``COV_CORE_SOURCE`` -* ``COV_CORE_CONFIG`` -* ``COV_CORE_DATAFILE`` -* ``COV_CORE_BRANCH`` -* ``COV_CORE_CONTEXT`` - -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 -that implemented common functionality for pytest-cov, nose-cov and nose2-cov. The dependency is gone but the convention is kept. It could -be changed but it would break all projects that manually set these intended-to-be-internal-but-sadly-not-in-reality environment variables. - -Coverage's subprocess support -============================= - -Now that you understand how pytest-cov works you can easily figure out that using -`coverage's recommended `_ way of dealing with subprocesses, -by either having this in a ``.pth`` file or ``sitecustomize.py`` will break everything: - -.. code-block:: - - import coverage; coverage.process_startup() # this will break pytest-cov - -Do not do that as that will restart coverage with the wrong options. - -If you use ``multiprocessing`` -============================== - -Builtin support for multiprocessing was dropped in pytest-cov 4.0. -This support was mostly working but very broken in certain scenarios (see `issue 82408 `_) -and made the test suite very flaky and slow. - -However, there is `builtin multiprocessing support in coverage `_ -and you can migrate to that. All you need is this in your preferred configuration file (example: ``.coveragerc``): +Subprocess support was removed in pytest-cov 7.0 due to various complexities resulting from coverage's own subprocess support. +To migrate you should change your coverage config to have at least this: .. code-block:: ini [run] - concurrency = multiprocessing - parallel = true - sigterm = true - -Now as a side-note, it's a good idea in general to properly close your Pool by using ``Pool.join()``: - -.. code-block:: python - - from multiprocessing import Pool - - def f(x): - return x*x - - if __name__ == '__main__': - p = Pool(5) - try: - print(p.map(f, [1, 2, 3])) - finally: - p.close() # Marks the pool as closed. - p.join() # Waits for workers to exit. - - -.. _cleanup_on_sigterm: - -Signal handlers -=============== - -pytest-cov provides a signal handling routines, mostly for special situations where you'd have custom signal handling that doesn't -allow atexit to properly run and the now-gone multiprocessing support: - -* ``pytest_cov.embed.cleanup_on_sigterm()`` -* ``pytest_cov.embed.cleanup_on_signal(signum)`` (e.g.: ``cleanup_on_signal(signal.SIGHUP)``) - -If you use multiprocessing --------------------------- - -It is not recommanded to use these signal handlers with multiprocessing as registering signal handlers will cause deadlocks in the pool, -see: https://bugs.python.org/issue38227). - -If you got custom signal handling ---------------------------------- - -**pytest-cov 2.6** has a rudimentary ``pytest_cov.embed.cleanup_on_sigterm`` you can use to register a SIGTERM handler -that flushes the coverage data. - -**pytest-cov 2.7** adds a ``pytest_cov.embed.cleanup_on_signal`` function and changes the implementation to be more -robust: the handler will call the previous handler (if you had previously registered any), and is re-entrant (will -defer extra signals if delivered while the handler runs). - -For example, if you reload on SIGHUP you should have something like this: - -.. code-block:: python - - import os - import signal - - def restart_service(frame, signum): - os.exec( ... ) # or whatever your custom signal would do - signal.signal(signal.SIGHUP, restart_service) - - try: - from pytest_cov.embed import cleanup_on_signal - except ImportError: - pass - else: - cleanup_on_signal(signal.SIGHUP) - -Note that both ``cleanup_on_signal`` and ``cleanup_on_sigterm`` will run the previous signal handler. - -Alternatively you can do this: - -.. code-block:: python - - import os - import signal - - try: - from pytest_cov.embed import cleanup - except ImportError: - cleanup = None - - def restart_service(frame, signum): - if cleanup is not None: - cleanup() - - os.exec( ... ) # or whatever your custom signal would do - signal.signal(signal.SIGHUP, restart_service) - -If you use Windows ------------------- - -On Windows you can register a handler for SIGTERM but it doesn't actually work. It will work if you -`os.kill(os.getpid(), signal.SIGTERM)` (send SIGTERM to the current process) but for most intents and purposes that's -completely useless. - -Consequently this means that if you use multiprocessing you got no choice but to use the close/join pattern as described -above. Using the context manager API or `terminate` won't work as it relies on SIGTERM. - -However you can have a working handler for SIGBREAK (with some caveats): - -.. code-block:: python + patch = subprocess - import os - import signal +Or if you use pyproject.toml: - def shutdown(frame, signum): - # your app's shutdown or whatever - signal.signal(signal.SIGBREAK, shutdown) +.. code-block:: toml - try: - from pytest_cov.embed import cleanup_on_signal - except ImportError: - pass - else: - cleanup_on_signal(signal.SIGBREAK) + [tool.coverage.run] + patch = ["subprocess"] -The `caveats `_ being -roughly: +Note that if you enable the subprocess patch then ``parallel = true`` is automatically set. -* you need to deliver ``signal.CTRL_BREAK_EVENT`` -* it gets delivered to the whole process group, and that can have unforeseen consequences +If it still doesn't produce the same coverage as before you may need to enable more patches, see the `coverage config `_ and `subprocess `_ documentation. diff --git a/setup.py b/setup.py index 3532adac..46d25a81 100755 --- a/setup.py +++ b/setup.py @@ -1,80 +1,17 @@ #!/usr/bin/env python import re -from itertools import chain from pathlib import Path -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 - def read(*names, **kwargs): with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get('encoding', 'utf8')) as fh: return fh.read() -class BuildWithPTH(build): - def run(self, *args, **kwargs): - super().run(*args, **kwargs) - path = str(Path(__file__).parent / 'src' / 'pytest-cov.pth') - dest = str(Path(self.build_lib) / Path(path).name) - self.copy_file(path, dest) - - -class EasyInstallWithPTH(easy_install): - def run(self, *args, **kwargs): - super().run(*args, **kwargs) - path = str(Path(__file__).parent / 'src' / 'pytest-cov.pth') - dest = str(Path(self.install_dir) / Path(path).name) - self.copy_file(path, dest) - - -class InstallLibWithPTH(install_lib): - def run(self, *args, **kwargs): - super().run(*args, **kwargs) - path = str(Path(__file__).parent / 'src' / 'pytest-cov.pth') - dest = str(Path(self.install_dir) / Path(path).name) - self.copy_file(path, dest) - self.outputs = [dest] - - def get_outputs(self): - return chain(super().get_outputs(), self.outputs) - - -class DevelopWithPTH(develop): - def run(self, *args, **kwargs): - super().run(*args, **kwargs) - path = str(Path(__file__).parent / 'src' / 'pytest-cov.pth') - dest = str(Path(self.install_dir) / Path(path).name) - self.copy_file(path, dest) - - -class GeneratePTH(Command): - user_options = () - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - with Path(__file__).parent.joinpath('src', 'pytest-cov.pth').open('w') as fh: - with Path(__file__).parent.joinpath('src', 'pytest-cov.embed').open() as sh: - fh.write(f'import os, sys;exec({sh.read().replace(" ", " ")!r})') - - setup( name='pytest-cov', version='6.3.0', @@ -125,7 +62,7 @@ def run(self): python_requires='>=3.9', install_requires=[ 'pytest>=6.2.5', - 'coverage[toml]>=7.5', + 'coverage[toml]>=7.10.6', 'pluggy>=1.2', ], extras_require={ @@ -142,11 +79,4 @@ def run(self): 'pytest_cov = pytest_cov.plugin', ], }, - cmdclass={ - 'build': BuildWithPTH, - 'easy_install': EasyInstallWithPTH, - 'install_lib': InstallLibWithPTH, - 'develop': DevelopWithPTH, - 'genpth': GeneratePTH, - }, ) diff --git a/src/pytest-cov.embed b/src/pytest-cov.embed deleted file mode 100644 index 630a2a72..00000000 --- a/src/pytest-cov.embed +++ /dev/null @@ -1,13 +0,0 @@ -if 'COV_CORE_SOURCE' in os.environ: - try: - from pytest_cov.embed import init - init() - except Exception as exc: - sys.stderr.write( - "pytest-cov: Failed to setup subprocess coverage. " - "Environ: {0!r} " - "Exception: {1!r}\n".format( - dict((k, v) for k, v in os.environ.items() if k.startswith('COV_CORE')), - exc - ) - ) diff --git a/src/pytest-cov.pth b/src/pytest-cov.pth deleted file mode 100644 index 8ed1a516..00000000 --- a/src/pytest-cov.pth +++ /dev/null @@ -1 +0,0 @@ -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/compat.py b/src/pytest_cov/compat.py deleted file mode 100644 index 453709d7..00000000 --- a/src/pytest_cov/compat.py +++ /dev/null @@ -1,15 +0,0 @@ -class SessionWrapper: - def __init__(self, session): - self._session = session - if hasattr(session, 'testsfailed'): - self._attr = 'testsfailed' - else: - self._attr = '_testsfailed' - - @property - def testsfailed(self): - return getattr(self._session, self._attr) - - @testsfailed.setter - def testsfailed(self, value): - setattr(self._session, self._attr, value) diff --git a/src/pytest_cov/embed.py b/src/pytest_cov/embed.py deleted file mode 100644 index 153cb83d..00000000 --- a/src/pytest_cov/embed.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Activate coverage at python startup if appropriate. - -The python site initialisation will ensure that anything we import -will be removed and not visible at the end of python startup. However -we minimise all work by putting these init actions in this separate -module and only importing what is needed when needed. - -For normal python startup when coverage should not be activated the pth -file checks a single env var and does not import or call the init fn -here. - -For python startup when an ancestor process has set the env indicating -that code coverage is being collected we activate coverage based on -info passed via env vars. -""" - -import atexit -import os -import signal - -_active_cov = None - - -def init(): - # Only continue if ancestor process has set everything needed in - # the env. - global _active_cov - - cov_source = os.environ.get('COV_CORE_SOURCE') - cov_config = os.environ.get('COV_CORE_CONFIG') - cov_datafile = os.environ.get('COV_CORE_DATAFILE') - cov_branch = True if os.environ.get('COV_CORE_BRANCH') == 'enabled' else None - cov_context = os.environ.get('COV_CORE_CONTEXT') - - if cov_datafile: - if _active_cov: - cleanup() - # Import what we need to activate coverage. - import coverage - - # Determine all source roots. - if cov_source in os.pathsep: - cov_source = None - else: - cov_source = cov_source.split(os.pathsep) - if cov_config == os.pathsep: - cov_config = True - - # Activate coverage for this process. - cov = _active_cov = coverage.Coverage( - source=cov_source, - branch=cov_branch, - data_suffix=True, - config_file=cov_config, - auto_data=True, - data_file=cov_datafile, - ) - cov.load() - cov.start() - if cov_context: - cov.switch_context(cov_context) - cov._warn_no_data = False - cov._warn_unimported_source = False - cov._warn_preimported_source = False - return cov - - -def _cleanup(cov): - if cov is not None: - cov.stop() - cov.save() - cov._auto_save = False # prevent autosaving from cov._atexit in case the interpreter lacks atexit.unregister - try: - atexit.unregister(cov._atexit) - except Exception: # noqa: S110 - pass - - -def cleanup(): - global _active_cov - global _cleanup_in_progress - global _pending_signal - - _cleanup_in_progress = True - _cleanup(_active_cov) - _active_cov = None - _cleanup_in_progress = False - if _pending_signal: - pending_signal = _pending_signal - _pending_signal = None - _signal_cleanup_handler(*pending_signal) - - -_previous_handlers = {} -_pending_signal = None -_cleanup_in_progress = False - - -def _signal_cleanup_handler(signum, frame): - global _pending_signal - if _cleanup_in_progress: - _pending_signal = signum, frame - return - cleanup() - _previous_handler = _previous_handlers.get(signum) - if _previous_handler == signal.SIG_IGN: - return - elif _previous_handler and _previous_handler is not _signal_cleanup_handler: - _previous_handler(signum, frame) - elif signum == signal.SIGTERM: - os._exit(128 + signum) - elif signum == signal.SIGINT: - raise KeyboardInterrupt - - -def cleanup_on_signal(signum): - previous = signal.getsignal(signum) - if previous is not _signal_cleanup_handler: - _previous_handlers[signum] = previous - signal.signal(signum, _signal_cleanup_handler) - - -def cleanup_on_sigterm(): - cleanup_on_signal(signal.SIGTERM) diff --git a/src/pytest_cov/engine.py b/src/pytest_cov/engine.py index 99ea6ddd..ca631272 100644 --- a/src/pytest_cov/engine.py +++ b/src/pytest_cov/engine.py @@ -10,7 +10,6 @@ import socket import sys import warnings -from io import StringIO from pathlib import Path from typing import Union @@ -20,7 +19,6 @@ from . import CentralCovContextWarning from . import DistCovError -from .embed import cleanup class BrokenCovConfigError(Exception): @@ -62,10 +60,6 @@ def ensure_topdir_wrapper(self, *args, **kwargs): return ensure_topdir_wrapper -def _data_suffix(name): - return f'{filename_suffix(True)}.{name}' - - class CovController: """Base class for different plugin implementations.""" @@ -100,12 +94,10 @@ def ensure_topdir(self): def pause(self): self.started = False self.cov.stop() - self.unset_env() @_ensure_topdir def resume(self): self.cov.start() - self.set_env() self.started = True def start(self): @@ -114,32 +106,6 @@ def start(self): def finish(self): self.started = False - @_ensure_topdir - def set_env(self): - """Put info about coverage into the env so that subprocesses can activate coverage.""" - if self.cov_source is None: - os.environ['COV_CORE_SOURCE'] = os.pathsep - else: - os.environ['COV_CORE_SOURCE'] = os.pathsep.join(self.cov_source) - config_file = Path(self.cov_config) - if config_file.exists(): - os.environ['COV_CORE_CONFIG'] = os.fspath(config_file.resolve()) - else: - os.environ['COV_CORE_CONFIG'] = os.pathsep - # this still uses the old abspath cause apparently Python 3.9 on Windows has a buggy Path.resolve() - os.environ['COV_CORE_DATAFILE'] = os.path.abspath(self.cov.config.data_file) # noqa: PTH100 - if self.cov_branch: - os.environ['COV_CORE_BRANCH'] = 'enabled' - - @staticmethod - def unset_env(): - """Remove coverage info from env.""" - os.environ.pop('COV_CORE_SOURCE', None) - os.environ.pop('COV_CORE_CONFIG', None) - os.environ.pop('COV_CORE_DATAFILE', None) - os.environ.pop('COV_CORE_BRANCH', None) - os.environ.pop('COV_CORE_CONTEXT', None) - @staticmethod def get_node_desc(platform, version_info): """Return a description of this node.""" @@ -291,12 +257,10 @@ class Central(CovController): @_ensure_topdir def start(self): - cleanup() - self.cov = coverage.Coverage( source=self.cov_source, branch=self.cov_branch, - data_suffix=_data_suffix('c'), + data_suffix=True, config_file=self.cov_config, ) if self.cov.config.dynamic_context == 'test_function': @@ -309,7 +273,7 @@ def start(self): self.combining_cov = coverage.Coverage( source=self.cov_source, branch=self.cov_branch, - data_suffix=_data_suffix('cc'), + data_suffix=f'{filename_suffix(True)}.combine', data_file=os.path.abspath(self.cov.config.data_file), # noqa: PTH100 config_file=self.cov_config, ) @@ -318,7 +282,6 @@ def start(self): if not self.cov_append: self.cov.erase() self.cov.start() - self.set_env() super().start() @@ -327,7 +290,6 @@ def finish(self): """Stop coverage, save data to file and set the list of coverage objects to report on.""" super().finish() - self.unset_env() self.cov.stop() self.cov.save() @@ -345,12 +307,10 @@ class DistMaster(CovController): @_ensure_topdir def start(self): - cleanup() - self.cov = coverage.Coverage( source=self.cov_source, branch=self.cov_branch, - data_suffix=_data_suffix('m'), + data_suffix=True, config_file=self.cov_config, ) if self.cov.config.dynamic_context == 'test_function': @@ -365,7 +325,7 @@ def start(self): self.combining_cov = coverage.Coverage( source=self.cov_source, branch=self.cov_branch, - data_suffix=_data_suffix('mc'), + data_suffix=f'{filename_suffix(True)}.combine', data_file=os.path.abspath(self.cov.config.data_file), # noqa: PTH100 config_file=self.cov_config, ) @@ -405,18 +365,10 @@ def testnodedown(self, node, error): output['cov_worker_node_id'], ) - cov = coverage.Coverage(source=self.cov_source, branch=self.cov_branch, data_suffix=data_suffix, config_file=self.cov_config) - cov.start() - if coverage.version_info < (5, 0): - data = CoverageData() - data.read_fileobj(StringIO(output['cov_worker_data'])) - cov.data.update(data) - else: - data = CoverageData(no_disk=True, suffix='should-not-exist') - data.loads(output['cov_worker_data']) - cov.get_data().update(data) - cov.stop() - cov.save() + cov_data = CoverageData( + suffix=data_suffix, + ) + cov_data.loads(output['cov_worker_data']) path = output['cov_worker_path'] self.cov.config.paths['source'].append(path) @@ -443,15 +395,13 @@ class DistWorker(CovController): @_ensure_topdir def start(self): - cleanup() - # Determine whether we are collocated with master. self.is_collocated = ( socket.gethostname() == self.config.workerinput['cov_master_host'] and self.topdir == self.config.workerinput['cov_master_topdir'] ) - # If we are not collocated then rewrite master paths to worker paths. + # If we are not collocated, then rewrite master paths to worker paths. if not self.is_collocated: master_topdir = self.config.workerinput['cov_master_topdir'] worker_topdir = self.topdir @@ -463,13 +413,13 @@ def start(self): self.cov = coverage.Coverage( source=self.cov_source, branch=self.cov_branch, - data_suffix=_data_suffix(f'w{self.nodeid}'), + data_suffix=True, config_file=self.cov_config, ) # Prevent workers from issuing module-not-measured type of warnings (expected for a workers to not have coverage in all the files). self.cov._warn_unimported_source = False self.cov.start() - self.set_env() + super().start() @_ensure_topdir @@ -477,7 +427,6 @@ def finish(self): """Stop coverage and send relevant info back to the master.""" super().finish() - self.unset_env() self.cov.stop() if self.is_collocated: @@ -497,12 +446,7 @@ def finish(self): # it on the master node. # Send all the data to the master over the channel. - if coverage.version_info < (5, 0): - buff = StringIO() - self.cov.data.write_fileobj(buff) - data = buff.getvalue() - else: - data = self.cov.get_data().dumps() + data = self.cov.get_data().dumps() self.config.workeroutput.update( { diff --git a/src/pytest_cov/plugin.py b/src/pytest_cov/plugin.py index c49a655d..553a9203 100644 --- a/src/pytest_cov/plugin.py +++ b/src/pytest_cov/plugin.py @@ -8,17 +8,11 @@ from pathlib import Path from typing import TYPE_CHECKING -import coverage import pytest -from coverage.exceptions import CoverageWarning -from coverage.results import display_covered -from coverage.results import should_fail_under from . import CovDisabledWarning from . import CovReportWarning from . import PytestCovWarning -from . import compat -from . import embed if TYPE_CHECKING: from .engine import CovController @@ -37,9 +31,6 @@ 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 @@ -70,8 +61,6 @@ def validate_fail_under(num_str): def validate_context(arg): - if coverage.version_info <= (5, 0): - raise argparse.ArgumentTypeError('Contexts are only supported with coverage.py >= 5.x') if arg != 'test': raise argparse.ArgumentTypeError('The only supported value is "test".') return arg @@ -345,6 +334,8 @@ def pytest_runtestloop(self, session): break else: warnings.simplefilter('once', PytestCovWarning) + from coverage.exceptions import CoverageWarning + for _, _, category, _, _ in warnings.filters: if category is CoverageWarning: break @@ -353,9 +344,7 @@ def pytest_runtestloop(self, session): result = yield - compat_session = compat.SessionWrapper(session) - - self.failed = bool(compat_session.testsfailed) + self.failed = bool(session.testsfailed) if self.cov_controller is not None: self.cov_controller.finish() @@ -363,6 +352,8 @@ def pytest_runtestloop(self, session): # import coverage lazily here to avoid importing # it for unit tests that don't need it from coverage.misc import CoverageException + from coverage.results import display_covered + from coverage.results import should_fail_under try: self.cov_total = self.cov_controller.summary(self.cov_report) @@ -384,7 +375,7 @@ def pytest_runtestloop(self, session): ) session.config.pluginmanager.getplugin('terminalreporter').write(f'\nERROR: {message}\n', red=True, bold=True) # make sure we get the EXIT_TESTSFAILED exit code - compat_session.testsfailed += 1 + session.testsfailed += 1 return result @@ -426,15 +417,6 @@ def pytest_terminal_summary(self, terminalreporter): ) terminalreporter.write(message, **markup) - def pytest_runtest_setup(self, item): - if os.getpid() != self.pid: - # test is run in another process than session, run - # coverage manually - embed.init() - - def pytest_runtest_teardown(self, item): - embed.cleanup() - @pytest.hookimpl(hookwrapper=True) def pytest_runtest_call(self, item): if item.get_closest_marker('no_cover') or 'no_cover' in getattr(item, 'fixturenames', ()): @@ -462,9 +444,7 @@ def pytest_runtest_call(self, item): def switch_context(self, item, when): if self.cov_controller.started: - context = f'{item.nodeid}|{when}' - self.cov_controller.cov.switch_context(context) - os.environ['COV_CORE_CONTEXT'] = context + self.cov_controller.cov.switch_context(f'{item.nodeid}|{when}') @pytest.fixture diff --git a/tests/contextful.py b/tests/contextful.py index b1d0804b..6e57a601 100644 --- a/tests/contextful.py +++ b/tests/contextful.py @@ -58,7 +58,7 @@ def test_06(some_data, more_data): assert len(some_data) == len(more_data) # r6 -@pytest.fixture(scope='session') +@pytest.fixture def expensive_data(): return list(range(10)) # s7 diff --git a/tests/test_pytest_cov.py b/tests/test_pytest_cov.py index c89dbac6..a17c4aa3 100644 --- a/tests/test_pytest_cov.py +++ b/tests/test_pytest_cov.py @@ -21,7 +21,6 @@ import pytest_cov.plugin -coverage, platform # required for skipif mark on test_cov_min_from_coveragerc max_worker_restart_0 = '--max-worker-restart=0' @@ -299,9 +298,8 @@ def test_term_report_does_not_interact_with_html_output(testdir): dest_dir = testdir.tmpdir.join(DEST_DIR) assert dest_dir.check(dir=True) expected = [dest_dir.join('index.html'), dest_dir.join('test_funcarg_py.html')] - if coverage.version_info >= (7, 5): - expected.insert(0, dest_dir.join('function_index.html')) - expected.insert(0, dest_dir.join('class_index.html')) + expected.insert(0, dest_dir.join('function_index.html')) + expected.insert(0, dest_dir.join('class_index.html')) assert sorted(dest_dir.visit('**/*.html')) == expected assert dest_dir.join('index.html').check() assert result.ret == 0 @@ -432,7 +430,6 @@ def test_markdown_and_markdown_append_pointing_to_same_file_throws_error(testdir assert result.ret == 4 -@pytest.mark.skipif('coverage.version_info < (6, 3)') def test_lcov_output_dir(testdir): script = testdir.makepyfile(SCRIPT) @@ -449,23 +446,6 @@ def test_lcov_output_dir(testdir): 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', - f'--cov={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) @@ -652,7 +632,6 @@ def test_central_with_path_aliasing(pytester, testdir, monkeypatch, opts, prop): aliased [coverage:run] source = mod -parallel = true {prop.conf} """ ) @@ -713,6 +692,75 @@ def test_foobar(bad): assert result.ret == 0 +@pytest.mark.skipif(sys.platform == 'win32', reason='No redis server on Windows') +@pytest.mark.skipif(sys.platform == 'darwin', reason='No redis server on OSX') +def test_celery(pytester): + pytester.makepyfile( + small_celery=""" +import os + +from celery import Celery +from celery.contrib.testing import worker +from testcontainers.redis import RedisContainer +import pytest + +app = Celery("tasks", broker="redis://localhost:6379/0", backend="redis://localhost:6379/0") + +@app.task +def add(x, y): + return x + y + +@pytest.fixture(scope="session") +def redis_container(): + with RedisContainer() as container: + yield container + + +@pytest.fixture +def celery_app(redis_container): + host = redis_container.get_container_host_ip() + port = redis_container.get_exposed_port(6379) + redis_url = f"redis://{host}:{port}/0" + + app.conf.update(broker_url=redis_url, result_backend=redis_url) + return app + +@pytest.fixture +def celery_worker(celery_app): + with worker.start_worker( + celery_app, + pool="prefork", + perform_ping_check=False, + ): + yield + print('CELERY SHUTDOWN') + print('CELERY SHUTDOWN DONE') + print(os.listdir()) + + +def test_add_task(celery_worker): + result = add.delay(4, 4) + assert result.get() == 8 +""" + ) + + pytester.makepyprojecttoml( + """ +[tool.coverage.run] +patch = ["subprocess", "_exit"] +""" + ) + result = pytester.runpytest('-vv', '-s', '--cov', '--cov-report=term-missing', 'small_celery.py') + + result.stdout.fnmatch_lines( + [ + '*_ coverage: platform *, python * _*', + f'small_celery* 100%*', + ] + ) + assert result.ret == 0 + + def test_subprocess_with_path_aliasing(pytester, testdir, monkeypatch): src = testdir.mkdir('src') src.join('parent_script.py').write(SCRIPT_PARENT) @@ -732,7 +780,7 @@ def test_subprocess_with_path_aliasing(pytester, testdir, monkeypatch): source = parent_script child_script -parallel = true +patch = subprocess """ ) @@ -948,6 +996,12 @@ def test_dist_not_collocated_coveragerc_source(pytester, testdir, prop): def test_central_subprocess(testdir): + testdir.makepyprojecttoml( + """ +[tool.coverage.run] +patch = ["subprocess"] +""" + ) scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT, child_script=SCRIPT_CHILD) parent_script = scripts.dirpath().join('parent_script.py') @@ -971,7 +1025,7 @@ def test_central_subprocess_change_cwd(testdir): coveragerc=""" [run] branch = true -parallel = true +patch = subprocess """, ) @@ -998,7 +1052,7 @@ def test_central_subprocess_change_cwd_with_pythonpath(pytester, testdir, monkey '', coveragerc=""" [run] -parallel = true +patch = subprocess """, ) @@ -1029,7 +1083,7 @@ def test_foo(): '', coveragerc=""" [run] -parallel = true +patch = subprocess """, ) result = testdir.runpytest('-v', '--cov-config=coveragerc', f'--cov={script.dirpath()}', '--cov-branch', script) @@ -1044,6 +1098,12 @@ def test_foo(): @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') def test_dist_subprocess_collocated(testdir): + testdir.makepyprojecttoml( + """ +[tool.coverage.run] +patch = ["subprocess"] +""" + ) scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT, child_script=SCRIPT_CHILD) parent_script = scripts.dirpath().join('parent_script.py') @@ -1071,6 +1131,9 @@ def test_dist_subprocess_not_collocated(pytester, testdir, tmpdir): dir2 = tmpdir.mkdir('dir2') testdir.tmpdir.join('.coveragerc').write( f""" +[run] +patch = subprocess + [paths] source = {scripts.dirpath()} @@ -1124,43 +1187,6 @@ def test_invalid_coverage_source(testdir): assert not matching_lines -@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') - virtualenv.cli_run([venv_path]) - if sys.platform == 'win32': - if platform.python_implementation() == 'PyPy': - exe = os.path.join(venv_path, 'bin', 'python.exe') - else: - exe = os.path.join(venv_path, 'Scripts', 'python.exe') - else: - exe = os.path.join(venv_path, 'bin', 'python') - subprocess.check_call( - [exe, '-mpip', 'install', f'py=={py.__version__}', f'pytest=={pytest.__version__}', f'pytest_xdist=={xdist.__version__}'] - ) - script = testdir.makepyfile(SCRIPT) - - result = testdir.runpytest( - '-v', - '--assert=plain', - f'--cov={script.dirpath()}', - '--cov-report=term-missing', - '--dist=load', - f'--tx=popen//python={exe}', - max_worker_restart_0, - str(script), - ) - result.stdout.fnmatch_lines( - ['The following workers failed to return coverage data, ensure that pytest-cov is installed on these workers.'] - ) - - def test_funcarg(testdir): script = testdir.makepyfile(SCRIPT_FUNCARG) @@ -1179,9 +1205,15 @@ def test_funcarg_not_active(testdir): 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') +@pytest.mark.skipif(sys.platform == 'win32', reason="SIGTERM isn't really supported on Windows") def test_cleanup_on_sigterm(testdir): + testdir.makepyprojecttoml( + """ +[tool.coverage.run] +patch = ["subprocess", "_exit"] +""" + ) + script = testdir.makepyfile( ''' import os, signal, subprocess, sys, time @@ -1204,9 +1236,6 @@ def test_run(): if __name__ == "__main__": signal.signal(signal.SIGTERM, cleanup) - from pytest_cov.embed import cleanup_on_sigterm - cleanup_on_sigterm() - try: time.sleep(10) except BaseException as exc: @@ -1216,25 +1245,43 @@ def test_run(): result = testdir.runpytest('-vv', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) - result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', 'test_cleanup_on_sigterm* 26-27', '*1 passed*']) + result.stdout.fnmatch_lines( + [ + '*_ coverage: platform *, python * _*', + 'test_cleanup_on_sigterm* 100%', + '*1 passed*', + ] + ) assert result.ret == 0 -@pytest.mark.skipif('sys.platform != "win32"') +@pytest.mark.skipif(sys.platform != 'win32', reason='SIGBREAK is Windows only') @pytest.mark.parametrize( 'setup', [ - ('signal.signal(signal.SIGBREAK, signal.SIG_DFL); cleanup_on_signal(signal.SIGBREAK)', '87% 21-22'), - ('cleanup_on_signal(signal.SIGBREAK)', '87% 21-22'), - ('cleanup()', '73% 19-22'), + ( + 'signal.signal(signal.SIGBREAK, signal.SIG_DFL)', + '*% 4, 2*-28' if platform.python_implementation() == 'PyPy' else '62% 4, 23-28', + ), + ('signal.signal(signal.SIGBREAK, cleanup)', '100%'), + ('', '*% 4, 2*-28' if platform.python_implementation() == 'PyPy' else '67% 4, 25-28'), ], ) 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/ + testdir.makepyprojecttoml( + """ +[tool.coverage.run] +patch = ["subprocess"] +""" + ) script = testdir.makepyfile( """ import os, signal, subprocess, sys, time +def cleanup(num, frame): + raise Exception() + def test_run(): proc = subprocess.Popen( [sys.executable, __file__], @@ -1245,10 +1292,13 @@ def test_run(): proc.send_signal(signal.CTRL_BREAK_EVENT) stdout, stderr = proc.communicate() assert not stderr - assert stdout in [b"^C", b"", b"captured IOError(4, 'Interrupted function call')\\n"] + assert stdout in [ + b"^C", + b"", + b"captured Exception()\\r\\n", + b"captured IOError(4, 'Interrupted function call')\\n"] if __name__ == "__main__": - from pytest_cov.embed import cleanup_on_signal, cleanup """ + setup[0] + """ @@ -1267,17 +1317,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(pytester, testdir, setup): +def test_cleanup_on_sigterm_sig_dfl(testdir): + testdir.makepyprojecttoml( + """ +[tool.coverage.run] +patch = ["subprocess"] +sigterm = true +""" + ) script = testdir.makepyfile( """ import os, signal, subprocess, sys, time @@ -1288,15 +1335,13 @@ def test_run(): proc.terminate() stdout, stderr = proc.communicate() assert not stderr + print([stdout, stderr]) assert stdout == b"" + assert proc.returncode in [128 + signal.SIGTERM, -signal.SIGTERM] if __name__ == "__main__": - from pytest_cov.embed import cleanup_on_sigterm, cleanup - """ - + setup[0] - + """ - + foobar = 123 try: time.sleep(10) except BaseException as exc: @@ -1304,16 +1349,23 @@ def test_run(): """ ) - result = testdir.runpytest('-vv', '--assert=plain', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) + result = testdir.runpytest( + '-v', '--assert=plain', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-report=html', script + ) - result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_cleanup_on_sigterm* {setup[1]}', '*1 passed*']) + result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_cleanup_on_sigterm* 88% * 18-19', '*1 passed*']) assert result.ret == 0 @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): + testdir.makepyprojecttoml( + """ +[tool.coverage.run] +patch = ["subprocess"] +sigterm = true +""" + ) script = testdir.makepyfile( ''' import os, signal, subprocess, sys, time @@ -1329,9 +1381,6 @@ def test_run(): assert proc.returncode == 0 if __name__ == "__main__": - from pytest_cov.embed import cleanup_on_signal - cleanup_on_signal(signal.SIGINT) - try: time.sleep(10) except BaseException as exc: @@ -1341,44 +1390,7 @@ def test_run(): result = testdir.runpytest('-vv', '--assert=plain', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) - result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', 'test_cleanup_on_sigterm* 88% 19-20', '*1 passed*']) - assert result.ret == 0 - - -@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 - -def test_run(): - proc = subprocess.Popen([sys.executable, __file__], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - time.sleep(1) - proc.send_signal(signal.SIGINT) - time.sleep(1) - proc.terminate() - stdout, stderr = proc.communicate() - assert not stderr - assert stdout == b"" - assert proc.returncode in [128 + signal.SIGTERM, -signal.SIGTERM] - -if __name__ == "__main__": - signal.signal(signal.SIGINT, signal.SIG_IGN) - - from pytest_cov.embed import cleanup_on_signal - cleanup_on_signal(signal.SIGINT) - - try: - time.sleep(10) - except BaseException as exc: - print("captured %r" % exc) - """ - ) - - result = testdir.runpytest('-vv', '--assert=plain', f'--cov={script.dirpath()}', '--cov-report=term-missing', script) - - result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', 'test_cleanup_on_sigterm* 89% 22-23', '*1 passed*']) + result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', 'test_cleanup_on_sigterm* 100%', '*1 passed*']) assert result.ret == 0 @@ -1624,17 +1636,6 @@ def test_foo(): SCRIPT_SIMPLE_RESULT = '4 * 100%' -@pytest.mark.skipif('tuple(map(int, xdist.__version__.split("."))) >= (3, 0, 2)', reason='--boxed option was removed in version 3.0.2') -@pytest.mark.skipif('sys.platform == "win32"') -def test_dist_boxed(testdir): - script = testdir.makepyfile(SCRIPT_SIMPLE) - - result = testdir.runpytest('-v', '--assert=plain', f'--cov={script.dirpath()}', '--boxed', script) - - result.stdout.fnmatch_lines(['*_ coverage: platform *, python * _*', f'test_dist_boxed* {SCRIPT_SIMPLE_RESULT}*', '*1 passed*']) - assert result.ret == 0 - - @pytest.mark.skipif('sys.platform == "win32"') @pytest.mark.skipif('platform.python_implementation() == "PyPy"', reason='strange optimization on PyPy3') def test_dist_bare_cov(testdir): @@ -1804,7 +1805,6 @@ def test_dynamic_context(pytester, testdir, opts, prop): testdir.makepyprojecttoml(f""" [tool.coverage.run] dynamic_context = "test_function" -parallel = true {prop.conf} """) result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script, *opts.split() + prop.args) @@ -1824,7 +1824,6 @@ def test_simple(pytester, testdir, opts, prop): script = testdir.makepyfile(test_1=prop.code) testdir.makepyprojecttoml(f""" [tool.coverage.run] -parallel = true {prop.conf} """) result = testdir.runpytest('-v', f'--cov={script.dirpath()}', script, *opts.split() + prop.args) @@ -1857,6 +1856,10 @@ def test_do_not_append_coverage(pytester, testdir, opts, prop): @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') def test_append_coverage_subprocess(testdir): + testdir.makepyprojecttoml(f""" +[tool.coverage.run] +patch = ["subprocess"] +""") scripts = testdir.makepyfile(parent_script=SCRIPT_PARENT, child_script=SCRIPT_CHILD) parent_script = scripts.dirpath().join('parent_script.py') @@ -1881,28 +1884,6 @@ def test_append_coverage_subprocess(testdir): assert result.ret == 0 -def test_pth_failure(monkeypatch): - with open('src/pytest-cov.pth') as fh: - payload = fh.read() - - class SpecificError(Exception): - pass - - def bad_init(): - raise SpecificError - - buff = StringIO() - - from pytest_cov import embed - - monkeypatch.setattr(embed, 'init', bad_init) - monkeypatch.setattr(sys, 'stderr', buff) - monkeypatch.setitem(os.environ, 'COV_CORE_SOURCE', 'foobar') - exec(payload) - 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): script = testdir.makepyfile(SCRIPT_SIMPLE) result = testdir.runpytest('-v', '--assert=plain', '--cov', f'--cov={script.dirpath()}', script) @@ -1968,6 +1949,7 @@ def find_labels(text, pattern): 'test_contexts.py::test_07|setup': 's7', 'test_contexts.py::test_07|run': 'r7', 'test_contexts.py::test_08|run': 'r8', + 'test_contexts.py::test_08|setup': 's7', 'test_contexts.py::test_09[1]|setup': 's9-1', 'test_contexts.py::test_09[1]|run': 'r9-1', 'test_contexts.py::test_09[2]|setup': 's9-2', @@ -1986,8 +1968,6 @@ def find_labels(text, pattern): } -@pytest.mark.skipif('coverage.version_info < (5, 0)') -@pytest.mark.skipif('coverage.version_info > (6, 4)') @xdist_params def test_contexts(pytester, testdir, opts): with open(os.path.join(os.path.dirname(__file__), 'contextful.py')) as f: @@ -2018,23 +1998,6 @@ def test_contexts(pytester, testdir, opts): assert line_data[label] == actual, f'Wrong lines for context {context!r}' -@pytest.mark.skipif('coverage.version_info >= (5, 0)') -def test_contexts_not_supported(testdir): - script = testdir.makepyfile('a = 1') - result = testdir.runpytest( - '-v', - f'--cov={script.dirpath()}', - '--cov-context=test', - script, - ) - result.stderr.fnmatch_lines( - [ - '*argument --cov-context: Contexts are only supported with coverage.py >= 5.x', - ] - ) - assert result.ret != 0 - - def test_contexts_no_cover(testdir): script = testdir.makepyfile(""" import pytest diff --git a/tox.ini b/tox.ini index 171e7b66..73c7759d 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ envlist = clean, check, docs, - {py39,py310,py311,py312,py313,pypy39,pypy310}-{pytest83,pytest84}-{xdist36,xdist37}-{coverage78}, + {py39,py310,py311,py312,py313,pypy39,pypy310,pypy311}-{pytest84}-{xdist38}-{coverage710}, report ignore_basepython_conflict = true @@ -41,7 +41,7 @@ setenv = pytest81: _DEP_PYTEST=pytest==8.1.1 pytest82: _DEP_PYTEST=pytest==8.2.2 pytest83: _DEP_PYTEST=pytest==8.3.5 - pytest84: _DEP_PYTEST=pytest==8.4.0 + pytest84: _DEP_PYTEST=pytest==8.4.2 xdist32: _DEP_PYTESTXDIST=pytest-xdist==3.2.0 xdist33: _DEP_PYTESTXDIST=pytest-xdist==3.3.1 @@ -49,6 +49,7 @@ setenv = xdist35: _DEP_PYTESTXDIST=pytest-xdist==3.5.0 xdist36: _DEP_PYTESTXDIST=pytest-xdist==3.6.1 xdist37: _DEP_PYTESTXDIST=pytest-xdist==3.7.0 + xdist38: _DEP_PYTESTXDIST=pytest-xdist==3.8.0 xdistdev: _DEP_PYTESTXDIST=git+https://github.com/pytest-dev/pytest-xdist.git#egg=pytest-xdist coverage72: _DEP_COVERAGE=coverage==7.2.7 @@ -58,6 +59,8 @@ setenv = coverage76: _DEP_COVERAGE=coverage==7.6.12 coverage77: _DEP_COVERAGE=coverage==7.7.1 coverage78: _DEP_COVERAGE=coverage==7.8.2 + coverage79: _DEP_COVERAGE=coverage==7.9.2 + coverage710: _DEP_COVERAGE=coverage==7.10.6 # For testing against a coverage.py working tree. coveragedev: _DEP_COVERAGE=-e{env:COVERAGE_HOME} passenv = @@ -66,6 +69,8 @@ deps = {env:_DEP_PYTEST:pytest} {env:_DEP_PYTESTXDIST:pytest-xdist} {env:_DEP_COVERAGE:coverage} + celery[redis] + testcontainers[redis] pip_pre = true commands = {posargs:pytest -vv}