From 0b5c994957074160e45503d0932432832a2fdb7e Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Wed, 17 Jun 2026 15:28:15 +0200 Subject: [PATCH] included coverage.py --- .gitignore | 2 + README.md | 3 +- package-lock.json | 4 +- package.json | 4 +- test-support/python_coverage_runner.py | 143 +++++++++++++++++++++++++ test/upload-detection-tools.test.js | 14 ++- 6 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 test-support/python_coverage_runner.py diff --git a/.gitignore b/.gitignore index 792848a..8698ef7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ coverage/ .coverage __pycache__/ *.pyc + +.python-test-deps/ diff --git a/README.md b/README.md index 82bd2c3..5bb3753 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,12 @@ Der Integrationstest prüft dabei ausdrücklich, dass lesende Requests nicht pr Die kombinierte Coverage wird mit folgenden Befehlen erzeugt: ```bash -python3 -m pip install -r requirements-dev.txt 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. + 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. ## Technische Hinweise diff --git a/package-lock.json b/package-lock.json index 0ac6f64..c1dac5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wegwichtel-next", - "version": "0.12.6", + "version": "0.12.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.12.6", + "version": "0.12.7", "hasInstallScript": true, "dependencies": { "@file-type/av": "0.2.0", diff --git a/package.json b/package.json index ded0b3b..8aab6b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.12.6", + "version": "0.12.7", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", @@ -18,7 +18,7 @@ "test:coverage": "npm run test:coverage:js && npm run test:coverage:python", "test:python": "python3 -m unittest discover -s test/python -p 'test_*.py'", "test:coverage:js": "c8 --exclude='test/**' --exclude='test-support/**' --reporter=text-summary --reporter=lcov --reports-dir=coverage/js node --test", - "test:coverage:python": "python3 -m coverage erase && python3 -m coverage run -m unittest discover -s test/python -p 'test_*.py' && python3 -m coverage xml -o coverage/python/coverage.xml && python3 -m coverage report" + "test:coverage:python": "python3 test-support/python_coverage_runner.py" }, "dependencies": { "@file-type/av": "0.2.0", diff --git a/test-support/python_coverage_runner.py b/test-support/python_coverage_runner.py new file mode 100644 index 0000000..c2fb3cb --- /dev/null +++ b/test-support/python_coverage_runner.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Erzeugt die Python-Coverage reproduzierbar ohne globale Paketinstallation.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import traceback + +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" + + +def _requirements_digest() -> str: + return hashlib.sha256(REQUIREMENTS_FILE.read_bytes()).hexdigest() + + +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 _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 _local_dependencies_are_current() -> bool: + if not REQUIREMENTS_MARKER.is_file(): + return False + return REQUIREMENTS_MARKER.read_text(encoding="ascii").strip() == _requirements_digest() + + +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"], + ) + + for command in commands: + subprocess.run(command, cwd=PROJECT_ROOT, env=env, check=True) + + +def main() -> int: + try: + _run_coverage(_coverage_environment()) + except Exception as error: # noqa: BLE001 - CI-Ausgabe soll jeden unerwarteten Fehler enthalten. + 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()) diff --git a/test/upload-detection-tools.test.js b/test/upload-detection-tools.test.js index d0a799a..dc53366 100644 --- a/test/upload-detection-tools.test.js +++ b/test/upload-detection-tools.test.js @@ -25,6 +25,7 @@ test('upload MIME types are detected from content and not trusted request metada test('Python API tools are syntax-valid and contain reusable 401 authentication handling', async () => { const toolsDir = path.join(root, 'tools/python'); + const coverageRunnerPath = path.join(root, 'test-support/python_coverage_runner.py'); const names = (await fs.readdir(toolsDir)).filter(name => name.endsWith('.py')); const expected = [ 'append_route.py', 'common.py', 'create_audio.py', 'create_picture.py', 'create_poi.py', @@ -36,15 +37,21 @@ test('Python API tools are syntax-valid and contain reusable 401 authentication expected.toSorted((left, right) => left.localeCompare(right)) ); - const compile = spawnSync('python3', ['-m', 'py_compile', ...names.map(name => path.join(toolsDir, name))], { + const compile = spawnSync('python3', [ + '-m', 'py_compile', + ...names.map(name => path.join(toolsDir, name)), + coverageRunnerPath + ], { cwd: root, encoding: 'utf8' }); assert.equal(compile.status, 0, compile.stderr); await fs.rm(path.join(toolsDir, '__pycache__'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'test-support/__pycache__'), { recursive: true, force: true }); 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/); @@ -58,7 +65,10 @@ test('Python API tools are syntax-valid and contain reusable 401 authentication assert.match(common, /traceback\.print_exception/); assert.doesNotMatch(common, /Content-Type:.*application\/octet-stream/); assert.match(pkg.scripts['test:coverage'], /test:coverage:python/); - assert.match(pkg.scripts['test:coverage:python'], /coverage\/python\/coverage\.xml/); + 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, /traceback\.print_exc/); assert.match(sonar, /sonar\.python\.coverage\.reportPaths=coverage\/python\/coverage\.xml/); assert.match(requirements, /^coverage==/m); });