diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..772866d2 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[paths] +source = src + +[run] +branch = True +source = src +parallel = true + +[report] +show_missing = true +precision = 2 +omit = *migrations* diff --git a/.gitignore b/.gitignore index cbb44d89..01f10962 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,60 @@ -*.pyo -*.pyc -*egg-info -build +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info dist -env* +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +venv*/ +pyvenv*/ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage .tox -.coverage* +.coverage.* +nosetests.xml +htmlcov + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject +.idea +*.iml +*.komodoproject + +# Complexity +output/*.html +output/*/index.html + +# Sphinx +docs/_build + +.DS_Store +*~ +.*.sw[po] +.build +.ve +.env +.cache +.pytest +.bootstrap +*.bak diff --git a/.travis.yml b/.travis.yml index daabdf11..1224b274 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,33 @@ language: python python: 2.7 +sudo: false env: - - TOX_ENV=flake8 - - TOX_ENV=pypy - - TOX_ENV=pypy3 - - TOX_ENV=py26 - - TOX_ENV=py27 - - TOX_ENV=py32 - - TOX_ENV=py33 - - TOX_ENV=py34 + global: + LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so + matrix: + - TOXENV=check + - TOXENV=2.6-37 + - TOXENV=2.7-37 + - TOXENV=3.3-37 + - TOXENV=3.4-37 + - TOXENV=pypy-37 + - TOXENV=2.6-40 + - TOXENV=2.7-40 + - TOXENV=3.3-40 + - TOXENV=3.4-40 + - TOXENV=pypy-40 +before_install: + - python --version + - virtualenv --version + - pip --version + - uname -a + - lsb_release -a install: - pip install tox script: - - tox -e $TOX_ENV + - tox -v notifications: email: on_success: never - on_failure: change + on_failure: always + diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 00000000..f46a017f --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,13 @@ +Authors +======= + +* Marc Schlaich - http://www.schlamar.org/ +* Rick van Hattem - http://wol.ph/ +* Buck Evan - https://github.com/bukzor +* Eric Larson - http://larsoner.com/ +* Marc Abramowitz - http://marc-abramowitz.com/ +* Thomas Kluyver - https://github.com/takluyver +* Guillaume Ayoub - http://www.yabz.fr/ +* Federico Ceratto - http://firelet.net/ +* Josh Kalderimis - http://blog.cookiestack.com/ +* Ionel Cristian Mărieș - http://blog.ionelmc.ro diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 00000000..8b94fa3d --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,22 @@ +Changelog +========= + +2.0.0 (??????????) +------------------ + +* Added ``--cov-fail-under``, akin to the new ``fail_under`` option in `coverage-4.0` + (automatically activated if there's a ``[report] fail_under = ...`` in ``.coveragerc``). +* Changed ``--cov-report=term`` to automatically upgrade to ``--cov-report=term-missing`` + if there's ``[run] show_missing = True`` in ``.coveragerc``. +* Changed ``--cov`` so it can be used with no path argument (in wich case the source + settings from ``.coveragerc`` will be used instead). +* Fixed `.pth` installation to work in all cases (install, easy_install, wheels, develop etc). +* Fixed `.pth` uninstallation to work for wheel installs. +* Support for coverage 4.0. +* Data file suffixing changed to use coverage's ``data_suffix=True`` option (instead of the + custom suffixing). + +1.8.2 (2014-11-06) +------------------ + +* N/A diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..91573192 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,89 @@ +============ +Contributing +============ + +Contributions are welcome, and they are greatly appreciated! Every +little bit helps, and credit will always be given. + +Bug reports +=========== + +When `reporting a bug `_ please include: + + * Your operating system name and version. + * Any details about your local setup that might be helpful in troubleshooting. + * Detailed steps to reproduce the bug. + +Documentation improvements +========================== + +pytest-cov could always use more documentation, whether as part of the +official pytest-cov docs, in docstrings, or even on the web in blog posts, +articles, and such. + +Feature requests and feedback +============================= + +The best way to send feedback is to file an issue at https://github.com/schlamar/pytest-cov/issues. + +If you are proposing a feature: + +* Explain in detail how it would work. +* Keep the scope as narrow as possible, to make it easier to implement. +* Remember that this is a volunteer-driven project, and that contributions are welcome :) + +Development +=========== + +To set up `pytest-cov` for local development: + +1. `Fork pytest-cov on GitHub `_. +2. Clone your fork locally:: + + git clone git@github.com:your_name_here/pytest-cov.git + +3. Create a branch for local development:: + + git checkout -b name-of-your-bugfix-or-feature + + Now you can make your changes locally. + +4. When you're done making changes, run all the checks, doc builder and spell checker with `tox `_ one command:: + + tox + +5. Commit your changes and push your branch to GitHub:: + + git add . + git commit -m "Your detailed description of your changes." + git push origin name-of-your-bugfix-or-feature + +6. Submit a pull request through the GitHub website. + +Pull Request Guidelines +----------------------- + +If you need some code review or feedback while you're developing the code just make the pull request. + +For merging, you should: + +1. Include passing tests (run ``tox``) [1]_. +2. Update documentation when there's new API, functionality etc. +3. Add a note to ``CHANGELOG.rst`` about the changes. +4. Add yourself to ``AUTHORS.rst``. + +.. [1] If you don't have all the necessary python versions available locally you can rely on Travis - it will + `run the tests `_ for each change you add in the pull request. + + It will be slower though ... + +Tips +---- + +To run a subset of tests:: + + tox -e envname -- py.test -k test_myfeature + +To run all the test environments in *parallel* (you need to ``pip install detox``):: + + detox diff --git a/LICENSE.txt b/LICENSE similarity index 100% rename from LICENSE.txt rename to LICENSE diff --git a/MANIFEST.in b/MANIFEST.in index 99e6799f..e64932b8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,21 @@ +graft docs +graft example +graft src +graft ci +graft tests + +include .bumpversion.cfg +include .coveragerc +include .cookiecutterrc +include .isort.cfg +include .pylintrc + +include AUTHORS.rst +include CHANGELOG.rst +include CONTRIBUTING.rst +include LICENSE include README.rst -include LICENSE.txt -include setup.py -include pytest_cov.py -include test_pytest_cov.py + +include tox.ini .travis.yml appveyor.yml + +global-exclude *.py[cod] __pycache__ *.so diff --git a/README.rst b/README.rst index 7a81bde0..4e277f4f 100644 --- a/README.rst +++ b/README.rst @@ -1,21 +1,71 @@ +=============================== pytest-cov -========== +=============================== -.. image:: https://travis-ci.org/schlamar/pytest-cov.svg?branch=master - :target: https://travis-ci.org/schlamar/pytest-cov - :alt: Build status - -.. image:: https://pypip.in/download/pytest-cov/badge.png - :target: https://pypi.python.org/pypi//pytest-cov/ - :alt: Downloads +.. list-table:: + :stub-columns: 1 -.. image:: https://pypip.in/version/pytest-cov/badge.png - :target: https://pypi.python.org/pypi/pytest-cov/ - :alt: Latest Version + * - docs + - |docs| + * - tests + - | |travis| |appveyor| + * - package + - |version| |downloads| -.. image:: https://pypip.in/license/pytest-cov/badge.png - :target: https://pypi.python.org/pypi/pytest-cov/ - :alt: License +.. + |wheel| |supported-versions| |supported-implementations| + +.. |docs| image:: https://readthedocs.org/projects/pytest-cov/badge/?style=flat + :target: https://readthedocs.org/projects/pytest-cov + :alt: Documentation Status + +.. |travis| image:: http://img.shields.io/travis/schlamar/pytest-cov/master.svg?style=flat&label=Travis + :alt: Travis-CI Build Status + :target: https://travis-ci.org/schlamar/pytest-cov + +.. |appveyor| image:: https://img.shields.io/appveyor/ci/schlamar/pytest-cov/master.svg?style=flat&label=AppVeyor + :alt: AppVeyor Build Status + :target: https://ci.appveyor.com/project/schlamar/pytest-cov + +.. |coveralls| image:: http://img.shields.io/coveralls/schlamar/pytest-cov/master.svg?style=flat&label=Coveralls + :alt: Coverage Status + :target: https://coveralls.io/r/schlamar/pytest-cov + +.. |codecov| image:: http://img.shields.io/codecov/c/github/schlamar/pytest-cov/master.svg?style=flat&label=Codecov + :alt: Coverage Status + :target: https://codecov.io/github/schlamar/pytest-cov + +.. |landscape| image:: https://landscape.io/github/schlamar/pytest-cov/master/landscape.svg?style=flat + :target: https://landscape.io/github/schlamar/pytest-cov/master + :alt: Code Quality Status + +.. |version| image:: http://img.shields.io/pypi/v/pytest-cov.svg?style=flat + :alt: PyPI Package latest release + :target: https://pypi.python.org/pypi/pytest-cov + +.. |downloads| image:: http://img.shields.io/pypi/dm/pytest-cov.svg?style=flat + :alt: PyPI Package monthly downloads + :target: https://pypi.python.org/pypi/pytest-cov + +.. |wheel| image:: https://pypip.in/wheel/pytest-cov/badge.svg?style=flat + :alt: PyPI Wheel + :target: https://pypi.python.org/pypi/pytest-cov + +.. |supported-versions| image:: https://pypip.in/py_versions/pytest-cov/badge.svg?style=flat + :alt: Supported versions + :target: https://pypi.python.org/pypi/pytest-cov + +.. |supported-implementations| image:: https://pypip.in/implementation/pytest-cov/badge.svg?style=flat + :alt: Supported imlementations + :target: https://pypi.python.org/pypi/pytest-cov + +.. |scrutinizer| image:: https://img.shields.io/scrutinizer/g/schlamar/pytest-cov/master.svg?style=flat + :alt: Scrutinizer Status + :target: https://scrutinizer-ci.com/g/schlamar/pytest-cov/ + +Pytest plugin for measuring coverage. + +* Free software: MIT license This plugin produces coverage reports. It supports centralised testing and distributed testing in both load and each modes. It also supports coverage of subprocesses. @@ -25,7 +75,7 @@ through coverage's config file. Installation ------------- +============ Install with pip:: @@ -42,33 +92,24 @@ For distributed testing support install pytest-xdist:: Uninstallation --------------- +============== Uninstall with pip:: pip uninstall pytest-cov - pip uninstall cov-core - -.. NOTE:: - - Ensure that you manually delete the init_cov_core.pth file in your site-packages directory. - - This file starts coverage collection of subprocesses if appropriate during site initialisation - at python startup. - Usage ------ +===== Centralised Testing -~~~~~~~~~~~~~~~~~~~ +------------------- Centralised testing will report on the combined coverage of the main process and all of it's subprocesses. Running centralised testing:: - py.test --cov myproj tests/ + py.test --cov=myproj tests/ Shows a terminal report:: @@ -83,7 +124,7 @@ Shows a terminal report:: Distributed Testing: Load -~~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------- Distributed testing with dist mode set to load will report on the combined coverage of all slaves. The slaves may be spread out over any number of hosts and each slave may be located anywhere on the @@ -91,7 +132,7 @@ file system. Each slave will have it's subprocesses measured. Running distributed testing with dist mode set to load:: - py.test --cov myproj -n 2 tests/ + py.test --cov=myproj -n 2 tests/ Shows a terminal report:: @@ -107,7 +148,7 @@ Shows a terminal report:: Again but spread over different hosts and different directories:: - py.test --cov myproj --dist load + py.test --cov=myproj --dist load --tx ssh=memedough@host1//chdir=testenv1 --tx ssh=memedough@host2//chdir=/tmp/testenv2//python=/tmp/env1/bin/python --rsyncdir myproj --rsyncdir tests --rsync examples @@ -126,7 +167,7 @@ Shows a terminal report:: Distributed Testing: Each -~~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------- Distributed testing with dist mode set to each will report on the combined coverage of all slaves. Since each slave is running all tests this allows generating a combined coverage report for multiple @@ -134,7 +175,7 @@ environments. Running distributed testing with dist mode set to each:: - py.test --cov myproj --dist each + py.test --cov=myproj --dist each --tx popen//chdir=/tmp/testenv3//python=/usr/local/python27/bin/python --tx ssh=memedough@host2//chdir=/tmp/testenv4//python=/tmp/env2/bin/python --rsyncdir myproj --rsyncdir tests --rsync examples @@ -155,7 +196,7 @@ Shows a terminal report:: Reporting ---------- +========= It is possible to generate any combination of the reports for a single test run. @@ -164,7 +205,7 @@ annotated source code. The terminal report without line numbers (default):: - py.test --cov-report term --cov myproj tests/ + py.test --cov-report term --cov=myproj tests/ -------------------- coverage: platform linux2, python 2.6.4-final-0 --------------------- Name Stmts Miss Cover @@ -178,7 +219,7 @@ The terminal report without line numbers (default):: The terminal report with line numbers:: - py.test --cov-report term-missing --cov myproj tests/ + py.test --cov-report term-missing --cov=myproj tests/ -------------------- coverage: platform linux2, python 2.6.4-final-0 --------------------- Name Stmts Miss Cover Missing @@ -190,17 +231,23 @@ The terminal report with line numbers:: TOTAL 353 20 94% -The remaining three reports output to files without showing anything on the terminal (useful for -when the output is going to a continuous integration server):: +These three report options output to files without showing anything on the terminal:: py.test --cov-report html --cov-report xml --cov-report annotate - --cov myproj tests/ + --cov=myproj tests/ + +The final report option can also suppress printing to the terminal:: + + py.test --cov-report= --cov=myproj tests/ +This mode can be especially useful on continuous integration servers, where a coverage file +is needed for subsequent processing, but no local report needs to be viewed. For example, +tests run on Travis-CI could produce a .coverage file for use with Coveralls. Coverage Data File ------------------- +================== The data file is erased at the beginning of testing to ensure clean data for each test run. @@ -209,7 +256,7 @@ examine it. Coverage Config File --------------------- +==================== This plugin provides a clean minimal set of command line options that are added to pytest. For further control of coverage use a coverage config file. @@ -218,7 +265,7 @@ For example if tests are contained within the directory tree being measured the excluded if desired by using a .coveragerc file with the omit option set:: py.test --cov-config .coveragerc - --cov myproj + --cov=myproj myproj/tests/ Where the .coveragerc file contains file globs:: @@ -234,12 +281,11 @@ Note that this plugin controls some options and setting the option in the config effect. These include specifying source to be measured (source option) and all data file handling (data_file and parallel options). - Limitations ------------ +=========== For distributed testing the slaves must have the pytest-cov package installed. This is needed since -the plugin must be registered through setuptools / distribute for pytest to start the plugin on the +the plugin must be registered through setuptools for pytest to start the plugin on the slave. For subprocess measurement environment variables must make it from the main process to the @@ -247,9 +293,8 @@ subprocess. The python used by the subprocess must have pytest-cov installed. do normal site initialisation so that the environment variables can be detected and coverage started. - Acknowledgements ----------------- +================ Whilst this plugin has been built fresh from the ground up it has been influenced by the work done on pytest-coverage (Ross Lawley, James Mills, Holger Krekel) and nose-cover (Jason Pellerin) which are diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..2d5de003 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,96 @@ +version: '{branch}-{build}' +build: off +environment: + global: + WITH_COMPILER: "cmd /E:ON /V:ON /C .\\ci\\appveyor-with-compiler.cmd" + matrix: + - TOXENV: check + PYTHON_HOME: "C:\\Python27" + PYTHON_VERSION: "2.7" + PYTHON_ARCH: "32" + + - TOXENV: "2.7-37" + TOXPYTHON: "C:\\Python27\\python.exe" + WINDOWS_SDK_VERSION: "v7.0" + PYTHON_HOME: "C:\\Python27" + PYTHON_VERSION: "2.7" + PYTHON_ARCH: "32" + - TOXENV: "2.7-37" + TOXPYTHON: "C:\\Python27-x64\\python.exe" + WINDOWS_SDK_VERSION: "v7.0" + PYTHON_HOME: "C:\\Python27-x64" + PYTHON_VERSION: "2.7" + PYTHON_ARCH: "64" + - TOXENV: "3.3-37" + TOXPYTHON: "C:\\Python33\\python.exe" + WINDOWS_SDK_VERSION: "v7.1" + PYTHON_HOME: "C:\\Python33" + PYTHON_VERSION: "3.3" + PYTHON_ARCH: "32" + - TOXENV: "3.3-37" + TOXPYTHON: "C:\\Python33-x64\\python.exe" + WINDOWS_SDK_VERSION: "v7.1" + PYTHON_HOME: "C:\\Python33-x64" + PYTHON_VERSION: "3.3" + PYTHON_ARCH: "64" + - TOXENV: "3.4-37" + TOXPYTHON: "C:\\Python34\\python.exe" + WINDOWS_SDK_VERSION: "v7.1" + PYTHON_HOME: "C:\\Python34" + PYTHON_VERSION: "3.4" + PYTHON_ARCH: "32" + - TOXENV: "3.4-37" + TOXPYTHON: "C:\\Python34-x64\\python.exe" + WINDOWS_SDK_VERSION: "v7.1" + PYTHON_HOME: "C:\\Python34-x64" + PYTHON_VERSION: "3.4" + PYTHON_ARCH: "64" + + - TOXENV: "2.7-40" + TOXPYTHON: "C:\\Python27\\python.exe" + WINDOWS_SDK_VERSION: "v7.0" + PYTHON_HOME: "C:\\Python27" + PYTHON_VERSION: "2.7" + PYTHON_ARCH: "32" + - TOXENV: "2.7-40" + TOXPYTHON: "C:\\Python27-x64\\python.exe" + WINDOWS_SDK_VERSION: "v7.0" + PYTHON_HOME: "C:\\Python27-x64" + PYTHON_VERSION: "2.7" + PYTHON_ARCH: "64" + - TOXENV: "3.3-40" + TOXPYTHON: "C:\\Python33\\python.exe" + WINDOWS_SDK_VERSION: "v7.1" + PYTHON_HOME: "C:\\Python33" + PYTHON_VERSION: "3.3" + PYTHON_ARCH: "32" + - TOXENV: "3.3-40" + TOXPYTHON: "C:\\Python33-x64\\python.exe" + WINDOWS_SDK_VERSION: "v7.1" + PYTHON_HOME: "C:\\Python33-x64" + PYTHON_VERSION: "3.3" + PYTHON_ARCH: "64" + - TOXENV: "3.4-40" + TOXPYTHON: "C:\\Python34\\python.exe" + WINDOWS_SDK_VERSION: "v7.1" + PYTHON_HOME: "C:\\Python34" + PYTHON_VERSION: "3.4" + PYTHON_ARCH: "32" + - TOXENV: "3.4-40" + TOXPYTHON: "C:\\Python34-x64\\python.exe" + WINDOWS_SDK_VERSION: "v7.1" + PYTHON_HOME: "C:\\Python34-x64" + PYTHON_VERSION: "3.4" + PYTHON_ARCH: "64" +init: + - "ECHO %TOXENV%" + - ps: "ls C:\\Python*" +install: + - "powershell ci\\appveyor-bootstrap.ps1" +test_script: + - "%PYTHON_HOME%\\Scripts\\tox --version" + - "%PYTHON_HOME%\\Scripts\\virtualenv --version" + - "%PYTHON_HOME%\\Scripts\\pip --version" + - "%WITH_COMPILER% %PYTHON_HOME%\\Scripts\\tox" +artifacts: + - path: dist\* diff --git a/ci/appveyor-bootstrap.ps1 b/ci/appveyor-bootstrap.ps1 new file mode 100644 index 00000000..1dd53420 --- /dev/null +++ b/ci/appveyor-bootstrap.ps1 @@ -0,0 +1,88 @@ +# Source: https://github.com/pypa/python-packaging-user-guide/blob/master/source/code/install.ps1 +# Sample script to install Python and pip under Windows +# Authors: Olivier Grisel and Kyle Kastner +# License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ + +$BASE_URL = "https://www.python.org/ftp/python/" +$GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" +$GET_PIP_PATH = "C:\get-pip.py" + + +function DownloadPython ($python_version, $platform_suffix) { + $webclient = New-Object System.Net.WebClient + $filename = "python-" + $python_version + $platform_suffix + ".msi" + $url = $BASE_URL + $python_version + "/" + $filename + + $basedir = $pwd.Path + "\" + $filepath = $basedir + $filename + if (Test-Path $filename) { + Write-Host "Reusing" $filepath + return $filepath + } + + # Download and retry up to 5 times in case of network transient errors. + Write-Host "Downloading" $filename "from" $url + $retry_attempts = 3 + for($i=0; $i -lt $retry_attempts; $i++){ + try { + $webclient.DownloadFile($url, $filepath) + break + } + Catch [Exception]{ + Start-Sleep 1 + } + } + Write-Host "File saved at" $filepath + return $filepath +} + + +function InstallPython ($python_version, $architecture, $python_home) { + Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home + if (Test-Path $python_home) { + Write-Host $python_home "already exists, skipping." + return $false + } + if ($architecture -eq "32") { + $platform_suffix = "" + } else { + $platform_suffix = ".amd64" + } + $filepath = DownloadPython $python_version $platform_suffix + Write-Host "Installing" $filepath "to" $python_home + $args = "/qn /i $filepath TARGETDIR=$python_home" + Write-Host "msiexec.exe" $args + Start-Process -FilePath "msiexec.exe" -ArgumentList $args -Wait -Passthru + Write-Host "Python $python_version ($architecture) installation complete" + return $true +} + + +function InstallPip ($python_home) { + $pip_path = $python_home + "/Scripts/pip.exe" + $python_path = $python_home + "/python.exe" + if (-not(Test-Path $pip_path)) { + Write-Host "Installing pip..." + $webclient = New-Object System.Net.WebClient + $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) + Write-Host "Executing:" $python_path $GET_PIP_PATH + Start-Process -FilePath "$python_path" -ArgumentList "$GET_PIP_PATH" -Wait -Passthru + } else { + Write-Host "pip already installed." + } +} + +function InstallPackage ($python_home, $pkg) { + $pip_path = $python_home + "/Scripts/pip.exe" + & $pip_path install $pkg +} + +function main () { + InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON_HOME + InstallPip $env:PYTHON_HOME + InstallPackage $env:PYTHON_HOME setuptools + InstallPackage $env:PYTHON_HOME wheel + InstallPackage $env:PYTHON_HOME tox +} + +main diff --git a/ci/appveyor-with-compiler.cmd b/ci/appveyor-with-compiler.cmd new file mode 100644 index 00000000..3619733b --- /dev/null +++ b/ci/appveyor-with-compiler.cmd @@ -0,0 +1,37 @@ +:: To build extensions for 64 bit Python 3, we need to configure environment +:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) +:: +:: To build extensions for 64 bit Python 2, we need to configure environment +:: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) +:: +:: 32 bit builds do not require specific environment configurations. +:: +:: Note: this script needs to be run with the /E:ON and /V:ON flags for the +:: cmd interpreter, at least for (SDK v7.0) +:: +:: More details at: +:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows +:: http://stackoverflow.com/a/13751649/163740 +:: +:: Author: Olivier Grisel +:: License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ +@ECHO OFF + +SET COMMAND_TO_RUN=%* +SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows + +IF "%PYTHON_ARCH%"=="64" ( + ECHO SDK: %WINDOWS_SDK_VERSION% ARCH: %PYTHON_ARCH% + SET DISTUTILS_USE_SDK=1 + SET MSSdk=1 + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 +) ELSE ( + ECHO SDK: %WINDOWS_SDK_VERSION% ARCH: %PYTHON_ARCH% + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 +) diff --git a/docs/authors.rst b/docs/authors.rst new file mode 100644 index 00000000..e122f914 --- /dev/null +++ b/docs/authors.rst @@ -0,0 +1 @@ +.. include:: ../AUTHORS.rst diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..565b0521 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1 @@ +.. include:: ../CHANGELOG.rst diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..d48985a3 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import os + + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.ifconfig', + 'sphinx.ext.viewcode', + 'sphinxcontrib.napoleon' +] +if os.getenv('SPELLCHECK'): + extensions += 'sphinxcontrib.spelling', + spelling_show_suggestions = True + spelling_lang = 'en_US' + +source_suffix = '.rst' +master_doc = 'index' +project = 'pytest-cov' +year = '2015' +author = 'Ionel Cristian Mărieș' +copyright = '{0}, {1}'.format(year, author) +version = release = '2.0.0' +import sphinx_py3doc_enhanced_theme +html_theme = "sphinx_py3doc_enhanced_theme" +html_theme_path = [sphinx_py3doc_enhanced_theme.get_html_theme_path()] +html_theme_options = { + 'githuburl': 'https://github.com/schlamar/pytest-cov/' +} + +pygments_style = 'trac' +templates_path = ['.'] +html_use_smartypants = True +html_last_updated_fmt = '%b %d, %Y' +html_split_index = True +html_sidebars = { + '**': ['searchbox.html', 'globaltoc.html', 'sourcelink.html'], +} +html_short_title = '%s-%s' % (project, version) diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 00000000..e582053e --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1 @@ +.. include:: ../CONTRIBUTING.rst diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..1d1c58c1 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,23 @@ +Welcome to pytest-cov's documentation! +====================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + readme + installation + usage + reference/index + contributing + authors + changelog + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..d12d3475 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,7 @@ +============ +Installation +============ + +At the command line:: + + pip install pytest-cov diff --git a/docs/readme.rst b/docs/readme.rst new file mode 100644 index 00000000..6be290b8 --- /dev/null +++ b/docs/readme.rst @@ -0,0 +1,5 @@ +######## +Overview +######## + +.. include:: ../README.rst diff --git a/docs/reference/index.rst b/docs/reference/index.rst new file mode 100644 index 00000000..5fd2c7f9 --- /dev/null +++ b/docs/reference/index.rst @@ -0,0 +1,7 @@ +Reference +========= + +.. toctree:: + :glob: + + pytest_cov* diff --git a/docs/reference/pytest_cover.rst b/docs/reference/pytest_cover.rst new file mode 100644 index 00000000..caf88b39 --- /dev/null +++ b/docs/reference/pytest_cover.rst @@ -0,0 +1,5 @@ +pytest_cov +============================= + +.. automodule:: pytest_cov + :members: diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..1632a968 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +sphinx +sphinxcontrib-napoleon +sphinx-py3doc-enhanced-theme +-e . diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt new file mode 100644 index 00000000..f95eb78d --- /dev/null +++ b/docs/spelling_wordlist.txt @@ -0,0 +1,11 @@ +builtin +builtins +classmethod +staticmethod +classmethods +staticmethods +args +kwargs +callstack +Changelog +Indices diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 00000000..89b4912e --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,7 @@ +===== +Usage +===== + +To use pytest-cov in a project:: + + import pytest_cov diff --git a/example/.coveragerc b/example/.coveragerc new file mode 100644 index 00000000..76f1eb15 --- /dev/null +++ b/example/.coveragerc @@ -0,0 +1,2 @@ +[run] +source = mylib \ No newline at end of file diff --git a/example/mylib/__init__.py b/example/mylib/__init__.py new file mode 100644 index 00000000..f014f944 --- /dev/null +++ b/example/mylib/__init__.py @@ -0,0 +1,13 @@ + +import sys + +PY3 = sys.version_info[0] == 3 + + +if PY3: + def add(a, b): + return a + b + +else: + def add(a, b): + return b + a diff --git a/example/setup.py b/example/setup.py new file mode 100644 index 00000000..588af2e3 --- /dev/null +++ b/example/setup.py @@ -0,0 +1,7 @@ + +from setuptools import setup, find_packages + + +setup( + packages=find_packages() +) diff --git a/example/tests/test_mylib.py b/example/tests/test_mylib.py new file mode 100644 index 00000000..f858b049 --- /dev/null +++ b/example/tests/test_mylib.py @@ -0,0 +1,7 @@ + +import mylib + + +def test_add(): + assert mylib.add(1, 1) == 2 + assert not mylib.add(0, 1) == 2 diff --git a/example/tox.ini b/example/tox.ini new file mode 100644 index 00000000..88f66529 --- /dev/null +++ b/example/tox.ini @@ -0,0 +1,30 @@ +[tox] +envlist = cov-init,py27,py34,cov-report + + +[testenv] +usedevelop=True +setenv = + COVERAGE_FILE = .coverage.{envname} +commands = py.test --cov --cov-report= {posargs} +deps = + ../cov-core + pytest + ../pytest-cov + + +[testenv:cov-init] +setenv = + COVERAGE_FILE = .coverage +deps = coverage +commands = + coverage erase + + +[testenv:cov-report] +setenv = + COVERAGE_FILE = .coverage +deps = coverage +commands = + coverage combine + coverage report diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..9846450e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,52 @@ +[bumpversion] +current_version = 2.0.0 +commit = True +tag = True + +[bdist_wheel] +universal = 1 + +[aliases] +release = register clean --all sdist bdist_wheel + +[flake8] +max-line-length = 140 +exclude = tests/*,*/migrations/*,*/south_migrations/* + +[bumpversion:file:setup.py] + +[bumpversion:file:docs/conf.py] + +[bumpversion:file:src/pytest_cov/__init__.py] + +[pytest] +norecursedirs = + .git + .tox + .env + dist + build + south_migrations + migrations + example +python_files = + test_*.py + *_test.py + tests.py +addopts = + -rxEfs + --strict + --ignore=docs/conf.py + --ignore=setup.py + --ignore=src + --ignore=ci + --doctest-modules + --doctest-glob=\*.rst + --tb=short + +[isort] +force_single_line = True +line_length = 120 +known_first_party = pytest_cov +default_section = THIRDPARTY +forced_separate = test_pytest_cover diff --git a/setup.py b/setup.py index ec7623a5..adb70197 100644 --- a/setup.py +++ b/setup.py @@ -1,32 +1,139 @@ -import setuptools - -setuptools.setup(name='pytest-cov', - version='1.8.1', - description='py.test plugin for coverage reporting with ' - 'support for both centralised and distributed testing, ' - 'including subprocesses and multiprocessing', - long_description=open('README.rst').read().strip(), - author='Marc Schlaich', - author_email='marc.schlaich@gmail.com', - url='https://github.com/schlamar/pytest-cov', - py_modules=['pytest_cov'], - install_requires=['py>=1.4.22', - 'pytest>=2.6.0', - 'coverage>=3.7.1', - 'cov-core>=1.14.0'], - entry_points={'pytest11': ['pytest_cov = pytest_cov']}, - license='MIT License', - zip_safe=False, - keywords='py.test pytest cover coverage distributed parallel', - classifiers=['Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.4', - 'Programming Language :: Python :: 2.5', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.0', - 'Programming Language :: Python :: 3.1', - 'Topic :: Software Development :: Testing']) +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from __future__ import absolute_import, print_function + +import io +from itertools import chain +import re +from glob import glob +from os.path import basename +from os.path import dirname +from os.path import join +from os.path import splitext +from distutils.command.build import build + +from setuptools import Command +from setuptools import find_packages +from setuptools import setup +from setuptools.command.develop import develop +from setuptools.command.install_lib import install_lib +from setuptools.command.easy_install import easy_install + + +def read(*names, **kwargs): + return io.open( + join(dirname(__file__), *names), + encoding=kwargs.get('encoding', 'utf8') + ).read() + + +class BuildWithPTH(build): + def run(self): + build.run(self) + path = join(dirname(__file__), 'src', 'pytest-cov.pth') + dest = join(self.build_lib, basename(path)) + self.copy_file(path, dest) + + +class EasyInstallWithPTH(easy_install): + def run(self): + easy_install.run(self) + path = join(dirname(__file__), 'src', 'pytest-cov.pth') + dest = join(self.install_dir, basename(path)) + self.copy_file(path, dest) + + +class InstallLibWithPTH(install_lib): + def run(self): + install_lib.run(self) + 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) + + +class DevelopWithPTH(develop): + def run(self): + develop.run(self) + path = join(dirname(__file__), 'src', 'pytest-cov.pth') + dest = join(self.install_dir, basename(path)) + self.copy_file(path, dest) + + +class GeneratePTH(Command): + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + with open(join(dirname(__file__), 'src', 'pytest-cov.pth'), 'w') as fh: + with open(join(dirname(__file__), 'src', 'pytest-cov.embed')) as sh: + fh.write( + 'import os, sys;' + 'exec(%r)' % sh.read().replace(' ', ' ') + ) + +setup( + name='pytest-cov', + version='2.0.0', + license='MIT', + description='Pytest plugin for measuring coverage.', + long_description='%s\n%s' % (read('README.rst'), re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst'))), + author='Marc Schlaich', + author_email='marc.schlaich@gmail.com', + url='https://github.com/schlamar/pytest-cov', + packages=find_packages('src'), + package_dir={'': 'src'}, + py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], + include_package_data=True, + zip_safe=False, + classifiers=[ + # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: Unix', + 'Operating System :: POSIX', + 'Operating System :: Microsoft :: Windows', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Topic :: Utilities', + 'Topic :: Software Development :: Testing' + ], + keywords=[ + 'cover', 'coverage', 'pytest', 'py.test', 'distributed', 'parallel', + ], + install_requires=[ + 'pytest>=2.6.0', + 'coverage>=3.7.1' + ], + extras_require={ + }, + entry_points={ + 'pytest11': [ + 'pytest_cov = pytest_cov.plugin', + ], + 'console_scripts': [ + ] + }, + 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 new file mode 100644 index 00000000..31f2fdab --- /dev/null +++ b/src/pytest-cov.embed @@ -0,0 +1,10 @@ +if 'COV_CORE_SOURCE' in os.environ: + try: + from pytest_cov.embed import init + init() + except ImportError: + sys.stderr.write( + "Failed to setup coverage." + "Sources: {[COV_CORE_SOURCE]!r}" + "Config: {[COV_CORE_CONFIG]!r}" + "Exception: {!r}\n".format(os.environ, exc)) diff --git a/src/pytest-cov.pth b/src/pytest-cov.pth new file mode 100644 index 00000000..8734b5ef --- /dev/null +++ b/src/pytest-cov.pth @@ -0,0 +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 ImportError:\n sys.stderr.write(\n "Failed to setup coverage."\n "Sources: {[COV_CORE_SOURCE]!r}"\n "Config: {[COV_CORE_CONFIG]!r}"\n "Exception: {!r}\\n".format(os.environ, exc))\n') diff --git a/src/pytest_cov/__init__.py b/src/pytest_cov/__init__.py new file mode 100644 index 00000000..8c0d5d5b --- /dev/null +++ b/src/pytest_cov/__init__.py @@ -0,0 +1 @@ +__version__ = "2.0.0" diff --git a/src/pytest_cov/embed.py b/src/pytest_cov/embed.py new file mode 100644 index 00000000..3cfc4c74 --- /dev/null +++ b/src/pytest_cov/embed.py @@ -0,0 +1,61 @@ +"""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 os + + +def multiprocessing_start(obj): + cov = init() + if cov: + multiprocessing.util.Finalize(None, multiprocessing_finish, args=(cov,), exitpriority=1000) + + +def multiprocessing_finish(cov): + cov.stop() + cov.save() + + +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. + + cov_source = os.environ.get('COV_CORE_SOURCE') + cov_config = os.environ.get('COV_CORE_CONFIG') + if cov_config: + # Import what we need to activate coverage. + import coverage + + # Determine all source roots. + if not cov_source: + cov_source = None + else: + cov_source = cov_source.split(os.pathsep) + + # Activate coverage for this process. + cov = coverage.coverage(source=cov_source, + data_suffix=True, + config_file=cov_config, + auto_data=True) + cov.erase() + cov.start() + return cov diff --git a/src/pytest_cov/engine.py b/src/pytest_cov/engine.py new file mode 100644 index 00000000..4c867cd9 --- /dev/null +++ b/src/pytest_cov/engine.py @@ -0,0 +1,238 @@ +"""Coverage controllers for use by pytest-cov and nose-cov.""" +import os +import socket +import sys + +import coverage + + +class CovController(object): + """Base class for different plugin implementations.""" + + def __init__(self, cov_source, cov_report, cov_config, config=None, nodeid=None): + """Get some common config used by multiple derived classes.""" + self.cov_source = cov_source + self.cov_report = cov_report + self.cov_config = cov_config + self.config = config + self.nodeid = nodeid + + self.cov = None + self.node_descs = set() + self.failed_slaves = [] + self.topdir = os.getcwd() + + 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'] = '' + else: + os.environ['COV_CORE_SOURCE'] = os.pathsep.join(self.cov_source) + os.environ['COV_CORE_CONFIG'] = self.cov_config + + @staticmethod + def unset_env(): + """Remove coverage info from env.""" + os.environ.pop('COV_CORE_SOURCE', None) + os.environ.pop('COV_CORE_CONFIG', None) + + @staticmethod + def get_node_desc(platform, version_info): + """Return a description of this node.""" + + return 'platform %s, python %s' % (platform, '%s.%s.%s-%s-%s' % version_info[:5]) + + @staticmethod + def sep(stream, s, txt): + if hasattr(stream, 'sep'): + stream.sep(s, txt) + else: + sep_total = max((70 - 2 - len(txt)), 2) + sep_len = sep_total // 2 + sep_extra = sep_total % 2 + out = '%s %s %s\n' % (s * sep_len, txt, s * (sep_len + sep_extra)) + stream.write(out) + + def summary(self, stream): + """Produce coverage reports.""" + total = 0 + + if not self.cov_report: + with open(os.devnull, 'w') as null: + total = self.cov.report(show_missing=True, ignore_errors=True, file=null) + return total + + # Output coverage section header. + if len(self.node_descs) == 1: + self.sep(stream, '-', 'coverage: %s' % ''.join(self.node_descs)) + else: + self.sep(stream, '-', 'coverage') + for node_desc in sorted(self.node_descs): + self.sep(stream, ' ', '%s' % node_desc) + + # Produce terminal report if wanted. + if 'term' in self.cov_report or 'term-missing' in self.cov_report: + show_missing = ('term-missing' in self.cov_report) or None + total = self.cov.report(show_missing=show_missing, ignore_errors=True, file=stream) + + # Produce annotated source code report if wanted. + if 'annotate' in self.cov_report: + total = self.cov.annotate(ignore_errors=True) + stream.write('Coverage annotated source written next to source\n') + + # Produce html report if wanted. + if 'html' in self.cov_report: + total = self.cov.html_report(ignore_errors=True) + stream.write('Coverage HTML written to dir %s\n' % self.cov.config.html_dir) + + # Produce xml report if wanted. + if 'xml' in self.cov_report: + total = self.cov.xml_report(ignore_errors=True) + stream.write('Coverage XML written to file %s\n' % self.cov.config.xml_output) + + # Report on any failed slaves. + if self.failed_slaves: + self.sep(stream, '-', 'coverage: failed slaves') + stream.write('The following slaves failed to return coverage data, ' + 'ensure that pytest-cov is installed on these slaves.\n') + for node in self.failed_slaves: + stream.write('%s\n' % node.gateway.id) + + return total + + +class Central(CovController): + """Implementation for centralised operation.""" + + def start(self): + """Erase any previous coverage data and start coverage.""" + + self.cov = coverage.coverage(source=self.cov_source, + config_file=self.cov_config) + self.cov.erase() + self.cov.start() + self.set_env() + + def finish(self): + """Stop coverage, save data to file and set the list of coverage objects to report on.""" + + self.unset_env() + self.cov.stop() + self.cov.combine() + self.cov.save() + node_desc = self.get_node_desc(sys.platform, sys.version_info) + self.node_descs.add(node_desc) + + +class DistMaster(CovController): + """Implementation for distributed master.""" + + def start(self): + """Ensure coverage rc file rsynced if appropriate.""" + + if self.cov_config and os.path.exists(self.cov_config): + self.config.option.rsyncdir.append(self.cov_config) + + self.cov = coverage.coverage(source=self.cov_source, + config_file=self.cov_config) + self.cov.erase() + self.cov.start() + self.cov.config.paths['source'] = [self.topdir] + + def configure_node(self, node): + """Slaves need to know if they are collocated and what files have moved.""" + + node.slaveinput['cov_master_host'] = socket.gethostname() + node.slaveinput['cov_master_topdir'] = self.topdir + node.slaveinput['cov_master_rsync_roots'] = [str(root) for root in node.nodemanager.roots] + + def testnodedown(self, node, error): + """Collect data file name from slave. Also save data to file if slave not collocated.""" + + # If slave doesn't return any data then it is likely that this + # plugin didn't get activated on the slave side. + if not (hasattr(node, 'slaveoutput') and 'cov_slave_node_id' in node.slaveoutput): + self.failed_slaves.append(node) + return + + # If slave is not collocated then we must save the data file + # that it returns to us. + if 'cov_slave_lines' in node.slaveoutput: + cov = coverage.coverage(source=self.cov_source, + data_suffix=True, + config_file=self.cov_config) + cov.start() + cov.data.lines = node.slaveoutput['cov_slave_lines'] + cov.data.arcs = node.slaveoutput['cov_slave_arcs'] + cov.stop() + cov.save() + path = node.slaveoutput['cov_slave_path'] + self.cov.config.paths['source'].append(path) + + # Record the slave types that contribute to the data file. + rinfo = node.gateway._rinfo() + node_desc = self.get_node_desc(rinfo.platform, rinfo.version_info) + self.node_descs.add(node_desc) + + def finish(self): + """Combines coverage data and sets the list of coverage objects to report on.""" + + # Combine all the suffix files into the data file. + self.cov.stop() + self.cov.combine() + self.cov.save() + + +class DistSlave(CovController): + """Implementation for distributed slaves.""" + + def start(self): + """Determine what data file and suffix to contribute to and start coverage.""" + + # Determine whether we are collocated with master. + self.is_collocated = (socket.gethostname() == self.config.slaveinput['cov_master_host'] and + self.topdir == self.config.slaveinput['cov_master_topdir']) + + # If we are not collocated then rewrite master paths to slave paths. + if not self.is_collocated: + master_topdir = self.config.slaveinput['cov_master_topdir'] + slave_topdir = self.topdir + self.cov_source = [source.replace(master_topdir, slave_topdir) + for source in self.cov_source] + self.cov_config = self.cov_config.replace(master_topdir, slave_topdir) + + # Erase any previous data and start coverage. + self.cov = coverage.coverage(source=self.cov_source, + data_suffix=True, + config_file=self.cov_config) + self.cov.erase() + self.cov.start() + self.set_env() + + def finish(self): + """Stop coverage and send relevant info back to the master.""" + + self.unset_env() + self.cov.stop() + self.cov.combine() + self.cov.save() + + if self.is_collocated: + # If we are collocated then just inform the master of our + # data file to indicate that we have finished. + self.config.slaveoutput['cov_slave_node_id'] = self.nodeid + else: + # If we are not collocated then add the current path + # and coverage data to the output so we can combine + # it on the master node. + + # Send all the data to the master over the channel. + self.config.slaveoutput['cov_slave_path'] = self.topdir + self.config.slaveoutput['cov_slave_node_id'] = self.nodeid + self.config.slaveoutput['cov_slave_lines'] = self.cov.data.lines + self.config.slaveoutput['cov_slave_arcs'] = self.cov.data.arcs + + def summary(self, stream): + """Only the master reports so do nothing.""" + + pass diff --git a/pytest_cov.py b/src/pytest_cov/plugin.py similarity index 75% rename from pytest_cov.py rename to src/pytest_cov/plugin.py index c9c6db4f..7abb0bef 100644 --- a/pytest_cov.py +++ b/src/pytest_cov/plugin.py @@ -1,20 +1,23 @@ """Coverage plugin for pytest.""" - import os import pytest -import cov_core -import cov_core_init +from . import embed +from . import engine + + +class CoverageError(Exception): + """Indicates that our coverage is too low""" def pytest_addoption(parser): """Add options to control coverage.""" - group = parser.getgroup('coverage reporting with distributed testing ' - 'support') + group = parser.getgroup( + 'cov', 'coverage reporting with distributed testing support') group.addoption('--cov', action='append', default=[], metavar='path', - dest='cov_source', + nargs='?', const=True, dest='cov_source', help='measure coverage for filesystem path ' '(multi-allowed)') group.addoption('--cov-report', action='append', default=[], @@ -30,12 +33,24 @@ def pytest_addoption(parser): dest='no_cov_on_fail', help='do not report coverage if test run fails, ' 'default: False') + group.addoption('--cov-fail-under', action='store', metavar='MIN', type='int', + help='Fail if the total coverage is less than MIN.') @pytest.mark.tryfirst def pytest_load_initial_conftests(early_config, parser, args): ns = parser.parse_known_args(args) - if ns.cov_source: + ns.cov = bool(ns.cov_source) + + if ns.cov_source == [True]: + ns.cov_source = None + + if not ns.cov_report: + ns.cov_report = ['term'] + elif ns.cov_report == ['']: + ns.cov_report = [] + + if ns.cov: plugin = CovPlugin(ns, early_config.pluginmanager) early_config.pluginmanager.register(plugin, '_cov') @@ -76,15 +91,15 @@ def __init__(self, options, pluginmanager, start=True): getattr(options, 'distload', False) or getattr(options, 'dist', 'no') != 'no') if is_dist and start: - self.start(cov_core.DistMaster) + self.start(engine.DistMaster) elif start: - self.start(cov_core.Central) + self.start(engine.Central) # slave is started in pytest hook def start(self, controller_cls, config=None, nodeid=None): if config is None: - # fake config option for cov_core + # fake config option for engine class Config(object): option = self.options @@ -92,12 +107,15 @@ class Config(object): self.cov_controller = controller_cls( self.options.cov_source, - self.options.cov_report or ['term'], + self.options.cov_report, self.options.cov_config, config, nodeid ) self.cov_controller.start() + cov_config = self.cov_controller.cov.config + if self.options.cov_fail_under is None and hasattr(cov_config, 'fail_under'): + self.options.cov_fail_under = cov_config.fail_under def pytest_sessionstart(self, session): """At session start determine our implementation and delegate to it.""" @@ -106,7 +124,7 @@ def pytest_sessionstart(self, session): if is_slave: nodeid = session.config.slaveinput.get('slaveid', getattr(session, 'nodeid')) - self.start(cov_core.DistSlave, session.config, nodeid) + self.start(engine.DistSlave, session.config, nodeid) def pytest_configure_node(self, node): """Delegate to our implementation. @@ -135,17 +153,23 @@ def pytest_terminal_summary(self, terminalreporter): if self.cov_controller is None: return if not (self.failed and self.options.no_cov_on_fail): - self.cov_controller.summary(terminalreporter._tw) + total = self.cov_controller.summary(terminalreporter.writer) + assert total is not None, 'Test coverage should never be `None`' + cov_fail_under = self.options.cov_fail_under + if cov_fail_under is not None and total < cov_fail_under: + raise CoverageError(('Required test coverage of %d%% not ' + 'reached. Total coverage: %.2f%%') + % (self.options.cov_fail_under, total)) def pytest_runtest_setup(self, item): if os.getpid() != self.pid: # test is run in another process than session, run # coverage manually - self.cov = cov_core_init.init() + self.cov = embed.init() def pytest_runtest_teardown(self, item): if self.cov is not None: - cov_core.multiprocessing_finish(self.cov) + embed.multiprocessing_finish(self.cov) self.cov = None diff --git a/tests/helper.py b/tests/helper.py new file mode 100644 index 00000000..3e7da4bb --- /dev/null +++ b/tests/helper.py @@ -0,0 +1,3 @@ +def do_stuff(): + a = 1 + return a diff --git a/test_pytest_cov.py b/tests/test_pytest_cov.py similarity index 66% rename from test_pytest_cov.py rename to tests/test_pytest_cov.py index 88e3d927..8b82dd3f 100644 --- a/test_pytest_cov.py +++ b/tests/test_pytest_cov.py @@ -1,18 +1,18 @@ - +from distutils.version import StrictVersion import os import sys +import subprocess +import coverage import virtualenv - import py import pytest -import pytest_cov - +import pytest_cov.plugin pytest_plugins = 'pytester', 'cov' SCRIPT = ''' -import sys +import sys, helper def pytest_generate_tests(metafunc): for i in range(10): @@ -20,11 +20,16 @@ def pytest_generate_tests(metafunc): def test_foo(): x = True - assert x + helper.do_stuff() # get some coverage in some other completely different location if sys.version_info[0] > 5: assert False ''' +COVERAGERC_SOURCE = '''\ +[run] +source = . +''' + SCRIPT_CHILD = ''' import sys @@ -80,7 +85,6 @@ def test_run_target(): p.join() ''' - SCRIPT_FAIL = ''' def test_fail(): assert False @@ -102,9 +106,130 @@ def test_central(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'test_central * %s *' % SCRIPT_RESULT, + 'test_central* %s *' % SCRIPT_RESULT, + '*10 passed*' + ]) + assert result.ret == 0 + + +def test_cov_min_100(testdir): + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + '--cov-report=term-missing', + '--cov-fail-under=100', + script) + + assert result.ret == 1 + + +def test_cov_min_50(testdir): + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + '--cov-report=term-missing', + '--cov-fail-under=50', + script) + + assert result.ret == 0 + + +def test_cov_min_no_report(testdir): + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + '--cov-report=', + '--cov-fail-under=50', + script) + + assert result.ret == 0 + + +def test_central_nonspecific(testdir): + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', + '--cov', + '--cov-report=term-missing', + script) + + result.stdout.fnmatch_lines([ + '*- coverage: platform *, python * -*', + 'test_central_nonspecific* %s *' % SCRIPT_RESULT, '*10 passed*' - ]) + ]) + + # multi-module coverage report + assert result.stdout.lines[-3].startswith('TOTAL ') + + assert result.ret == 0 + + +@pytest.mark.skipif('StrictVersion(coverage.__version__) <= StrictVersion("3.8")') +def test_cov_min_from_coveragerc(testdir): + script = testdir.makepyfile(SCRIPT) + testdir.tmpdir.join('.coveragerc').write(""" +[report] +fail_under = 100 +""") + + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + '--cov-report=term-missing', + script) + + assert result.ret == 1 + + +def test_central_coveragerc(testdir): + script = testdir.makepyfile(SCRIPT) + testdir.tmpdir.join('.coveragerc').write(COVERAGERC_SOURCE) + + result = testdir.runpytest('-v', + '--cov', + '--cov-report=term-missing', + script) + + result.stdout.fnmatch_lines([ + '*- coverage: platform *, python * -*', + 'test_central_coveragerc* %s *' % SCRIPT_RESULT, + '*10 passed*', + ]) + + # single-module coverage report + assert result.stdout.lines[-3].startswith('test_central_coveragerc') + + assert result.ret == 0 + + +def test_show_missing_coveragerc(testdir): + script = testdir.makepyfile(SCRIPT) + testdir.tmpdir.join('.coveragerc').write(""" +[run] +source = . + +[report] +show_missing = true +""") + + result = testdir.runpytest('-v', + '--cov', + '--cov-report=term', + script) + + result.stdout.fnmatch_lines([ + '*- coverage: platform *, python * -*', + 'Name * Stmts * Miss * Cover * Missing', + 'test_show_missing_coveragerc* %s * 11' % SCRIPT_RESULT, + '*10 passed*', + ]) + + # single-module coverage report + assert result.stdout.lines[-3].startswith('test_show_missing_coveragerc') + assert result.ret == 0 @@ -133,9 +258,9 @@ def test_dist_collocated(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'test_dist_collocated * %s *' % SCRIPT_RESULT, + 'test_dist_collocated* %s *' % SCRIPT_RESULT, '*10 passed*' - ]) + ]) assert result.ret == 0 @@ -155,9 +280,9 @@ def test_dist_not_collocated(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'test_dist_not_collocated * %s *' % SCRIPT_RESULT, + 'test_dist_not_collocated* %s *' % SCRIPT_RESULT, '*10 passed*' - ]) + ]) assert result.ret == 0 @@ -173,9 +298,9 @@ def test_central_subprocess(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'child_script * %s *' % CHILD_SCRIPT_RESULT, - 'parent_script * %s *' % PARENT_SCRIPT_RESULT, - ]) + 'child_script* %s *' % CHILD_SCRIPT_RESULT, + 'parent_script* %s *' % PARENT_SCRIPT_RESULT, + ]) assert result.ret == 0 @@ -193,9 +318,9 @@ def test_dist_subprocess_collocated(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'child_script * %s *' % CHILD_SCRIPT_RESULT, - 'parent_script * %s *' % PARENT_SCRIPT_RESULT, - ]) + 'child_script* %s *' % CHILD_SCRIPT_RESULT, + 'parent_script* %s *' % PARENT_SCRIPT_RESULT, + ]) assert result.ret == 0 @@ -220,9 +345,9 @@ def test_dist_subprocess_not_collocated(testdir, tmpdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'child_script * %s *' % CHILD_SCRIPT_RESULT, - 'parent_script * %s *' % PARENT_SCRIPT_RESULT, - ]) + 'child_script* %s *' % CHILD_SCRIPT_RESULT, + 'parent_script* %s *' % PARENT_SCRIPT_RESULT, + ]) assert result.ret == 0 @@ -237,7 +362,7 @@ def test_empty_report(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', '*10 passed*' - ]) + ]) assert result.ret == 0 matching_lines = [line for line in result.outlines if '%' in line] assert not matching_lines @@ -250,6 +375,7 @@ def test_dist_missing_data(testdir): exe = os.path.join(venv_path, 'Scripts', 'python.exe') else: exe = os.path.join(venv_path, 'bin', 'python') + subprocess.check_call([exe, '-mpip' if sys.version_info >= (2, 7) else '-mpip.__main__', 'install', 'py', 'pytest']) script = testdir.makepyfile(SCRIPT) result = testdir.runpytest('-v', @@ -261,7 +387,7 @@ def test_dist_missing_data(testdir): result.stdout.fnmatch_lines([ '*- coverage: failed slaves -*' - ]) + ]) assert result.ret == 0 @@ -275,9 +401,9 @@ def test_funcarg(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'test_funcarg * 3 * 100%*', + 'test_funcarg* 3 * 100%*', '*1 passed*' - ]) + ]) assert result.ret == 0 @@ -289,7 +415,7 @@ def test_funcarg_not_active(testdir): result.stdout.fnmatch_lines([ '*1 passed*' - ]) + ]) assert result.ret == 0 @@ -305,9 +431,9 @@ def test_multiprocessing_subprocess(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'test_multiprocessing_subprocess * 8 * 100%*', + 'test_multiprocessing_subprocess* 8 * 100%*', '*1 passed*' - ]) + ]) assert result.ret == 0 @@ -332,7 +458,7 @@ def test_basic(): ''' -CONF_RESULT = 'mod * 2 * 100% *' +CONF_RESULT = 'mod* 2 * 100% *' def test_cover_conftest(testdir): @@ -392,7 +518,7 @@ def test_coveragerc(testdir): '--cov-report=term-missing', script) assert result.ret == 0 - result.stdout.fnmatch_lines(['test_coveragerc * %s' % EXCLUDED_RESULT]) + result.stdout.fnmatch_lines(['test_coveragerc* %s' % EXCLUDED_RESULT]) def test_coveragerc_dist(testdir): @@ -406,7 +532,7 @@ def test_coveragerc_dist(testdir): script) assert result.ret == 0 result.stdout.fnmatch_lines( - ['test_coveragerc_dist * %s' % EXCLUDED_RESULT]) + ['test_coveragerc_dist* %s' % EXCLUDED_RESULT]) CLEAR_ENVIRON_TEST = ''' @@ -451,13 +577,52 @@ def test_dist_boxed(testdir): result.stdout.fnmatch_lines([ '*- coverage: platform *, python * -*', - 'test_dist_boxed * %s*' % SCRIPT_SIMPLE_RESULT, + 'test_dist_boxed* %s*' % SCRIPT_SIMPLE_RESULT, '*1 passed*' - ]) + ]) assert result.ret == 0 def test_not_started_plugin_does_not_fail(testdir): - plugin = pytest_cov.CovPlugin(None, None, start=False) + plugin = pytest_cov.plugin.CovPlugin(None, None, start=False) plugin.pytest_sessionfinish(None, None) plugin.pytest_terminal_summary(None) + + +def test_default_output_setting(testdir): + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + script) + + result.stdout.fnmatch_lines([ + '*coverage*' + ]) + assert result.ret == 0 + + +def test_disabled_output(testdir): + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', + '--cov=%s' % script.dirpath(), + '--cov-report=', + script) + + assert 'coverage' not in result.stdout.str() + assert result.ret == 0 + + +def test_coverage_file(testdir): + script = testdir.makepyfile(SCRIPT) + data_file_name = 'covdata' + os.environ['COVERAGE_FILE'] = data_file_name + try: + result = testdir.runpytest('-v', '--cov=%s' % script.dirpath(), + script) + assert result.ret == 0 + data_file = testdir.tmpdir.join(data_file_name) + assert data_file.check() + finally: + os.environ.pop('COVERAGE_FILE') diff --git a/tox.ini b/tox.ini index 0588e3b9..c59abb73 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,72 @@ +; a generative tox configuration, see: https://testrun.org/tox/latest/config.html#generative-envlist + [tox] -envlist = py26, py27, pypy, pypy3, py32, py33, py34, flake8 +envlist = + check, + {2.6,2.7,3.3,3.4,pypy}-{37,40}, + docs [testenv] -usedevelop = True +basepython = + pypy: pypy + 2.6: {env:TOXPYTHON:python2.6} + {2.7,docs}: {env:TOXPYTHON:python2.7} + 3.3: {env:TOXPYTHON:python3.3} + 3.4: {env:TOXPYTHON:python3.4} + {clean,check,report,extension-coveralls,coveralls}: python3.4 setenv = - PYTHONHASHSEED = random + PYTHONPATH={toxinidir}/tests + PYTHONUNBUFFERED=yes +passenv = + * deps = - pytest - pytest-xdist + pytest==2.7.1 + pytest-capturelog + 37: coverage==3.7.1 + 40: coverage==4.0a6 virtualenv -commands = py.test -v test_pytest_cov.py {posargs} + pytest-xdist==1.12 + pytest-cache==1.0.0 +pip_pre = true + +commands = + {posargs:py.test -vv} +usedevelop = false + +[testenv:spell] +setenv = + SPELLCHECK=1 +commands = + sphinx-build -b spelling docs dist/docs +usedevelop = true +deps = + -r{toxinidir}/docs/requirements.txt + sphinxcontrib-spelling + pyenchant + +[testenv:docs] +whitelist_externals = + rm +commands = + sphinx-build {posargs:-E} -b html docs dist/docs + sphinx-build -b linkcheck docs dist/docs +usedevelop = true +deps = + -r{toxinidir}/docs/requirements.txt + +[testenv:check] +basepython = python3.4 +deps = + docutils + check-manifest + flake8 + readme + pygments +usedevelop = true +commands = + python setup.py check --strict --metadata --restructuredtext + check-manifest {toxinidir} + flake8 src + + -[testenv:flake8] -deps = flake8 -commands = flake8 pytest_cov.py setup.py test_pytest_cov.py