├── tests ├── __init__.py ├── dummy │ ├── dummy │ │ ├── __init__.py │ │ ├── dummy2.py │ │ └── dummy.py │ ├── dummy.zip │ ├── dummy-with-prefix.zip │ ├── test_dummy.py │ └── setup.cfg ├── dummy.moved │ ├── dummy │ │ ├── __init__.py │ │ ├── dummy2.py │ │ ├── dummy.py │ │ └── dummy5.py │ ├── test_dummy.py │ ├── setup.cfg │ └── coverage.xml ├── dummy.source1 │ ├── dummy │ │ ├── __init__.py │ │ ├── dummy2.py │ │ ├── dummy.py │ │ └── dummy4.py │ ├── test_dummy.py │ ├── setup.cfg │ └── coverage.xml ├── dummy.source2 │ ├── dummy │ │ ├── __init__.py │ │ ├── dummy2.py │ │ ├── dummy.py │ │ └── dummy3.py │ ├── test_dummy.py │ ├── setup.cfg │ └── coverage.xml ├── dummy.uncovered │ ├── dummy │ │ ├── __init__.py │ │ └── dummy.py │ ├── test_dummy.py │ ├── setup.cfg │ └── coverage.xml ├── dummy.zeroexit1 │ ├── dummy │ │ ├── __init__.py │ │ └── dummy.py │ ├── test_dummy.py │ ├── coverage.xml │ └── setup.cfg ├── dummy.zeroexit2 │ ├── dummy │ │ ├── __init__.py │ │ └── dummy.py │ ├── test_dummy.py │ ├── coverage.xml │ └── setup.cfg ├── dummy.uncovered.addcode │ ├── dummy │ │ ├── __init__.py │ │ └── dummy.py │ ├── test_dummy.py │ ├── setup.cfg │ └── coverage.xml ├── dummy.with-branch-condition │ ├── dummy │ │ ├── __init__.py │ │ └── dummy.py │ ├── test_dummy.py │ ├── setup.cfg │ └── coverage.xml ├── dummy.linestatus │ ├── dummy │ │ └── __init__.py │ ├── test2.xml │ └── test1.xml ├── .testgitignore ├── test_colorize.py ├── test_imports.py ├── test_main.py ├── utils.py ├── test_calculate_line_rate.py ├── cobertura-no-branch-rate.xml ├── test_extrapolate_coverage.py ├── test_rangify.py ├── README.generate-dummy-coverage-for-testing.md ├── dummy.original.xml ├── dummy.original-full-cov.xml ├── dummy.original-better-cov.xml ├── test_get_line_status.py ├── test_regex_utils.py ├── dummy.with-dummy2-full-cov.xml ├── dummy.with-dummy2-no-cov.xml ├── dummy.with-dummy2-better-cov.xml ├── dummy.with-dummy2-better-and-worse.xml ├── cobertura-generated-by-istanbul-from-coffeescript.xml ├── test_reconcile_lines.py ├── dummy.diffcoverage │ ├── new-coverage.xml │ └── old-coverage.xml ├── test_stringify.py ├── test_cobertura_diff.py ├── test_hunkify_coverage.py ├── test_filesystem.py ├── cobertura.xml └── test_cobertura.py ├── __version__ ├── pycobertura ├── templates │ ├── __init__.py │ ├── macro.source.jinja2 │ ├── filters.py │ ├── html.jinja2 │ ├── html-delta.jinja2 │ ├── normalize.css │ └── skeleton.css ├── __init__.py ├── __main__.py ├── utils.py ├── filesystem.py ├── cli.py └── cobertura.py ├── setup.py ├── .coveragerc ├── images ├── example_json_output.png ├── example_yaml_output.png ├── example_csv_diff_output.png ├── example_markdown_diff_output.png ├── example_github_annotation_diff.png └── example_github_annotation_show.png ├── test-requirements.txt ├── pyproject.toml ├── release.sh ├── tox.ini ├── .gitignore ├── pytest.ini ├── .github └── workflows │ └── build-and-test-pycobertura.yml ├── LICENSE ├── setup.cfg ├── aysha-logo.svg └── CHANGES.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /__version__: -------------------------------------------------------------------------------- 1 | 4.1.0 2 | -------------------------------------------------------------------------------- /pycobertura/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy.moved/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy.source1/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy.source2/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy.uncovered/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit1/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit2/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy.uncovered.addcode/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy.with-branch-condition/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/dummy.linestatus/dummy/__init__.py: -------------------------------------------------------------------------------- 1 | #one fake line -------------------------------------------------------------------------------- /tests/dummy/dummy/dummy2.py: -------------------------------------------------------------------------------- 1 | def baz(): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/dummy.moved/dummy/dummy2.py: -------------------------------------------------------------------------------- 1 | def baz(): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/dummy.source1/dummy/dummy2.py: -------------------------------------------------------------------------------- 1 | def baz(): 2 | pass 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = 4 | pycobertura/* 5 | -------------------------------------------------------------------------------- /tests/dummy/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | pass 3 | 4 | def bar(): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/dummy/dummy.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aconrad/pycobertura/HEAD/tests/dummy/dummy.zip -------------------------------------------------------------------------------- /tests/dummy.source2/dummy/dummy2.py: -------------------------------------------------------------------------------- 1 | def baz(): 2 | c = 'c' 3 | 4 | def bat(): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/.testgitignore: -------------------------------------------------------------------------------- 1 | # Test file for ignore regexes 2 | 3 | **/__pycache__ 4 | **/dummy* 5 | 6 | *.xml 7 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit1/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | from dummy.dummy import foo 3 | foo() 4 | -------------------------------------------------------------------------------- /tests/dummy.moved/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | pass 3 | 4 | def bar(): 5 | a = 'a' 6 | b = 'b' 7 | -------------------------------------------------------------------------------- /images/example_json_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aconrad/pycobertura/HEAD/images/example_json_output.png -------------------------------------------------------------------------------- /images/example_yaml_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aconrad/pycobertura/HEAD/images/example_yaml_output.png -------------------------------------------------------------------------------- /tests/dummy.source1/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | pass 3 | 4 | def bar(): 5 | a = 'a' 6 | b = 'b' 7 | -------------------------------------------------------------------------------- /tests/dummy.source2/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | pass 3 | 4 | def bar(): 5 | a = 'a' 6 | d = 'd' 7 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit2/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | from dummy.dummy import foo 3 | foo(value=True) 4 | -------------------------------------------------------------------------------- /images/example_csv_diff_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aconrad/pycobertura/HEAD/images/example_csv_diff_output.png -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | . #current directory itself, i.e. pycobertura 2 | coverage>=6.0,<6.4 3 | pytest==7.* 4 | mock 5 | -------------------------------------------------------------------------------- /tests/dummy.moved/dummy/dummy5.py: -------------------------------------------------------------------------------- 1 | def barbaz(): 2 | pass 3 | 4 | def foobarbaz(): 5 | a = 1 + 3 6 | pass 7 | -------------------------------------------------------------------------------- /tests/dummy.source1/dummy/dummy4.py: -------------------------------------------------------------------------------- 1 | def barbaz(): 2 | pass 3 | 4 | def foobarbaz(): 5 | a = 1 + 3 6 | pass 7 | -------------------------------------------------------------------------------- /tests/dummy/dummy-with-prefix.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aconrad/pycobertura/HEAD/tests/dummy/dummy-with-prefix.zip -------------------------------------------------------------------------------- /images/example_markdown_diff_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aconrad/pycobertura/HEAD/images/example_markdown_diff_output.png -------------------------------------------------------------------------------- /images/example_github_annotation_diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aconrad/pycobertura/HEAD/images/example_github_annotation_diff.png -------------------------------------------------------------------------------- /images/example_github_annotation_show.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aconrad/pycobertura/HEAD/images/example_github_annotation_show.png -------------------------------------------------------------------------------- /pycobertura/__init__.py: -------------------------------------------------------------------------------- 1 | from .cobertura import Cobertura, CoberturaDiff # noqa 2 | from .reporters import TextReporter, TextReporterDelta # noqa 3 | -------------------------------------------------------------------------------- /tests/test_colorize.py: -------------------------------------------------------------------------------- 1 | def test_colorize(): 2 | from pycobertura.utils import colorize 3 | assert colorize('YAY!', 'green') == '\x1b[32mYAY!\x1b[39m' 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=51", 4 | "wheel", 5 | "pip==21.3", 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit1/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def foo(value=False): 2 | a = 'a' 3 | b = 'b' 4 | if value: 5 | c = 'c' 6 | d = 'd' 7 | e = 'e' 8 | -------------------------------------------------------------------------------- /tests/dummy.moved/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | from dummy.dummy import foo 3 | foo() 4 | 5 | 6 | def test_baz(): 7 | from dummy.dummy2 import baz 8 | baz() 9 | -------------------------------------------------------------------------------- /tests/dummy.with-branch-condition/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_qualified_for_discount(): 2 | from dummy.dummy import qualifies_for_discount 3 | qualifies_for_discount(8, 90) 4 | -------------------------------------------------------------------------------- /tests/dummy.source1/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | from dummy.dummy import foo 3 | foo() 4 | 5 | 6 | def test_baz(): 7 | from dummy.dummy2 import baz 8 | baz() 9 | -------------------------------------------------------------------------------- /pycobertura/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from .cli import pycobertura 4 | 5 | cli = click.CommandCollection(sources=[pycobertura]) 6 | 7 | if __name__ == "__main__": 8 | cli() 9 | -------------------------------------------------------------------------------- /tests/dummy.uncovered/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def baz(): 2 | pass 3 | 4 | def foo(): 5 | if foo is None: 6 | print("I never happen") 7 | pass 8 | 9 | def bar(): 10 | pass 11 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit2/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def foo(value=False): 2 | a = 'a' 3 | b = 'b' 4 | if value: 5 | c = 'c' 6 | d = 'd' 7 | e = 'e' 8 | else: 9 | f = 'f' 10 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | def test_imports(): 2 | from pycobertura import Cobertura 3 | from pycobertura import CoberturaDiff 4 | from pycobertura import TextReporter 5 | from pycobertura import TextReporterDelta 6 | -------------------------------------------------------------------------------- /tests/dummy.with-branch-condition/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def qualifies_for_discount(quantity, price): 2 | if quantity >= 10 or price <= 100: 3 | print("You qualify for a discount!") 4 | else: 5 | print("No discount available.") 6 | -------------------------------------------------------------------------------- /tests/dummy.source2/dummy/dummy3.py: -------------------------------------------------------------------------------- 1 | def foobar(): 2 | pass # This is a very long comment that was purposefully written so we could test how HTML rendering looks like when the boundaries of the page are reached. And here is a non-ascii char: Ş 3 | -------------------------------------------------------------------------------- /tests/dummy/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | from dummy.dummy import foo 3 | foo() 4 | 5 | 6 | def test_bar(): 7 | from dummy.dummy import bar 8 | bar() 9 | 10 | 11 | def test_baz(): 12 | from dummy.dummy2 import baz 13 | baz() 14 | -------------------------------------------------------------------------------- /tests/dummy.source2/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | from dummy.dummy import foo 3 | foo() 4 | 5 | 6 | def test_bar(): 7 | from dummy.dummy import bar 8 | bar() 9 | 10 | 11 | def test_baz(): 12 | from dummy.dummy2 import baz 13 | baz() 14 | -------------------------------------------------------------------------------- /tests/dummy.uncovered/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | from dummy.dummy import foo 3 | foo() 4 | 5 | 6 | def test_bar(): 7 | from dummy.dummy import bar 8 | bar() 9 | 10 | 11 | def test_baz(): 12 | from dummy.dummy import baz 13 | baz() 14 | -------------------------------------------------------------------------------- /tests/dummy.uncovered.addcode/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | from dummy.dummy import foo 3 | foo() 4 | 5 | 6 | def test_bar(): 7 | from dummy.dummy import bar 8 | bar() 9 | 10 | 11 | def test_baz(): 12 | from dummy.dummy import baz 13 | baz() 14 | -------------------------------------------------------------------------------- /tests/dummy.uncovered.addcode/dummy/dummy.py: -------------------------------------------------------------------------------- 1 | def baz(): 2 | if baz is str: 3 | print("something else that doesn't happen") 4 | pass 5 | 6 | def foo(): 7 | if foo is None: 8 | print("I never happen") 9 | pass 10 | 11 | def bar(): 12 | pass 13 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import runpy 3 | 4 | 5 | def test_cli(): 6 | with mock.patch("click.CommandCollection") as mock_cli: 7 | runpy.run_module("pycobertura", run_name="__main__") 8 | 9 | mock_cli.assert_called_once() 10 | mock_cli.return_value.assert_called_once_with() 11 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from pycobertura import Cobertura 2 | from pycobertura.filesystem import filesystem_factory 3 | from pycobertura.utils import get_dir_from_file_path 4 | 5 | 6 | def make_cobertura(xml='tests/cobertura.xml', source=None, **kwargs): 7 | if not source: 8 | source = get_dir_from_file_path(xml) 9 | source_filesystem = filesystem_factory(source, **kwargs) 10 | cobertura = Cobertura(xml, filesystem=source_filesystem) 11 | return cobertura 12 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | # Before you release 2 | # - install twine: pip install twine 3 | # - bump package version in `__version__` 4 | # - update the package version in the CHANGES file 5 | # - commit the changes to master and push 6 | 7 | PKG_NAME=pycobertura 8 | PKG_VERSION=$(cat __version__) 9 | 10 | git tag -am "release v${PKG_VERSION}" "v${PKG_VERSION}" 11 | git push --tags 12 | pip install build 13 | python -m build 14 | twine upload dist/"${PKG_NAME}-${PKG_VERSION}"*{.tar.gz,.whl} 15 | -------------------------------------------------------------------------------- /tests/test_calculate_line_rate.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import pytest 3 | from pycobertura.utils import calculate_line_rate, get_line_status 4 | 5 | 6 | @pytest.mark.parametrize("total_statments, total_missing, expected_output", [ 7 | (0, 0, 1), 8 | (100, 0, 1), 9 | (100, 1, 0.99), 10 | (100, 100, 0), 11 | ]) 12 | def test_calculate_line_rate(total_statments, total_missing, expected_output): 13 | assert calculate_line_rate(total_statments, total_missing) == expected_output 14 | -------------------------------------------------------------------------------- /tests/cobertura-no-branch-rate.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | minimal-coverage 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/test_extrapolate_coverage.py: -------------------------------------------------------------------------------- 1 | def test_extrapolate_coverage(): 2 | from pycobertura.utils import extrapolate_coverage 3 | lines_w_status = [ 4 | (1, "hit"), 5 | (4, "hit"), 6 | (7, "miss"), 7 | (9, "miss"), 8 | ] 9 | 10 | assert extrapolate_coverage(lines_w_status) == [ 11 | (1, "hit"), 12 | (2, "hit"), 13 | (3, "hit"), 14 | (4, "hit"), 15 | (5, None), 16 | (6, None), 17 | (7, "miss"), 18 | (8, "miss"), 19 | (9, "miss"), 20 | ] 21 | -------------------------------------------------------------------------------- /pycobertura/templates/macro.source.jinja2: -------------------------------------------------------------------------------- 1 | {% macro render_source(lines) -%} 2 | 3 | 4 | 5 | 12 | 19 | 20 | 21 |
6 |
 7 | {%- for line in lines -%}
 8 | {{ line.number  }} {{ line|line_reason }}
 9 | {% endfor -%}
10 |         
11 |
13 |
14 | {%- for line in lines -%}
15 |           {{ line.source|escape }}
16 | {%- endfor -%}
17 |         
18 |
22 | {%- endmacro %} 23 | -------------------------------------------------------------------------------- /tests/dummy.linestatus/test2.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/dummy.linestatus/test1.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = py39, py310, py311, py312, py313, pep8, black 4 | 5 | [gh-actions] 6 | python = 7 | 3.9: py39 8 | 3.10: py310 9 | 3.11: py311 10 | 3.12: py312 11 | 3.13: py313, pep8, black 12 | 13 | [flake8] 14 | max-line-length = 88 15 | 16 | [testenv] 17 | commands = 18 | pip install -r test-requirements.txt --force-reinstall 19 | coverage run -m pytest 20 | coverage report --show-missing 21 | coverage xml 22 | 23 | passenv = 24 | LANG 25 | 26 | [testenv:pep8] 27 | commands = 28 | pip install flake8 29 | flake8 pycobertura/ 30 | 31 | [testenv:black] 32 | commands = 33 | pip install black 34 | black --check --diff pycobertura/ 35 | -------------------------------------------------------------------------------- /tests/test_rangify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize( 5 | "line_statuses, expected_output", 6 | [ 7 | ([(1, "hit")], [(1, 1, "hit")]), 8 | ([(1, "hit"), (2, "hit")], [(1, 2, "hit")]), 9 | ([(1, "hit"), (2, "hit"), (3, "hit")], [(1, 3, "hit")]), 10 | ([(1, "hit"), (3, "hit")], [(1, 1, "hit"), (3, 3, "hit")]), 11 | ([(1, "hit"), (2, "hit"), (3, "miss"), (4, "hit")], [(1, 2, "hit"), (3, 3, "miss"), (4, 4, "hit")]), 12 | ([(1, "hit"), (2, "partial"), (3, "miss"), (4, "hit")], [(1, 1, "hit"), (2, 2, "partial"), (3, 3, "miss"), (4, 4, "hit")]), 13 | ], 14 | ) 15 | def test_rangify_by_status(line_statuses, expected_output): 16 | from pycobertura.utils import rangify_by_status 17 | 18 | assert rangify_by_status(line_statuses) == expected_output 19 | -------------------------------------------------------------------------------- /tests/README.generate-dummy-coverage-for-testing.md: -------------------------------------------------------------------------------- 1 | # Generating custom coverage for testing purpose 2 | 3 | The `dummy/` directory contains a Python application with tests that was 4 | created for the purpose of generating coverage files (using coverage.py). You 5 | can tweak the tests and the dummy code in order to have pytest generate 6 | different coverage reports that can be used as part of the tests for 7 | pycobertura. 8 | 9 | # Running tests for the dummy app 10 | 11 | To run the tests for dummy, cd into the directory and simply run pytest. 12 | 13 | ``` 14 | $ cd dummy/ 15 | $ py.test 16 | ``` 17 | 18 | Save the generated `coverage.xml` file under the filename 19 | `dummy..xml` and move it in the 20 | directory of the pycobertura tests. You can then start writing tests cases for 21 | pycobertura using the new coverage file. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | .eggs 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | junit.xml 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Mr Developer 43 | .mr.developer.cfg 44 | .project 45 | .pydevproject 46 | 47 | # Rope 48 | .ropeproject 49 | 50 | # Django stuff: 51 | *.log 52 | *.pot 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # Editors 58 | *.swp 59 | .vscode 60 | 61 | # Mac OS 62 | .DS_Store 63 | -------------------------------------------------------------------------------- /tests/dummy.original.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/dummy.original-full-cov.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/dummy.original-better-cov.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/test_get_line_status.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import pytest 3 | from pycobertura.utils import get_line_status 4 | 5 | class FakeLine: 6 | def __init__(self, hits: str, condition_coverage: Union[str, None]): 7 | self.condition_coverage = condition_coverage 8 | self.hits = hits 9 | 10 | def get(self, attr: str): 11 | if attr == 'condition-coverage': 12 | return self.condition_coverage 13 | 14 | if attr == 'hits': 15 | return self.hits 16 | 17 | raise ValueError(f"FakeLine.get() received an unexpected attribute: {attr}") 18 | 19 | @pytest.mark.parametrize("line, expected_output", [ 20 | (FakeLine("0", None), "miss"), 21 | (FakeLine("1", None), "hit"), 22 | (FakeLine("1", "50% (1/2)"), "partial"), 23 | (FakeLine("1", "100% (2/2)"), "hit"), 24 | (FakeLine("0", "0% (0/2)"), "miss"), 25 | ]) 26 | def test_get_line_status(line, expected_output): 27 | assert get_line_status(line) == expected_output 28 | -------------------------------------------------------------------------------- /tests/test_regex_utils.py: -------------------------------------------------------------------------------- 1 | from pycobertura.utils import get_non_empty_non_commented_lines_from_file_in_ascii, get_filenames_that_do_not_match_regex 2 | 3 | def test_read_ignore_regex_file(): 4 | result = get_non_empty_non_commented_lines_from_file_in_ascii('tests/.testgitignore', '#') 5 | assert result == ['**/__pycache__','**/dummy*','*.xml'] 6 | 7 | def test_get_filenames_that_do_not_match_regex_given_file_path(): 8 | filenames = ["tests/dummy/test_dummy.py", "tests/cobertura.xml", "tests/test_cli.py"] 9 | result = get_filenames_that_do_not_match_regex(filenames, "tests/.testgitignore") 10 | assert result == ["tests/test_cli.py"] 11 | 12 | def test_get_filenames_that_do_not_match_regex_given_list_of_filenames(): 13 | filenames = ["tests/dummy/test_dummy.py", "tests/cobertura.xml", "tests/test_cli.py"] 14 | regex_param = "^tests/dummy|(.*).xml$" 15 | result = get_filenames_that_do_not_match_regex(filenames, regex_param) 16 | assert result == ["tests/test_cli.py"] 17 | 18 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy* 3 | junit_family = xunit1 4 | addopts = 5 | # Shows a line for every test 6 | # You probably want to turn this off if you use pytest-sugar. 7 | # Or you can keep it and run `py.test -q`. 8 | 9 | --verbose 10 | 11 | # Shorter tracebacks are sometimes easier to read 12 | --tb=short 13 | 14 | # Turn on --capture to have brief, less noisy output. 15 | # You will only see output if the test fails. 16 | # Use --capture no (same as -s) if you want to see it all or have problems 17 | # debugging. 18 | # --capture=fd 19 | # --capture=no 20 | 21 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 22 | -rfEsxX 23 | 24 | #--cov-config=.coveragerc 25 | 26 | # Previous versions included the following, but it's a bad idea because it 27 | # hard-codes the value and makes it hard to change from the command-line 28 | tests/ 29 | -------------------------------------------------------------------------------- /pycobertura/templates/filters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Jinja2 filters meant to be used by templates. 3 | """ 4 | 5 | line_status_style = { 6 | "hit": "hit", 7 | "miss": "miss", 8 | "partial": "partial", 9 | None: "noop", 10 | } 11 | 12 | 13 | line_reason_html_icon = { 14 | "line-edit": "+", 15 | "cov-up": " ", 16 | "cov-down": " ", 17 | None: " ", 18 | } 19 | 20 | 21 | def is_not_equal_to_dash(arg): 22 | return not (arg == "-") 23 | 24 | 25 | def misses_color(arg): 26 | if isinstance(arg, str): 27 | if arg.startswith("+") or arg[0].isdigit(): 28 | return "red" 29 | return "green" 30 | 31 | *_, status = arg 32 | if status == "partial": 33 | return "yellow" 34 | 35 | if status == "miss": 36 | return "red" 37 | 38 | if status == "hit": 39 | return "green" 40 | 41 | 42 | def line_status(line): 43 | return line_status_style[line.status] 44 | 45 | 46 | def line_reason_icon(line): 47 | if line.status is None: 48 | return " " 49 | return line_reason_html_icon[line.reason] 50 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit1/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test-pycobertura.yml: -------------------------------------------------------------------------------- 1 | name: pycobertura 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install tox tox-gh-actions 30 | 31 | - name: Test with tox 32 | run: tox 33 | 34 | - name: Upload coverage artifact 35 | if: ${{ github.event_name == 'push' }} 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: cobertura-coverage-${{ github.sha }}-${{ matrix.python-version }} 39 | path: coverage.xml 40 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit2/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/dummy/setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | --junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /tests/dummy.source1/setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | --junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /tests/dummy.source2/setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | --junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 SurveyMonkey Inc. and its affiliates 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/dummy.moved/setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | --junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /tests/dummy.uncovered/setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | --junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit1/setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | --junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /tests/dummy.zeroexit2/setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | --junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /tests/dummy.uncovered.addcode/setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | --junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /tests/dummy.with-branch-condition/setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | norecursedirs = build docs/_build *.egg .tox *.venv tests/dummy 3 | addopts = 4 | # Shows a line for every test 5 | # You probably want to turn this off if you use pytest-sugar. 6 | # Or you can keep it and run `py.test -q`. 7 | --verbose 8 | 9 | # Shorter tracebacks are sometimes easier to read 10 | --tb=short 11 | 12 | # Turn on --capture to have brief, less noisy output. 13 | # You will only see output if the test fails. 14 | # Use --capture no (same as -s) if you want to see it all or have problems 15 | # debugging. 16 | # --capture=fd 17 | # --capture=no 18 | 19 | # Show extra test summary info as specified by chars (f)ailed, (E)error, (s)skipped, (x)failed, (X)passed. 20 | -rfEsxX 21 | 22 | # Output test results to junit.xml for Jenkins to consume 23 | #--junitxml=junit.xml 24 | 25 | # Measure code coverage 26 | --cov=dummy --cov-report=xml --cov-report=term-missing --cov-branch 27 | 28 | # Previous versions included the following, but it's a bad idea because it 29 | # hard-codes the value and makes it hard to change from the command-line 30 | test_dummy.py 31 | -------------------------------------------------------------------------------- /tests/dummy.with-dummy2-full-cov.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/dummy.with-dummy2-no-cov.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/dummy.with-dummy2-better-cov.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/dummy.with-dummy2-better-and-worse.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/dummy.with-branch-condition/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /Users/alexandre/Development/pycobertura/tests/dummy.with-branch-condition/dummy 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/cobertura-generated-by-istanbul-from-coffeescript.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | /Users/aconrad/lever/coverage-test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/dummy.uncovered/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /home/ec2-user/pycobertura/tests/dummy.uncovered/dummy 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/test_reconcile_lines.py: -------------------------------------------------------------------------------- 1 | def test_reconcile_lines__identical(): 2 | from pycobertura.utils import reconcile_lines 3 | 4 | lines1 = [ 5 | 'hello', # 1 6 | 'world', # 2 7 | ] 8 | 9 | lines2 = [ 10 | 'hello', # 1 11 | 'world', # 2 12 | ] 13 | 14 | assert reconcile_lines(lines1, lines2) == {1: 1, 2: 2} 15 | 16 | 17 | def test_reconcile_lines__less_to_more(): 18 | from pycobertura.utils import reconcile_lines 19 | 20 | lines1 = [ 21 | 'hello', # 1 22 | 'world', # 2 23 | ] 24 | lines2 = [ 25 | 'dear all', # 1 26 | 'hello', # 2 27 | 'beautiful', # 3 28 | 'world', # 4 29 | 'of', # 5 30 | 'pain', # 6 31 | ] 32 | 33 | assert reconcile_lines(lines1, lines2) == {1: 2, 2: 4} 34 | 35 | 36 | def test_reconcile_lines__more_to_less(): 37 | from pycobertura.utils import reconcile_lines 38 | 39 | lines1 = [ 40 | 'dear all', # 1 41 | 'hello', # 2 42 | 'beautiful', # 3 43 | 'world', # 4 44 | 'of', # 5 45 | 'pain', # 6 46 | ] 47 | 48 | lines2 = [ 49 | 'hello', # 1 50 | 'world', # 2 51 | ] 52 | 53 | assert reconcile_lines(lines1, lines2) == {2: 1, 4: 2} 54 | -------------------------------------------------------------------------------- /tests/dummy.diffcoverage/new-coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/dummy.diffcoverage/old-coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/test_stringify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize( 5 | "input, expected_output", 6 | [ 7 | ([], ""), 8 | ([(1, "miss"), (2, "miss")], "1-2"), 9 | ([(1, "miss"), (2, "partial")], "1, ~2"), 10 | ([(1, "miss"), (2, "miss"), (3, "miss")], "1-3"), 11 | ([(1, "miss"), (2, "partial"), (3, "miss")], "1, ~2, 3"), 12 | ([(1, "miss"), (2, "partial"), (3, "partial")], "1, ~2-3"), 13 | ([(1, "miss"), (2, "partial"), (3, "partial"), (4, "miss")], "1, ~2-3, 4"), 14 | ([(1, "miss"), (2, "miss"), (3, "partial"), (4, "partial")], "1-2, ~3-4"), 15 | ([(1, "partial"), (2, "partial"), (3, "miss"), (4, "miss")], "~1-2, 3-4"), 16 | ([(1, "miss"), (2, "miss"), (3, "miss"), (7, "miss")], "1-3, 7"), 17 | ([(1, "miss"), (2, "miss"), (3, "miss"), (7, "miss"), (8, "miss")], "1-3, 7-8"), 18 | ([(1, "miss"), (2, "miss"), (3, "miss"), (7, "miss"), (8, "miss"), (9, "miss")], "1-3, 7-9"), 19 | ([(1, "miss"), (7, "miss"), (8, "miss"), (9, "miss")], "1, 7-9"), 20 | ([(1, "miss"), (2, "miss"), (7, "miss"), (8, "miss"), (9, "miss")], "1-2, 7-9"), 21 | ([(1, "miss"), (7, "miss")], "1, 7"), 22 | ], 23 | ) 24 | def test_stringify_func(input, expected_output): 25 | from pycobertura.utils import stringify 26 | 27 | assert stringify(input) == expected_output 28 | -------------------------------------------------------------------------------- /tests/dummy.uncovered.addcode/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /home/ec2-user/pycobertura/tests/dummy.uncovered.addcode/dummy 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/dummy.source2/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Documentation of setup.cfg: 2 | # https://setuptools.pypa.io/en/latest/userguide/declarative_config.html 3 | 4 | [metadata] 5 | name = pycobertura 6 | version = file: __version__ 7 | description = "A Cobertura coverage parser that can diff reports and show coverage progress." 8 | author = "Alex Conrad" 9 | author_email = "alexandre.conrad@gmail.com" 10 | long_description = file: README.md 11 | long_description_content_type = text/markdown 12 | maintainer = "Alex Conrad" 13 | maintainer_email = "alexandre.conrad@gmail.com" 14 | license_files = LICENSE 15 | keywords = "cobertura coverage diff report parser parse xml" 16 | url = https://github.com/aconrad/pycobertura 17 | classifiers = 18 | Topic :: Software Development :: Libraries :: Python Modules 19 | License :: OSI Approved :: MIT License 20 | Natural Language :: English 21 | Operating System :: OS Independent 22 | Programming Language :: Python 23 | Programming Language :: Python :: 3 24 | Programming Language :: Python :: 3 :: Only 25 | Programming Language :: Python :: 3.7 26 | Programming Language :: Python :: 3.8 27 | Programming Language :: Python :: 3.9 28 | 29 | [options] 30 | zip_safe = False 31 | python_requires = >= 3.7 32 | packages = find: 33 | setup_requires = 34 | setuptools_git 35 | install_requires = 36 | click>=4.0 37 | jinja2 38 | lxml 39 | tabulate 40 | ruamel.yaml 41 | 42 | [options.package_data] 43 | * = *.jinja2, *.css 44 | 45 | [options.packages.find] 46 | exclude = tests 47 | 48 | [options.entry_points] 49 | console_scripts = 50 | pycobertura = pycobertura.cli:pycobertura 51 | -------------------------------------------------------------------------------- /tests/dummy.source1/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /tests/dummy.moved/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /src/pycobertura/tests/dummy.moved/dummy 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /pycobertura/templates/html.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ title }} 4 | 5 | 14 | 15 | 16 |
17 |

{{ title }}

18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {%- for line in lines["Filename"] %} 30 | 31 | {%- if sources %} 32 | 33 | {%- else %} 34 | 35 | {%- endif %} 36 | 37 | 38 | 39 | 40 | 41 | {%- endfor %} 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
FilenameStmtsMissCoverMissing
{{ lines["Filename"][loop.index0] }}{{ lines["Filename"][loop.index0] }}{{ lines["Stmts"][loop.index0] }}{{ lines["Miss"][loop.index0] }}{{ lines["Cover"][loop.index0] }}{{ lines["Missing"][loop.index0] }}
{{ footer["Filename"] }}{{ footer["Stmts"] }}{{ footer["Miss"] }}{{ footer["Cover"] }}{{ footer["Missing"] }}
53 | {%- if sources %} 54 | {%- from 'macro.source.jinja2' import render_source -%} 55 | {%- for filename, source in sources %} 56 |

{{ filename }}

57 | {{ render_source(source) }} 58 | {%- endfor %} 59 | {% else %} 60 |

{{ no_file_sources_message }}

61 | {%- endif %} 62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /aysha-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | 39 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /pycobertura/templates/html-delta.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | pycobertura report 4 | 5 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {%- if show_source and show_missing %} 34 | 35 | {%- endif %} 36 | 37 | 38 | 39 | {%- for file_index in range(lines["Filename"]|length) %} 40 | 41 | 42 | 43 | 44 | 45 | {%- if show_source and show_missing %} 46 | 51 | {%- endif %} 52 | 53 | {%- endfor %} 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | {%- if show_source and show_missing %} 62 | 63 | {%- endif %} 64 | 65 | 66 |
FilenameStmtsMissCoverMissing
{{ lines["Filename"][file_index]}}{{ lines["Stmts"][file_index] }}{{ lines["Miss"][file_index] }}{{ lines["Cover"][file_index] }} 47 | {%- for missed_line in lines["Missing"][file_index] %} 48 | {%- if not loop.first %}, {% endif %}{{ missed_line[0] }} 49 | {%- endfor %} 50 |
{{ footer["Filename"] }}{{ footer["Stmts"] }}{{ footer["Miss"] }}{{ footer["Cover"] }}
67 | {%- if show_source %} 68 | {%- from 'macro.source.jinja2' import render_source -%} 69 |
70 |
71 |
code
coverage unchanged
72 |
code
coverage increased
73 |
code
coverage decreased
74 |
+
line added or modified
75 |
76 |
77 | 78 | {%- for filename, source_hunks in sources %} 79 |

{{ filename }}

80 | {%- for hunk in source_hunks %} 81 | {{ render_source(hunk) }} 82 | {%- endfor %} 83 | {%- endfor %} 84 | {%- endif %} 85 |
86 | 87 | 88 | -------------------------------------------------------------------------------- /tests/test_cobertura_diff.py: -------------------------------------------------------------------------------- 1 | from .utils import make_cobertura 2 | 3 | 4 | def test_diff_class_source(): 5 | from pycobertura.cobertura import CoberturaDiff 6 | from pycobertura.cobertura import Line 7 | 8 | cobertura1 = make_cobertura('tests/dummy.source1/coverage.xml') 9 | cobertura2 = make_cobertura('tests/dummy.source2/coverage.xml') 10 | differ = CoberturaDiff(cobertura1, cobertura2) 11 | 12 | expected_sources = { 13 | 'dummy/__init__.py': [], 14 | 'dummy/dummy.py': [ 15 | Line(1, u'def foo():\n', None, None), 16 | Line(2, u' pass\n', None, None), 17 | Line(3, u'\n', None, None), 18 | Line(4, u'def bar():\n', None, None), 19 | Line(5, u" a = 'a'\n", "hit", 'cov-up'), 20 | Line(6, u" d = 'd'\n", "hit", 'line-edit') 21 | ], 22 | 'dummy/dummy2.py': [ 23 | Line(1, u'def baz():\n', None, None), 24 | Line(2, u" c = 'c'\n", "hit", 'line-edit'), 25 | Line(3, u'\n', None, 'line-edit'), 26 | Line(4, u'def bat():\n', "hit", 'line-edit'), 27 | Line(5, u' pass\n', "miss", 'cov-down') 28 | ], 29 | 'dummy/dummy3.py': [ 30 | Line(1, u'def foobar():\n', "miss", 'line-edit'), 31 | Line(2, u' pass # This is a very long comment that was purposefully written so we could test how HTML rendering looks like when the boundaries of the page are reached. And here is a non-ascii char: \u015e\n', "miss", 'line-edit') 32 | ], 33 | } 34 | 35 | for filename in cobertura2.files(): 36 | assert differ.file_source(filename) == \ 37 | expected_sources[filename] 38 | 39 | 40 | def test_diff_total_misses(): 41 | from pycobertura.cobertura import CoberturaDiff 42 | 43 | cobertura1 = make_cobertura('tests/dummy.source1/coverage.xml') 44 | cobertura2 = make_cobertura('tests/dummy.source2/coverage.xml') 45 | differ = CoberturaDiff(cobertura1, cobertura2) 46 | 47 | assert differ.diff_total_misses() == -4 48 | 49 | def test_diff_total_misses_move(): 50 | from pycobertura.cobertura import CoberturaDiff 51 | 52 | cobertura1 = make_cobertura('tests/dummy.source1/coverage.xml') 53 | cobertura2 = make_cobertura('tests/dummy.moved/coverage.xml') 54 | differ = CoberturaDiff(cobertura1, cobertura2) 55 | 56 | assert differ.diff_total_misses() == 0 57 | 58 | def test_diff_total_misses_by_class_file(): 59 | from pycobertura.cobertura import CoberturaDiff 60 | 61 | cobertura1 = make_cobertura('tests/dummy.source1/coverage.xml') 62 | cobertura2 = make_cobertura('tests/dummy.source2/coverage.xml') 63 | differ = CoberturaDiff(cobertura1, cobertura2) 64 | 65 | expected_sources = { 66 | 'dummy/__init__.py': 0, 67 | 'dummy/dummy.py': -2, 68 | 'dummy/dummy2.py': 1, 69 | 'dummy/dummy3.py': 2, 70 | } 71 | 72 | for filename in cobertura2.files(): 73 | assert differ.diff_total_misses(filename) == \ 74 | expected_sources[filename] 75 | 76 | 77 | def test_diff_line_rate(): 78 | from pycobertura.cobertura import CoberturaDiff 79 | 80 | cobertura1 = make_cobertura('tests/dummy.source1/coverage.xml') 81 | cobertura2 = make_cobertura('tests/dummy.source2/coverage.xml') 82 | differ = CoberturaDiff(cobertura1, cobertura2) 83 | 84 | assert differ.diff_line_rate() == 0.31059999999999993 85 | 86 | 87 | def test_diff_line_rate_by_class_file(): 88 | from pycobertura.cobertura import CoberturaDiff 89 | 90 | cobertura1 = make_cobertura('tests/dummy.source1/coverage.xml') 91 | cobertura2 = make_cobertura('tests/dummy.source2/coverage.xml') 92 | differ = CoberturaDiff(cobertura1, cobertura2) 93 | 94 | expected_sources = { 95 | 'dummy/__init__.py': 0, 96 | 'dummy/dummy.py': 0.4, 97 | 'dummy/dummy2.py': -0.25, 98 | 'dummy/dummy3.py': 0.0, 99 | } 100 | 101 | for filename in cobertura2.files(): 102 | assert differ.diff_line_rate(filename) == \ 103 | expected_sources[filename] 104 | 105 | 106 | def test_diff_same_report_different_source_dirs(): 107 | from pycobertura.cobertura import CoberturaDiff 108 | 109 | cobertura1 = make_cobertura('tests/dummy.uncovered.addcode/coverage.xml', source='tests/dummy.uncovered/dummy/') 110 | cobertura2 = make_cobertura('tests/dummy.uncovered.addcode/coverage.xml', source='tests/dummy.uncovered.addcode/dummy/') 111 | differ = CoberturaDiff(cobertura1, cobertura2) 112 | 113 | assert differ.diff_missed_lines('dummy.py') == [(3, "miss")] 114 | 115 | 116 | def test_diff_total_hits(): 117 | from pycobertura.cobertura import CoberturaDiff 118 | 119 | cobertura1 = make_cobertura('tests/dummy.source1/coverage.xml') 120 | cobertura2 = make_cobertura('tests/dummy.source2/coverage.xml') 121 | differ = CoberturaDiff(cobertura1, cobertura2) 122 | 123 | assert differ.diff_total_hits() == 3 124 | 125 | 126 | def test_diff_total_hits_by_class_file(): 127 | from pycobertura.cobertura import CoberturaDiff 128 | 129 | cobertura1 = make_cobertura('tests/dummy.source1/coverage.xml') 130 | cobertura2 = make_cobertura('tests/dummy.source2/coverage.xml') 131 | differ = CoberturaDiff(cobertura1, cobertura2) 132 | 133 | expected_total_hits = { 134 | 'dummy/__init__.py': 0, 135 | 'dummy/dummy.py': 2, 136 | 'dummy/dummy2.py': 1, 137 | 'dummy/dummy3.py': 0, 138 | } 139 | 140 | for filename in cobertura2.files(): 141 | assert differ.diff_total_hits(filename) == \ 142 | expected_total_hits[filename] 143 | 144 | 145 | def test_diff__has_all_changes_covered__some_changed_code_is_still_uncovered(): 146 | from pycobertura.cobertura import Cobertura, CoberturaDiff 147 | 148 | cobertura1 = make_cobertura('tests/dummy.zeroexit1/coverage.xml') 149 | cobertura2 = make_cobertura('tests/dummy.zeroexit2/coverage.xml') 150 | 151 | differ = CoberturaDiff(cobertura1, cobertura2) 152 | assert differ.has_all_changes_covered() is False 153 | 154 | 155 | def test_diff__has_better_coverage(): 156 | from pycobertura.cobertura import Cobertura, CoberturaDiff 157 | 158 | cobertura1 = Cobertura('tests/dummy.zeroexit1/coverage.xml') 159 | cobertura2 = Cobertura('tests/dummy.zeroexit2/coverage.xml') 160 | differ = CoberturaDiff(cobertura1, cobertura2) 161 | assert differ.has_better_coverage() is True 162 | 163 | 164 | def test_diff__has_not_better_coverage(): 165 | from pycobertura.cobertura import Cobertura, CoberturaDiff 166 | 167 | cobertura1 = Cobertura('tests/dummy.zeroexit2/coverage.xml') 168 | cobertura2 = Cobertura('tests/dummy.zeroexit1/coverage.xml') 169 | differ = CoberturaDiff(cobertura1, cobertura2) 170 | assert differ.has_better_coverage() is False 171 | 172 | 173 | def test_diff__mixed_filesystems(): 174 | from pycobertura.cobertura import Cobertura, CoberturaDiff 175 | 176 | cobertura1 = make_cobertura('tests/dummy.original.xml', source='tests/dummy/dummy.zip') 177 | cobertura2 = make_cobertura('tests/dummy.original.xml', source='tests/dummy') 178 | 179 | differ = CoberturaDiff(cobertura1, cobertura2) 180 | assert differ.has_all_changes_covered() is True 181 | -------------------------------------------------------------------------------- /tests/test_hunkify_coverage.py: -------------------------------------------------------------------------------- 1 | def test_hunkify_coverage__top(): 2 | from pycobertura.utils import hunkify_lines 3 | from pycobertura.cobertura import Line 4 | 5 | lines = [ 6 | Line(1, 'a', True, None), 7 | Line(2, 'b', True, None), 8 | Line(3, 'c', None, None), 9 | Line(4, 'd', None, None), 10 | Line(5, 'e', None, None), 11 | Line(6, 'f', None, None), 12 | Line(7, 'g', None, None), 13 | ] 14 | 15 | hunks = hunkify_lines(lines) 16 | 17 | assert hunks == [ 18 | [ 19 | Line(1, 'a', True, None), 20 | Line(2, 'b', True, None), 21 | Line(3, 'c', None, None), 22 | Line(4, 'd', None, None), 23 | Line(5, 'e', None, None), 24 | ], 25 | ] 26 | 27 | 28 | def test_hunkify_coverage__bottom(): 29 | from pycobertura.utils import hunkify_lines 30 | from pycobertura.cobertura import Line 31 | 32 | lines = [ 33 | Line(1, 'a', None, None), 34 | Line(2, 'b', None, None), 35 | Line(3, 'c', None, None), 36 | Line(4, 'd', None, None), 37 | Line(5, 'e', None, None), 38 | Line(6, 'f', True, None), 39 | Line(7, 'g', True, None), 40 | ] 41 | 42 | hunks = hunkify_lines(lines) 43 | 44 | assert hunks == [ 45 | [ 46 | Line(3, 'c', None, None), 47 | Line(4, 'd', None, None), 48 | Line(5, 'e', None, None), 49 | Line(6, 'f', True, None), 50 | Line(7, 'g', True, None), 51 | ], 52 | ] 53 | 54 | 55 | def test_hunkify_coverage__middle(): 56 | from pycobertura.utils import hunkify_lines 57 | from pycobertura.cobertura import Line 58 | 59 | lines = [ 60 | Line(1, 'a', None, None), 61 | Line(2, 'b', None, None), 62 | Line(3, 'c', None, None), 63 | Line(4, 'd', False, None), 64 | Line(5, 'e', False, None), 65 | Line(6, 'f', None, None), 66 | Line(7, 'g', None, None), 67 | Line(8, 'h', None, None), 68 | ] 69 | 70 | hunks = hunkify_lines(lines) 71 | 72 | assert hunks == [ 73 | [ 74 | Line(1, 'a', None, None), 75 | Line(2, 'b', None, None), 76 | Line(3, 'c', None, None), 77 | Line(4, 'd', False, None), 78 | Line(5, 'e', False, None), 79 | Line(6, 'f', None, None), 80 | Line(7, 'g', None, None), 81 | Line(8, 'h', None, None), 82 | ], 83 | ] 84 | 85 | 86 | def test_hunkify_coverage__overlapping(): 87 | from pycobertura.utils import hunkify_lines 88 | from pycobertura.cobertura import Line 89 | 90 | lines = [ 91 | Line(1, 'a', None, None), 92 | Line(2, 'b', True, None), 93 | Line(3, 'c', True, None), 94 | Line(4, 'd', None, None), 95 | Line(5, 'e', None, None), 96 | Line(6, 'f', True, None), 97 | Line(7, 'g', True, None), 98 | Line(8, 'h', None, None), 99 | ] 100 | 101 | hunks = hunkify_lines(lines) 102 | 103 | assert hunks == [ 104 | [ 105 | Line(1, 'a', None, None), 106 | Line(2, 'b', True, None), 107 | Line(3, 'c', True, None), 108 | Line(4, 'd', None, None), 109 | Line(5, 'e', None, None), 110 | Line(6, 'f', True, None), 111 | Line(7, 'g', True, None), 112 | Line(8, 'h', None, None), 113 | ], 114 | ] 115 | 116 | 117 | def test_hunkify_coverage__2_contiguous_hunks_w_full_context_makes_1_hunk(): 118 | from pycobertura.utils import hunkify_lines 119 | from pycobertura.cobertura import Line 120 | 121 | lines = [ 122 | Line(1, 'a', None, None), # context 1 123 | Line(2, 'b', None, None), # context 1 124 | Line(3, 'c', None, None), # context 1 125 | Line(4, 'd', True, None), 126 | Line(5, 'e', None, None), # context 1 127 | Line(6, 'f', None, None), # context 1 128 | Line(7, 'g', None, None), # context 1 129 | Line(8, 'h', None, None), # context 2 130 | Line(9, 'i', None, None), # context 2 131 | Line(10, 'j', None, None), # context 2 132 | Line(11, 'k', False, None), 133 | Line(12, 'l', None, None), # context 2 134 | Line(13, 'm', None, None), # context 2 135 | Line(14, 'n', None, None), # context 2 136 | ] 137 | 138 | hunks = hunkify_lines(lines) 139 | 140 | assert hunks == [ 141 | [ 142 | Line(1, 'a', None, None), 143 | Line(2, 'b', None, None), 144 | Line(3, 'c', None, None), 145 | Line(4, 'd', True, None), 146 | Line(5, 'e', None, None), 147 | Line(6, 'f', None, None), 148 | Line(7, 'g', None, None), 149 | Line(8, 'h', None, None), 150 | Line(9, 'i', None, None), 151 | Line(10, 'j', None, None), 152 | Line(11, 'k', False, None), 153 | Line(12, 'l', None, None), 154 | Line(13, 'm', None, None), 155 | Line(14, 'n', None, None), 156 | ], 157 | ] 158 | 159 | 160 | def test_hunkify_coverage__2_distant_hunks_w_full_context_makes_2_hunk(): 161 | from pycobertura.utils import hunkify_lines 162 | from pycobertura.cobertura import Line 163 | 164 | lines = [ 165 | Line(1, 'a', None, None), # context 1 166 | Line(2, 'b', None, None), # context 1 167 | Line(3, 'c', None, None), # context 1 168 | Line(4, 'd', True, None), 169 | Line(5, 'e', None, None), # context 1 170 | Line(6, 'f', None, None), # context 1 171 | Line(7, 'g', None, None), # context 1 172 | Line(8, 'h', None, None), 173 | Line(9, 'i', None, None), # context 2 174 | Line(10, 'j', None, None), # context 2 175 | Line(11, 'k', None, None), # context 2 176 | Line(12, 'l', False, None), 177 | Line(13, 'm', None, None), # context 2 178 | Line(14, 'n', None, None), # context 2 179 | Line(15, 'o', None, None), # context 2 180 | ] 181 | 182 | hunks = hunkify_lines(lines) 183 | 184 | assert hunks == [ 185 | [ 186 | Line(1, 'a', None, None), 187 | Line(2, 'b', None, None), 188 | Line(3, 'c', None, None), 189 | Line(4, 'd', True, None), 190 | Line(5, 'e', None, None), 191 | Line(6, 'f', None, None), 192 | Line(7, 'g', None, None), 193 | ], [ 194 | Line(9, 'i', None, None), 195 | Line(10, 'j', None, None), 196 | Line(11, 'k', None, None), 197 | Line(12, 'l', False, None), 198 | Line(13, 'm', None, None), 199 | Line(14, 'n', None, None), 200 | Line(15, 'o', None, None), 201 | ], 202 | ] 203 | 204 | 205 | def test_hunkify_coverage__no_valid_hunks_found(): 206 | from pycobertura.utils import hunkify_lines 207 | from pycobertura.cobertura import Line 208 | 209 | lines = [ 210 | Line(1, 'a', None, None), 211 | Line(2, 'b', None, None), 212 | Line(3, 'c', None, None), 213 | Line(4, 'd', None, None), 214 | Line(5, 'e', None, None), 215 | Line(6, 'f', None, None), 216 | Line(7, 'g', None, None), 217 | Line(8, 'h', None, None), 218 | Line(9, 'i', None, None), 219 | ] 220 | 221 | hunks = hunkify_lines(lines) 222 | 223 | assert hunks == [] 224 | -------------------------------------------------------------------------------- /tests/test_filesystem.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from unittest.mock import patch, MagicMock 3 | import pytest 4 | 5 | 6 | FIRST_PYCOBERTURA_COMMIT_SHA = "d1fe88da6b18340762b24bb1f89067a3439c4041" 7 | 8 | 9 | def test_filesystem_directory__file_not_found(): 10 | from pycobertura.filesystem import DirectoryFileSystem 11 | 12 | fs = DirectoryFileSystem('foo/bar/baz') 13 | 14 | expected_filepaths = { 15 | 'Main.java': 'foo/bar/baz/Main.java', 16 | 'search/BinarySearch.java': 'foo/bar/baz/search/BinarySearch.java', 17 | 'search/ISortedArraySearch.java': 'foo/bar/baz/search/ISortedArraySearch.java', 18 | 'search/LinearSearch.java': 'foo/bar/baz/search/LinearSearch.java', 19 | } 20 | 21 | for filename in expected_filepaths: 22 | try: 23 | with fs.open(filename) as f: 24 | pass 25 | except DirectoryFileSystem.FileNotFound as fnf: 26 | assert fnf.path == expected_filepaths[filename] 27 | 28 | 29 | def test_filesystem_directory__returns_fileobject(): 30 | from pycobertura.filesystem import DirectoryFileSystem 31 | 32 | fs = DirectoryFileSystem('tests/dummy') 33 | 34 | expected_filepaths = { 35 | 'dummy/dummy.py': 'dummy/dummy/dummy.py', 36 | } 37 | 38 | for filename in expected_filepaths: 39 | with fs.open(filename) as f: 40 | assert hasattr(f, 'read') 41 | 42 | 43 | def test_filesystem_directory__with_source_prefix(): 44 | from pycobertura.filesystem import DirectoryFileSystem 45 | 46 | fs = DirectoryFileSystem( 47 | 'tests/', 48 | source_prefix='dummy' # should resolve to tests/dummy/ 49 | ) 50 | 51 | expected_filepaths = { 52 | 'dummy/dummy.py': 'dummy/dummy/dummy.py', 53 | } 54 | 55 | for filename in expected_filepaths: 56 | with fs.open(filename) as f: 57 | assert hasattr(f, 'read') 58 | 59 | 60 | def test_filesystem_zip__file_not_found(): 61 | from pycobertura.filesystem import ZipFileSystem 62 | 63 | fs = ZipFileSystem("tests/dummy/dummy.zip") 64 | 65 | dummy_source_file = 'foo/non-existent-file.py' 66 | try: 67 | with fs.open(dummy_source_file) as f: 68 | pass 69 | except ZipFileSystem.FileNotFound as fnf: 70 | assert fnf.path == dummy_source_file 71 | 72 | 73 | def test_filesystem_zip__returns_fileobject(): 74 | from pycobertura.filesystem import ZipFileSystem 75 | 76 | fs = ZipFileSystem("tests/dummy/dummy.zip") 77 | 78 | source_files_in_zip = [ 79 | 'dummy/dummy.py', 80 | 'dummy/dummy2.py', 81 | ] 82 | 83 | for source_file in source_files_in_zip: 84 | with fs.open(source_file) as f: 85 | assert hasattr(f, 'read') 86 | 87 | 88 | def test_filesystem_zip__with_source_prefix(): 89 | from pycobertura.filesystem import ZipFileSystem 90 | 91 | fs = ZipFileSystem( 92 | "tests/dummy/dummy-with-prefix.zip", # code zipped as dummy-with-prefix/dummy/dummy.py 93 | source_prefix="dummy-with-prefix", 94 | ) 95 | 96 | source_files_in_zip = [ 97 | 'dummy/dummy.py', 98 | 'dummy/dummy2.py', 99 | ] 100 | 101 | for source_file in source_files_in_zip: 102 | with fs.open(source_file) as f: 103 | assert hasattr(f, 'read') 104 | 105 | 106 | def test_filesystem_zip__read_str(): 107 | from pycobertura.filesystem import ZipFileSystem 108 | 109 | fs = ZipFileSystem("tests/dummy/dummy.zip") 110 | 111 | source_files_in_zip = [ 112 | 'dummy/dummy.py', 113 | 'dummy/dummy2.py', 114 | ] 115 | 116 | for source_file in source_files_in_zip: 117 | with fs.open(source_file) as f: 118 | assert isinstance(f.read(), str) 119 | 120 | 121 | def test_filesystem_git(): 122 | import pycobertura.filesystem as fsm 123 | 124 | branch, folder, filename = "master", "tests/dummy", "test-file" 125 | repo_root = "/tmp/repo" 126 | 127 | with patch.object(fsm, "subprocess") as subprocess_mock: 128 | # Mock for _get_root_path 129 | subprocess_mock.check_output.return_value = repo_root.encode('utf-8') 130 | 131 | # Mock for open 132 | mock_process = MagicMock() 133 | mock_process.communicate.return_value = ('some_hash blob 12\n'.encode(), b'') 134 | mock_process.wait.return_value = 0 135 | subprocess_mock.Popen.return_value = mock_process 136 | 137 | fs = fsm.GitFileSystem(folder, branch) 138 | 139 | with fs.open(filename) as f: 140 | assert hasattr(f, 'read') 141 | 142 | expected_git_filename = f"{branch}:{folder}/{filename}" 143 | git_filename = fs.real_filename(filename) 144 | assert git_filename == expected_git_filename 145 | 146 | expected_command = ["git", "cat-file", "--batch", "--follow-symlinks"] 147 | subprocess_mock.Popen.assert_called_with(expected_command, cwd=repo_root, stdin=subprocess_mock.PIPE, 148 | stdout=subprocess_mock.PIPE, stderr=subprocess_mock.PIPE) 149 | 150 | 151 | def test_filesystem_git_integration(): 152 | from pycobertura.filesystem import GitFileSystem 153 | 154 | fs = GitFileSystem(".", FIRST_PYCOBERTURA_COMMIT_SHA) 155 | 156 | # Files included in pycobertura's first commit. 157 | source_files = [ 158 | 'README.md', 159 | '.gitignore', 160 | ] 161 | 162 | for source_file in source_files: 163 | with fs.open(source_file) as f: 164 | assert hasattr(f, 'read') 165 | 166 | 167 | def test_filesystem_git_integration__not_found(): 168 | from pycobertura.filesystem import GitFileSystem 169 | 170 | fs = GitFileSystem(".", FIRST_PYCOBERTURA_COMMIT_SHA) 171 | 172 | dummy_source_file = "CHANGES.md" 173 | 174 | try: 175 | with fs.open(dummy_source_file) as f: 176 | pass 177 | except GitFileSystem.FileNotFound as fnf: 178 | assert fnf.path == fs.real_filename(dummy_source_file) 179 | 180 | 181 | def test_filesystem_git__git_not_found(): 182 | import pycobertura.filesystem as fsm 183 | 184 | branch, folder, filename = "master", "tests/dummy", "test-file" 185 | 186 | error = subprocess.CalledProcessError 187 | with patch.object(fsm, "subprocess") as subprocess_mock: 188 | subprocess_mock.check_output = MagicMock(side_effect=OSError) 189 | subprocess_mock.CalledProcessError = error 190 | 191 | try: 192 | fs = fsm.GitFileSystem(folder, branch) 193 | except ValueError as e: 194 | assert folder in str(e) 195 | 196 | 197 | def test_filesystem_git_has_file_integration(): 198 | from pycobertura.filesystem import GitFileSystem 199 | 200 | fs = GitFileSystem(".", FIRST_PYCOBERTURA_COMMIT_SHA) 201 | 202 | # Files included in pycobertura's first commit. 203 | source_files = [ 204 | "README.md", 205 | ".gitignore", 206 | ] 207 | 208 | for source_file in source_files: 209 | assert fs.has_file(source_file), source_file 210 | 211 | 212 | def test_filesystem_git_integration__not_found(): 213 | from pycobertura.filesystem import GitFileSystem 214 | 215 | fs = GitFileSystem(".", FIRST_PYCOBERTURA_COMMIT_SHA) 216 | 217 | dummy_source_file = "CHANGES.md" 218 | 219 | with pytest.raises(GitFileSystem.FileNotFound) as excinfo: 220 | with fs.open(dummy_source_file) as f: 221 | pass 222 | 223 | assert excinfo.value.path == fs.real_filename(dummy_source_file) 224 | 225 | 226 | def test_filesystem_git_has_file_integration__not_found(): 227 | from pycobertura.filesystem import GitFileSystem 228 | 229 | fs = GitFileSystem(".", FIRST_PYCOBERTURA_COMMIT_SHA) 230 | 231 | dummy_source_file = "CHANGES.md" 232 | 233 | assert not fs.has_file(dummy_source_file) 234 | 235 | 236 | def test_filesystem_factory(): 237 | from pycobertura.filesystem import filesystem_factory, GitFileSystem 238 | 239 | fs = filesystem_factory(source=".", ref=FIRST_PYCOBERTURA_COMMIT_SHA) 240 | assert isinstance(fs, GitFileSystem) 241 | -------------------------------------------------------------------------------- /tests/cobertura.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | C:/local/mvn-coverage-example/src/main/java 7 | --source 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | -------------------------------------------------------------------------------- /pycobertura/templates/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } -------------------------------------------------------------------------------- /pycobertura/utils.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | import os 3 | import re 4 | import fnmatch 5 | from functools import partial 6 | 7 | from typing import List, Tuple, Union 8 | 9 | try: 10 | from typing import Literal 11 | except ImportError: # pragma: no cover 12 | from typing_extensions import Literal 13 | 14 | ANSI_ESCAPE_CODES = { 15 | "green": "\x1b[32m", 16 | "red": "\x1b[31m", 17 | "reset": "\x1b[39m", 18 | } 19 | 20 | 21 | # Recipe from 22 | # https://github.com/ActiveState/code/blob/master/recipes/Python/577452_memoize_decorator_instance/recipe-577452.py 23 | class memoize: 24 | """cache the return value of a method 25 | 26 | This class is meant to be used as a decorator of methods. The return value 27 | from a given method invocation will be cached on the instance whose method 28 | was invoked. All arguments passed to a method decorated with memoize must 29 | be hashable. 30 | 31 | If a memoized method is invoked directly on its class the result will not 32 | be cached. Instead the method will be invoked like a static method: 33 | class Obj: 34 | @memoize 35 | def add_to(self, arg): 36 | return self + arg 37 | Obj.add_to(1) # not enough arguments 38 | Obj.add_to(1, 2) # returns 3, result is not cached 39 | """ 40 | 41 | def __init__(self, func): 42 | self.target_func = func 43 | 44 | def __get__(self, obj, objtype=None): 45 | if obj is None: 46 | return self.target_func 47 | return partial(self, obj) 48 | 49 | def __call__(self, *args, **kw): 50 | target_self = args[0] 51 | try: 52 | cache = target_self.__cache 53 | except AttributeError: 54 | cache = target_self.__cache = {} 55 | key = (self.target_func, args[1:], frozenset(kw.items())) 56 | try: 57 | res = cache[key] 58 | except KeyError: 59 | res = cache[key] = self.target_func(*args, **kw) 60 | return res 61 | 62 | 63 | def colorize(text, color): 64 | color_code = ANSI_ESCAPE_CODES[color] 65 | return f'{color_code}{text}{ANSI_ESCAPE_CODES["reset"]}' 66 | 67 | 68 | def red(text): 69 | return colorize(text, "red") 70 | 71 | 72 | def green(text): 73 | return colorize(text, "green") 74 | 75 | 76 | LineStatus = Literal["hit", "miss", "partial"] 77 | LineStatusTuple = Tuple[int, LineStatus] 78 | LineTupleWithStatusNone = Tuple[int, Union[LineStatus, None]] 79 | LineRangeWithStatusNone = Tuple[int, int, Union[LineStatus, None]] 80 | 81 | 82 | def rangify_by_status(line_statuses: List[LineTupleWithStatusNone]): 83 | """ 84 | Returns a list of range tuples that represent continuous segments by status, 85 | such as `(range_start, range_end, status)` given a list of sorted 86 | non-continuous integers `line_statuses` with their status. 87 | 88 | For example: [(1, "hit"), (2, "hit"), (3, "miss"), (4, "hit")] 89 | Would return: [(1, 2, "hit"), (3, 3, "miss"), (4, 4, "hit")] 90 | """ 91 | ranges: List[LineRangeWithStatusNone] = [] 92 | if not line_statuses: 93 | return ranges 94 | 95 | range_start, *_ = prev_num, prev_status = line_statuses[0] 96 | for num, status in line_statuses[1:]: 97 | if num != (prev_num + 1) or status != prev_status: 98 | ranges.append((range_start, prev_num, prev_status)) 99 | range_start = num 100 | prev_num = num 101 | prev_status = status 102 | 103 | ranges.append((range_start, prev_num, prev_status)) 104 | return ranges 105 | 106 | 107 | def stringify(line_statuses): 108 | """Assumes the list is sorted.""" 109 | rangified_list = rangify_by_status(line_statuses) 110 | 111 | stringified_list = [] 112 | for line_start, line_stop, status in rangified_list: 113 | prefix = "~" if status == "partial" else "" 114 | if line_start == line_stop: 115 | stringified = f"{prefix}{line_start}" 116 | else: 117 | stringified = f"{prefix}{line_start}-{line_stop}" 118 | stringified_list.append(stringified) 119 | 120 | return ", ".join(stringified_list) 121 | 122 | 123 | def extrapolate_coverage(lines_w_status): 124 | """ 125 | Given the following input: 126 | 127 | >>> lines_w_status = [ 128 | (1, "hit"), 129 | (4, "hit"), 130 | (7, "miss"), 131 | (9, "miss"), 132 | ] 133 | 134 | Return expanded lines with their extrapolated line status. 135 | 136 | >>> extrapolate_coverage(lines_w_status) == [ 137 | (1, "hit"), 138 | (2, "hit"), 139 | (3, "hit"), 140 | (4, "hit"), 141 | (5, None), 142 | (6, None), 143 | (7, "miss"), 144 | (8, "miss"), 145 | (9, "miss"), 146 | ] 147 | 148 | """ 149 | lines: List[LineTupleWithStatusNone] = [] 150 | 151 | prev_lineno = 0 152 | prev_status = "hit" 153 | for lineno, status in lines_w_status: 154 | while (lineno - prev_lineno) > 1: 155 | prev_lineno += 1 156 | if prev_status == status: 157 | lines.append((prev_lineno, status)) 158 | else: 159 | lines.append((prev_lineno, None)) 160 | lines.append((lineno, status)) 161 | prev_lineno = lineno 162 | prev_status = status 163 | 164 | return lines 165 | 166 | 167 | def reconcile_lines(lines1, lines2): 168 | """ 169 | Return a dict `{lineno1: lineno2}` which reconciles line numbers `lineno1` 170 | of list `lines1` to line numbers `lineno2` of list `lines2`. Only lines 171 | that are common in both sets are present in the dict, lines unique to one 172 | of the sets are omitted. 173 | """ 174 | differ = difflib.Differ() 175 | diff = differ.compare(lines1, lines2) 176 | 177 | SAME = " " 178 | ADDED = "+ " 179 | REMOVED = "- " 180 | INFO = "? " 181 | 182 | lineno_map = {} # {lineno1: lineno2, ...} 183 | lineno1_offset = 0 184 | lineno2 = 1 185 | 186 | for diffline in diff: 187 | if diffline.startswith(INFO): 188 | continue 189 | 190 | if diffline.startswith(SAME): 191 | lineno1 = lineno2 + lineno1_offset 192 | lineno_map[lineno1] = lineno2 193 | 194 | elif diffline.startswith(ADDED): 195 | lineno1_offset -= 1 196 | 197 | elif diffline.startswith(REMOVED): 198 | lineno1_offset += 1 199 | continue 200 | 201 | lineno2 += 1 202 | 203 | return lineno_map 204 | 205 | 206 | def hunkify_lines(lines, context=3): 207 | """ 208 | Return a list of line hunks given a list of lines `lines`. The number of 209 | context lines can be control with `context` which will return line hunks 210 | surrounded with `context` lines before and after the code change. 211 | """ 212 | # Find contiguous line changes 213 | ranges = [] 214 | range_start = None 215 | for i, line in enumerate(lines): 216 | if line.status is not None: 217 | if range_start is None: 218 | range_start = i 219 | continue 220 | elif range_start is not None: 221 | range_stop = i 222 | ranges.append((range_start, range_stop)) 223 | range_start = None 224 | else: 225 | # Append the last range 226 | if range_start is not None: 227 | range_stop = i 228 | ranges.append((range_start, range_stop)) 229 | 230 | # add context 231 | ranges_w_context = [] 232 | for range_start, range_stop in ranges: 233 | range_start = range_start - context 234 | range_start = range_start if range_start >= 0 else 0 235 | range_stop = range_stop + context 236 | ranges_w_context.append((range_start, range_stop)) 237 | 238 | # merge overlapping hunks 239 | merged_ranges = ranges_w_context[:1] 240 | for range_start, range_stop in ranges_w_context[1:]: 241 | prev_start, prev_stop = merged_ranges[-1] 242 | if range_start <= prev_stop: 243 | range_start = prev_start 244 | merged_ranges[-1] = (range_start, range_stop) 245 | else: 246 | merged_ranges.append((range_start, range_stop)) 247 | 248 | # build final hunks 249 | hunks = [] 250 | for range_start, range_stop in merged_ranges: 251 | hunk = lines[range_start:range_stop] 252 | hunks.append(hunk) 253 | 254 | return hunks 255 | 256 | 257 | def get_dir_from_file_path(file_path): 258 | return os.path.dirname(file_path) or "." 259 | 260 | 261 | def get_non_empty_non_commented_lines_from_file_in_ascii(file_path, comment_character): 262 | with open(file_path, "rb") as f: # read in binary (more secure) 263 | result = [ 264 | line.decode("ascii").strip() 265 | for line in f.readlines() 266 | if not line.decode("ascii").startswith(comment_character) 267 | ] 268 | return [res for res in result if res != ""] 269 | 270 | 271 | def get_filenames_that_do_not_match_regex( 272 | filenames, regex_param, comment_character="#" 273 | ): 274 | if os.path.isfile(regex_param): 275 | ignore_patterns = get_non_empty_non_commented_lines_from_file_in_ascii( 276 | regex_param, comment_character 277 | ) 278 | remove_filenames = [ 279 | filename 280 | for igp in ignore_patterns 281 | for filename in fnmatch.filter(filenames, igp) 282 | ] 283 | else: 284 | remove_filenames = list(filter(re.compile(regex_param).match, filenames)) 285 | return [fname for fname in filenames if fname not in remove_filenames] 286 | 287 | 288 | def get_line_status(line): 289 | """ 290 | Returns the line status as "hit", "miss", or "partial". Line is an XML 291 | Element from a Cobertura report of type `line`. 292 | """ 293 | condition = line.get("condition-coverage") 294 | status: LineStatus 295 | if condition: 296 | if condition.startswith("100%"): 297 | status = "hit" 298 | elif condition.startswith("0%"): 299 | status = "miss" 300 | else: 301 | status = "partial" 302 | else: 303 | status: LineStatus = "miss" if line.get("hits") == "0" else "hit" 304 | 305 | return status 306 | 307 | 308 | def calculate_line_rate(total_statements: int, total_misses: int): 309 | return ( 310 | (total_statements - total_misses) / total_statements if total_statements else 1 311 | ) 312 | -------------------------------------------------------------------------------- /pycobertura/filesystem.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import io 4 | import zipfile 5 | import subprocess 6 | 7 | from contextlib import contextmanager 8 | 9 | 10 | class FileSystem: 11 | class FileNotFound(Exception): 12 | def __init__(self, path): 13 | super(self.__class__, self).__init__(path) 14 | self.path = path 15 | 16 | 17 | class DirectoryFileSystem(FileSystem): 18 | def __init__(self, source_dir, source_prefix=None): 19 | self.source_dir = source_dir 20 | self.source_prefix = source_prefix 21 | 22 | def real_filename(self, filename): 23 | if self.source_prefix is not None: 24 | filename = os.path.join(self.source_prefix, filename) 25 | return os.path.join(self.source_dir, filename) 26 | 27 | def has_file(self, filename): 28 | # FIXME: make this O(1) 29 | filename = self.real_filename(filename) 30 | return os.path.isfile(filename) 31 | 32 | @contextmanager 33 | def open(self, filename): 34 | """ 35 | Yield a file-like object for file `filename`. 36 | 37 | This function is a context manager. 38 | """ 39 | filename = self.real_filename(filename) 40 | 41 | if not os.path.exists(filename): 42 | raise self.FileNotFound(filename) 43 | 44 | with codecs.open(filename, encoding="utf-8") as f: 45 | yield f 46 | 47 | 48 | class ZipFileSystem(FileSystem): 49 | def __init__(self, zip_file, source_prefix=None): 50 | self.zipfile = zipfile.ZipFile(zip_file) 51 | self.source_prefix = source_prefix 52 | 53 | def real_filename(self, filename): 54 | if self.source_prefix is not None: 55 | filename = os.path.join(self.source_prefix, filename) 56 | 57 | return filename 58 | 59 | def has_file(self, filename): 60 | # FIXME: make this O(1) 61 | return self.real_filename(filename) in self.zipfile.namelist() 62 | 63 | @contextmanager 64 | def open(self, filename): 65 | filename = self.real_filename(filename) 66 | 67 | try: 68 | with self.zipfile.open(filename) as f: 69 | with io.TextIOWrapper(f, encoding="utf-8") as t: 70 | yield t 71 | except KeyError: 72 | raise self.FileNotFound(filename) 73 | 74 | 75 | class GitFileSystem(FileSystem): 76 | def __init__(self, repo_folder, ref): 77 | self.repository = repo_folder 78 | self.ref = ref 79 | self.repository_root = self._get_root_path(repo_folder) 80 | # the report may have been collected in a subfolder of the repository 81 | # root. Each file path shall thus be completed by the prefix. 82 | self.prefix = self.repository.replace(self.repository_root, "").lstrip("/") 83 | # Cache submodule paths and commit SHAs for the provided ref 84 | self._submodules = self._discover_submodules() 85 | 86 | def _git_cat_file_check(self, repo_root, spec): 87 | """ 88 | Call `git cat-file --batch-check --follow-symlinks` 89 | and return existence as bool. 90 | """ 91 | args = ["git", "cat-file", "--batch-check", "--follow-symlinks"] 92 | input_data = f"{spec}\n".encode() 93 | try: 94 | process = subprocess.Popen( 95 | args, 96 | cwd=repo_root, 97 | stdin=subprocess.PIPE, 98 | stdout=subprocess.PIPE, 99 | stderr=subprocess.PIPE, 100 | ) 101 | output, _ = process.communicate(input=input_data) 102 | return_code = process.wait() 103 | except (OSError, subprocess.CalledProcessError): 104 | return False 105 | return return_code == 0 and not output.endswith(b"missing\n") 106 | 107 | def _git_cat_file_read(self, repo_root, spec): 108 | """ 109 | Call `git cat-file --batch --follow-symlinks` and return blob content as bytes. 110 | Raises FileNotFound if the object is missing or on error. 111 | """ 112 | args = ["git", "cat-file", "--batch", "--follow-symlinks"] 113 | input_data = f"{spec}\n".encode() 114 | try: 115 | process = subprocess.Popen( 116 | args, 117 | cwd=repo_root, 118 | stdin=subprocess.PIPE, 119 | stdout=subprocess.PIPE, 120 | stderr=subprocess.PIPE, 121 | ) 122 | output, _ = process.communicate(input=input_data) 123 | return_code = process.wait() 124 | except (OSError, subprocess.CalledProcessError): 125 | raise self.FileNotFound(spec) 126 | 127 | if return_code != 0 or output.endswith(b"missing\n"): 128 | raise self.FileNotFound(spec) 129 | lines = output.split(b"\n", 1) 130 | if len(lines) < 2: 131 | raise self.FileNotFound(spec) 132 | return lines[1] 133 | 134 | def real_filename(self, filename): 135 | """ 136 | Constructs the Git path for a given filename. 137 | This method should NOT resolve symlinks on the local disk. 138 | """ 139 | prefix = f"{self.prefix}/" if self.prefix else "" 140 | return f"{self.ref}:{prefix}{filename}" 141 | 142 | def has_file(self, filename): 143 | """ 144 | Check for a file's existence in the specified commit's tree. 145 | """ 146 | # If the file is within a submodule, query the submodule repository 147 | submodule_ctx = self._resolve_submodule_ctx(filename) 148 | if submodule_ctx is not None: 149 | submodule_root, sub_commit, rel_path = submodule_ctx 150 | return self._git_cat_file_check(submodule_root, f"{sub_commit}:{rel_path}") 151 | 152 | real_filename = self.real_filename(filename) 153 | return self._git_cat_file_check(self.repository_root, real_filename) 154 | 155 | def _get_root_path(self, repository_folder): 156 | command = ["git", "rev-parse", "--show-toplevel"] 157 | try: 158 | output = subprocess.check_output(command, cwd=repository_folder) 159 | except (OSError, subprocess.CalledProcessError): 160 | raise ValueError( 161 | f"The folder {repository_folder} is not a valid git repository." 162 | ) 163 | return output.decode("utf-8").rstrip() 164 | 165 | @contextmanager 166 | def open(self, filename): 167 | """ 168 | Yield a file-like object for the given filename, 169 | following symlinks if necessary. 170 | 171 | This function is a context manager. 172 | """ 173 | # If the file is within a submodule, 174 | # read from the submodule repository at the pinned commit 175 | submodule_ctx = self._resolve_submodule_ctx(filename) 176 | if submodule_ctx is not None: 177 | submodule_root, sub_commit, rel_path = submodule_ctx 178 | content = self._git_cat_file_read( 179 | submodule_root, f"{sub_commit}:{rel_path}" 180 | ) 181 | yield io.StringIO(content.decode("utf-8")) 182 | return 183 | 184 | real_filename = self.real_filename(filename) 185 | content = self._git_cat_file_read(self.repository_root, real_filename) 186 | yield io.StringIO(content.decode("utf-8")) 187 | 188 | def _discover_submodules(self): 189 | """ 190 | Discover submodule paths and SHAs for the given ref by inspecting the tree. 191 | Returns a mapping of submodule path -> commit SHA. 192 | """ 193 | try: 194 | output = subprocess.check_output( 195 | [ 196 | "git", 197 | "ls-tree", 198 | "-r", 199 | "--full-tree", 200 | self.ref, 201 | ], 202 | cwd=self.repository_root, 203 | ) 204 | except (OSError, subprocess.CalledProcessError): 205 | return {} 206 | 207 | submodules = {} 208 | for line in output.decode("utf-8", errors="replace").splitlines(): 209 | # Expected format: "160000 commit \t" 210 | try: 211 | meta, path = line.split("\t", 1) 212 | except ValueError: 213 | continue 214 | parts = meta.split() 215 | if len(parts) < 3: 216 | continue 217 | mode, obj_type, sha = parts[0], parts[1], parts[2] 218 | if mode == "160000" and obj_type == "commit": 219 | submodules[path] = sha 220 | return submodules 221 | 222 | def _resolve_submodule_ctx(self, filename): 223 | """ 224 | If the path points into a submodule, return a tuple of 225 | (submodule_root_abs_path, submodule_commit_sha, relative_path_inside_submodule). 226 | Otherwise, return None. 227 | """ 228 | # Find the longest matching submodule path that prefixes the filename 229 | matching = [ 230 | p 231 | for p in self._submodules.keys() 232 | if filename == p or filename.startswith(p + "/") 233 | ] 234 | if not matching: 235 | return None 236 | # Use the longest (deepest) match in case of nested submodules 237 | sub_path = max(matching, key=len) 238 | sub_sha = self._submodules.get(sub_path) 239 | if not sub_sha: 240 | return None 241 | 242 | rel_path = filename.removeprefix(sub_path).lstrip("/") 243 | submodule_root = os.path.join(self.repository_root, sub_path) 244 | if not os.path.isdir(submodule_root): 245 | # Submodule not checked out; 246 | # we cannot read without local checkout of objects 247 | return None 248 | return (submodule_root, sub_sha, rel_path) 249 | 250 | 251 | def filesystem_factory(source, source_prefix=None, ref=None): 252 | """ 253 | The argument `source` is the location of the source code provided as a 254 | directory path or a file object zip archive containing the source code. 255 | 256 | The optional argument `source` is the location of the source code provided 257 | as a directory path or a file object zip archive containing the source code. 258 | 259 | The optional argument `ref` will be taken into account when instantiating a 260 | GitFileSystem, and it shall be a branch name, a commit ID or a git ref ID. 261 | """ 262 | if zipfile.is_zipfile(source): 263 | return ZipFileSystem(source, source_prefix=source_prefix) 264 | 265 | if ref: 266 | return GitFileSystem(source, ref) 267 | 268 | return DirectoryFileSystem(source, source_prefix=source_prefix) 269 | -------------------------------------------------------------------------------- /pycobertura/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from pycobertura.cobertura import Cobertura, CoberturaDiff 4 | from pycobertura.reporters import ( 5 | GitHubAnnotationReporter, 6 | HtmlReporter, 7 | TextReporter, 8 | CsvReporter, 9 | MarkdownReporter, 10 | JsonReporter, 11 | YamlReporter, 12 | HtmlReporterDelta, 13 | TextReporterDelta, 14 | CsvReporterDelta, 15 | MarkdownReporterDelta, 16 | JsonReporterDelta, 17 | YamlReporterDelta, 18 | GitHubAnnotationReporterDelta, 19 | ) 20 | from pycobertura.filesystem import filesystem_factory 21 | from pycobertura.utils import get_dir_from_file_path 22 | 23 | pycobertura = click.Group() 24 | 25 | 26 | reporters = { 27 | "html": HtmlReporter, 28 | "text": TextReporter, 29 | "csv": CsvReporter, 30 | "markdown": MarkdownReporter, 31 | "json": JsonReporter, 32 | "yaml": YamlReporter, 33 | "github-annotation": GitHubAnnotationReporter, 34 | } 35 | 36 | 37 | class ExitCodes: 38 | OK = 0 39 | EXCEPTION = 1 40 | COVERAGE_WORSENED = 2 41 | NOT_ALL_CHANGES_COVERED = 3 42 | 43 | 44 | def get_exit_code(differ: CoberturaDiff, source): 45 | # Compute the non-zero exit code. This is a 2-step process which involves 46 | # checking whether code coverage is any better first and then check if all 47 | # changes are covered (stricter) which can only be done if the source code 48 | # is available (and enabled via the --source option). 49 | 50 | if not differ.has_better_coverage(): 51 | return ExitCodes.COVERAGE_WORSENED 52 | 53 | if source: 54 | if differ.has_all_changes_covered(): 55 | return ExitCodes.OK 56 | else: 57 | return ExitCodes.NOT_ALL_CHANGES_COVERED 58 | else: 59 | return ExitCodes.OK 60 | 61 | 62 | @pycobertura.command() 63 | @click.argument("cobertura_file") 64 | @click.option( 65 | "--ignore-regex", 66 | default=None, 67 | type=str, 68 | help="Regex for which files to ignore in output\n\t", 69 | ) 70 | @click.option("-f", "--format", default="text", type=click.Choice(list(reporters))) 71 | @click.option( 72 | "-delim", 73 | "--delimiter", 74 | default=";", 75 | type=str, 76 | help="Delimiter for csv format, e.g. ,;\n\t", 77 | ) 78 | @click.option( 79 | "--annotation-title", 80 | default="pycobertura", 81 | type=str, 82 | help="annotation title for github annotation format", 83 | ) 84 | @click.option( 85 | "--annotation-level", 86 | default="notice", 87 | type=str, 88 | help="annotation level for github annotation format", 89 | ) 90 | @click.option( 91 | "--annotation-message", 92 | default="not covered", 93 | type=str, 94 | help="annotation message for github annotation format", 95 | ) 96 | @click.option( 97 | "-o", 98 | "--output", 99 | metavar="", 100 | type=click.File("wb"), 101 | help="Write output to instead of stdout.", 102 | ) 103 | @click.option( 104 | "-s", 105 | "--source", 106 | metavar="", 107 | help="Provide path to source code directory for HTML output. The path can " 108 | "also be a zip archive instead of a directory.", 109 | ) 110 | @click.option( 111 | "-p", 112 | "--source-prefix", 113 | metavar="", 114 | help="For every file found in the coverage report, it will use this " 115 | "prefix to lookup files on disk. This is especially useful when " 116 | "the --source is a zip archive and the files were zipped under " 117 | "a directory prefix that is not part of the source.", 118 | ) 119 | def show( 120 | cobertura_file, 121 | ignore_regex, 122 | format, 123 | delimiter, 124 | output, 125 | source, 126 | source_prefix, 127 | annotation_level, 128 | annotation_title, 129 | annotation_message, 130 | ): 131 | """show coverage summary of a Cobertura report""" 132 | 133 | if not source: 134 | source = get_dir_from_file_path(cobertura_file) 135 | 136 | cobertura = Cobertura( 137 | cobertura_file, 138 | filesystem=filesystem_factory(source, source_prefix=source_prefix), 139 | ) 140 | Reporter = reporters[format] 141 | reporter = Reporter(cobertura, ignore_regex) 142 | 143 | if format == "csv": 144 | report = reporter.generate(delimiter) 145 | elif format == "github-annotation": 146 | report = reporter.generate( 147 | annotation_level=annotation_level, 148 | annotation_title=annotation_title, 149 | annotation_message=annotation_message, 150 | ) 151 | else: 152 | report = reporter.generate() 153 | 154 | if not isinstance(report, bytes): 155 | report = report.encode("utf-8") 156 | 157 | isatty = True if output is None else output.isatty() 158 | click.echo(report, file=output, nl=isatty) 159 | 160 | 161 | delta_reporters = { 162 | "text": TextReporterDelta, 163 | "csv": CsvReporterDelta, 164 | "markdown": MarkdownReporterDelta, 165 | "html": HtmlReporterDelta, 166 | "json": JsonReporterDelta, 167 | "yaml": YamlReporterDelta, 168 | "github-annotation": GitHubAnnotationReporterDelta, 169 | } 170 | 171 | 172 | @pycobertura.command( 173 | help="""\ 174 | The diff command compares and shows the changes between two Cobertura reports. 175 | 176 | NOTE: Reporting missing lines or showing the source code with the diff command 177 | can only be accurately computed if the versions of the source code used to 178 | generate each of the coverage reports is accessible. By default, the source 179 | will read from the Cobertura report and resolved relatively from the report's 180 | location. If the source is not accessible from the report's location, the 181 | options `--source1` and `--source2` are necessary to point to the source code 182 | directories (or zip archives). If the source is not available at all, pass 183 | `--no-source` but missing lines and source code will not be reported. 184 | """ 185 | ) 186 | @click.argument("cobertura_file1") 187 | @click.argument("cobertura_file2") 188 | @click.option( 189 | "--ignore-regex", 190 | default=None, 191 | type=str, 192 | help="Regex for which files to ignore in output\n\t", 193 | ) 194 | @click.option( 195 | "-delim", 196 | "--delimiter", 197 | default=";", 198 | type=str, 199 | help="Delimiter for csv format, e.g. ,;\n\t", 200 | ) 201 | @click.option( 202 | "--color/--no-color", 203 | default=None, 204 | help="Colorize the output. By default, pycobertura emits color codes only " 205 | "when standard output is connected to a terminal. This has no effect " 206 | "with the HTML output format.", 207 | ) 208 | @click.option( 209 | "-f", "--format", default="text", type=click.Choice(list(delta_reporters)) 210 | ) 211 | @click.option( 212 | "-o", 213 | "--output", 214 | metavar="", 215 | type=click.File("wb"), 216 | help="Write output to instead of stdout.", 217 | ) 218 | @click.option( 219 | "-s1", 220 | "--source1", 221 | metavar="", 222 | help="Provide path to source code directory or zip archive of first " 223 | "Cobertura report. This is necessary if the filename path defined " 224 | "in the report is not accessible from the location of the report.", 225 | ) 226 | @click.option( 227 | "-s2", 228 | "--source2", 229 | metavar="", 230 | help="Like --source1 but for the second coverage report of the diff.", 231 | ) 232 | @click.option( 233 | "-p1", 234 | "--source-prefix1", 235 | metavar="", 236 | help="For every file found in the coverage report, it will use this " 237 | "prefix to lookup files on disk. This is especially useful when " 238 | "the --source1 is a zip archive and the files were zipped under " 239 | "a directory prefix that is not part of the source", 240 | ) 241 | @click.option( 242 | "-p2", 243 | "--source-prefix2", 244 | metavar="", 245 | help="Like --source-prefix1, but for applies for --source2.", 246 | ) 247 | @click.option( 248 | "--source/--no-source", 249 | default=True, 250 | help="Show missing lines and source code. When enabled (default), this " 251 | "option requires access to the source code that was used to generate " 252 | "both Cobertura reports (see --source1 and --source2). When " 253 | "`--no-source` is passed, missing lines and the source code will " 254 | "not be displayed.", 255 | ) 256 | @click.option( 257 | "--annotation-title", 258 | default="pycobertura", 259 | type=str, 260 | help="annotation title for github annotation format", 261 | ) 262 | @click.option( 263 | "--annotation-level", 264 | default="notice", 265 | type=str, 266 | help="annotation level for github annotation format", 267 | ) 268 | @click.option( 269 | "--annotation-message", 270 | default="not covered", 271 | type=str, 272 | help="annotation message for github annotation format", 273 | ) 274 | def diff( 275 | cobertura_file1, 276 | cobertura_file2, 277 | ignore_regex, 278 | delimiter, 279 | color, 280 | format, 281 | output, 282 | source1, 283 | source2, 284 | source_prefix1, 285 | source_prefix2, 286 | source, 287 | annotation_level, 288 | annotation_title, 289 | annotation_message, 290 | ): 291 | """compare coverage of two Cobertura reports""" 292 | # Assume that the source is located in the same directory as the provided 293 | # coverage files if no source directories are provided. 294 | if not source1: 295 | source1 = get_dir_from_file_path(cobertura_file1) 296 | 297 | if not source2: 298 | source2 = get_dir_from_file_path(cobertura_file2) 299 | 300 | filesystem1 = filesystem_factory(source1, source_prefix=source_prefix1) 301 | cobertura1 = Cobertura(cobertura_file1, filesystem=filesystem1) 302 | 303 | filesystem2 = filesystem_factory(source2, source_prefix=source_prefix2) 304 | cobertura2 = Cobertura(cobertura_file2, filesystem=filesystem2) 305 | 306 | Reporter = delta_reporters[format] 307 | reporter_args = [cobertura1, cobertura2, ignore_regex] 308 | reporter_kwargs = {"show_source": source} 309 | 310 | isatty = True if output is None else output.isatty() 311 | 312 | if format in {"text", "csv", "json", "markdown", "yaml"}: 313 | color = isatty if color is None else color is True 314 | reporter_kwargs["color"] = color 315 | 316 | reporter = Reporter(*reporter_args, **reporter_kwargs) 317 | if format == "csv": 318 | report = reporter.generate(delimiter) 319 | elif format == "github-annotation": 320 | report = reporter.generate( 321 | annotation_level=annotation_level, 322 | annotation_message=annotation_message, 323 | annotation_title=annotation_title, 324 | ) 325 | else: 326 | report = reporter.generate() 327 | 328 | if not isinstance(report, bytes): 329 | report = report.encode("utf-8") 330 | 331 | click.echo(report, file=output, nl=isatty, color=color) 332 | 333 | exit_code = get_exit_code(reporter.differ, source) 334 | raise SystemExit(exit_code) 335 | -------------------------------------------------------------------------------- /pycobertura/templates/skeleton.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V2.0.4 3 | * Copyright 2014, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 12/29/2014 8 | */ 9 | 10 | 11 | /* Table of contents 12 | –––––––––––––––––––––––––––––––––––––––––––––––––– 13 | - Grid 14 | - Base Styles 15 | - Typography 16 | - Links 17 | - Buttons 18 | - Forms 19 | - Lists 20 | - Code 21 | - Tables 22 | - Spacing 23 | - Utilities 24 | - Clearing 25 | - Media Queries 26 | */ 27 | 28 | 29 | /* Grid 30 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 | .container { 32 | position: relative; 33 | width: 100%; 34 | max-width: 960px; 35 | margin: 0 auto; 36 | padding: 0 20px; 37 | box-sizing: border-box; } 38 | .column, 39 | .columns { 40 | width: 100%; 41 | float: left; 42 | box-sizing: border-box; } 43 | 44 | /* For devices larger than 400px */ 45 | @media (min-width: 400px) { 46 | .container { 47 | width: 85%; 48 | padding: 0; } 49 | } 50 | 51 | /* For devices larger than 550px */ 52 | @media (min-width: 550px) { 53 | .container { 54 | width: 80%; } 55 | .column, 56 | .columns { 57 | margin-left: 4%; } 58 | .column:first-child, 59 | .columns:first-child { 60 | margin-left: 0; } 61 | 62 | .one.column, 63 | .one.columns { width: 4.66666666667%; } 64 | .two.columns { width: 13.3333333333%; } 65 | .three.columns { width: 22%; } 66 | .four.columns { width: 30.6666666667%; } 67 | .five.columns { width: 39.3333333333%; } 68 | .six.columns { width: 48%; } 69 | .seven.columns { width: 56.6666666667%; } 70 | .eight.columns { width: 65.3333333333%; } 71 | .nine.columns { width: 74.0%; } 72 | .ten.columns { width: 82.6666666667%; } 73 | .eleven.columns { width: 91.3333333333%; } 74 | .twelve.columns { width: 100%; margin-left: 0; } 75 | 76 | .one-third.column { width: 30.6666666667%; } 77 | .two-thirds.column { width: 65.3333333333%; } 78 | 79 | .one-half.column { width: 48%; } 80 | 81 | /* Offsets */ 82 | .offset-by-one.column, 83 | .offset-by-one.columns { margin-left: 8.66666666667%; } 84 | .offset-by-two.column, 85 | .offset-by-two.columns { margin-left: 17.3333333333%; } 86 | .offset-by-three.column, 87 | .offset-by-three.columns { margin-left: 26%; } 88 | .offset-by-four.column, 89 | .offset-by-four.columns { margin-left: 34.6666666667%; } 90 | .offset-by-five.column, 91 | .offset-by-five.columns { margin-left: 43.3333333333%; } 92 | .offset-by-six.column, 93 | .offset-by-six.columns { margin-left: 52%; } 94 | .offset-by-seven.column, 95 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 | .offset-by-eight.column, 97 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 | .offset-by-nine.column, 99 | .offset-by-nine.columns { margin-left: 78.0%; } 100 | .offset-by-ten.column, 101 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 | .offset-by-eleven.column, 103 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 | 105 | .offset-by-one-third.column, 106 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 | .offset-by-two-thirds.column, 108 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 | 110 | .offset-by-one-half.column, 111 | .offset-by-one-half.columns { margin-left: 52%; } 112 | 113 | } 114 | 115 | 116 | /* Base Styles 117 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 | /* NOTE 119 | html is set to 62.5% so that all the REM measurements throughout Skeleton 120 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 | html { 122 | font-size: 62.5%; } 123 | body { 124 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 | line-height: 1.6; 126 | font-weight: 400; 127 | font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 128 | color: #222; } 129 | 130 | 131 | /* Typography 132 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 | h1, h2, h3, h4, h5, h6 { 134 | margin-top: 0; 135 | margin-bottom: 2rem; 136 | font-weight: 300; } 137 | h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 | h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 | h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 | h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 | 144 | /* Larger than phablet */ 145 | @media (min-width: 550px) { 146 | h1 { font-size: 5.0rem; } 147 | h2 { font-size: 4.2rem; } 148 | h3 { font-size: 3.6rem; } 149 | h4 { font-size: 3.0rem; } 150 | h5 { font-size: 2.4rem; } 151 | h6 { font-size: 1.5rem; } 152 | } 153 | 154 | p { 155 | margin-top: 0; } 156 | 157 | 158 | /* Links 159 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 | a { 161 | color: #1EAEDB; } 162 | a:hover { 163 | color: #0FA0CE; } 164 | 165 | 166 | /* Buttons 167 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 | .button, 169 | button, 170 | input[type="submit"], 171 | input[type="reset"], 172 | input[type="button"] { 173 | display: inline-block; 174 | height: 38px; 175 | padding: 0 30px; 176 | color: #555; 177 | text-align: center; 178 | font-size: 11px; 179 | font-weight: 600; 180 | line-height: 38px; 181 | letter-spacing: .1rem; 182 | text-transform: uppercase; 183 | text-decoration: none; 184 | white-space: nowrap; 185 | background-color: transparent; 186 | border-radius: 4px; 187 | border: 1px solid #bbb; 188 | cursor: pointer; 189 | box-sizing: border-box; } 190 | .button:hover, 191 | button:hover, 192 | input[type="submit"]:hover, 193 | input[type="reset"]:hover, 194 | input[type="button"]:hover, 195 | .button:focus, 196 | button:focus, 197 | input[type="submit"]:focus, 198 | input[type="reset"]:focus, 199 | input[type="button"]:focus { 200 | color: #333; 201 | border-color: #888; 202 | outline: 0; } 203 | .button.button-primary, 204 | button.button-primary, 205 | input[type="submit"].button-primary, 206 | input[type="reset"].button-primary, 207 | input[type="button"].button-primary { 208 | color: #FFF; 209 | background-color: #33C3F0; 210 | border-color: #33C3F0; } 211 | .button.button-primary:hover, 212 | button.button-primary:hover, 213 | input[type="submit"].button-primary:hover, 214 | input[type="reset"].button-primary:hover, 215 | input[type="button"].button-primary:hover, 216 | .button.button-primary:focus, 217 | button.button-primary:focus, 218 | input[type="submit"].button-primary:focus, 219 | input[type="reset"].button-primary:focus, 220 | input[type="button"].button-primary:focus { 221 | color: #FFF; 222 | background-color: #1EAEDB; 223 | border-color: #1EAEDB; } 224 | 225 | 226 | /* Forms 227 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 | input[type="email"], 229 | input[type="number"], 230 | input[type="search"], 231 | input[type="text"], 232 | input[type="tel"], 233 | input[type="url"], 234 | input[type="password"], 235 | textarea, 236 | select { 237 | height: 38px; 238 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 | background-color: #fff; 240 | border: 1px solid #D1D1D1; 241 | border-radius: 4px; 242 | box-shadow: none; 243 | box-sizing: border-box; } 244 | /* Removes awkward default styles on some inputs for iOS */ 245 | input[type="email"], 246 | input[type="number"], 247 | input[type="search"], 248 | input[type="text"], 249 | input[type="tel"], 250 | input[type="url"], 251 | input[type="password"], 252 | textarea { 253 | -webkit-appearance: none; 254 | -moz-appearance: none; 255 | appearance: none; } 256 | textarea { 257 | min-height: 65px; 258 | padding-top: 6px; 259 | padding-bottom: 6px; } 260 | input[type="email"]:focus, 261 | input[type="number"]:focus, 262 | input[type="search"]:focus, 263 | input[type="text"]:focus, 264 | input[type="tel"]:focus, 265 | input[type="url"]:focus, 266 | input[type="password"]:focus, 267 | textarea:focus, 268 | select:focus { 269 | border: 1px solid #33C3F0; 270 | outline: 0; } 271 | label, 272 | legend { 273 | display: block; 274 | margin-bottom: .5rem; 275 | font-weight: 600; } 276 | fieldset { 277 | padding: 0; 278 | border-width: 0; } 279 | input[type="checkbox"], 280 | input[type="radio"] { 281 | display: inline; } 282 | label > .label-body { 283 | display: inline-block; 284 | margin-left: .5rem; 285 | font-weight: normal; } 286 | 287 | 288 | /* Lists 289 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 | ul { 291 | list-style: circle inside; } 292 | ol { 293 | list-style: decimal inside; } 294 | ol, ul { 295 | padding-left: 0; 296 | margin-top: 0; } 297 | ul ul, 298 | ul ol, 299 | ol ol, 300 | ol ul { 301 | margin: 1.5rem 0 1.5rem 3rem; 302 | font-size: 90%; } 303 | li { 304 | margin-bottom: 1rem; } 305 | 306 | 307 | /* Code 308 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 | code { 310 | padding: .2rem .5rem; 311 | margin: 0 .2rem; 312 | font-size: 90%; 313 | white-space: nowrap; 314 | background: #F1F1F1; 315 | border: 1px solid #E1E1E1; 316 | border-radius: 4px; } 317 | pre > code { 318 | display: block; 319 | padding: 1rem 1.5rem; 320 | white-space: pre; } 321 | 322 | 323 | /* Tables 324 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 | th, 326 | td { 327 | padding: 12px 15px; 328 | text-align: left; 329 | border-bottom: 1px solid #E1E1E1; } 330 | th:first-child, 331 | td:first-child { 332 | padding-left: 0; } 333 | th:last-child, 334 | td:last-child { 335 | padding-right: 0; } 336 | 337 | 338 | /* Spacing 339 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 340 | button, 341 | .button { 342 | margin-bottom: 1rem; } 343 | input, 344 | textarea, 345 | select, 346 | fieldset { 347 | margin-bottom: 1.5rem; } 348 | pre, 349 | blockquote, 350 | dl, 351 | figure, 352 | table, 353 | p, 354 | ul, 355 | ol, 356 | form { 357 | margin-bottom: 2.5rem; } 358 | 359 | 360 | /* Utilities 361 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 362 | .u-full-width { 363 | width: 100%; 364 | box-sizing: border-box; } 365 | .u-max-full-width { 366 | max-width: 100%; 367 | box-sizing: border-box; } 368 | .u-pull-right { 369 | float: right; } 370 | .u-pull-left { 371 | float: left; } 372 | 373 | 374 | /* Misc 375 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 376 | hr { 377 | margin-top: 3rem; 378 | margin-bottom: 3.5rem; 379 | border-width: 0; 380 | border-top: 1px solid #E1E1E1; } 381 | 382 | 383 | /* Clearing 384 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 385 | 386 | /* Self Clearing Goodness */ 387 | .container:after, 388 | .row:after, 389 | .u-cf { 390 | content: ""; 391 | display: table; 392 | clear: both; } 393 | 394 | 395 | /* Media Queries 396 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 397 | /* 398 | Note: The best way to structure the use of media queries is to create the queries 399 | near the relevant code. For example, if you wanted to change the styles for buttons 400 | on small devices, paste the mobile query code up in the buttons section and style it 401 | there. 402 | */ 403 | 404 | 405 | /* Larger than mobile */ 406 | @media (min-width: 400px) {} 407 | 408 | /* Larger than phablet (also point when grid becomes active) */ 409 | @media (min-width: 550px) {} 410 | 411 | /* Larger than tablet */ 412 | @media (min-width: 750px) {} 413 | 414 | /* Larger than desktop */ 415 | @media (min-width: 1000px) {} 416 | 417 | /* Larger than Desktop HD */ 418 | @media (min-width: 1200px) {} 419 | -------------------------------------------------------------------------------- /tests/test_cobertura.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import pytest 3 | import lxml.etree as ET 4 | 5 | from .utils import make_cobertura 6 | 7 | 8 | def test_parse_path(): 9 | from pycobertura import Cobertura 10 | 11 | xml_path = 'tests/cobertura.xml' 12 | with mock.patch('pycobertura.cobertura.ET.parse') as mock_parse: 13 | cobertura = Cobertura(xml_path) 14 | 15 | assert cobertura.xml is mock_parse.return_value.getroot.return_value 16 | 17 | 18 | def test_parse_file_object(): 19 | from pycobertura import Cobertura 20 | 21 | xml_path = 'tests/cobertura.xml' 22 | with mock.patch('pycobertura.cobertura.ET.parse') as mock_parse: 23 | cobertura = Cobertura(open(xml_path)) 24 | 25 | assert cobertura.xml is mock_parse.return_value.getroot.return_value 26 | 27 | 28 | def test_parse_string(): 29 | from pycobertura import Cobertura 30 | 31 | xml_path = 'tests/cobertura.xml' 32 | with open(xml_path) as f: 33 | xml_string = f.read() 34 | assert ET.tostring(Cobertura(xml_path).xml) == ET.tostring(Cobertura(xml_string).xml) 35 | 36 | 37 | def test_invalid_coverage_report(): 38 | from pycobertura import Cobertura 39 | 40 | xml_path = 'non-existent.xml' 41 | pytest.raises(Cobertura.InvalidCoverageReport, Cobertura, xml_path) 42 | 43 | 44 | def test_version(): 45 | cobertura = make_cobertura() 46 | assert cobertura.version == '1.9' 47 | 48 | 49 | def test_line_rate(): 50 | cobertura = make_cobertura() 51 | assert cobertura.line_rate() == 0.9 52 | 53 | 54 | def test_line_rate_by_class_file(): 55 | cobertura = make_cobertura() 56 | expected_line_rates = { 57 | 'Main.java': 1.0, 58 | 'search/BinarySearch.java': 0.9166666666666666, 59 | 'search/ISortedArraySearch.java': 1.0, 60 | 'search/LinearSearch.java': 0.7142857142857143, 61 | } 62 | 63 | for filename in cobertura.files(): 64 | assert cobertura.line_rate(filename) == \ 65 | expected_line_rates[filename] 66 | 67 | 68 | def test_branch_rate(): 69 | cobertura = make_cobertura() 70 | assert cobertura.branch_rate() == 0.75 71 | 72 | 73 | def test_no_branch_rate(): 74 | from pycobertura import Cobertura 75 | 76 | assert Cobertura('tests/cobertura-no-branch-rate.xml').branch_rate() == None 77 | 78 | 79 | def test_branch_rate_by_class_file(): 80 | cobertura = make_cobertura() 81 | expected_branch_rates = { 82 | 'Main.java': 1.0, 83 | 'search/BinarySearch.java': 0.8333333333333334, 84 | 'search/ISortedArraySearch.java': 1.0, 85 | 'search/LinearSearch.java': 0.6666666666666666, 86 | } 87 | 88 | for filename in cobertura.files(): 89 | assert cobertura.branch_rate(filename) == \ 90 | expected_branch_rates[filename] 91 | 92 | 93 | def test_missed_statements_by_class_file(): 94 | cobertura = make_cobertura() 95 | expected_missed_statements = { 96 | 'Main.java': [], 97 | 'search/BinarySearch.java': [23, 24], 98 | 'search/ISortedArraySearch.java': [], 99 | 'search/LinearSearch.java': [13, 17, 19, 24], 100 | } 101 | 102 | for filename in cobertura.files(): 103 | assert cobertura.missed_statements(filename) == \ 104 | expected_missed_statements[filename] 105 | 106 | 107 | def test_list_packages(): 108 | cobertura = make_cobertura() 109 | 110 | packages = cobertura.packages() 111 | assert packages == ['', 'search'] 112 | 113 | 114 | @pytest.mark.parametrize("report, expected", [ 115 | ('tests/cobertura-generated-by-istanbul-from-coffeescript.xml', [ 116 | 'app.coffee' 117 | ]), 118 | ('tests/cobertura.xml', [ 119 | 'Main.java', 120 | 'search/BinarySearch.java', 121 | 'search/ISortedArraySearch.java', 122 | 'search/LinearSearch.java' 123 | ]) 124 | ]) 125 | def test_list_classes(report, expected): 126 | cobertura = make_cobertura(xml=report) 127 | 128 | classes = cobertura.files() 129 | assert classes == expected 130 | 131 | 132 | def test_hit_lines__by_iterating_over_classes(): 133 | cobertura = make_cobertura() 134 | 135 | expected_lines = { 136 | 'Main.java': [10, 16, 17, 18, 19, 23, 25, 26, 28, 29, 30, 31, 32, 33, 34], 137 | 'search/BinarySearch.java': [12, 16, 18, 20, 21, 25, 26, 28, 29, 31], 138 | 'search/ISortedArraySearch.java': [], 139 | 'search/LinearSearch.java': [9, 15, 16], 140 | } 141 | 142 | for filename in cobertura.files(): 143 | assert cobertura.hit_statements(filename) == expected_lines[filename] 144 | 145 | 146 | def test_missed_lines(): 147 | cobertura = make_cobertura() 148 | 149 | expected_lines = { 150 | 'Main.java': [], 151 | 'search/BinarySearch.java': [(23, 'partial'), (24, 'miss')], 152 | 'search/ISortedArraySearch.java': [], 153 | 'search/LinearSearch.java': [(13, "partial"), (17, "partial"), (19, "miss"), (20, "miss"), (21, "miss"), (22, "miss"), (23, "miss"), (24, "miss")], 154 | } 155 | 156 | for filename in cobertura.files(): 157 | assert cobertura.missed_lines(filename) == expected_lines[filename] 158 | 159 | 160 | def test_total_statements(): 161 | cobertura = make_cobertura() 162 | assert cobertura.total_statements() == 34 163 | 164 | 165 | def test_total_statements_by_class_file(): 166 | cobertura = make_cobertura() 167 | expected_total_statements = { 168 | 'Main.java': 15, 169 | 'search/BinarySearch.java': 12, 170 | 'search/ISortedArraySearch.java': 0, 171 | 'search/LinearSearch.java': 7, 172 | } 173 | for filename in cobertura.files(): 174 | assert cobertura.total_statements(filename) == \ 175 | expected_total_statements[filename] 176 | 177 | 178 | def test_total_misses(): 179 | cobertura = make_cobertura() 180 | assert cobertura.total_misses() == 6 181 | 182 | 183 | def test_total_misses_by_class_file(): 184 | cobertura = make_cobertura() 185 | expected_total_misses = { 186 | 'Main.java': 0, 187 | 'search/BinarySearch.java': 2, 188 | 'search/ISortedArraySearch.java': 0, 189 | 'search/LinearSearch.java': 4, 190 | } 191 | for filename in cobertura.files(): 192 | assert cobertura.total_misses(filename) == \ 193 | expected_total_misses[filename] 194 | 195 | 196 | def test_total_hits(): 197 | cobertura = make_cobertura() 198 | assert cobertura.total_hits() == 28 199 | 200 | 201 | def test_total_hits_by_class_file(): 202 | cobertura = make_cobertura() 203 | expected_total_misses = { 204 | 'Main.java': 15, 205 | 'search/BinarySearch.java': 10, 206 | 'search/ISortedArraySearch.java': 0, 207 | 'search/LinearSearch.java': 3, 208 | } 209 | for filename in cobertura.files(): 210 | assert cobertura.total_hits(filename) == \ 211 | expected_total_misses[filename] 212 | 213 | 214 | def test_class_file_source__sources_not_found(): 215 | from pycobertura.cobertura import Line 216 | cobertura = make_cobertura() 217 | expected_sources = { 218 | 'Main.java': [Line(0, 'tests/Main.java not found', None, None)], 219 | 'search/BinarySearch.java': [Line(0, 'tests/search/BinarySearch.java not found', None, None)], 220 | 'search/ISortedArraySearch.java': [Line(0, 'tests/search/ISortedArraySearch.java not found', None, None)], 221 | 'search/LinearSearch.java': [Line(0, 'tests/search/LinearSearch.java not found', None, None)], 222 | } 223 | for filename in cobertura.files(): 224 | assert cobertura.file_source(filename) == expected_sources[filename] 225 | 226 | 227 | @pytest.mark.parametrize( 228 | "report, expected_line_statuses", 229 | [ 230 | ( 231 | "tests/dummy.source1/coverage.xml", 232 | { 233 | "dummy/__init__.py": [], 234 | "dummy/dummy.py": [ 235 | (1, "hit"), 236 | (2, "hit"), 237 | (4, "hit"), 238 | (5, "miss"), 239 | (6, "miss"), 240 | ], 241 | "dummy/dummy2.py": [ 242 | (1, "hit"), 243 | (2, "hit"), 244 | ], 245 | "dummy/dummy4.py": [ 246 | (1, "miss"), 247 | (2, "miss"), 248 | (4, "miss"), 249 | (5, "miss"), 250 | (6, "miss"), 251 | ], 252 | }, 253 | ), 254 | ( 255 | "tests/cobertura.xml", 256 | { 257 | "Main.java": [ 258 | (10, "hit"), 259 | (16, "hit"), 260 | (17, "hit"), 261 | (18, "hit"), 262 | (19, "hit"), 263 | (23, "hit"), 264 | (25, "hit"), 265 | (26, "hit"), 266 | (28, "hit"), 267 | (29, "hit"), 268 | (30, "hit"), 269 | (31, "hit"), 270 | (32, "hit"), 271 | (33, "hit"), 272 | (34, "hit"), 273 | ], 274 | "search/BinarySearch.java": [ 275 | (12, "hit"), 276 | (16, "hit"), 277 | (18, "hit"), 278 | (20, "hit"), 279 | (21, "hit"), 280 | (23, "partial"), 281 | (24, "miss"), 282 | (25, "hit"), 283 | (26, "hit"), 284 | (28, "hit"), 285 | (29, "hit"), 286 | (31, "hit"), 287 | ], 288 | "search/ISortedArraySearch.java": [], 289 | "search/LinearSearch.java": [(9, 'hit'), (13, 'partial'), (15, 'hit'), (16, 'hit'), (17, 'partial'), (19, 'miss'), (24, 'miss')], 290 | }, 291 | ), 292 | ( 293 | "tests/dummy.with-branch-condition/coverage.xml", 294 | { 295 | "__init__.py": [], 296 | "dummy.py": [(1, 'hit'), (2, 'partial'), (3, 'hit'), (5, 'miss')], 297 | }, 298 | ), 299 | ], 300 | ) 301 | def test_line_statuses(report, expected_line_statuses): 302 | cobertura = make_cobertura(report) 303 | for filename in cobertura.files(): 304 | assert cobertura.line_statuses(filename) == \ 305 | expected_line_statuses[filename] 306 | 307 | 308 | @pytest.mark.parametrize("report, source, source_prefix", [ 309 | ("tests/dummy.source1/coverage.xml", None, None), 310 | ("tests/dummy.source1/coverage.xml", "tests/", "dummy.source1/"), 311 | ]) 312 | def test_class_file_source__sources_found(report, source, source_prefix): 313 | from pycobertura.cobertura import Line 314 | cobertura = make_cobertura(report, source=source, source_prefix=source_prefix) 315 | expected_sources = { 316 | "dummy/__init__.py": [], 317 | "dummy/dummy.py": [ 318 | Line(1, "def foo():\n", "hit", None), 319 | Line(2, " pass\n", "hit", None), 320 | Line(3, "\n", None, None), 321 | Line(4, "def bar():\n", "hit", None), 322 | Line(5, " a = 'a'\n", "miss", None), 323 | Line(6, " b = 'b'\n", "miss", None), 324 | ], 325 | "dummy/dummy2.py": [ 326 | Line(1, "def baz():\n", "hit", None), 327 | Line(2, " pass\n", "hit", None), 328 | ], 329 | "dummy/dummy4.py": [ 330 | Line(1, "def barbaz():\n", "miss", None), 331 | Line(2, " pass\n", "miss", None), 332 | Line(3, "\n", None, None), 333 | Line(4, "def foobarbaz():\n", "miss", None), 334 | Line(5, " a = 1 + 3\n", "miss", None), 335 | Line(6, " pass\n", "miss", None), 336 | ], 337 | } 338 | for filename in cobertura.files(): 339 | assert cobertura.file_source(filename) == \ 340 | expected_sources[filename] 341 | 342 | 343 | def test_class_file_source__raises_when_no_filesystem(): 344 | from pycobertura.cobertura import Cobertura 345 | cobertura = Cobertura('tests/cobertura.xml') 346 | for filename in cobertura.files(): 347 | pytest.raises( 348 | Cobertura.MissingFileSystem, 349 | cobertura.file_source, 350 | filename 351 | ) 352 | 353 | def test_class_source_lines__raises_when_no_filesystem(): 354 | from pycobertura.cobertura import Cobertura 355 | cobertura = Cobertura('tests/cobertura.xml') 356 | for filename in cobertura.files(): 357 | pytest.raises( 358 | Cobertura.MissingFileSystem, 359 | cobertura.source_lines, 360 | filename 361 | ) 362 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## Unreleased 4 | 5 | ## 4.1.0 (2025-04-13) 6 | 7 | * Improve `GitFileSystem` to support symbolic links by using `git cat-file 8 | --batch --follow-symlinks`. Thanks @JokeWaumans 9 | * Improve `GitFileSystem` to support source files located in submodules. Thanks 10 | @JokeWaumans 11 | * Use a slightly darker yellow in the HTML report for the numbers of 12 | partially covered lines, for readability. Thanks @JokeWaumans 13 | 14 | * Add support for Python 3.12 and 3.13. 15 | 16 | ## 4.0.0 (2025-04-13) 17 | 18 | * Add support for partially covered lines and will be highlighted in yellow. 19 | 20 | ## 3.3.2 (2024-05-26) 21 | * Improve error message as to why parsing the Cobertura report failed. 22 | 23 | ## 3.3.1 (2024-02-17) 24 | * Fix total stmts, miss and cover values in the coverage report when an ignore 25 | regex is passed. Thanks @danctorres 26 | 27 | ## 3.3.0 (2024-01-14) 28 | 29 | * Feat: Support executing pycobertura as a python module with: `python -m 30 | pycobertura`. Thanks @paveltsialnou 31 | 32 | ## 3.2.4 (2023-12-08) 33 | 34 | * Fix: `Cobertura.branch_rate()` returns `None` when `branch-rate` is absent 35 | from the Cobertura coverage report instead of raising `TypeError`. Thanks 36 | @starcruiseromega 37 | 38 | ## 3.2.3 (2023-10-29) 39 | 40 | * Fix: Account for moved/renamed/delted files in miss counts. Thanks @wdouglass 41 | 42 | ## 3.2.2 (2023-10-10) 43 | 44 | * Fix `IndexError: list index out of range` error raised by `pycobertura diff` 45 | when number of files with coverage change is less then total number of files 46 | in coverage report. Thanks @ulasozguler 47 | 48 | ## 3.2.1 (2023-06-01) 49 | 50 | * Make `ZipFileSystem` read files in text mode to fix diffing mixed file 51 | systems. Thanks @ernestask 52 | * Add Python 3.11 as a supported version. Thanks @ernestask 53 | 54 | ## 3.2.0 (2023-05-12) 55 | 56 | * `pycobertura diff` now supports output format: `github-annotation`. Thanks 57 | @goatwu1993 58 | 59 | ## 3.1.0 (2023-04-29) 60 | 61 | * `pycobertura show` now supports output format: `github-annotation`. Thanks 62 | @goatwu1993 63 | 64 | ## 3.0.0 (2022-10-08) 65 | 66 | * BACKWARD INCOMPATIBLE: 67 | * Deprecate Python3.5 and 3.6, support Python 3.7 onwards (see 68 | python_requires, classifiers in `setup.cfg`) 69 | * Update tests to Python 3.7 through 3.9 (affects `tox.ini` and `.travis.yml`) 70 | * Migrate to `setup.cfg` and `pyproject.toml`; empty `setup.py` kept for 71 | compatibility reasons 72 | * Update `release.sh` script 73 | * Address markdown lint in `README.md` 74 | * Do not use `object` in class inheritance as this is default behaviour in 75 | Python3 76 | * Move formatting in `reporters.py` into `Reporter` and `DeltaReporter` base 77 | classes to have a uniform formatting: new `stringify` in `utils.py` method 78 | and corresponding test in `test_stringify.py`. The subclasses and only 79 | inherit from these two base classes to ensure a consistent formatting across 80 | the reporters 81 | * Change datastructure of coverage information from `namedtuple` (immutable) 82 | to `dictionary` (mutable) in `reporters.py`. Adjusted `html-delta.jinja2`, 83 | `html.jinja2` and `filters.py` accordingly. This change in datastructure 84 | leads to a more compact and more readable code 85 | * Only uncovered lines are reported in `Missing` column (instead of 86 | additionally reporting newly covered lines) 87 | * Coverage diff for new files now make the assumption that "previous files" 88 | were covered at 100% and will no longer show an empty value represented by 89 | a dash "-". 90 | 91 | Thanks @gro1m 92 | 93 | * Fix handling of multiple classes in same file - thanks to @smortvedt, @gro1m 94 | * Replace Travis-ci with Github Actions. We were having issues with Travis 95 | pipeline not triggering because of quota issues. 96 | * Changes in Cobertura class. All `"./packages//class"` elements extracted at 97 | once now, and this leads to significant speedup. Thanks @oev81 98 | * Changes in setup.cfg related to package data. Remove 99 | `include_package_data=True`, because it requires `MANIFEST.in` and add 100 | `[options.package_data]` instead. This change makes the package installing 101 | from archive not miss `*.css` and `*.jinja2` files. Thanks @oev81 102 | * Add option `--ignore-regex` to ignore some files using either a Python regex 103 | pattern or a path to a `.gitignore` file. Thanks @gro1m 104 | * `pycobertura show` and `pycobertura diff` now support output formats: 105 | `json`, `markdown`, `csv`, and `yaml`. Thanks @gro1m 106 | 107 | ## 2.1.0 (2021-09-23) 108 | 109 | * Added the option `show_missing` to `HtmlReporterDelta`, which specifies 110 | whether or not the "Missing" column is displayed in the generated 111 | summary table. Thanks @loganharbour 112 | 113 | ## 2.0.1 (2021-01-20) 114 | 115 | * Drop the `colorama` dependency in favor of hardcoded ANSI escape codes. 116 | Thanks @luislew 117 | 118 | ## 2.0.0 (2020-09-03) 119 | 120 | * BACKWARD INCOMPATIBLE: The class `Cobertura` no longer instantiates a default 121 | `FileSystem` object if none is provided in the constructor 122 | `Cobertura.__init__(filesystem=...)`. If the content of files is accessed 123 | (e.g. to render a full coverage report) then a `FileSystem` instance must be 124 | provided. 125 | * The class `Cobertura` will raise `MissingFileSystem` if no 126 | `FileSystem` object was provided via the keyword argument 127 | `Cobertura(filesystem=...)`. It will only be raised when calling methods that 128 | attempt to read the content of files, e.g. `Cobertura.file_source(...)` or 129 | `Cobertura.source_lines(...)`. 130 | 131 | ## 1.1.0 (2020-08-26) 132 | 133 | * Support loading Cobertura reports from an XML string. Thanks @williamfzc 134 | * Fix (or add?!) support for providing file objects to `Cobertura`. 135 | 136 | ## 1.0.1 (2020-07-08) 137 | 138 | * Fix misreported coverage when a single coverage file is used with 139 | `pycobertura diff` to overlay covered and uncovered changes between two 140 | different sources. Thanks @bastih 141 | 142 | ## 1.0.0 (2020-06-21) 143 | 144 | * Let the caller customize the appearance of the HTML report providing a 145 | `title`, omitting the rendering of sources by means of the boolean 146 | `render_file_sources` and providing an helpful message to the end-users (in 147 | place of the sources) by means of the `no_file_sources_message` parameter. 148 | Contributed by @nilleb. 149 | * Add a `GitFilesystem` to allow pycobertura to access source files at different 150 | revisions from a git repository. Thanks @nilleb. 151 | * BACKWARDS INCOMPATIBLE: Change the signature of the Cobertura object in order 152 | to accept a filesystem. 153 | * BACKWARDS INCOMPATIBLE: Drop support for Python 2. 154 | * Added tox task `black`, to use the the uncompromising Python code formatter. 155 | See https://black.readthedocs.io/en/stable/ to learn more about black. Thanks 156 | @nilleb. 157 | 158 | ## 0.10.5 (2018-12-11) 159 | * Use a different `memoize()` implementation so that cached objects can be 160 | freed/garbage collected and prevent from running out of memory when processing 161 | a lot of cobertura files. Thanks @kannaiah 162 | 163 | ## 0.10.4 (2018-04-17) 164 | * Calculate the correct line rate for diffs (#83). Previously 165 | `CoberturaDiff.diff_line_rate` with no filename argument would total up the 166 | different line rate changes from all of the modified files, which is not the 167 | correct difference in line rates between all files. Now the difference in line 168 | rate from the two reports objects will be directly used if no argument is 169 | passed. (@borgstrom) 170 | 171 | ## 0.10.3 (2018-03-20) 172 | * Update author/repository info 173 | * Update release script to use twine 174 | 175 | ## 0.10.2 (2018-03-20) 176 | * Avoid duplicate file names in files() (#82). Some coverage reports include 177 | metrics for multiple classes within the same file and redundant rows would be 178 | generated for such reports. Thanks James DeFelice! (@jdef) 179 | 180 | ## 0.10.1 (2017-12-30) 181 | * Drop support for Python 2.6 182 | * Fix a `IndexError: list index out of range` error by being less specific about 183 | where to find `class` elements in the Cobertura report. 184 | 185 | ## 0.10.0 (2016-09-27) 186 | * BACKWARDS INCOMPATIBLE: when a source file is not found in disk pycobertura 187 | will now raise a `pycobertura.filesystem.FileSystem.FileNotFound` exception 188 | instead of an `IOError`. 189 | * possibility to pass a zip archive containing the source code instead of a 190 | directory 191 | * BACKWARDS INCOMPATIBLE: Rename keyword argument `Cobertura(base_path=None)` > 192 | `Cobertura(source=None)` 193 | * Introduce new keyword argument `Cobertura(source_prefix=None)` 194 | * Fix an `IOError` / `FileNotFound` error which happens when the same coverage 195 | report is provided twice to `pycobertura diff` (diff in degraded mode) but the 196 | first code base (`--source1`) is missing a file mentioned in the coverage 197 | report. 198 | * Fix a rare bug when diffing coverage xml where one file goes from zero lines 199 | to non-zero lines. 200 | 201 | ## 0.9.0 (2016-01-29) 202 | * The coverage report now displays the class's filename instead of the class's 203 | name, the latter being more subject to different interpretations by coverage 204 | tools. This change was done to support coverage.py versions 3.x and 4.x. 205 | * BACKWARDS INCOMPATIBLE: removed `CoberturaDiff.filename()` 206 | * BACKWARDS INCOMPATIBLE: removed the term "class" from the API which make it 207 | more difficult to reason about. Now preferring "filename": 208 | 209 | - `Cobertura.line_rate(class_name=None)` > `Cobertura.line_rate(filename=None)` 210 | - `Cobertura.branch_rate(class_name=None)` > `Cobertura.branch_rate(filename=None)` 211 | - `Cobertura.missed_statements(class_name)` > `Cobertura.missed_statements(filename)` 212 | - `Cobertura.hit_statements(class_name)` > `Cobertura.hit_statements(filename)` 213 | - `Cobertura.line_statuses(class_name)` > `Cobertura.line_statuses(filename)` 214 | - `Cobertura.missed_lines(class_name)` > `Cobertura.missed_lines(filename)` 215 | - `Cobertura.class_source(class_name)` > `Cobertura.file_source(filename)` 216 | - `Cobertura.total_misses(class_name=None)` > `Cobertura.total_misses(filename=None)` 217 | - `Cobertura.total_hits(class_name=None)` > `Cobertura.total_hits(filename=None)` 218 | - `Cobertura.total_statements(class_name=None)` > `Cobertura.total_statements(filename=None)` 219 | - `Cobertura.filepath(class_name)` > `Cobertura.filepath(filename)` 220 | - `Cobertura.classes()` > `Cobertura.files()` 221 | - `Cobertura.has_classfile(class_name)` > `Cobertura.has_file(filename)` 222 | - `Cobertura.class_lines(class_name)` > `Cobertura.source_lines(filename)` 223 | - `CoberturaDiff.diff_total_statements(class_name=None)` > `CoberturaDiff.diff_total_statements(filename=None)` 224 | - `CoberturaDiff.diff_total_misses(class_name=None)` > `CoberturaDiff.diff_total_misses(filename=None)` 225 | - `CoberturaDiff.diff_total_hits(class_name=None)` > `CoberturaDiff.diff_total_hits(filename=None)` 226 | - `CoberturaDiff.diff_line_rate(class_name=None)` > `CoberturaDiff.diff_line_rate(filename=None)` 227 | - `CoberturaDiff.diff_missed_lines(class_name)` > `CoberturaDiff.diff_missed_lines(filename)` 228 | - `CoberturaDiff.classes()` > `CoberturaDiff.files()` 229 | - `CoberturaDiff.class_source(class_name)` > `CoberturaDiff.file_source(filename)` 230 | - `CoberturaDiff.class_source_hunks(class_name)` > `CoberturaDiff.file_source_hunks(filename)` 231 | - `Reporter.get_source(class_name)` > `Reporter.get_source(filename)` 232 | - `HtmlReporter.get_class_row(class_name)` > `HtmlReporter.get_class_row(filename)` 233 | - `DeltaReporter.get_source_hunks(class_name)` > `DeltaReporter.get_source_hunks(filename)` 234 | - `DeltaReporter.get_class_row(class_name)` > `DeltaReporter.get_file_row(filename)` 235 | 236 | ## 0.8.0 (2015-09-28) 237 | 238 | * *BACKWARDS INCOMPATIBLE*: return different exit codes depending on `diff` 239 | status. Thanks Marc Abramowitz. 240 | 241 | ## 0.7.3 (2015-07-23) 242 | 243 | * a non-zero exit code will be returned if not all changes have been covered. If 244 | `--no-source` is provided then it will only check if coverage has worsened, 245 | which is less strict. 246 | 247 | ## 0.7.2 (2015-05-29) 248 | 249 | * memoize expensive methods of `Cobertura` (lxml/disk) 250 | * assume source code is UTF-8 251 | 252 | ## 0.7.1 (2015-04-20) 253 | 254 | * prevent misalignment of source code and line numbers, this would happen when 255 | the source is too long causing it to wrap around. 256 | 257 | ## 0.7.0 (2015-04-17) 258 | 259 | * pycobertura diff now renders colors in terminal with Python 2.x (worked for 260 | Python 3.x). For this to work we need to require Click 4.0 so that the color 261 | auto-detection of Click can be overridden (not possible in Click 3.0) 262 | * Introduce `Line` namedtuple object which represents a line of source code and 263 | coverage status. 264 | * *BACKWARDS INCOMPATIBLE*: List of tuples generated or handled by various 265 | function now return `Line` objects (namedtuple) for each line. 266 | * add plus sign (+) in front of lines that were added/modified on HTML diff 267 | report 268 | * upgrade to Skeleton 2.0.4 (88f03612b05f093e3f235ced77cf89d3a8fcf846) 269 | * add legend to HTML diff report 270 | 271 | ## 0.6.0 (2015-02-03) 272 | 273 | * expose `CoberturaDiff` under the pycobertura namespace 274 | * pycobertura diff no longer reports unchanged classes 275 | 276 | ## 0.5.2 (2015-01-13) 277 | 278 | * fix incorrect "TOTAL" row counts of the diff command when classes were added 279 | or removed from the second report. 280 | 281 | ## 0.5.1 (2015-01-08) 282 | 283 | * Options of pycobertura diff `--missed` and `--no-missed` have been renamed to 284 | `--source` and `--no-source` which will not show the source code nor display 285 | missing lines since they cannot be accurately computed without the source. 286 | * Optimized xpath syntax for faster class name lookup (~3x) 287 | * Colorize total missed statements 288 | * `pycobertura diff` exit code will be non-zero until all changes are covered 289 | 290 | ## 0.5.0 (2015-01-07) 291 | 292 | * `pycobertura diff` HTML output now only includes hunks of lines that have 293 | coverage changes and skips unchanged classes 294 | * handle asymmetric presence of classes in the reports (regression introduced in 295 | 0.4.0) 296 | * introduce `CoberturaDiff.diff_missed_lines()` 297 | * introduce `CoberturaDiff.classes()` 298 | * introduce `CoberturaDiff.filename()` 299 | * introduce `Cobertura.filepath()` which will return the system path to the 300 | file. It uses `base_path` to resolve the path. 301 | * the summary table of `pycobertura diff` no longer shows classes that are no 302 | longer present 303 | * `Cobertura.filename()` now only returns the filename of the class as found in 304 | the Cobertura report, any `base_path` computation is omitted. 305 | * Argument `xml_source` of `Cobertura.__init__()` is renamed to `xml_path` and 306 | only accepts an XML path because much of the logic involved in source code 307 | path resolution is based on the path provided which cannot work with file 308 | objects or XML strings. 309 | * Rename `Cobertura.source` -> `Cobertura.xml_path` 310 | * `pycobertura diff` now takes options `--missed` (default) or `--no-missed` to 311 | show missed line numbers. If `--missed` is given, the paths to the source code 312 | must be accessible. 313 | 314 | ## 0.4.1 (2015-01-05) 315 | 316 | * return non-zero exit code if uncovered lines rises (previously based on line 317 | rate) 318 | 319 | ## 0.4.0 (2015-01-04) 320 | 321 | * rename `Cobertura.total_lines()` -> `Cobertura.total_statements()` 322 | * rename `Cobertura.line_hits()` -> `Cobertura.hit_statements()` 323 | * introduce `Cobertura.missed_statements()` 324 | * introduce `Cobertura.line_statuses()` which returns line numbers for a given 325 | class name with hit/miss statuses 326 | * introduce `Cobertura.class_source()` which returns the source code for a given 327 | class along with hit/miss status 328 | * `pycobertura show` now includes HTML source 329 | * `pycobertura show` now accepts `--source` which indicates where the source 330 | code directory is located 331 | * `Cobertura()` now takes an optional `base_path` argument which will be used to 332 | resolve the path to the source code by joining the `base_path` value to the 333 | path found in the Cobertura report. 334 | * an error is now raised if `Cobertura` is passed a non-existent XML file path 335 | * `pycobertura diff` now includes HTML source 336 | * `pycobertura diff` now accepts `--source1` and `--source2` which indicates 337 | where the source code directory of each of the Cobertura reports are located 338 | * introduce `CoberturaDiff` used to diff `Cobertura` objects 339 | * argument `class_name` for `Cobertura.total_statements` is now optional 340 | * argument `class_name` for `Cobertura.total_misses` is now optional 341 | * argument `class_name` for `Cobertura.total_hits` is now optional 342 | 343 | ## 0.3.0 (2014-12-23) 344 | 345 | * update description of pycobertura 346 | * pep8-ify 347 | * add pep8 tasks for tox and travis 348 | * diff command returns non-zero exit code if coverage worsened 349 | * `Cobertura.branch_rate` is now a method that can take an optional `class_name` 350 | argument 351 | * refactor internals for improved readability 352 | * show classes that contain no lines, e.g. `__init__.py` 353 | * add `Cobertura.filename(class_name)` to retrieve the filename of a class 354 | * fix erroneous reporting of missing lines which was equal to the number of 355 | missed statements (wrong because of multiline statements) 356 | 357 | ## 0.2.1 (2014-12-10) 358 | 359 | * fix py26 compatibility by switching the XML parser to `lxml` which has a more 360 | predictible behavior when used across all Python versions. 361 | * add Travis CI 362 | 363 | ## 0.2.0 (2014-12-10) 364 | 365 | * apply Skeleton 2.0 theme to html output 366 | * add `-o` / `--output` option to write reports to a file. 367 | * known issue: diffing 2 files with options `--format text`, `--color` and 368 | `--output` does not render color under PY2. 369 | 370 | ## 0.1.0 (2014-12-03) 371 | 372 | * add `--color` and `--no-color` options to `pycobertura diff`. 373 | * add option `-f` and `--format` with output of `text` (default) and `html`. 374 | * change class naming from `report` to `reporter` 375 | 376 | ## 0.0.2 (2014-11-27) 377 | 378 | * MIT license 379 | * use pypandoc to convert the `long_description` in setup.py from Markdown to 380 | reStructuredText so pypi can digest and format the pycobertura page properly. 381 | 382 | ## 0.0.1 (2014-11-24) 383 | 384 | * Initial version 385 | -------------------------------------------------------------------------------- /pycobertura/cobertura.py: -------------------------------------------------------------------------------- 1 | import lxml.etree as ET 2 | from collections import namedtuple 3 | from pycobertura.utils import ( 4 | LineStatus, 5 | LineStatusTuple, 6 | extrapolate_coverage, 7 | get_line_status, 8 | reconcile_lines, 9 | hunkify_lines, 10 | get_filenames_that_do_not_match_regex, 11 | memoize, 12 | ) 13 | 14 | from typing import Dict, List, Tuple 15 | 16 | try: 17 | from typing import Literal 18 | except ImportError: # pragma: no cover 19 | from typing_extensions import Literal 20 | 21 | 22 | class Line(namedtuple("Line", ["number", "source", "status", "reason"])): 23 | """ 24 | A namedtuple object representing a line of source code. 25 | 26 | The namedtuple has the following attributes: 27 | `number`: line number in the source code 28 | `source`: actual source code of line 29 | `status`: "hit" (covered), "miss" (uncovered), "partial", or None (coverage 30 | unchanged) 31 | `reason`: If `Line.status` is not `None` the possible values may be 32 | `"line-edit"`, `"cov-up"` or `"cov-down"`. Otherwise `None`. 33 | """ 34 | 35 | 36 | class Cobertura: 37 | """ 38 | An XML Cobertura parser. 39 | """ 40 | 41 | class InvalidCoverageReport(Exception): 42 | pass 43 | 44 | class MissingFileSystem(Exception): 45 | pass 46 | 47 | def __init__(self, report, filesystem=None): 48 | """ 49 | Initialize a Cobertura report given a coverage report `report` that is 50 | an XML file in the Cobertura format. It can represented as either: 51 | 52 | - a file object 53 | - a file path 54 | - an XML string 55 | 56 | The optional keyword argument `filesystem` describes how to retrieve the 57 | source files referenced in the report. Please check the 58 | `pycobertura.filesystem` module to learn more about filesystems. 59 | """ 60 | errors = [] 61 | for load_func in [ 62 | self._load_from_file, 63 | self._load_from_string, 64 | ]: 65 | try: 66 | self.xml: ET._Element = load_func(report) 67 | break 68 | except BaseException as e: 69 | errors.append(e) 70 | pass 71 | else: 72 | raise self.InvalidCoverageReport( 73 | """\ 74 | Invalid coverage report: {}. 75 | The following exceptions occurred while attempting to parse the report: 76 | * While treating the report as a filename: {}. 77 | * While treating the report as an XML Cobertura string: {}""".format( 78 | report, errors[0], errors[1] 79 | ) 80 | ) 81 | 82 | self.filesystem = filesystem 83 | self.report = report 84 | 85 | self._class_elements_by_file_name = self._make_class_elements_by_filename() 86 | 87 | def _make_class_elements_by_filename(self): 88 | result = {} 89 | for elem in self.xml.xpath("./packages//class"): 90 | filename = elem.attrib["filename"] 91 | result.setdefault(filename, []).append(elem) 92 | 93 | return result 94 | 95 | def __eq__(self, other): 96 | return self.report and other.report and self.report == other.report 97 | 98 | def _load_from_file(self, report_file): 99 | return ET.parse(report_file).getroot() 100 | 101 | def _load_from_string(self, s): 102 | return ET.fromstring(s) 103 | 104 | @memoize 105 | def _get_lines_by_filename(self, filename): 106 | classElements = self._class_elements_by_file_name[filename] 107 | return [ 108 | line 109 | for classElement in classElements 110 | for line in classElement.xpath("./lines/line") 111 | ] 112 | 113 | @property 114 | def version(self): 115 | """Return the version number of the coverage report.""" 116 | return self.xml.get("version") 117 | 118 | def line_rate(self, filename=None, ignore_regex=None): 119 | """ 120 | Return the global line rate of the coverage report. If the 121 | `filename` file is given, return the line rate of the file. 122 | """ 123 | 124 | if filename is None and ignore_regex is None: 125 | return float(self.xml.get("line-rate")) 126 | 127 | if ignore_regex is None: 128 | elements = self._class_elements_by_file_name[filename] 129 | if len(elements) == 1: 130 | return float(elements[0].get("line-rate")) 131 | total = self.total_statements(filename, ignore_regex) 132 | return ( 133 | float(self.total_hits(filename, ignore_regex) / total) if total != 0 else 0 134 | ) 135 | 136 | def branch_rate(self, filename=None): 137 | """ 138 | Return the global branch rate of the coverage report. If the 139 | `filename` file is given, return the branch rate of the file. 140 | """ 141 | branch_rate = None 142 | if filename is None: 143 | branch_rate = self.xml.get("branch-rate") 144 | else: 145 | classElement = self._class_elements_by_file_name[filename][0] 146 | branch_rate = classElement.get("branch-rate") 147 | return None if branch_rate is None else float(branch_rate) 148 | 149 | @memoize 150 | def missed_statements(self, filename): 151 | """ 152 | Return a list of uncovered line numbers for each of the missed 153 | statements found for the file `filename`. 154 | """ 155 | classElements = self._class_elements_by_file_name[filename] 156 | return [ 157 | int(line.get("number")) 158 | for classElement in classElements 159 | for line in classElement.xpath("./lines/line") 160 | if get_line_status(line) != "hit" 161 | ] 162 | 163 | @memoize 164 | def hit_statements(self, filename): 165 | """ 166 | Return a list of covered line numbers for each of the hit statements 167 | found for the file `filename`. 168 | """ 169 | classElements = self._class_elements_by_file_name[filename] 170 | return [ 171 | int(line.get("number")) 172 | for classElement in classElements 173 | for line in classElement.xpath("./lines/line[@hits>0]") 174 | if get_line_status(line) == "hit" 175 | ] 176 | 177 | def line_statuses(self, filename: str): 178 | """ 179 | Return a list of tuples `(lineno, status)` of all the lines found in 180 | the Cobertura report for the given file `filename` where `lineno` is 181 | the line number and `status` is coverage status of the line which can 182 | be either `True` (line hit) or `False` (line miss). 183 | """ 184 | line_elements = self._get_lines_by_filename(filename) 185 | 186 | output: List[LineStatusTuple] = [] 187 | for line in line_elements: 188 | lineno = int(line.get("number")) 189 | status = get_line_status(line) 190 | output.append((lineno, status)) 191 | 192 | return output 193 | 194 | def missed_lines(self, filename): 195 | """ 196 | Return a list of extrapolated uncovered or partially uncovered line 197 | numbers for the file `filename` according to `Cobertura.line_statuses`. 198 | """ 199 | statuses = self.line_statuses(filename) 200 | extrapolated_statuses = extrapolate_coverage(statuses) 201 | return [ 202 | (lineno, status) 203 | for lineno, status in extrapolated_statuses 204 | if (status == "miss" or status == "partial") 205 | ] 206 | 207 | def _raise_MissingFileSystem(self, filename): 208 | raise self.MissingFileSystem( 209 | f"Unable to read file: {filename}. " 210 | f"A FileSystem instance must be provided via " 211 | f"Cobertura(filesystem=...) to locate and read the source " 212 | f"content of the file." 213 | ) 214 | 215 | @memoize 216 | def file_source(self, filename): 217 | """ 218 | Return a list of namedtuple `Line` for each line of code found in the 219 | source file with the given `filename`. 220 | """ 221 | if self.filesystem is None: 222 | self._raise_MissingFileSystem(filename) 223 | 224 | lines = [] 225 | try: 226 | with self.filesystem.open(filename) as f: 227 | line_statuses = dict(self.line_statuses(filename)) 228 | for lineno, source in enumerate(f, start=1): 229 | line_status = line_statuses.get(lineno) 230 | line = Line(lineno, source, line_status, None) 231 | lines.append(line) 232 | 233 | except self.filesystem.FileNotFound as file_not_found: 234 | lines.append(Line(0, f"{file_not_found.path} not found", None, None)) 235 | 236 | return lines 237 | 238 | def total_misses(self, filename=None, ignore_regex=None): 239 | """ 240 | Return the total number of uncovered statements for the file 241 | `filename`. If `filename` is not given, return the total 242 | number of uncovered statements for all files. 243 | """ 244 | if filename is not None: 245 | return len(self.missed_statements(filename)) 246 | 247 | return sum( 248 | [ 249 | len(self.missed_statements(filename)) 250 | for filename in self.files(ignore_regex) 251 | ] 252 | ) 253 | 254 | def total_hits(self, filename=None, ignore_regex=None): 255 | """ 256 | Return the total number of covered statements for the file 257 | `filename`. If `filename` is not given, return the total 258 | number of covered statements for all files. 259 | """ 260 | if filename is not None: 261 | return len(self.hit_statements(filename)) 262 | return sum( 263 | [ 264 | len(self.hit_statements(filename)) 265 | for filename in self.files(ignore_regex) 266 | ] 267 | ) 268 | 269 | def total_statements(self, filename=None, ignore_regex=None): 270 | """ 271 | Return the total number of statements for the file 272 | `filename`. If `filename` is not given, return the total 273 | number of statements for all files. 274 | """ 275 | if filename is not None: 276 | return len(self._get_lines_by_filename(filename)) 277 | return sum( 278 | [ 279 | len(self._get_lines_by_filename(filename)) 280 | for filename in self.files(ignore_regex) 281 | ] 282 | ) 283 | 284 | @memoize 285 | def files(self, ignore_regex=None): 286 | """ 287 | Return the list of available files in the coverage report. 288 | """ 289 | # maybe replace with a trie at some point? see has_file FIXME 290 | already_seen = set() 291 | filenames = [] 292 | 293 | for el in self.xml.xpath("//class"): 294 | filename = el.get("filename") 295 | if filename in already_seen: 296 | continue 297 | already_seen.add(filename) 298 | filenames.append(filename) 299 | 300 | return ( 301 | filenames 302 | if not ignore_regex 303 | else get_filenames_that_do_not_match_regex(filenames, ignore_regex) 304 | ) 305 | 306 | def has_file(self, filename): 307 | """ 308 | Return `True` if the file `filename` is present in the report, return 309 | `False` otherwise. 310 | """ 311 | # FIXME: this will lookup a list which is slow, make it O(1) 312 | return filename in self.files() 313 | 314 | @memoize 315 | def source_lines(self, filename: str): 316 | """ 317 | Return a list for source lines of file `filename`. 318 | """ 319 | if self.filesystem is None: 320 | self._raise_MissingFileSystem(filename) 321 | 322 | with self.filesystem.open(filename) as f: 323 | return f.readlines() 324 | 325 | @memoize 326 | def packages(self): 327 | """ 328 | Return the list of available packages in the coverage report. 329 | """ 330 | return [el.get("name") for el in self.xml.xpath("//package")] 331 | 332 | 333 | class CoberturaDiff: 334 | """ 335 | Diff Cobertura objects. 336 | """ 337 | 338 | def __init__(self, cobertura1, cobertura2): 339 | self.cobertura1: Cobertura = cobertura1 340 | self.cobertura2: Cobertura = cobertura2 341 | 342 | def has_better_coverage(self): 343 | """ 344 | Return `True` if coverage of has improved, `False` otherwise. 345 | 346 | This does not ensure that all changes have been covered. If this is 347 | what you want, use `CoberturaDiff.has_all_changes_covered()` instead. 348 | """ 349 | return not ( 350 | any(self.diff_total_misses(filename) > 0 for filename in self.files()) 351 | ) 352 | 353 | def has_all_changes_covered(self): 354 | """ 355 | Return `True` if all changes have been covered, `False` otherwise. 356 | """ 357 | for filename in self.files(): 358 | for hunk in self.file_source_hunks(filename): 359 | for line in hunk: 360 | if line.reason is None: 361 | continue # line untouched 362 | if line.status != "hit": 363 | return False # line not covered 364 | return True 365 | 366 | def _diff_attr(self, attr_name, filename): 367 | """ 368 | Return the difference between 369 | `self.cobertura2.(filename)` and 370 | `self.cobertura1.(filename)`. 371 | 372 | This generic method is meant to diff the count of methods that return 373 | counts for a given file `filename`, e.g. `Cobertura.total_statements`, 374 | `Cobertura.total_misses`, ... 375 | 376 | The returned count may be a float. 377 | """ 378 | files = [filename] if filename else self.files() 379 | 380 | total_count = 0.0 381 | for filename in files: 382 | count = [0, 0] 383 | if self.cobertura1.has_file(filename): 384 | method = getattr(self.cobertura1, attr_name) 385 | count[0] = method(filename) 386 | if self.cobertura2.has_file(filename): 387 | method = getattr(self.cobertura2, attr_name) 388 | count[1] = method(filename) 389 | total_count += count[1] - count[0] 390 | 391 | return total_count 392 | 393 | def diff_total_statements(self, filename=None): 394 | return int(self._diff_attr("total_statements", filename)) 395 | 396 | def diff_total_misses(self, filename=None): 397 | return int(self._diff_attr("total_misses", filename)) 398 | 399 | def diff_total_hits(self, filename=None): 400 | return int(self._diff_attr("total_hits", filename)) 401 | 402 | def diff_line_rate(self, filename=None): 403 | if filename is not None: 404 | return self._diff_attr("line_rate", filename) 405 | return self.cobertura2.line_rate() - self.cobertura1.line_rate() 406 | 407 | def diff_missed_lines( 408 | self, filename: str 409 | ) -> List[Tuple[int, Literal["miss", "partial"]]]: 410 | """ 411 | Return a list of 2-element tuples `(lineno, status)` for uncovered lines. 412 | The given file `filename` where `lineno` is a missed line number. 413 | """ 414 | return [ 415 | (line.number, line.status) 416 | for line in self.file_source(filename) 417 | if (line.status == "miss" or line.status == "partial") 418 | ] 419 | 420 | def files(self, ignore_regex=None): 421 | """ 422 | Return the total of all files we're comparing. 423 | """ 424 | f = list( 425 | set( 426 | self.cobertura2.files(ignore_regex) 427 | + self.cobertura1.files(ignore_regex) 428 | ) 429 | ) 430 | f.sort() 431 | return f 432 | 433 | def file_source(self, filename: str): 434 | """ 435 | Return a list of namedtuple `Line` for each line of code found in the 436 | given file `filename`. 437 | 438 | """ 439 | nonexistent = True 440 | if self.cobertura1.has_file(filename) and self.cobertura1.filesystem.has_file( 441 | filename 442 | ): 443 | lines1 = self.cobertura1.source_lines(filename) 444 | line_statuses1 = dict(self.cobertura1.line_statuses(filename)) 445 | nonexistent = False 446 | else: 447 | lines1 = [] 448 | line_statuses1: Dict[int, LineStatus] = {} 449 | 450 | if self.cobertura2.has_file(filename) and self.cobertura2.filesystem.has_file( 451 | filename 452 | ): 453 | lines2 = self.cobertura2.source_lines(filename) 454 | line_statuses2 = dict(self.cobertura2.line_statuses(filename)) 455 | nonexistent = False 456 | else: 457 | lines2 = [] 458 | line_statuses2 = {} 459 | 460 | if nonexistent: 461 | # try to get source lines anyway, to get the exception traceback 462 | self.cobertura2.source_lines(filename) 463 | 464 | # Build a dict of lineno2 -> lineno1 465 | lineno_map = reconcile_lines(lines2, lines1) 466 | 467 | # if we are using a single coverage file, we need to translate the 468 | # coverage of lines1 so that it corresponds to its real lines. 469 | if self.cobertura1 == self.cobertura2: 470 | line_statuses1 = {} 471 | for l2, l1 in lineno_map.items(): 472 | line_statuses1[l1] = line_statuses2.get(l2) 473 | 474 | lines = [] 475 | for lineno, source in enumerate(lines2, start=1): 476 | status = None 477 | reason = None 478 | if lineno not in lineno_map: 479 | # line was added or removed, just use whatever coverage status 480 | # is available as there is nothing to compare against. 481 | status = line_statuses2.get(lineno) 482 | reason = "line-edit" 483 | else: 484 | other_lineno = lineno_map[lineno] 485 | line_status1 = line_statuses1.get(other_lineno) 486 | line_status2 = line_statuses2.get(lineno) 487 | if line_status1 == line_status2: 488 | status = None # unchanged 489 | reason = None 490 | elif (line_status1 == "hit" and line_status2 != "hit") or ( 491 | line_status1 == "partial" and line_status2 == "miss" 492 | ): 493 | status = line_status2 # decreased 494 | reason = "cov-down" 495 | elif (line_status1 != "hit" and line_status2 == "hit") or ( 496 | line_status1 == "miss" and line_status2 == "partial" 497 | ): 498 | status = line_status2 # increased 499 | reason = "cov-up" 500 | 501 | line = Line(lineno, source, status, reason) 502 | lines.append(line) 503 | 504 | return lines 505 | 506 | def file_source_hunks(self, filename): 507 | """ 508 | Like `CoberturaDiff.file_source`, but returns a list of line hunks of 509 | the lines that have changed for the given file `filename`. An empty 510 | list means that the file has no lines that have a change in coverage 511 | status. 512 | """ 513 | lines = self.file_source(filename) 514 | hunks = hunkify_lines(lines) 515 | return hunks 516 | --------------------------------------------------------------------------------