Skip to content
118 changes: 107 additions & 11 deletions pycobertura/cli.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
import click
import lxml.etree as ET

from pycobertura.cobertura import Cobertura, CoberturaDiff
from pycobertura.filesystem import filesystem_factory
from pycobertura.merge import (
merge_reports,
parse_source_pattern,
)
from pycobertura.reporters import (
CsvReporter,
CsvReporterDelta,
GitHubAnnotationReporter,
GitHubAnnotationReporterDelta,
HtmlReporter,
TextReporter,
CsvReporter,
MarkdownReporter,
JsonReporter,
YamlReporter,
HtmlReporterDelta,
TextReporterDelta,
CsvReporterDelta,
MarkdownReporterDelta,
JsonReporter,
JsonReporterDelta,
MarkdownReporter,
MarkdownReporterDelta,
TextReporter,
TextReporterDelta,
YamlReporter,
YamlReporterDelta,
GitHubAnnotationReporterDelta,
)
from pycobertura.filesystem import filesystem_factory
from pycobertura.utils import get_dir_from_file_path
from pycobertura.utils import (
get_dir_from_file_path,
)

pycobertura = click.Group()

Expand Down Expand Up @@ -351,3 +358,92 @@ def diff(

exit_code = get_exit_code(reporter.differ, source)
raise SystemExit(exit_code)


def _source_callback(ctx, param, value):
parsed = []
for v in value:
try:
parsed.append(parse_source_pattern(v))
except ValueError as e:
raise click.BadParameter(str(e), ctx=ctx, param=param)
return parsed


@pycobertura.command(help="""\
Combine multiple Cobertura XML reports into a single merged report.

Hits are summed across reports for matching lines; files are unioned across
reports. Branch coverage is merged by taking the entry with the largest
denominator (most branch outcomes detected, treated as the most complete
view of the line) and, among ties, the largest numerator (most branches
covered).

Use --source to canonicalize file paths that differ across reports (e.g.
between Linux and Windows runs). A --source pattern names a filesystem
prefix that identifies a logical root; the matching prefix is stripped
from each filename. Patterns are anchored: 'src/' matches only at the
start of a path. Use '**/' to match anywhere; '*' matches a single path
segment.

Limitations:
- Cobertura XML records only the fraction of branches covered at each line
(e.g. 1/2), not which specific branches. When reports cover different
branches of the same line, the merged report keeps a single entry (largest
denominator, then largest numerator) rather than unioning the covered
branches, so it may understate the true combined branch coverage.

EXAMPLES

pycobertura merge linux.xml windows.xml \\
--source '/home/runner/work/repo/' \\
--source 'C:/Users/runner/work/repo/'

pycobertura merge a.xml b.xml c.xml -o merged.xml
""")
@click.argument("cobertura_files", nargs=-1, required=True)
@click.option(
"--ignore-regex",
default=None,
type=str,
help="Regex for which files to ignore from each input before merging.",
)
@click.option(
"--source",
"sources",
multiple=True,
metavar="<pattern>",
callback=_source_callback,
help="Filesystem prefix identifying a logical root; the matching "
"prefix is stripped from each filename. Patterns are anchored at "
"the start of the path; use '**/' for match-anywhere (e.g. "
"'**/site-packages/'). Repeatable; first match wins.",
)
@click.option(
"-o",
"--output",
metavar="<file>",
type=click.File("wb"),
help="Write merged XML to <file> instead of stdout.",
)
def merge(cobertura_files, ignore_regex, sources, output):
"""combine multiple Cobertura reports into one"""
roots = []
for path in cobertura_files:
try:
root = ET.parse(path).getroot()
except (ET.XMLSyntaxError, OSError) as e:
raise click.ClickException(f"Failed to read {path}: {e}")
roots.append(root)

merged = merge_reports(roots, sources=sources, ignore_regex=ignore_regex)

report = ET.tostring(
merged,
xml_declaration=True,
encoding="UTF-8",
pretty_print=True,
)

isatty = True if output is None else output.isatty()
click.echo(report, file=output, nl=isatty)
Loading
Loading