diff --git a/.gitignore b/.gitignore index 8698ef7..43afe87 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,3 @@ coverage/ __pycache__/ *.pyc -.python-test-deps/ diff --git a/README.md b/README.md index 5bb3753..36006ed 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ npm ci 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. diff --git a/package-lock.json b/package-lock.json index c1dac5f..69d7fda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wegwichtel-next", - "version": "0.12.7", + "version": "0.12.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.12.7", + "version": "0.12.8", "hasInstallScript": true, "dependencies": { "@file-type/av": "0.2.0", diff --git a/package.json b/package.json index 8aab6b2..0b95a70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.12.7", + "version": "0.12.8", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", diff --git a/test-support/python_coverage_runner.py b/test-support/python_coverage_runner.py index c2fb3cb..0f11537 100644 --- a/test-support/python_coverage_runner.py +++ b/test-support/python_coverage_runner.py @@ -1,135 +1,237 @@ #!/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 -import hashlib -import os +from dataclasses import dataclass +import dis +import inspect +import token +import tokenize from pathlib import Path -import shutil -import subprocess 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] -REQUIREMENTS_FILE = PROJECT_ROOT / "requirements-dev.txt" -DEPENDENCY_DIR = PROJECT_ROOT / ".python-test-deps" -REQUIREMENTS_MARKER = DEPENDENCY_DIR / ".requirements.sha256" +SOURCE_ROOT = PROJECT_ROOT / "tools" / "python" +TEST_ROOT = PROJECT_ROOT / "test" / "python" +REPORT_PATH = PROJECT_ROOT / "coverage/python/coverage.xml" -def _requirements_digest() -> str: - return hashlib.sha256(REQUIREMENTS_FILE.read_bytes()).hexdigest() +@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 _dependency_environment(include_local: bool) -> dict[str, str]: - env = os.environ.copy() - if include_local: - existing = env.get("PYTHONPATH", "") - paths = [str(DEPENDENCY_DIR)] - if existing: - paths.append(existing) - env["PYTHONPATH"] = os.pathsep.join(paths) - return env +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 _coverage_available(env: dict[str, str]) -> bool: - check = subprocess.run( - [ - sys.executable, - "-c", - "import coverage; assert hasattr(coverage, 'Coverage')", - ], - cwd=PROJECT_ROOT, - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - return check.returncode == 0 +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 _local_dependencies_are_current() -> bool: - if not REQUIREMENTS_MARKER.is_file(): - return False - return REQUIREMENTS_MARKER.read_text(encoding="ascii").strip() == _requirements_digest() +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 _install_local_dependencies() -> dict[str, str]: - if DEPENDENCY_DIR.exists(): - shutil.rmtree(DEPENDENCY_DIR) - DEPENDENCY_DIR.mkdir(parents=True) - - 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"], +def _discover_tests() -> unittest.TestSuite: + return unittest.defaultTestLoader.discover( + start_dir=str(TEST_ROOT), + pattern="test_*.py", ) - 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: try: - _run_coverage(_coverage_environment()) - except Exception as error: # noqa: BLE001 - CI-Ausgabe soll jeden unerwarteten Fehler enthalten. + 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, diff --git a/test/upload-detection-tools.test.js b/test/upload-detection-tools.test.js index dc53366..8489de4 100644 --- a/test/upload-detection-tools.test.js +++ b/test/upload-detection-tools.test.js @@ -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 sonar = await read('sonar-project.properties'); 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')); assert.match(common, /class ApiClient/); 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:python'], /python_coverage_runner\.py/); 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(sonar, /sonar\.python\.coverage\.reportPaths=coverage\/python\/coverage\.xml/); - assert.match(requirements, /^coverage==/m); }); diff --git a/tools/python/README.md b/tools/python/README.md index 80c6194..1cd005e 100644 --- a/tools/python/README.md +++ b/tools/python/README.md @@ -1,6 +1,6 @@ # 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 @@ -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: ```bash -python3 -m pip install -r requirements-dev.txt npm run test: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.