#!/usr/bin/env python3 """Run Python tests and emit a SonarQube-compatible Cobertura report. The runner intentionally uses only the Python standard library so CI images do not need pip or third-party packages. """ from __future__ import annotations from dataclasses import dataclass import dis import inspect import token import tokenize from pathlib import Path import sys import time import trace import traceback import types import unittest import xml.etree.ElementTree as ET PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_ROOT = PROJECT_ROOT / "tools" / "python" TEST_ROOT = PROJECT_ROOT / "test" / "python" REPORT_PATH = PROJECT_ROOT / "coverage/python/coverage.xml" @dataclass(frozen=True) class FileCoverage: """Coverage data for one Python source file.""" path: Path executable_lines: tuple[int, ...] hits: dict[int, int] @property def covered_lines(self) -> int: return sum(1 for line in self.executable_lines if self.hits.get(line, 0) > 0) @property def line_rate(self) -> float: if not self.executable_lines: return 1.0 return self.covered_lines / len(self.executable_lines) def _docstring_lines(path: Path, encoding: str) -> set[int]: lines: set[int] = set() previous_type = token.INDENT with path.open(encoding=encoding) as source_file: tokens = tokenize.generate_tokens(source_file.readline) for token_type, _token_text, start, end, _source_line in tokens: if token_type == token.STRING and previous_type == token.INDENT: lines.update(range(start[0], end[0] + 1)) previous_type = token_type return lines def _collect_code_lines( code: types.CodeType, docstring_lines: set[int], lines: set[int], ) -> None: for _, line_number in dis.findlinestarts(code): if ( isinstance(line_number, int) and line_number > 0 and line_number not in docstring_lines ): lines.add(line_number) for constant in code.co_consts: if inspect.iscode(constant): _collect_code_lines(constant, docstring_lines, lines) def _executable_lines(path: Path) -> tuple[int, ...]: with tokenize.open(path) as source_file: source = source_file.read() encoding = source_file.encoding code = compile(source, str(path), "exec") lines: set[int] = set() _collect_code_lines(code, _docstring_lines(path, encoding), lines) return tuple(sorted(lines)) def _discover_tests() -> unittest.TestSuite: return unittest.defaultTestLoader.discover( start_dir=str(TEST_ROOT), pattern="test_*.py", ) def _execute_tests() -> unittest.TestResult: suite = _discover_tests() return unittest.TextTestRunner(verbosity=1).run(suite) def _run_tests() -> trace.CoverageResults: tracer = trace.Trace( count=True, trace=False, ignoredirs=[sys.prefix, sys.exec_prefix], ) result = tracer.runfunc(_execute_tests) if not result.wasSuccessful(): raise RuntimeError("Mindestens ein Python-Test ist fehlgeschlagen.") return tracer.results() def _normalise_path(filename: str) -> Path: return Path(filename).resolve() def _collect_coverage(results: trace.CoverageResults) -> list[FileCoverage]: counts_by_file: dict[Path, dict[int, int]] = {} for (filename, line_number), hit_count in results.counts.items(): resolved = _normalise_path(filename) if not resolved.is_relative_to(SOURCE_ROOT): continue counts_by_file.setdefault(resolved, {})[line_number] = hit_count coverage = [] for path in sorted(SOURCE_ROOT.glob("*.py")): coverage.append( FileCoverage( path=path, executable_lines=_executable_lines(path), hits=counts_by_file.get(path.resolve(), {}), ) ) return coverage def _rate(covered: int, valid: int) -> str: if valid == 0: return "1" return f"{covered / valid:.6f}" def _write_report(files: list[FileCoverage]) -> None: total_valid = sum(len(item.executable_lines) for item in files) total_covered = sum(item.covered_lines for item in files) root = ET.Element( "coverage", { "version": "stdlib-trace", "timestamp": str(int(time.time())), "lines-valid": str(total_valid), "lines-covered": str(total_covered), "line-rate": _rate(total_covered, total_valid), "branches-valid": "0", "branches-covered": "0", "branch-rate": "0", "complexity": "0", }, ) sources = ET.SubElement(root, "sources") ET.SubElement(sources, "source").text = str(PROJECT_ROOT) packages = ET.SubElement(root, "packages") package = ET.SubElement( packages, "package", { "name": "tools.python", "line-rate": _rate(total_covered, total_valid), "branch-rate": "0", "complexity": "0", }, ) classes = ET.SubElement(package, "classes") for item in files: relative = item.path.relative_to(PROJECT_ROOT).as_posix() class_element = ET.SubElement( classes, "class", { "name": item.path.stem, "filename": relative, "line-rate": f"{item.line_rate:.6f}", "branch-rate": "0", "complexity": "0", }, ) ET.SubElement(class_element, "methods") lines = ET.SubElement(class_element, "lines") for line_number in item.executable_lines: ET.SubElement( lines, "line", { "number": str(line_number), "hits": str(item.hits.get(line_number, 0)), "branch": "false", }, ) REPORT_PATH.parent.mkdir(parents=True, exist_ok=True) tree = ET.ElementTree(root) ET.indent(tree, space=" ") tree.write(REPORT_PATH, encoding="utf-8", xml_declaration=True) def _print_summary(files: list[FileCoverage]) -> None: print("Name Stmts Miss Cover") print("-----------------------------------------------------") total_valid = 0 total_covered = 0 for item in files: valid = len(item.executable_lines) covered = item.covered_lines total_valid += valid total_covered += covered missing = valid - covered relative = item.path.relative_to(PROJECT_ROOT).as_posix() print(f"{relative:<33} {valid:>5} {missing:>6} {item.line_rate:>6.0%}") print("-----------------------------------------------------") print( f"TOTAL{'':<28} {total_valid:>5} {total_valid - total_covered:>6} " f"{(total_covered / total_valid if total_valid else 1):>6.0%}" ) print(f"Cobertura-Bericht: {REPORT_PATH.relative_to(PROJECT_ROOT)}") def main() -> int: try: results = _run_tests() files = _collect_coverage(results) _write_report(files) _print_summary(files) except Exception as error: # noqa: BLE001 - CI output must include unexpected errors. print( f"Python-Coverage fehlgeschlagen: {type(error).__name__}: {error}", file=sys.stderr, ) traceback.print_exc(file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())