replace faulty coverage module with unittest and included libraries
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m41s
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m41s
This commit is contained in:
parent
0b5c994957
commit
6a4d84ac6a
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,4 +16,3 @@ coverage/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
.python-test-deps/
|
|
||||||
|
|||||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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:
|
||||||
|
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",
|
||||||
)
|
)
|
||||||
return check.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def _local_dependencies_are_current() -> bool:
|
def _execute_tests() -> unittest.TestResult:
|
||||||
if not REQUIREMENTS_MARKER.is_file():
|
suite = _discover_tests()
|
||||||
return False
|
return unittest.TextTestRunner(verbosity=1).run(suite)
|
||||||
return REQUIREMENTS_MARKER.read_text(encoding="ascii").strip() == _requirements_digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _install_local_dependencies() -> dict[str, str]:
|
def _run_tests() -> trace.CoverageResults:
|
||||||
if DEPENDENCY_DIR.exists():
|
tracer = trace.Trace(
|
||||||
shutil.rmtree(DEPENDENCY_DIR)
|
count=True,
|
||||||
DEPENDENCY_DIR.mkdir(parents=True)
|
trace=False,
|
||||||
|
ignoredirs=[sys.prefix, sys.exec_prefix],
|
||||||
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")
|
result = tracer.runfunc(_execute_tests)
|
||||||
|
if not result.wasSuccessful():
|
||||||
env = _dependency_environment(include_local=True)
|
raise RuntimeError("Mindestens ein Python-Test ist fehlgeschlagen.")
|
||||||
if not _coverage_available(env):
|
return tracer.results()
|
||||||
raise RuntimeError("Das lokal installierte Python-Coverage-Modul kann nicht importiert werden.")
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def _coverage_environment() -> dict[str, str]:
|
def _normalise_path(filename: str) -> Path:
|
||||||
system_env = _dependency_environment(include_local=False)
|
return Path(filename).resolve()
|
||||||
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:
|
def _collect_coverage(results: trace.CoverageResults) -> list[FileCoverage]:
|
||||||
commands = (
|
counts_by_file: dict[Path, dict[int, int]] = {}
|
||||||
[sys.executable, "-m", "coverage", "erase"],
|
for (filename, line_number), hit_count in results.counts.items():
|
||||||
[
|
resolved = _normalise_path(filename)
|
||||||
sys.executable,
|
if not resolved.is_relative_to(SOURCE_ROOT):
|
||||||
"-m",
|
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",
|
"coverage",
|
||||||
"run",
|
{
|
||||||
"-m",
|
"version": "stdlib-trace",
|
||||||
"unittest",
|
"timestamp": str(int(time.time())),
|
||||||
"discover",
|
"lines-valid": str(total_valid),
|
||||||
"-s",
|
"lines-covered": str(total_covered),
|
||||||
"test/python",
|
"line-rate": _rate(total_covered, total_valid),
|
||||||
"-p",
|
"branches-valid": "0",
|
||||||
"test_*.py",
|
"branches-covered": "0",
|
||||||
],
|
"branch-rate": "0",
|
||||||
[
|
"complexity": "0",
|
||||||
sys.executable,
|
},
|
||||||
"-m",
|
)
|
||||||
"coverage",
|
sources = ET.SubElement(root, "sources")
|
||||||
"xml",
|
ET.SubElement(sources, "source").text = str(PROJECT_ROOT)
|
||||||
"-o",
|
packages = ET.SubElement(root, "packages")
|
||||||
"coverage/python/coverage.xml",
|
package = ET.SubElement(
|
||||||
],
|
packages,
|
||||||
[sys.executable, "-m", "coverage", "report"],
|
"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",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
for command in commands:
|
REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
subprocess.run(command, cwd=PROJECT_ROOT, env=env, check=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,
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user