feat: unify pdm test defaults and verbosity controls (#836)

This commit is contained in:
Jens
2026-02-23 16:44:13 +01:00
committed by GitHub
parent 6aab9761f1
commit 930b3f6028
7 changed files with 236 additions and 187 deletions

View File

@@ -1,116 +0,0 @@
"""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()

165
scripts/run_tests.py Normal file
View File

@@ -0,0 +1,165 @@
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""Unified pytest runner for public and CI test execution.
This module invokes pytest via ``pytest.main()``. Programmatic callers should
avoid repeated in-process invocations because Python's import cache can retain
test module state between runs. CLI usage via ``pdm run`` is unaffected because
each invocation runs in a fresh process.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import Final
import pytest
ROOT:Final = Path(__file__).resolve().parent.parent
TEMP:Final = ROOT / ".temp"
# Most tests are currently unmarked, so utest intentionally uses negative markers
# to select the default "unit-like" population while excluding integration/smoke.
PROFILE_CONFIGS:Final[dict[str, tuple[str | None, str]]] = {
"test": (None, "auto"),
"utest": ("not itest and not smoke", "auto"),
"itest": ("itest and not smoke", "0"),
"smoke": ("smoke", "auto"),
}
def _append_verbosity(pytest_args:list[str], verbosity:int) -> None:
if verbosity == 0:
pytest_args.append("-q")
else:
pytest_args.append("-" + ("v" * verbosity))
pytest_args.extend([
"--durations=25",
"--durations-min=0.5",
])
def _pytest_base_args(*, workers:str, verbosity:int) -> list[str]:
# Stable pytest defaults (strict markers, doctest, coverage) live in pyproject addopts.
# This runner only adds dynamic execution policy (workers and verbosity).
pytest_args = [
"-n",
workers,
]
_append_verbosity(pytest_args, verbosity)
return pytest_args
def _resolve_path(path:Path) -> Path:
if path.is_absolute():
return path
return ROOT / path
def _display_path(path:Path) -> str:
try:
return str(path.relative_to(ROOT))
except ValueError:
return str(path)
def _cleanup_coverage_artifacts() -> None:
TEMP.mkdir(parents = True, exist_ok = True)
for pattern in ("coverage-*.xml", ".coverage-*.sqlite"):
for stale_file in TEMP.glob(pattern):
stale_file.unlink(missing_ok = True)
for stale_path in (TEMP / "coverage.sqlite", ROOT / ".coverage"):
stale_path.unlink(missing_ok = True)
def _run_profile(*, profile:str, verbosity:int, passthrough:list[str]) -> int:
marker, workers = PROFILE_CONFIGS[profile]
pytest_args = _pytest_base_args(workers = workers, verbosity = verbosity)
if marker is not None:
pytest_args.extend(["-m", marker])
pytest_args.extend(passthrough)
return pytest.main(pytest_args)
def _run_ci(*, marker:str, coverage_file:Path, xml_file:Path, workers:str, verbosity:int, passthrough:list[str]) -> int:
resolved_coverage_file = _resolve_path(coverage_file)
resolved_xml_file = _resolve_path(xml_file)
resolved_coverage_file.parent.mkdir(parents = True, exist_ok = True)
resolved_xml_file.parent.mkdir(parents = True, exist_ok = True)
previous_coverage_file = os.environ.get("COVERAGE_FILE")
os.environ["COVERAGE_FILE"] = str(resolved_coverage_file)
pytest_args = _pytest_base_args(workers = workers, verbosity = verbosity)
pytest_args.extend([
"-m",
marker,
f"--cov-report=xml:{_display_path(resolved_xml_file)}",
])
pytest_args.extend(passthrough)
try:
return pytest.main(pytest_args)
finally:
if previous_coverage_file is None:
os.environ.pop("COVERAGE_FILE", None)
else:
os.environ["COVERAGE_FILE"] = previous_coverage_file
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description = "Run project tests")
subparsers = parser.add_subparsers(dest = "command", required = True)
run_parser = subparsers.add_parser("run", help = "Run tests for a predefined profile")
run_parser.add_argument("profile", choices = sorted(PROFILE_CONFIGS))
run_parser.add_argument("-v", "--verbose", action = "count", default = 0)
subparsers.add_parser("ci-prepare", help = "Clean stale coverage artifacts")
ci_run_parser = subparsers.add_parser("ci-run", help = "Run tests with explicit coverage outputs")
ci_run_parser.add_argument("--marker", required = True)
ci_run_parser.add_argument("--coverage-file", type = Path, required = True)
ci_run_parser.add_argument("--xml-file", type = Path, required = True)
ci_run_parser.add_argument("-n", "--workers", default = "auto")
ci_run_parser.add_argument("-v", "--verbose", action = "count", default = 0)
return parser
def main(argv:list[str] | None = None) -> int:
os.chdir(ROOT)
effective_argv = sys.argv[1:] if argv is None else argv
parser = _build_parser()
args, passthrough = parser.parse_known_args(effective_argv)
# This entrypoint is intended for one-shot CLI usage, not same-process
# repeated invocations that can reuse imports loaded by pytest.main().
if args.command == "run":
return _run_profile(profile = args.profile, verbosity = args.verbose, passthrough = passthrough)
if args.command == "ci-prepare":
_cleanup_coverage_artifacts()
return 0
if args.command == "ci-run":
return _run_ci(
marker = args.marker,
coverage_file = args.coverage_file,
xml_file = args.xml_file,
workers = args.workers,
verbosity = args.verbose,
passthrough = passthrough,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())