├── .gitignore ├── CHANGES.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── setup.py └── smartcov.py /.gitignore: -------------------------------------------------------------------------------- 1 | pytest_smartcov.egg-info/ 2 | .tox/ 3 | doc/_build/ 4 | dist/ 5 | .coverage 6 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 5 | 0.1 (2014.10.28) 6 | ---------------- 7 | 8 | * Initial working version. 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Carl Meyer and contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of the author nor the names of other 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CHANGES.rst 3 | include LICENSE.txt 4 | include MANIFEST.in 5 | include README.rst 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | pytest-smartcov 3 | =============== 4 | 5 | Smart coverage measurement and reporting for py.test test suites. 6 | 7 | Test suites are usually structured parallel to (or integrated with) the 8 | structure of the code they test. If you ask py.test to run a certain subset of 9 | your tests, you shouldn't have to also tell coverage which subset of your code 10 | it should measure coverage on for that run. With ``pytest-smartcov``, you don't 11 | have to. 12 | 13 | 14 | Prerequisites 15 | ============= 16 | 17 | ``pytest-smartcov`` requires Python 2.7 or higher and `coverage`_ 3.6 or higher. 18 | 19 | .. _coverage: http://nedbatchelder.com/code/coverage/ 20 | 21 | 22 | Usage 23 | ===== 24 | 25 | If ``pytest-smartcov`` is installed and you provide a ``smartcov_paths_hook`` 26 | setting in your ``pytest.ini``, coverage will automatically be measured on all 27 | your test runs, unless you provide the ``--no-cov`` flag. 28 | 29 | 30 | Configuration 31 | ------------- 32 | 33 | To use ``pytest-smartcov``, provide a ``smartcov_paths_hook`` ini-config 34 | setting which is the Python dotted import path to a function. This function 35 | should accept as its single parameter the list of test paths specified on a 36 | ``py.test`` command line, and should return the list of paths on which code 37 | coverage will be measured. 38 | 39 | 40 | Reporting 41 | --------- 42 | 43 | If 100% of the measured code was covered, ``pytest-smartcov`` will output a 44 | single line at the end of test run notifying you that you have 100% coverage. 45 | 46 | If you have less than 100% coverage on the measured code, ``pytest-smartcov`` 47 | will output a terminal report including only those files which had less than 48 | 100% coverage. 49 | 50 | If there was less than 100% overall coverage, ``pytest-smartcov`` will also 51 | output an HTML report (to the ``htmlcov/`` directory by default, though this 52 | can be configured normally in ``.coveragerc``). 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | from setuptools import setup 3 | 4 | 5 | long_description = ( 6 | open('README.rst').read() + '\n\n' + open('CHANGES.rst').read()) 7 | 8 | 9 | def get_version(): 10 | with open('smartcov.py') as f: 11 | for line in f: 12 | if line.startswith('__version__ ='): 13 | return line.split('=')[1].strip().strip('"\'') 14 | 15 | 16 | setup( 17 | name='pytest-smartcov', 18 | version=get_version(), 19 | description="Smart coverage plugin for pytest.", 20 | long_description=long_description, 21 | author='Carl Meyer', 22 | author_email='carl@oddbird.net', 23 | url='https://github.com/carljm/pytest-smartcov/', 24 | py_modules=['smartcov'], 25 | install_requires=['coverage>=3.6'], 26 | classifiers=[ 27 | 'Development Status :: 3 - Alpha', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: BSD License', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python', 32 | 'Programming Language :: Python :: 2.6', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.2', 36 | 'Programming Language :: Python :: 3.3', 37 | 'Programming Language :: Python :: 3.4', 38 | ], 39 | entry_points={'pytest11': ['smartcov = smartcov']}, 40 | zip_safe=False, 41 | ) 42 | -------------------------------------------------------------------------------- /smartcov.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | import os 3 | 4 | import coverage 5 | import pytest 6 | 7 | 8 | __version__ = '0.3' 9 | 10 | 11 | def pytest_addoption(parser): 12 | parser.addoption( 13 | '--no-cov', 14 | action='store_false', 15 | dest='coverage', 16 | default=True, 17 | help="Turn off coverage measurement.", 18 | ) 19 | parser.addoption( 20 | '--cov-fail-under', 21 | action='store', 22 | type="int", 23 | dest='cov_fail_under', 24 | metavar="MIN", 25 | help="Fail if total coverage is less than MIN.", 26 | default=None, 27 | ) 28 | 29 | 30 | @pytest.mark.tryfirst 31 | def pytest_load_initial_conftests(early_config, parser, args): 32 | paths_hook = _get_paths_hook( 33 | early_config.inicfg.get('smartcov_paths_hook', '')) 34 | ns = parser.parse_known_args(args) 35 | cov_paths = paths_hook(ns.file_or_dir) 36 | if ns.coverage and cov_paths: 37 | plugin = CoveragePlugin(cov_paths, ns.cov_fail_under) 38 | plugin.start() 39 | early_config.pluginmanager.register(plugin, '_coverage') 40 | 41 | 42 | def _null_paths_hook(paths): 43 | return [] 44 | 45 | 46 | def _get_paths_hook(hook_import_path): 47 | if not hook_import_path: 48 | return _null_paths_hook 49 | module_path, funcname = hook_import_path.rsplit('.', 1) 50 | module = import_module(module_path) 51 | return getattr(module, funcname) 52 | 53 | 54 | class CoveragePlugin(object): 55 | def __init__(self, cov_source, cov_fail_under=None): 56 | self.cov_source = cov_source 57 | self.cov_config = '.coveragerc' 58 | self.cov_data_file = '.coverage' 59 | self.cov_fail_under = cov_fail_under 60 | 61 | self.cov = None 62 | 63 | def start(self): 64 | self.cov = coverage.coverage( 65 | source=self.cov_source, 66 | data_file=self.cov_data_file, 67 | config_file=self.cov_config, 68 | ) 69 | self.cov.erase() 70 | self.cov.start() 71 | 72 | def stop(self): 73 | self.cov.stop() 74 | self.cov.save() 75 | 76 | def report(self, stream): 77 | stream.sep('-', 'coverage') 78 | pct = int( 79 | self.cov.report(file=FilteredStream(stream), ignore_errors=True)) 80 | stream.write("Overall test coverage: %s%%\n" % pct) 81 | if pct < 100: 82 | self.cov.html_report(ignore_errors=True) 83 | stream.write( 84 | "Coverage HTML written to %s dir\n" % self.cov.config.html_dir) 85 | return pct 86 | 87 | @pytest.hookimpl(hookwrapper=True) 88 | def pytest_runtestloop(self, session): 89 | yield 90 | with open(os.devnull, 'w') as dn: 91 | total = int(self.cov.report(file=dn)) 92 | if self.cov_fail_under and total < self.cov_fail_under: 93 | session.testsfailed += 1 94 | 95 | def pytest_sessionfinish(self): 96 | self.stop() 97 | 98 | def pytest_terminal_summary(self, terminalreporter): 99 | pct = self.report(terminalreporter._tw) 100 | if self.cov_fail_under: 101 | if pct < self.cov_fail_under: 102 | kwargs = {'red': True, 'bold': True} 103 | msg = ( 104 | "FAIL Required test coverage of %d%% not " 105 | "reached. Total coverage: %d%%\n" 106 | % (self.cov_fail_under, pct) 107 | ) 108 | else: 109 | kwargs = {'green': True} 110 | msg = ( 111 | "Required test coverage of %d%% " 112 | "reached. Total coverage: %d%%\n" 113 | % (self.cov_fail_under, pct) 114 | ) 115 | terminalreporter.write(msg, **kwargs) 116 | 117 | 118 | class FilteredStream(object): 119 | """Filter the coverage terminal report to lines we are interested in.""" 120 | def __init__(self, stream): 121 | self.stream = stream 122 | self._interesting = False 123 | self._buffered = [] 124 | self._last_line_was_printable = False 125 | 126 | def line_is_interesting(self, line): 127 | """Return True, False, or None. 128 | 129 | True means always output, False means never output, None means output 130 | only if there are interesting lines. 131 | 132 | """ 133 | if line.startswith('Name'): 134 | return None 135 | if line.startswith('--------'): 136 | return None 137 | if line.startswith('TOTAL'): 138 | return None 139 | if '100%' in line: 140 | return False 141 | if line == '\n': 142 | return None if self._last_line_was_printable else False 143 | return True 144 | 145 | def write(self, msg): 146 | is_interesting = self.line_is_interesting(msg) 147 | self._last_line_was_printable = True 148 | if is_interesting: 149 | if not self._interesting: 150 | for line in self._buffered: 151 | self.stream.write(line) 152 | self._interesting = True 153 | self.stream.write(msg) 154 | elif is_interesting is None: 155 | if not self._interesting: 156 | self._buffered.append(msg) 157 | else: 158 | self.stream.write(msg) 159 | else: 160 | self._last_line_was_printable = False 161 | --------------------------------------------------------------------------------