from __future__ import annotations import os from collections.abc import Callable, Sequence from pathlib import Path from time import monotonic import pytest from .models import CaseResult, HarnessCase, HarnessRun, RunStatus UpdateCallback = Callable[[HarnessRun], None] def selector_matches_node(selector: str, nodeid: str) -> bool: normalized_selector = selector.replace("\\", "/") normalized_nodeid = nodeid.replace("\\", "/") if "::" in normalized_selector: return normalized_nodeid == normalized_selector or normalized_nodeid.startswith( f"{normalized_selector}[" ) return normalized_nodeid == normalized_selector or normalized_nodeid.startswith( f"{normalized_selector}::" ) def selector_path(selector: str) -> Path: return Path(selector.split("::", 1)[0]) def runnable_selectors( cases: Sequence[HarnessCase], repo_root: Path ) -> tuple[str, ...]: selectors = { selector for case in cases for selector in case.selectors if (repo_root / selector_path(selector)).exists() } return tuple(sorted(selectors)) class HarnessPytestPlugin: def __init__(self, run: HarnessRun, on_update: UpdateCallback) -> None: self.run = run self.on_update = on_update self.node_to_results: dict[str, list[CaseResult]] = {} def _notify(self) -> None: self.on_update(self.run) def pytest_collection_modifyitems(self, items: list[pytest.Item]) -> None: for item in items: matched_results: list[CaseResult] = [] for result in self.run.results.values(): if any( selector_matches_node(selector, item.nodeid) for selector in result.case.selectors ): result.collected.add(item.nodeid) matched_results.append(result) if matched_results: self.node_to_results[item.nodeid] = matched_results for result in self.run.results.values(): if result.status is RunStatus.QUEUED and not result.collected: result.status = RunStatus.MISSING self._notify() def pytest_runtest_logstart( self, nodeid: str, location: tuple[str, int | None, str] ) -> None: del location self.run.current_nodeid = nodeid for result in self.node_to_results.get(nodeid, []): if result.status not in {RunStatus.FAILED, RunStatus.ERROR}: result.status = RunStatus.RUNNING self._notify() def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: if report.when not in {"setup", "call", "teardown"}: return results = self.node_to_results.get(report.nodeid, []) if not results: return terminal = report.when == "call" or report.failed or report.skipped if not terminal: for result in results: result.durations[report.nodeid] = ( result.durations.get(report.nodeid, 0.0) + report.duration ) return for result in results: if report.when == "teardown" and not report.failed: result.durations[report.nodeid] = ( result.durations.get(report.nodeid, 0.0) + report.duration ) continue if report.skipped: status = RunStatus.SKIPPED elif report.failed and report.when in {"setup", "teardown"}: status = RunStatus.ERROR elif report.failed: status = RunStatus.FAILED else: status = RunStatus.PASSED result.record(report.nodeid, status, report.duration) if report.failed: failure = (report.nodeid, str(report.longrepr)) if failure not in self.run.failures: self.run.failures.append(failure) self._notify() def pytest_sessionfinish( self, session: pytest.Session, exitstatus: int | pytest.ExitCode ) -> None: del session, exitstatus self.run.current_nodeid = None self.run.finished_at = monotonic() for result in self.run.results.values(): result.finalize() self._notify() def run_pytest( cases: Sequence[HarnessCase], repo_root: Path, on_update: UpdateCallback, pytest_args: Sequence[str] = (), ) -> tuple[int, HarnessRun]: run = HarnessRun.from_cases(cases) selectors = runnable_selectors(cases, repo_root) if not selectors: for result in run.results.values(): result.finalize() run.finished_at = monotonic() on_update(run) has_missing_test = any( result.status is RunStatus.MISSING for result in run.results.values() ) exit_code = ( int(pytest.ExitCode.TESTS_FAILED) if has_missing_test else int(pytest.ExitCode.OK) ) return exit_code, run plugin = HarnessPytestPlugin(run=run, on_update=on_update) args = [*selectors, "-p", "no:terminal", *pytest_args] previous_directory = Path.cwd() try: os.chdir(repo_root) exit_code = int(pytest.main(args, plugins=[plugin])) finally: os.chdir(previous_directory) if exit_code == 0 and any( result.status is RunStatus.MISSING for result in run.results.values() ): exit_code = int(pytest.ExitCode.TESTS_FAILED) return exit_code, run