Wegwichtel/test-support/python_coverage_runner.py
Florian Zumpe 6a4d84ac6a
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m41s
replace faulty coverage module with unittest and included libraries
2026-06-17 15:40:33 +02:00

246 lines
7.4 KiB
Python

#!/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())