From ee90c48096b27f8d9f9d0a3fad4e81ab8614396c Mon Sep 17 00:00:00 2001 From: Pavel Tsialnou <40162322+paveltsialnou@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:32:25 +0200 Subject: [PATCH 1/6] feat: add --fail-under option --- README.md | 6 ++++++ pycobertura/cli.py | 16 ++++++++++++++-- tests/test_cli.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 71a4393..ec59321 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,12 @@ $ pycobertura show --format github-annotation tests/cobertura.xml ::notice file=dummy/dummy4.py,line=1,endLine=6,title=pycobertura::not covered ``` +The following shows how to return a non-zero exit code when the line rate falls below the specified threshold. + +```shell +$ pycobertura show --fail-under=80 cobertura.xml +``` + If you run it in GitHub Actions/Apps, the above log generates check annotations. ![Example output of github-annotation formatted pycobertura show command](images/example_github_annotation_show.png) diff --git a/pycobertura/cli.py b/pycobertura/cli.py index b5e9295..e5a330a 100644 --- a/pycobertura/cli.py +++ b/pycobertura/cli.py @@ -39,6 +39,7 @@ class ExitCodes: EXCEPTION = 1 COVERAGE_WORSENED = 2 NOT_ALL_CHANGES_COVERED = 3 + LINE_RATE_BELOW_THRESHOLD = 4 def get_exit_code(differ: CoberturaDiff, source): @@ -121,6 +122,12 @@ def get_exit_code(differ: CoberturaDiff, source): default=False, help="Sort the summary so files with the most uncovered lines appear first.", ) +@click.option( + "--fail-under", + metavar="", + type=click.FloatRange(0, 100, min_open=True), + help="Return a non-zero code if the line rate falls below the threshold.", +) def show( cobertura_file, ignore_regex, @@ -133,6 +140,7 @@ def show( annotation_level, annotation_title, annotation_message, + fail_under, ): """show coverage summary of a Cobertura report""" @@ -145,8 +153,7 @@ def show( ) Reporter = reporters[format] reporter_kwargs = {} - reporter_kwargs["sort_by_uncovered_lines"] = sort_by_uncovered_lines - + reporter_kwargs["sort_by_uncovered_lines"] = sort_by_uncovered_lines reporter = Reporter(cobertura, ignore_regex, **reporter_kwargs) if format == "csv": @@ -166,6 +173,11 @@ def show( isatty = True if output is None else output.isatty() click.echo(report, file=output, nl=isatty) + if fail_under is not None: + line_rate = 100 * cobertura.line_rate() + if line_rate < fail_under: + raise SystemExit(ExitCodes.LINE_RATE_BELOW_THRESHOLD) + delta_reporters = { "text": TextReporterDelta, diff --git a/tests/test_cli.py b/tests/test_cli.py index 67a2284..725eea2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ import json import os +from pycobertura.cli import ExitCodes import pytest from click.testing import CliRunner @@ -12,6 +13,37 @@ def test_exit_codes(): assert ExitCodes.EXCEPTION == 1 assert ExitCodes.COVERAGE_WORSENED == 2 assert ExitCodes.NOT_ALL_CHANGES_COVERED == 3 + assert ExitCodes.LINE_RATE_BELOW_THRESHOLD == 4 + + +@pytest.mark.parametrize("fail_under, exit_code", ((0.1, ExitCodes.OK), (100, ExitCodes.LINE_RATE_BELOW_THRESHOLD))) +def test_show__fail_under__exit_status(fail_under, exit_code): + from pycobertura.cli import show + + runner = CliRunner() + result = runner.invoke(show, [ + 'tests/dummy.with-dummy2-better-and-worse.xml', # has covered AND uncovered lines + f'--fail-under={fail_under}', + ], catch_exceptions=False) + assert result.exit_code == exit_code + + +@pytest.mark.parametrize('fail_under', (-1, 0, 0.0, 100.1, 101)) +def test_show__fail_under__invalid_value(fail_under): + from pycobertura.cli import show + + runner = CliRunner() + result = runner.invoke( + show, + ['tests/dummp.original.xml', f'--fail-under={fail_under}'], + catch_exceptions=False, + ) + assert result.output == f"""\ +Usage: show [OPTIONS] COBERTURA_FILE +Try 'show --help' for help. + +Error: Invalid value for '--fail-under': {fail_under:.1f} is not in the range 0 Date: Fri, 17 Oct 2025 12:09:19 +0200 Subject: [PATCH 2/6] feat: add --fail-thershold option --- README.md | 4 ++-- pycobertura/cli.py | 18 +++++++++--------- tests/test_cli.py | 36 +++++++++++++++++++++++++++--------- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ec59321..266275e 100644 --- a/README.md +++ b/README.md @@ -219,10 +219,10 @@ $ pycobertura show --format github-annotation tests/cobertura.xml ::notice file=dummy/dummy4.py,line=1,endLine=6,title=pycobertura::not covered ``` -The following shows how to return a non-zero exit code when the line rate falls below the specified threshold. +The following shows how to return a non-zero exit code when the total number of uncovered lines falls above the specified threshold. ```shell -$ pycobertura show --fail-under=80 cobertura.xml +$ pycobertura show --fail-threshold=123 cobertura.xml ``` If you run it in GitHub Actions/Apps, the above log generates check annotations. diff --git a/pycobertura/cli.py b/pycobertura/cli.py index e5a330a..74c9d7c 100644 --- a/pycobertura/cli.py +++ b/pycobertura/cli.py @@ -39,7 +39,7 @@ class ExitCodes: EXCEPTION = 1 COVERAGE_WORSENED = 2 NOT_ALL_CHANGES_COVERED = 3 - LINE_RATE_BELOW_THRESHOLD = 4 + TOTAL_MISSES_ABOVE_THRESHOLD = 4 def get_exit_code(differ: CoberturaDiff, source): @@ -123,10 +123,11 @@ def get_exit_code(differ: CoberturaDiff, source): help="Sort the summary so files with the most uncovered lines appear first.", ) @click.option( - "--fail-under", + "--fail-threshold", metavar="", - type=click.FloatRange(0, 100, min_open=True), - help="Return a non-zero code if the line rate falls below the threshold.", + type=click.IntRange(min=1), + help="Return a non-zero code if the total number of uncovered statements " + "falls above the threshold.", ) def show( cobertura_file, @@ -140,7 +141,7 @@ def show( annotation_level, annotation_title, annotation_message, - fail_under, + fail_threshold, ): """show coverage summary of a Cobertura report""" @@ -173,10 +174,9 @@ def show( isatty = True if output is None else output.isatty() click.echo(report, file=output, nl=isatty) - if fail_under is not None: - line_rate = 100 * cobertura.line_rate() - if line_rate < fail_under: - raise SystemExit(ExitCodes.LINE_RATE_BELOW_THRESHOLD) + if fail_threshold is not None: + if cobertura.total_misses() > fail_threshold: + raise SystemExit(ExitCodes.TOTAL_MISSES_ABOVE_THRESHOLD) delta_reporters = { diff --git a/tests/test_cli.py b/tests/test_cli.py index 725eea2..c20ae08 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,36 +13,54 @@ def test_exit_codes(): assert ExitCodes.EXCEPTION == 1 assert ExitCodes.COVERAGE_WORSENED == 2 assert ExitCodes.NOT_ALL_CHANGES_COVERED == 3 - assert ExitCodes.LINE_RATE_BELOW_THRESHOLD == 4 + assert ExitCodes.TOTAL_MISSES_ABOVE_THRESHOLD == 4 -@pytest.mark.parametrize("fail_under, exit_code", ((0.1, ExitCodes.OK), (100, ExitCodes.LINE_RATE_BELOW_THRESHOLD))) -def test_show__fail_under__exit_status(fail_under, exit_code): +@pytest.mark.parametrize("fail_threshold, exit_code", ((1000, ExitCodes.OK), (1, ExitCodes.TOTAL_MISSES_ABOVE_THRESHOLD))) +def test_show__fail_threshold__exit_status(fail_threshold, exit_code): from pycobertura.cli import show runner = CliRunner() result = runner.invoke(show, [ - 'tests/dummy.with-dummy2-better-and-worse.xml', # has covered AND uncovered lines - f'--fail-under={fail_under}', + 'tests/dummy.original.xml', + f'--fail-threshold={fail_threshold}', ], catch_exceptions=False) assert result.exit_code == exit_code -@pytest.mark.parametrize('fail_under', (-1, 0, 0.0, 100.1, 101)) -def test_show__fail_under__invalid_value(fail_under): +@pytest.mark.parametrize('fail_threshold', (-1, 0)) +def test_show__fail_threshold__invalid_value(fail_threshold): + from pycobertura.cli import show + + runner = CliRunner() + result = runner.invoke( + show, + ['tests/dummp.original.xml', f'--fail-threshold={fail_threshold}'], + catch_exceptions=False, + ) + assert result.output == f"""\ +Usage: show [OPTIONS] COBERTURA_FILE +Try 'show --help' for help. + +Error: Invalid value for '--fail-threshold': {fail_threshold} is not in the range x>=1. +""" + + +@pytest.mark.parametrize('fail_threshold', (42.0, True, False, None)) +def test_show__fail_threshold__invalid_type(fail_threshold): from pycobertura.cli import show runner = CliRunner() result = runner.invoke( show, - ['tests/dummp.original.xml', f'--fail-under={fail_under}'], + ['tests/dummp.original.xml', f'--fail-threshold={fail_threshold}'], catch_exceptions=False, ) assert result.output == f"""\ Usage: show [OPTIONS] COBERTURA_FILE Try 'show --help' for help. -Error: Invalid value for '--fail-under': {fail_under:.1f} is not in the range 0 Date: Sun, 9 Nov 2025 20:12:04 +0100 Subject: [PATCH 3/6] Update README.md Co-authored-by: Alexandre Conrad-Dormoy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 266275e..70ed5c5 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ $ pycobertura show --format github-annotation tests/cobertura.xml ::notice file=dummy/dummy4.py,line=1,endLine=6,title=pycobertura::not covered ``` -The following shows how to return a non-zero exit code when the total number of uncovered lines falls above the specified threshold. +The following shows how to return a non-zero exit code when the total number of uncovered lines exceeds the specified threshold. ```shell $ pycobertura show --fail-threshold=123 cobertura.xml From dafad6c51045ce7aa68625ce140198e9b7f32037 Mon Sep 17 00:00:00 2001 From: Pavel Tsialnou <40162322+paveltsialnou@users.noreply.github.com> Date: Sun, 9 Nov 2025 20:12:54 +0100 Subject: [PATCH 4/6] Update pycobertura/cli.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pycobertura/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pycobertura/cli.py b/pycobertura/cli.py index 74c9d7c..9863742 100644 --- a/pycobertura/cli.py +++ b/pycobertura/cli.py @@ -127,7 +127,7 @@ def get_exit_code(differ: CoberturaDiff, source): metavar="", type=click.IntRange(min=1), help="Return a non-zero code if the total number of uncovered statements " - "falls above the threshold.", + "exceeds the threshold.", ) def show( cobertura_file, From 564d66f3a0f6a2d5400c41420d825fb18d4f3058 Mon Sep 17 00:00:00 2001 From: Pavel Tsialnou <40162322+paveltsialnou@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:36:27 +0100 Subject: [PATCH 5/6] Fix typo in test file for dummy XML --- pycobertura/cli.py | 2 +- tests/test_cli.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pycobertura/cli.py b/pycobertura/cli.py index 9863742..86b8d1c 100644 --- a/pycobertura/cli.py +++ b/pycobertura/cli.py @@ -154,7 +154,7 @@ def show( ) Reporter = reporters[format] reporter_kwargs = {} - reporter_kwargs["sort_by_uncovered_lines"] = sort_by_uncovered_lines + reporter_kwargs["sort_by_uncovered_lines"] = sort_by_uncovered_lines reporter = Reporter(cobertura, ignore_regex, **reporter_kwargs) if format == "csv": diff --git a/tests/test_cli.py b/tests/test_cli.py index c20ae08..e699e82 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -35,7 +35,7 @@ def test_show__fail_threshold__invalid_value(fail_threshold): runner = CliRunner() result = runner.invoke( show, - ['tests/dummp.original.xml', f'--fail-threshold={fail_threshold}'], + ['tests/dummy.original.xml', f'--fail-threshold={fail_threshold}'], catch_exceptions=False, ) assert result.output == f"""\ @@ -53,7 +53,7 @@ def test_show__fail_threshold__invalid_type(fail_threshold): runner = CliRunner() result = runner.invoke( show, - ['tests/dummp.original.xml', f'--fail-threshold={fail_threshold}'], + ['tests/dummy.original.xml', f'--fail-threshold={fail_threshold}'], catch_exceptions=False, ) assert result.output == f"""\ From 577c1b0ac528543ad5e557b334a8af15482d9219 Mon Sep 17 00:00:00 2001 From: Pavel Tsialnou <40162322+paveltsialnou@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:51:15 +0100 Subject: [PATCH 6/6] Add `--fail-threshold` option for exit code handling Add `--fail-threshold` option to return non-zero exit code for uncovered lines exceeding threshold. --- CHANGES.md | 1 + pycobertura/cli.py | 6 ++---- pycobertura/reporters.py | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 24a72a4..bbe08ca 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ * Add `--sort-by-uncovered-lines` to sort output, so the file with the most missing lines is at the top. Thanks @guettli * Fix: JSON and YAML output when using `--ignore-regex` in `show` command. Thanks @OidaTiftla +* Add `--fail-threshold` to return a non-zero exit code when the total number of uncovered lines exceeds the specified threshold ## 4.1.0 (2025-04-13) diff --git a/pycobertura/cli.py b/pycobertura/cli.py index 86b8d1c..163368c 100644 --- a/pycobertura/cli.py +++ b/pycobertura/cli.py @@ -190,8 +190,7 @@ def show( } -@pycobertura.command( - help="""\ +@pycobertura.command(help="""\ The diff command compares and shows the changes between two Cobertura reports. NOTE: Reporting missing lines or showing the source code with the diff command @@ -202,8 +201,7 @@ def show( options `--source1` and `--source2` are necessary to point to the source code directories (or zip archives). If the source is not available at all, pass `--no-source` but missing lines and source code will not be reported. -""" -) +""") @click.argument("cobertura_file1") @click.argument("cobertura_file2") @click.option( diff --git a/pycobertura/reporters.py b/pycobertura/reporters.py index eef7a2f..29a8c05 100644 --- a/pycobertura/reporters.py +++ b/pycobertura/reporters.py @@ -13,7 +13,6 @@ import json import io - env = Environment(loader=PackageLoader("pycobertura", "templates")) env.filters["line_status"] = filters.line_status env.filters["line_reason"] = filters.line_reason_icon