diff --git a/pyproject.toml b/pyproject.toml index d82eac6..9a78ccf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,11 +123,12 @@ test = { composite = ["utest", "itest", "smoke"] } # Coverage scripts: # - Each group writes its own data file to .temp/.coverage..xml # -"test:cov" = { composite = ["utest:cov", "itest:cov", "smoke:cov", "coverage:combine"] } -"utest:cov" = { shell = "python -m pytest --capture=tee-sys -m \"not itest and not smoke\" --cov=src/kleinanzeigen_bot --cov-report=xml:.temp/coverage-unit.xml --cov-append" } -"itest:cov" = { shell = "python -m pytest --capture=tee-sys -m \"itest and not smoke\" --cov=src/kleinanzeigen_bot --cov-report=xml:.temp/coverage-integration.xml --cov-append" } -"smoke:cov" = { shell = "python -m pytest --capture=tee-sys -m smoke --cov=src/kleinanzeigen_bot --cov-report=xml:.temp/coverage-smoke.xml --cov-append" } -"coverage:combine" = { shell = "coverage report -m" } +"coverage:prepare" = { shell = "python scripts/coverage_helper.py prepare" } +"test:cov" = { composite = ["coverage:prepare", "utest:cov", "itest:cov", "smoke:cov", "coverage:combine"] } +"utest:cov" = { shell = "python scripts/coverage_helper.py run .temp/.coverage-unit.sqlite .temp/coverage-unit.xml \"not itest and not smoke\"" } +"itest:cov" = { shell = "python scripts/coverage_helper.py run .temp/.coverage-itest.sqlite .temp/coverage-integration.xml \"itest and not smoke\"" } +"smoke:cov" = { shell = "python scripts/coverage_helper.py run .temp/.coverage-smoke.sqlite .temp/coverage-smoke.xml smoke" } +"coverage:combine" = { shell = "python scripts/coverage_helper.py combine .temp/.coverage-unit.sqlite .temp/.coverage-itest.sqlite .temp/.coverage-smoke.sqlite" } # Run all tests with coverage in a single invocation "test:cov:unified" = "python -m pytest --capture=tee-sys --cov=src/kleinanzeigen_bot --cov-report=term-missing" @@ -375,6 +376,7 @@ relative_files = true precision = 2 show_missing = true skip_covered = false +include = ["src/kleinanzeigen_bot/*"] ##################### # yamlfix diff --git a/scripts/coverage_helper.py b/scripts/coverage_helper.py new file mode 100644 index 0000000..496a591 --- /dev/null +++ b/scripts/coverage_helper.py @@ -0,0 +1,116 @@ +"""Utility helpers for the coverage pipeline used by the pdm test scripts.""" +# SPDX-FileCopyrightText: © Jens Bergmann and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +from __future__ import annotations + +import argparse +import logging +import os +import subprocess # noqa: S404 subprocess usage is limited to known internal binaries +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TEMP = ROOT / ".temp" + +logging.basicConfig(level = logging.INFO, format = "%(asctime)s %(levelname)s %(name)s %(message)s") +logger = logging.getLogger(__name__) + + +def prepare() -> None: + logger.info("Preparing coverage artifacts in %s", TEMP) + try: + TEMP.mkdir(parents = True, exist_ok = True) + removed_patterns = 0 + for pattern in ("coverage-*.xml", ".coverage-*.sqlite"): + for coverage_file in TEMP.glob(pattern): + coverage_file.unlink() + removed_patterns += 1 + removed_paths = 0 + for path in (TEMP / "coverage.sqlite", ROOT / ".coverage"): + if path.exists(): + path.unlink() + removed_paths += 1 + except Exception as exc: # noqa: S110 suppress to log + logger.exception("Failed to clean coverage artifacts: %s", exc) + raise + logger.info( + "Removed %d pattern-matching files and %d fixed paths during prepare", + removed_patterns, + removed_paths, + ) + + +def run_suite(data_file:Path, xml_file:Path, marker:str, extra_args:list[str]) -> None: + os.environ["COVERAGE_FILE"] = str(ROOT / data_file) + cmd = [ + sys.executable, + "-m", + "pytest", + "--capture=tee-sys", + "-m", + marker, + "--cov=src/kleinanzeigen_bot", + f"--cov-report=xml:{ROOT / xml_file}", + ] + if extra_args: + cmd.extend(extra_args) + logger.info("Running pytest marker=%s coverage_data=%s xml=%s", marker, data_file, xml_file) + subprocess.run(cmd, cwd = ROOT, check = True) # noqa: S603 arguments are constant and controlled + logger.info("Pytest marker=%s finished", marker) + + +def combine(data_files:list[Path]) -> None: + combined = TEMP / "coverage.sqlite" + os.environ["COVERAGE_FILE"] = str(combined) + resolved = [] + missing = [] + for data in data_files: + candidate = ROOT / data + if not candidate.exists(): + missing.append(str(candidate)) + else: + resolved.append(candidate) + if missing: + message = f"Coverage data files missing: {', '.join(missing)}" + logger.error(message) + raise FileNotFoundError(message) + cmd = [sys.executable, "-m", "coverage", "combine"] + [str(path) for path in resolved] + logger.info("Combining coverage data files: %s", ", ".join(str(path) for path in resolved)) + subprocess.run(cmd, cwd = ROOT, check = True) # noqa: S603 arguments controlled by this script + logger.info("Coverage combine completed, generating report") + subprocess.run([sys.executable, "-m", "coverage", "report", "-m"], cwd = ROOT, check = True) # noqa: S603 + + +def main() -> None: + parser = argparse.ArgumentParser(description = "Coverage helper commands") + subparsers = parser.add_subparsers(dest = "command", required = True) + + subparsers.add_parser("prepare", help = "Clean coverage artifacts") + + run_parser = subparsers.add_parser("run", help = "Run pytest with a custom coverage file") + run_parser.add_argument("data_file", type = Path, help = "Coverage data file to write") + run_parser.add_argument("xml_file", type = Path, help = "XML report path") + run_parser.add_argument("marker", help = "pytest marker expression") + + combine_parser = subparsers.add_parser("combine", help = "Combine coverage data files") + combine_parser.add_argument( + "data_files", + nargs = "+", + type = Path, + help = "List of coverage data files to combine", + ) + + args, extra_args = parser.parse_known_args() + + if args.command == "prepare": + prepare() + elif args.command == "run": + run_suite(args.data_file, args.xml_file, args.marker, extra_args) + else: + combine(args.data_files) + + +if __name__ == "__main__": + main()