diff --git a/pycobertura/cli.py b/pycobertura/cli.py index 163368c..9ab4382 100644 --- a/pycobertura/cli.py +++ b/pycobertura/cli.py @@ -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() @@ -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="", + 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="", + type=click.File("wb"), + help="Write merged XML to 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) diff --git a/pycobertura/merge.py b/pycobertura/merge.py new file mode 100644 index 0000000..2947783 --- /dev/null +++ b/pycobertura/merge.py @@ -0,0 +1,391 @@ +import copy +import re +import time +from typing import Dict, List, Sequence, Tuple, Union + +import lxml.etree as ET + +from pycobertura.utils import ( + calculate_line_rate, + get_line_status, + make_filename_ignore_matcher, +) + +try: + from importlib.metadata import version as _pkg_version +except ImportError: # pragma: no cover + from importlib_metadata import version as _pkg_version + + +_CONDITION_COVERAGE_RE = re.compile(r"^\s*(\d+)%\s*\((\d+)/(\d+)\)\s*$") + + +def parse_source_pattern(pattern: str) -> List[str]: + """Validate and tokenize a `--source` value into segments. + + Each segment is a literal directory name, `*` (one segment), or + `**` (zero or more segments). Leading and trailing `/` are + cosmetic; backslashes are normalized to forward slashes. + + Raises `ValueError` for in-segment wildcards (`*name`, `lib*`), + empty segments (`a//b`), an empty pattern, or a pattern that + reduces to a single `**` (which would match everything). + """ + body = pattern.replace("\\", "/") + if body.startswith("/"): + body = body[1:] + if body.endswith("/"): + body = body[:-1] + + if not body: + raise ValueError(f"--source pattern is empty: {pattern!r}") + + segments = body.split("/") + for seg in segments: + if not seg: + raise ValueError(f"--source pattern has empty segments: {pattern!r}") + if seg in ("*", "**"): + continue + if "*" in seg: + raise ValueError( + f"--source pattern uses an in-segment wildcard in {seg!r}; " + f"only whole-segment '*' and '**' are supported: {pattern!r}" + ) + + if segments == ["**"]: + raise ValueError(f"--source pattern '**' matches everything: {pattern!r}") + + return segments + + +def canonicalize_filename(filename: str, patterns: Sequence[Sequence[str]]) -> str: + """Normalize separators and apply source remapping to `filename`. + + `patterns` is the list of segment-lists produced by + `parse_source_pattern`. Each pattern is matched anchored at the + first segment (after a single leading `/` is stripped for matching). + `*` matches one segment; `**` matches zero or more, leftmost. + The first pattern that matches wins; the canonical name is the + remaining segments joined by `/`. If no pattern matches, the + filename is returned with backslashes normalized to forward slashes + only. + """ + norm = filename.replace("\\", "/") + if not patterns: + return norm + + work = norm[1:] if norm.startswith("/") else norm + segments = work.split("/") + for pattern_segs in patterns: + if not pattern_segs: + continue + rest = _match_prefix(segments, list(pattern_segs)) + if rest is not None: + return "/".join(rest) + return norm + + +def _match_prefix(segments: List[str], pattern: List[str]) -> Union[List[str], None]: + """Match `pattern` anchored at the start of `segments`. + + Returns the remaining segments after the matched prefix, or `None` + if no match. `**` chooses the leftmost match (fewest consumed + segments such that the rest of the pattern still matches). + """ + if not pattern: + return segments + head = pattern[0] + rest = pattern[1:] + if head == "**": + # Try consuming 0, 1, 2, ... segments — leftmost match. + for k in range(0, len(segments) + 1): + tail = _match_prefix(segments[k:], rest) + if tail is not None: + return tail + return None + if not segments: + return None + if head == "*" or head == segments[0]: + return _match_prefix(segments[1:], rest) + return None + + +def merge_reports( + xml_roots: Sequence[ET._Element], + sources: Sequence[Sequence[str]] = (), + ignore_regex: str = None, +) -> ET._Element: + """Merge a sequence of Cobertura `` roots into one fresh root. + + Inputs are treated as read-only. If `ignore_regex` is given, any + `` whose (pre-canonicalization) `filename` matches it is dropped + from every input before merging; the value is interpreted exactly as in + the rest of the CLI (a regex, or a path to a file of `fnmatch` patterns). + """ + if not xml_roots: + raise ValueError("merge_reports requires at least one input root") + + is_ignored = make_filename_ignore_matcher(ignore_regex) if ignore_regex else None + + sources_seen: Dict[str, None] = {} # stdlib OrderedSet + # packages_data: package_name -> + # dict[(class_name, canonical_filename) -> list[]] + packages_data: Dict[str, Dict[Tuple[str, str], List[ET._Element]]] = {} + + for root in xml_roots: + for src in root.iterfind("./sources/source"): + text = (src.text or "").strip() + if text: + sources_seen.setdefault(text, None) + + for pkg in root.iterfind("./packages/package"): + pkg_name = pkg.get("name", "") or "" + pkg_classes = packages_data.setdefault(pkg_name, {}) + for cls in pkg.iterfind("./classes/class"): + filename = cls.get("filename", "") or "" + if is_ignored is not None and is_ignored(filename): + continue + class_name = cls.get("name", "") or "" + canonical = canonicalize_filename(filename, sources) + key = (class_name, canonical) + pkg_classes.setdefault(key, []).append(cls) + + new_root = ET.Element("coverage") + new_root.set("timestamp", str(int(time.time() * 1000))) + new_root.set("version", _pycobertura_version()) + new_root.append( + ET.Comment( + " Generated by pycobertura merge: https://github.com/aconrad/pycobertura " + ) + ) + + sources_elem = ET.SubElement(new_root, "sources") + for src_text in sources_seen: + s = ET.SubElement(sources_elem, "source") + s.text = src_text + + packages_elem = ET.SubElement(new_root, "packages") + + total_lines = 0 + total_hits = 0 + total_branches = 0 + total_branch_hits = 0 + + for pkg_name, classes_map in packages_data.items(): + pkg_elem = ET.SubElement(packages_elem, "package") + pkg_elem.set("name", pkg_name) + classes_elem = ET.SubElement(pkg_elem, "classes") + + pkg_lines = 0 + pkg_hits = 0 + pkg_branches = 0 + pkg_branch_hits = 0 + + for (class_name, canonical_filename), versions in classes_map.items(): + cls_elem, c_lines, c_hits, c_branches, c_branch_hits = _merge_class( + class_name, canonical_filename, versions + ) + classes_elem.append(cls_elem) + + pkg_lines += c_lines + pkg_hits += c_hits + pkg_branches += c_branches + pkg_branch_hits += c_branch_hits + + pkg_elem.set("line-rate", _rate_str(pkg_hits, pkg_lines)) + pkg_elem.set("branch-rate", _rate_str(pkg_branch_hits, pkg_branches)) + + total_lines += pkg_lines + total_hits += pkg_hits + total_branches += pkg_branches + total_branch_hits += pkg_branch_hits + + new_root.set("line-rate", _rate_str(total_hits, total_lines)) + new_root.set("branch-rate", _rate_str(total_branch_hits, total_branches)) + new_root.set("lines-valid", str(total_lines)) + new_root.set("lines-covered", str(total_hits)) + new_root.set("branches-valid", str(total_branches)) + new_root.set("branches-covered", str(total_branch_hits)) + + return new_root + + +def _merge_class( + class_name: str, + canonical_filename: str, + versions: Sequence[ET._Element], +) -> Tuple[ET._Element, int, int, int, int]: + cls_elem = ET.Element("class") + cls_elem.set("name", class_name) + cls_elem.set("filename", canonical_filename) + + # Group method line-elements by (name, signature) -> + # dict[line_number -> list[]] + methods_data: Dict[Tuple[str, str], Dict[int, List[ET._Element]]] = {} + # Class-level lines (direct ./lines/line on ): line_number -> list[] + class_lines: Dict[int, List[ET._Element]] = {} + + for v in versions: + for m in v.iterfind("./methods/method"): + key = (m.get("name", "") or "", m.get("signature", "") or "") + method_lines = methods_data.setdefault(key, {}) + for line in m.iterfind("./lines/line"): + n = int(line.get("number")) + method_lines.setdefault(n, []).append(line) + + for line in v.iterfind("./lines/line"): + n = int(line.get("number")) + class_lines.setdefault(n, []).append(line) + + methods_elem = ET.SubElement(cls_elem, "methods") + for (m_name, m_sig), lines_by_number in methods_data.items(): + m_elem = ET.SubElement(methods_elem, "method") + m_elem.set("name", m_name) + m_elem.set("signature", m_sig) + m_lines_elem = ET.SubElement(m_elem, "lines") + m_lines_count, m_hits, m_branches, m_branch_hits = _emit_merged_lines( + lines_by_number, m_lines_elem + ) + m_elem.set("line-rate", _rate_str(m_hits, m_lines_count)) + m_elem.set("branch-rate", _rate_str(m_branch_hits, m_branches)) + + # Class-level : merge from inputs' direct ./lines/line. + # For inputs that store data only in methods (class-level is empty + # or absent), fall back to the union of merged method lines so the + # class-level block is populated. + cls_lines_elem = ET.SubElement(cls_elem, "lines") + if class_lines: + c_total, c_hits, c_branches, c_branch_hits = _emit_merged_lines( + class_lines, cls_lines_elem + ) + else: + # Build from union of merged method lines. + union: Dict[int, List[ET._Element]] = {} + for lines_by_number in methods_data.values(): + for n, line_elems in lines_by_number.items(): + union.setdefault(n, []).extend(line_elems) + c_total, c_hits, c_branches, c_branch_hits = _emit_merged_lines( + union, cls_lines_elem + ) + + cls_elem.set("line-rate", _rate_str(c_hits, c_total)) + cls_elem.set("branch-rate", _rate_str(c_branch_hits, c_branches)) + + # Complexity is a property of the source, not the coverage run, so the same + # class should carry the same value across inputs; max guards against drift + # (e.g. conditional compilation). It is kept at the class level only: the + # Cobertura format uses average complexity at the package/root level, which + # cannot be faithfully recomputed from merged class values, so aggregate + # complexity is deliberately not emitted rather than invented. + complexity = None + for v in versions: + c = v.get("complexity") + if c is None: + continue + try: + f = float(c) + except ValueError: + continue + complexity = f if complexity is None else max(complexity, f) + if complexity is not None: + cls_elem.set("complexity", _format_float(complexity)) + + return cls_elem, c_total, c_hits, c_branches, c_branch_hits + + +def _emit_merged_lines( + lines_by_number: Dict[int, List[ET._Element]], + parent: ET._Element, +) -> Tuple[int, int, int, int]: + """Append merged children to `parent`. + + Returns (total, hits, branches, branch_hits). + """ + total = 0 + hits_count = 0 + branches = 0 + branch_hits = 0 + + for n in sorted(lines_by_number): + line_elems = lines_by_number[n] + merged = _merge_line_elements(n, line_elems) + parent.append(merged) + total += 1 + if get_line_status(merged) == "hit": + hits_count += 1 + cc = merged.get("condition-coverage") + if cc: + parsed = _parse_condition_coverage(cc) + if parsed is not None: + a, b = parsed + branches += b + branch_hits += a + + return total, hits_count, branches, branch_hits + + +def _merge_line_elements( + line_no: int, line_elems: Sequence[ET._Element] +) -> ET._Element: + new_line = ET.Element("line") + new_line.set("number", str(line_no)) + + total_hits = sum(int(el.get("hits", "0") or "0") for el in line_elems) + new_line.set("hits", str(total_hits)) + + branch_elems = [el for el in line_elems if el.get("branch") == "true"] + is_branch = bool(branch_elems) + new_line.set("branch", "true" if is_branch else "false") + + if not is_branch: + return new_line + + parsed_entries = [] # list of (a, b, source_element) + for el in branch_elems: + cc = el.get("condition-coverage") + if not cc: + continue + parsed = _parse_condition_coverage(cc) + if parsed is None: + continue + a, b = parsed + parsed_entries.append((a, b, el)) + + if not parsed_entries: + return new_line + + # Largest denominator wins (most complete view of branch outcomes); break + # ties by largest numerator (most branches actually covered). + best_a, best_b, best_el = max(parsed_entries, key=lambda x: (x[1], x[0])) + pct = int(round(100 * best_a / best_b)) if best_b > 0 else 0 + new_line.set("condition-coverage", f"{pct}% ({best_a}/{best_b})") + + conditions = best_el.find("conditions") + if conditions is not None: + new_line.append(copy.deepcopy(conditions)) + + return new_line + + +def _parse_condition_coverage(cc: str) -> Union[Tuple[int, int], None]: + m = _CONDITION_COVERAGE_RE.match(cc) + if not m: + return None + return int(m.group(2)), int(m.group(3)) + + +def _rate_str(numerator: int, denominator: int) -> str: + # numerator is the covered count; calculate_line_rate takes total and misses. + return _format_float(calculate_line_rate(denominator, denominator - numerator)) + + +def _format_float(value: float) -> str: + # Match the formatting style of typical Cobertura output (e.g. "0.875"). + return f"{value:.6g}" + + +def _pycobertura_version() -> str: + try: + return _pkg_version("pycobertura") + except Exception: # pragma: no cover + return "" diff --git a/pycobertura/utils.py b/pycobertura/utils.py index 492dc2b..d9a8f57 100644 --- a/pycobertura/utils.py +++ b/pycobertura/utils.py @@ -268,21 +268,29 @@ def get_non_empty_non_commented_lines_from_file_in_ascii(file_path, comment_char return [res for res in result if res != ""] -def get_filenames_that_do_not_match_regex( - filenames, regex_param, comment_character="#" -): +def make_filename_ignore_matcher(regex_param, comment_character="#"): + """Return a predicate that is True for filenames that should be ignored. + + `regex_param` is either a path to a file of `fnmatch` patterns (one per + line, `#` comments) or a regular expression matched against the start of + each filename. The matcher is built once and can then be applied per file. + """ if os.path.isfile(regex_param): ignore_patterns = get_non_empty_non_commented_lines_from_file_in_ascii( regex_param, comment_character ) - remove_filenames = [ - filename - for igp in ignore_patterns - for filename in fnmatch.filter(filenames, igp) - ] - else: - remove_filenames = list(filter(re.compile(regex_param).match, filenames)) - return [fname for fname in filenames if fname not in remove_filenames] + return lambda filename: any( + fnmatch.fnmatch(filename, igp) for igp in ignore_patterns + ) + pattern = re.compile(regex_param) + return lambda filename: pattern.match(filename) is not None + + +def get_filenames_that_do_not_match_regex( + filenames, regex_param, comment_character="#" +): + ignore = make_filename_ignore_matcher(regex_param, comment_character) + return [fname for fname in filenames if not ignore(fname)] def get_line_status(line): diff --git a/tests/merge-branch-a.xml b/tests/merge-branch-a.xml new file mode 100644 index 0000000..d9dd40b --- /dev/null +++ b/tests/merge-branch-a.xml @@ -0,0 +1,22 @@ + + + + . + + + + + + + + + + + + + + + + + + diff --git a/tests/merge-branch-b.xml b/tests/merge-branch-b.xml new file mode 100644 index 0000000..7cc74d0 --- /dev/null +++ b/tests/merge-branch-b.xml @@ -0,0 +1,22 @@ + + + + . + + + + + + + + + + + + + + + + + + diff --git a/tests/merge-branch-conflict-a.xml b/tests/merge-branch-conflict-a.xml new file mode 100644 index 0000000..d9dd40b --- /dev/null +++ b/tests/merge-branch-conflict-a.xml @@ -0,0 +1,22 @@ + + + + . + + + + + + + + + + + + + + + + + + diff --git a/tests/merge-branch-conflict-b.xml b/tests/merge-branch-conflict-b.xml new file mode 100644 index 0000000..44fd916 --- /dev/null +++ b/tests/merge-branch-conflict-b.xml @@ -0,0 +1,22 @@ + + + + . + + + + + + + + + + + + + + + + + + diff --git a/tests/merge-linux.xml b/tests/merge-linux.xml new file mode 100644 index 0000000..4cb0b24 --- /dev/null +++ b/tests/merge-linux.xml @@ -0,0 +1,19 @@ + + + + /home/runner/work/repo + + + + + + + + + + + + + + + diff --git a/tests/merge-overlap-a.xml b/tests/merge-overlap-a.xml new file mode 100644 index 0000000..f027789 --- /dev/null +++ b/tests/merge-overlap-a.xml @@ -0,0 +1,22 @@ + + + + . + + + + + + + + + + + + + + + + + + diff --git a/tests/merge-overlap-b.xml b/tests/merge-overlap-b.xml new file mode 100644 index 0000000..9e7aa61 --- /dev/null +++ b/tests/merge-overlap-b.xml @@ -0,0 +1,22 @@ + + + + . + + + + + + + + + + + + + + + + + + diff --git a/tests/merge-python.xml b/tests/merge-python.xml new file mode 100644 index 0000000..7937808 --- /dev/null +++ b/tests/merge-python.xml @@ -0,0 +1,26 @@ + + + + app + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/merge-rust.xml b/tests/merge-rust.xml new file mode 100644 index 0000000..af8c71e --- /dev/null +++ b/tests/merge-rust.xml @@ -0,0 +1,28 @@ + + + + src + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/merge-windows.xml b/tests/merge-windows.xml new file mode 100644 index 0000000..88633a9 --- /dev/null +++ b/tests/merge-windows.xml @@ -0,0 +1,19 @@ + + + + C:\Users\runner\work\repo + + + + + + + + + + + + + + + diff --git a/tests/test_cli.py b/tests/test_cli.py index e699e82..5d451a2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1255,3 +1255,148 @@ def test_diff__format_yaml__with_ignore_regex(): # Verify total is present assert 'Filename: TOTAL' in result.output + + +# ---- merge command ---- + + +def test_merge__stdout_output(): + from pycobertura.cli import merge + + runner = CliRunner() + result = runner.invoke( + merge, + ['tests/merge-rust.xml', 'tests/merge-python.xml'], + catch_exceptions=False, + ) + assert result.exit_code == 0 + # Output is XML; check it parses and contains expected files. + import lxml.etree as ET + root = ET.fromstring(result.output.encode("utf-8") if isinstance(result.output, str) else result.output) + files = sorted(c.get("filename") for c in root.xpath(".//class")) + assert files == sorted(["src/lib.rs", "src/parser.rs", "app/main.py", "app/util.py"]) + + +def test_merge__output_to_file(tmp_path): + from pycobertura.cli import merge + + runner = CliRunner() + out = tmp_path / "merged.xml" + result = runner.invoke( + merge, + ['tests/merge-rust.xml', 'tests/merge-python.xml', '-o', str(out)], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert out.exists() + import lxml.etree as ET + root = ET.parse(str(out)).getroot() + assert root.tag == "coverage" + + +def test_merge__single_input_roundtrip(): + from pycobertura.cli import merge + + runner = CliRunner() + result = runner.invoke( + merge, + ['tests/merge-overlap-a.xml'], + catch_exceptions=False, + ) + assert result.exit_code == 0 + + +def test_merge__requires_at_least_one_input(): + from pycobertura.cli import merge + + runner = CliRunner() + result = runner.invoke(merge, [], catch_exceptions=False) + assert result.exit_code != 0 + + +def test_merge__source_unifies_cross_os_paths(): + from pycobertura.cli import merge + + runner = CliRunner() + result = runner.invoke( + merge, + [ + 'tests/merge-linux.xml', + 'tests/merge-windows.xml', + '--source', '/home/runner/work/repo/', + '--source', 'C:/Users/runner/work/repo/', + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + import lxml.etree as ET + root = ET.fromstring(result.output.encode("utf-8") if isinstance(result.output, str) else result.output) + files = sorted(c.get("filename") for c in root.xpath(".//class")) + assert files == ["src/main.py"] + + +def test_merge__source_rejects_in_segment_wildcard(): + from pycobertura.cli import merge + + runner = CliRunner() + result = runner.invoke( + merge, + ['tests/merge-rust.xml', '--source', 'src/*name/'], + catch_exceptions=False, + ) + assert result.exit_code != 0 + assert "in-segment wildcard" in result.output + + +def test_merge__source_rejects_lone_double_star(): + from pycobertura.cli import merge + + runner = CliRunner() + result = runner.invoke( + merge, + ['tests/merge-rust.xml', '--source', '**/'], + catch_exceptions=False, + ) + assert result.exit_code != 0 + assert "matches everything" in result.output + + +def test_merge__branch_denominator_mismatch_succeeds(): + from pycobertura.cli import merge + + runner = CliRunner() + result = runner.invoke( + merge, + [ + 'tests/merge-branch-conflict-a.xml', # 1/2 + 'tests/merge-branch-conflict-b.xml', # 1/3 + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + import lxml.etree as ET + root = ET.fromstring(result.output.encode("utf-8") if isinstance(result.output, str) else result.output) + line = root.xpath(".//class[@filename='app/main.py']/lines/line[@number='10']")[0] + # Largest denominator wins: 1/3 from the second input. + assert line.get("condition-coverage") == "33% (1/3)" + + +def test_merge__ignore_regex_drops_files_pre_merge(): + from pycobertura.cli import merge + + runner = CliRunner() + result = runner.invoke( + merge, + [ + 'tests/merge-rust.xml', + 'tests/merge-python.xml', + '--ignore-regex', r'.*\.rs$', + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + import lxml.etree as ET + root = ET.fromstring(result.output.encode("utf-8") if isinstance(result.output, str) else result.output) + files = sorted(c.get("filename") for c in root.xpath(".//class")) + assert all(not f.endswith(".rs") for f in files) + assert "app/main.py" in files diff --git a/tests/test_merge.py b/tests/test_merge.py new file mode 100644 index 0000000..3cfd2e3 --- /dev/null +++ b/tests/test_merge.py @@ -0,0 +1,409 @@ +import lxml.etree as ET +import pytest + +from pycobertura.merge import ( + canonicalize_filename, + merge_reports, + parse_source_pattern, +) + +# ---- parse_source_pattern ---- + + +def test_parse_source_pattern_literal(): + assert parse_source_pattern("src/") == ["src"] + + +def test_parse_source_pattern_multi_segment(): + assert parse_source_pattern("a/b/c/") == ["a", "b", "c"] + + +def test_parse_source_pattern_trailing_slash_optional(): + assert parse_source_pattern("src") == ["src"] + assert parse_source_pattern("a/b/c") == ["a", "b", "c"] + + +def test_parse_source_pattern_leading_slash_optional(): + assert parse_source_pattern("/src/") == ["src"] + assert parse_source_pattern("/src") == ["src"] + assert parse_source_pattern("/Users/jdoe/proj/") == ["Users", "jdoe", "proj"] + + +def test_parse_source_pattern_normalizes_backslashes(): + assert parse_source_pattern("C:\\Users\\runner\\repo\\") == [ + "C:", "Users", "runner", "repo" + ] + assert parse_source_pattern("C:\\Users\\runner\\repo") == [ + "C:", "Users", "runner", "repo" + ] + + +def test_parse_source_pattern_double_star_segment(): + assert parse_source_pattern("**/site-packages/") == ["**", "site-packages"] + assert parse_source_pattern("**/a/b/") == ["**", "a", "b"] + + +def test_parse_source_pattern_single_star_segment(): + assert parse_source_pattern("*/foo.py") == ["*", "foo.py"] + assert parse_source_pattern("**/build/*/src/") == [ + "**", "build", "*", "src" + ] + + +def test_parse_source_pattern_rejects_in_segment_wildcard(): + with pytest.raises(ValueError, match="in-segment wildcard"): + parse_source_pattern("src/*name/") + with pytest.raises(ValueError, match="in-segment wildcard"): + parse_source_pattern("src/lib*/") + with pytest.raises(ValueError, match="in-segment wildcard"): + parse_source_pattern("src/na*me/") + + +def test_parse_source_pattern_rejects_lone_double_star(): + with pytest.raises(ValueError, match="matches everything"): + parse_source_pattern("**/") + with pytest.raises(ValueError, match="matches everything"): + parse_source_pattern("**") + + +def test_parse_source_pattern_rejects_empty(): + with pytest.raises(ValueError, match="empty"): + parse_source_pattern("") + with pytest.raises(ValueError, match="empty"): + parse_source_pattern("/") + + +def test_parse_source_pattern_rejects_empty_segment(): + with pytest.raises(ValueError, match="empty segments"): + parse_source_pattern("a//b/") + + +# ---- canonicalize_filename ---- + + +def test_canonicalize_filename_no_patterns_normalizes_separators(): + assert canonicalize_filename(r"a\b\c.py", []) == "a/b/c.py" + + +def test_canonicalize_filename_strips_project_root_linux(): + patterns = [["Users", "jdoe", "proj"]] + assert ( + canonicalize_filename("/Users/jdoe/proj/src/foo.py", patterns) + == "src/foo.py" + ) + + +def test_canonicalize_filename_strips_project_root_windows(): + patterns = [["C:", "Users", "runner", "repo"]] + assert ( + canonicalize_filename(r"C:\Users\runner\repo\src\foo.py", patterns) + == "src/foo.py" + ) + + +def test_canonicalize_filename_cross_os_unification(): + patterns = [ + ["Users", "jdoe", "proj"], + ["C:", "Users", "runner", "repo"], + ] + a = canonicalize_filename("/Users/jdoe/proj/src/foo.py", patterns) + b = canonicalize_filename( + r"C:\Users\runner\repo\src\foo.py", patterns + ) + assert a == b == "src/foo.py" + + +def test_canonicalize_filename_strips_single_segment_prefix(): + patterns = [["src"]] + assert canonicalize_filename("src/foo.py", patterns) == "foo.py" + + +def test_canonicalize_filename_matches_multi_segment_pattern(): + patterns = [["src", "main"]] + assert ( + canonicalize_filename("src/main/python/app.py", patterns) + == "python/app.py" + ) + + +def test_canonicalize_filename_double_star_leftmost(): + patterns = [["**", "site-packages"]] + assert ( + canonicalize_filename( + "/usr/lib/python3.11/site-packages/mypkg/__init__.py", patterns + ) + == "mypkg/__init__.py" + ) + # Two site-packages — leftmost wins. + assert ( + canonicalize_filename( + "/a/site-packages/b/site-packages/c.py", patterns + ) + == "b/site-packages/c.py" + ) + + +def test_canonicalize_filename_double_star_matches_zero_segments(): + patterns = [["**", "src"]] + assert canonicalize_filename("src/foo.py", patterns) == "foo.py" + + +def test_canonicalize_filename_single_star_one_segment(): + patterns = [["*", "foo.py"]] + # '*' matches 'a', then 'foo.py' matches; whole path consumed. + assert canonicalize_filename("a/foo.py", patterns) == "" + # 'a/b/foo.py' has 'b' as second segment, not 'foo.py' → no match. + assert ( + canonicalize_filename("a/b/foo.py", patterns) == "a/b/foo.py" + ) + + +def test_canonicalize_filename_anchored_not_match_anywhere(): + patterns = [["src"]] + # Without leading '**', a pattern only matches at the start. + assert ( + canonicalize_filename("/home/runner/repo/src/foo.py", patterns) + == "/home/runner/repo/src/foo.py" + ) + + +def test_canonicalize_filename_no_match_normalizes_only(): + patterns = [["src"]] + assert canonicalize_filename(r"foo\bar\baz.py", patterns) == "foo/bar/baz.py" + + +def test_canonicalize_filename_no_match_preserves_leading_slash(): + patterns = [["src"]] + assert ( + canonicalize_filename("/some/random/path.py", patterns) + == "/some/random/path.py" + ) + + +def test_canonicalize_filename_first_pattern_wins(): + patterns = [["src"], ["*"]] + # Both could match; first declared wins. + assert canonicalize_filename("src/foo.py", patterns) == "foo.py" + + +def test_canonicalize_filename_partial_segment_does_not_match(): + patterns = [["src"]] + # 'mysrc' should not match 'src'. + assert ( + canonicalize_filename("mysrc/bar.py", patterns) == "mysrc/bar.py" + ) + + +# ---- merge_reports ---- + + +def _parse(path): + return ET.parse(path).getroot() + + +def _files(root): + return sorted(cls.get("filename") for cls in root.xpath(".//class")) + + +def _line(root, filename, lineno): + """Return the merged element for (filename, lineno) at the class level.""" + classes = root.xpath(f".//class[@filename={filename!r}]") + for cls in classes: + for line in cls.xpath("./lines/line"): + if int(line.get("number")) == lineno: + return line + return None + + +def test_merge_disjoint_files(): + a = _parse("tests/merge-rust.xml") + b = _parse("tests/merge-python.xml") + merged = merge_reports([a, b]) + files = _files(merged) + assert files == sorted( + ["src/lib.rs", "src/parser.rs", "app/main.py", "app/util.py"] + ) + + +def test_merge_overlapping_files_sums_hits_and_unions_lines(): + a = _parse("tests/merge-overlap-a.xml") + b = _parse("tests/merge-overlap-b.xml") + merged = merge_reports([a, b]) + + # Line 3: hit=2 (a) + 1 (b) = 3 + assert _line(merged, "app/main.py", 3).get("hits") == "3" + # Line 1: only in a, hits=2 + assert _line(merged, "app/main.py", 1).get("hits") == "2" + # Line 7: only in b, hits=0 + assert _line(merged, "app/main.py", 7).get("hits") == "0" + # Union of lines: {1,2,3,4,5,6,7} + line_numbers = sorted( + int(l.get("number")) + for l in merged.xpath(".//class[@filename='app/main.py']/lines/line") + ) + assert line_numbers == [1, 2, 3, 4, 5, 6, 7] + + +def test_merge_branch_lines_takes_max_coverage(): + a = _parse("tests/merge-branch-a.xml") # 1/2 + b = _parse("tests/merge-branch-b.xml") # 2/2 + merged = merge_reports([a, b]) + line = _line(merged, "app/main.py", 10) + assert line.get("branch") == "true" + assert line.get("condition-coverage") == "100% (2/2)" + # hits are summed + assert line.get("hits") == "8" + + +def test_merge_branch_denominator_mismatch_picks_largest_denominator(): + # a has 1/2 with 3 hits; b has 1/3 with 2 hits. Largest denominator wins + # (treated as the most complete view of the source at that line), then + # largest numerator breaks ties. + a = _parse("tests/merge-branch-conflict-a.xml") + b = _parse("tests/merge-branch-conflict-b.xml") + merged = merge_reports([a, b]) + line = _line(merged, "app/main.py", 10) + assert line.get("branch") == "true" + assert line.get("condition-coverage") == "33% (1/3)" + # hits are still summed across inputs + assert line.get("hits") == "5" + + +def test_merge_branch_denominator_mismatch_breaks_ties_by_numerator(): + # Build two roots in-memory that share a denominator-mismatching line where + # the smaller-denominator entry has the bigger numerator. Largest + # denominator must still win. + def _root(num, denom, hits): + xml = f""" + + . + + + + + + + + +""" + return ET.fromstring(xml.encode("utf-8")) + + a = _root(2, 2, 1) # fully covered, but only 2 branches detected + b = _root(1, 4, 1) # 4 branches detected, only 1 covered + merged = merge_reports([a, b]) + line = _line(merged, "app/m.py", 1) + assert line.get("condition-coverage") == "25% (1/4)" + + +def test_merge_with_sources_unifies_cross_os_paths(): + linux = _parse("tests/merge-linux.xml") + windows = _parse("tests/merge-windows.xml") + patterns = [ + parse_source_pattern("/home/runner/work/repo/"), + parse_source_pattern("C:/Users/runner/work/repo/"), + ] + merged = merge_reports([linux, windows], sources=patterns) + + # Both inputs' main.py merge under canonical path + files = _files(merged) + assert files == ["src/main.py"] + + # Line 2 appears in both → hits = 2 + 3 = 5 + assert _line(merged, "src/main.py", 2).get("hits") == "5" + # Line 1 only in linux + assert _line(merged, "src/main.py", 1).get("hits") == "2" + # Line 3 only in windows + assert _line(merged, "src/main.py", 3).get("hits") == "3" + + +def test_merge_multi_class_per_file_preserved(): + # Java-style: two classes share a filename. cobertura.xml has Main and Main$Helper. + a = _parse("tests/cobertura.xml") + merged = merge_reports([a]) + + main_classes = merged.xpath(".//class[@filename='Main.java']") + names = sorted(cls.get("name") for cls in main_classes) + assert names == ["Main", "Main$Helper"] + + +def test_merge_no_branch_rate_input(): + a = _parse("tests/cobertura-no-branch-rate.xml") + b = _parse("tests/merge-python.xml") + # Should not raise + merged = merge_reports([a, b]) + assert merged is not None + + +def test_merge_empty_methods_handled(): + # search.ISortedArraySearch in cobertura.xml has empty and empty . + a = _parse("tests/cobertura.xml") + merged = merge_reports([a]) + # The class is still present in the merged output. + isarr = merged.xpath(".//class[@name='search.ISortedArraySearch']") + assert len(isarr) == 1 + + +def test_merge_single_input_roundtrip(): + a = _parse("tests/merge-overlap-a.xml") + merged = merge_reports([a]) + # Same files, same lines, hits unchanged + line3 = _line(merged, "app/main.py", 3) + assert line3.get("hits") == "2" + line5 = _line(merged, "app/main.py", 5) + assert line5.get("hits") == "0" + + +def test_merge_does_not_mutate_inputs(): + a = _parse("tests/merge-overlap-a.xml") + b = _parse("tests/merge-overlap-b.xml") + before_a = ET.tostring(a) + before_b = ET.tostring(b) + merge_reports([a, b]) + assert ET.tostring(a) == before_a + assert ET.tostring(b) == before_b + + +def test_merge_sets_bare_pycobertura_version(): + # Follow coverage.py's convention: bare version string, no tool-name prefix. + a = _parse("tests/merge-overlap-a.xml") + merged = merge_reports([a]) + version = merged.get("version", "") + assert version + assert version[0].isdigit() + + +def test_merge_emits_generator_comment(): + # Follow coverage.py's convention: a "Generated by ..." XML comment + # under the root element. + a = _parse("tests/merge-overlap-a.xml") + merged = merge_reports([a]) + comments = [node for node in merged if isinstance(node, ET._Comment)] + assert len(comments) == 1 + assert "Generated by pycobertura" in comments[0].text + + +def test_merge_unions_sources(): + a = _parse("tests/merge-rust.xml") # src + b = _parse("tests/merge-python.xml") # app + merged = merge_reports([a, b]) + sources = [s.text for s in merged.xpath("./sources/source")] + assert "src" in sources + assert "app" in sources + + +def test_merge_aggregates_root_line_rate(): + # rust file has 6 lines (4 in lib.rs: 2 hit + 2 miss; 2 in parser.rs: 1 hit + 1 miss) + # python file has 4 lines (2 in main.py: 2 hit; 2 in util.py: 1 hit + 1 miss) + # Total: 10 lines, 6 hit + a = _parse("tests/merge-rust.xml") + b = _parse("tests/merge-python.xml") + merged = merge_reports([a, b]) + assert merged.get("lines-valid") == "10" + assert merged.get("lines-covered") == "6" + # line-rate = 6/10 = 0.6 + assert float(merged.get("line-rate")) == pytest.approx(0.6) + + +def test_merge_raises_on_empty_input(): + with pytest.raises(ValueError): + merge_reports([])