├── 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 |
6 |
7 | {%- for line in lines -%}
8 | {{ line.number }} {{ line|line_reason }}
9 | {% endfor -%}
10 |
11 | |
12 |
13 |
14 | {%- for line in lines -%}
15 | {{ line.source|escape }}
16 | {%- endfor -%}
17 |
18 | |
19 |
20 |
21 |
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 | | Filename |
22 | Stmts |
23 | Miss |
24 | Cover |
25 | Missing |
26 |
27 |
28 |
29 | {%- for line in lines["Filename"] %}
30 |
31 | {%- if sources %}
32 | | {{ lines["Filename"][loop.index0] }} |
33 | {%- else %}
34 | {{ lines["Filename"][loop.index0] }} |
35 | {%- endif %}
36 | {{ lines["Stmts"][loop.index0] }} |
37 | {{ lines["Miss"][loop.index0] }} |
38 | {{ lines["Cover"][loop.index0] }} |
39 | {{ lines["Missing"][loop.index0] }} |
40 |
41 | {%- endfor %}
42 |
43 |
44 |
45 | | {{ footer["Filename"] }} |
46 | {{ footer["Stmts"] }} |
47 | {{ footer["Miss"] }} |
48 | {{ footer["Cover"] }} |
49 | {{ footer["Missing"] }} |
50 |
51 |
52 |
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 |
51 |
--------------------------------------------------------------------------------
/pycobertura/templates/html-delta.jinja2:
--------------------------------------------------------------------------------
1 |
2 |
3 | pycobertura report
4 |
5 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | | Filename |
30 | Stmts |
31 | Miss |
32 | Cover |
33 | {%- if show_source and show_missing %}
34 | Missing |
35 | {%- endif %}
36 |
37 |
38 |
39 | {%- for file_index in range(lines["Filename"]|length) %}
40 |
41 | | {{ lines["Filename"][file_index]}} |
42 | {{ lines["Stmts"][file_index] }} |
43 | {{ lines["Miss"][file_index] }} |
44 | {{ lines["Cover"][file_index] }} |
45 | {%- if show_source and show_missing %}
46 |
47 | {%- for missed_line in lines["Missing"][file_index] %}
48 | {%- if not loop.first %}, {% endif %}{{ missed_line[0] }}
49 | {%- endfor %}
50 | |
51 | {%- endif %}
52 |
53 | {%- endfor %}
54 |
55 |
56 |
57 | | {{ footer["Filename"] }} |
58 | {{ footer["Stmts"] }} |
59 | |
60 | {{ footer["Cover"] }} |
61 | {%- if show_source and show_missing %}
62 | |
63 | {%- endif %}
64 |
65 |
66 |
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 |
--------------------------------------------------------------------------------