replace faulty coverage module with unittest and included libraries
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m41s

This commit is contained in:
Florian Zumpe 2026-06-17 15:40:33 +02:00
parent 0b5c994957
commit 6a4d84ac6a
7 changed files with 218 additions and 118 deletions

1
.gitignore vendored
View File

@ -16,4 +16,3 @@ coverage/
__pycache__/ __pycache__/
*.pyc *.pyc
.python-test-deps/

View File

@ -112,7 +112,7 @@ npm ci
npm run test:coverage npm run test:coverage
``` ```
Der Python-Coverage-Runner verwendet ein bereits installiertes kompatibles `coverage`-Modul. Fehlt es, installiert er die in `requirements-dev.txt` festgelegte Version isoliert unter `.python-test-deps/`; eine globale Python-Paketinstallation ist nicht erforderlich. Installations- und Ausführungsfehler werden mit vollständigem Python-Stacktrace auf `stderr` ausgegeben. Der Python-Coverage-Runner verwendet ausschließlich die Python-Standardbibliothek. Er führt die Unit-Tests mit `unittest` unter dem eingebauten `trace`-Modul aus und erzeugt daraus ohne `pip` oder zusätzliche Pakete einen Cobertura-kompatiblen Bericht. Ausführungsfehler werden mit vollständigem Python-Stacktrace auf `stderr` ausgegeben.
Dabei entstehen `coverage/js/lcov.info` für JavaScript und `coverage/python/coverage.xml` für die Python-Werkzeuge. Die zugehörigen SonarQube-Pfade stehen in `sonar-project.properties`. Die Coverage-Skripte erzwingen selbst keinen Mindestwert; die Bewertung und die Hinweise zu nicht abgedeckten Stellen bleiben Aufgabe des SonarQube Quality Gates. Dabei entstehen `coverage/js/lcov.info` für JavaScript und `coverage/python/coverage.xml` für die Python-Werkzeuge. Die zugehörigen SonarQube-Pfade stehen in `sonar-project.properties`. Die Coverage-Skripte erzwingen selbst keinen Mindestwert; die Bewertung und die Hinweise zu nicht abgedeckten Stellen bleiben Aufgabe des SonarQube Quality Gates.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.12.7", "version": "0.12.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.12.7", "version": "0.12.8",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@file-type/av": "0.2.0", "@file-type/av": "0.2.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "wegwichtel-next", "name": "wegwichtel-next",
"version": "0.12.7", "version": "0.12.8",
"private": true, "private": true,
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
"type": "module", "type": "module",

View File

@ -1,135 +1,237 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Erzeugt die Python-Coverage reproduzierbar ohne globale Paketinstallation.""" """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 __future__ import annotations
import hashlib from dataclasses import dataclass
import os import dis
import inspect
import token
import tokenize
from pathlib import Path from pathlib import Path
import shutil
import subprocess
import sys import sys
import time
import trace
import traceback import traceback
import types
import unittest
import xml.etree.ElementTree as ET
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
REQUIREMENTS_FILE = PROJECT_ROOT / "requirements-dev.txt" SOURCE_ROOT = PROJECT_ROOT / "tools" / "python"
DEPENDENCY_DIR = PROJECT_ROOT / ".python-test-deps" TEST_ROOT = PROJECT_ROOT / "test" / "python"
REQUIREMENTS_MARKER = DEPENDENCY_DIR / ".requirements.sha256" REPORT_PATH = PROJECT_ROOT / "coverage/python/coverage.xml"
def _requirements_digest() -> str: @dataclass(frozen=True)
return hashlib.sha256(REQUIREMENTS_FILE.read_bytes()).hexdigest() 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 _dependency_environment(include_local: bool) -> dict[str, str]: def _docstring_lines(path: Path, encoding: str) -> set[int]:
env = os.environ.copy() lines: set[int] = set()
if include_local: previous_type = token.INDENT
existing = env.get("PYTHONPATH", "") with path.open(encoding=encoding) as source_file:
paths = [str(DEPENDENCY_DIR)] tokens = tokenize.generate_tokens(source_file.readline)
if existing: for token_type, _token_text, start, end, _source_line in tokens:
paths.append(existing) if token_type == token.STRING and previous_type == token.INDENT:
env["PYTHONPATH"] = os.pathsep.join(paths) lines.update(range(start[0], end[0] + 1))
return env previous_type = token_type
return lines
def _coverage_available(env: dict[str, str]) -> bool: def _collect_code_lines(
check = subprocess.run( code: types.CodeType,
[ docstring_lines: set[int],
sys.executable, lines: set[int],
"-c", ) -> None:
"import coverage; assert hasattr(coverage, 'Coverage')", for _, line_number in dis.findlinestarts(code):
], if (
cwd=PROJECT_ROOT, isinstance(line_number, int)
env=env, and line_number > 0
stdout=subprocess.DEVNULL, and line_number not in docstring_lines
stderr=subprocess.DEVNULL, ):
check=False, lines.add(line_number)
) for constant in code.co_consts:
return check.returncode == 0 if inspect.iscode(constant):
_collect_code_lines(constant, docstring_lines, lines)
def _local_dependencies_are_current() -> bool: def _executable_lines(path: Path) -> tuple[int, ...]:
if not REQUIREMENTS_MARKER.is_file(): with tokenize.open(path) as source_file:
return False source = source_file.read()
return REQUIREMENTS_MARKER.read_text(encoding="ascii").strip() == _requirements_digest() 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 _install_local_dependencies() -> dict[str, str]: def _discover_tests() -> unittest.TestSuite:
if DEPENDENCY_DIR.exists(): return unittest.defaultTestLoader.discover(
shutil.rmtree(DEPENDENCY_DIR) start_dir=str(TEST_ROOT),
DEPENDENCY_DIR.mkdir(parents=True) pattern="test_*.py",
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-warn-script-location",
"--target",
str(DEPENDENCY_DIR),
"--requirement",
str(REQUIREMENTS_FILE),
],
cwd=PROJECT_ROOT,
check=True,
)
REQUIREMENTS_MARKER.write_text(_requirements_digest() + "\n", encoding="ascii")
env = _dependency_environment(include_local=True)
if not _coverage_available(env):
raise RuntimeError("Das lokal installierte Python-Coverage-Modul kann nicht importiert werden.")
return env
def _coverage_environment() -> dict[str, str]:
system_env = _dependency_environment(include_local=False)
if _coverage_available(system_env):
return system_env
local_env = _dependency_environment(include_local=True)
if _local_dependencies_are_current() and _coverage_available(local_env):
return local_env
return _install_local_dependencies()
def _run_coverage(env: dict[str, str]) -> None:
commands = (
[sys.executable, "-m", "coverage", "erase"],
[
sys.executable,
"-m",
"coverage",
"run",
"-m",
"unittest",
"discover",
"-s",
"test/python",
"-p",
"test_*.py",
],
[
sys.executable,
"-m",
"coverage",
"xml",
"-o",
"coverage/python/coverage.xml",
],
[sys.executable, "-m", "coverage", "report"],
) )
for command in commands:
subprocess.run(command, cwd=PROJECT_ROOT, env=env, check=True) 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: def main() -> int:
try: try:
_run_coverage(_coverage_environment()) results = _run_tests()
except Exception as error: # noqa: BLE001 - CI-Ausgabe soll jeden unerwarteten Fehler enthalten. files = _collect_coverage(results)
_write_report(files)
_print_summary(files)
except Exception as error: # noqa: BLE001 - CI output must include unexpected errors.
print( print(
f"Python-Coverage fehlgeschlagen: {type(error).__name__}: {error}", f"Python-Coverage fehlgeschlagen: {type(error).__name__}: {error}",
file=sys.stderr, file=sys.stderr,

View File

@ -52,7 +52,6 @@ test('Python API tools are syntax-valid and contain reusable 401 authentication
const common = await read('tools/python/common.py'); const common = await read('tools/python/common.py');
const sonar = await read('sonar-project.properties'); const sonar = await read('sonar-project.properties');
const coverageRunner = await read('test-support/python_coverage_runner.py'); const coverageRunner = await read('test-support/python_coverage_runner.py');
const requirements = await read('requirements-dev.txt');
const pkg = JSON.parse(await read('package.json')); const pkg = JSON.parse(await read('package.json'));
assert.match(common, /class ApiClient/); assert.match(common, /class ApiClient/);
assert.match(common, /error\.code != 401/); assert.match(common, /error\.code != 401/);
@ -67,10 +66,11 @@ test('Python API tools are syntax-valid and contain reusable 401 authentication
assert.match(pkg.scripts['test:coverage'], /test:coverage:python/); assert.match(pkg.scripts['test:coverage'], /test:coverage:python/);
assert.match(pkg.scripts['test:coverage:python'], /python_coverage_runner\.py/); assert.match(pkg.scripts['test:coverage:python'], /python_coverage_runner\.py/);
assert.match(coverageRunner, /coverage\/python\/coverage\.xml/); assert.match(coverageRunner, /coverage\/python\/coverage\.xml/);
assert.match(coverageRunner, /--target/); assert.match(coverageRunner, /import trace/);
assert.match(coverageRunner, /xml\.etree\.ElementTree/);
assert.doesNotMatch(coverageRunner, /-m[', ]+pip|requirements-dev|python-test-deps/);
assert.match(coverageRunner, /traceback\.print_exc/); assert.match(coverageRunner, /traceback\.print_exc/);
assert.match(sonar, /sonar\.python\.coverage\.reportPaths=coverage\/python\/coverage\.xml/); assert.match(sonar, /sonar\.python\.coverage\.reportPaths=coverage\/python\/coverage\.xml/);
assert.match(requirements, /^coverage==/m);
}); });

View File

@ -1,6 +1,6 @@
# Python-Werkzeuge für die Wegwichtel-REST-API # Python-Werkzeuge für die Wegwichtel-REST-API
Die Skripte benötigen für den regulären Betrieb nur Python 3 und die Standardbibliothek. Node.js, `curl` und zusätzliche Python-Pakete sind auf dem aufrufenden Rechner nicht erforderlich. Nur die automatisierte Coverage-Erzeugung verwendet das in `requirements-dev.txt` festgelegte Paket `coverage`. Die Skripte und die automatisierte Python-Coverage-Erzeugung benötigen nur Python 3 und die Standardbibliothek. Node.js, `curl`, `pip` und zusätzliche Python-Pakete sind auf dem aufrufenden Rechner nicht erforderlich.
## Authentifizierung ## Authentifizierung
@ -72,9 +72,8 @@ Alle Werkzeuge kapseln ihre Aktion in der gemeinsamen `run()`-Funktion. Bei API-
Die Python-Tests und der Cobertura-kompatible Bericht werden über die NPM-Skripte ausgeführt: Die Python-Tests und der Cobertura-kompatible Bericht werden über die NPM-Skripte ausgeführt:
```bash ```bash
python3 -m pip install -r requirements-dev.txt
npm run test:python npm run test:python
npm run test:coverage:python npm run test:coverage:python
``` ```
Der Bericht liegt anschließend unter `coverage/python/coverage.xml` und wird über `sonar.python.coverage.reportPaths` von SonarQube eingelesen. Der Coverage-Runner verwendet `unittest` und das Standardbibliotheksmodul `trace`. Der Cobertura-kompatible Bericht liegt anschließend unter `coverage/python/coverage.xml` und wird über `sonar.python.coverage.reportPaths` von SonarQube eingelesen.