import ast from contextlib import contextmanager import os from pathlib import Path def split_targets(root, targets): unittest_targets, pytest_targets = [], [] for target in dict.fromkeys(targets): path = root / target.split("::", 1)[0] tree = ast.parse(path.read_text(), filename=str(path)) requires_pytest = any( isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_") or isinstance(node, ast.ClassDef) and node.name.startswith("Test") and (not node.bases or node.decorator_list) for node in tree.body) (pytest_targets if requires_pytest else unittest_targets).append(target) return unittest_targets, pytest_targets def test_id(nodeid): path, *nodes = nodeid.split("::") return ".".join((Path(path).with_suffix("").as_posix().replace("/", "."), *nodes)) class Recorder: def __init__(self): self.nodeids = [] self.errors = [] self.records = {} def pytest_configure(self, config): if hasattr(config, "workerinput") or getattr(config.option, "numprocesses", None) not in (None, 0) or getattr(config.option, "dist", "no") != "no": import pytest raise pytest.UsageError("vehicle tests require serial execution in the pinned native process") def pytest_collection_modifyitems(self, session, config, items): selected, imported = [], [] for item in items: owner = item.cls if item.cls is not None else item.obj (selected if getattr(owner, "__module__", None) == item.module.__name__ else imported).append(item) if imported: config.hook.pytest_deselected(items=imported) items[:] = selected def pytest_collection_finish(self, session): self.nodeids = [item.nodeid for item in session.items] def pytest_collectreport(self, report): if report.failed: self.errors.append(str(report.longrepr)) def pytest_runtest_logreport(self, report): identity = test_id(report.nodeid) record = self.records.setdefault(identity, {"id": identity, "status": "passed", "detail": "", "time": 0.0, "stdout": "", "stderr": ""}) record["time"] += report.duration xfail = getattr(report, "wasxfail", None) if report.failed: status = "xpassed" if report.when == "call" and str(report.longrepr).startswith("[XPASS(strict)]") else "failed" if report.when == "call" else "error" elif report.skipped: status = "xfailed" if xfail else "skipped" elif report.when == "call" and xfail: status = "xpassed" else: status = "passed" priorities = {"passed": 0, "skipped": 1, "xfailed": 1, "xpassed": 2, "failed": 3, "error": 4} if priorities[status] >= priorities[record["status"]]: record["status"] = status if status != "passed": record["detail"] = str(report.longrepr) if report.longrepr else str(xfail or "") for name, content in report.sections: key = "stderr" if "stderr" in name else "stdout" if "stdout" in name else None if key and content not in record[key]: record[key] += content @contextmanager def serial_environment(): values = {"PYTEST_ADDOPTS": "", "PYTEST_PLUGINS": "", "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"} saved = {key: os.environ.get(key) for key in values} os.environ.update(values) try: yield finally: for key, value in saved.items(): if value is None: os.environ.pop(key, None) else: os.environ[key] = value def invoke(arguments, recorder): import pytest with serial_environment(): return pytest.main(["-q", "--rootdir=.", "-o", "addopts=", "-p", "no:xdist", "-p", "no:xdist.plugin", *arguments], plugins=[recorder]) def collect(targets): if not targets: return [], [] recorder = Recorder() code = invoke(["--collect-only", *targets], recorder) errors = list(recorder.errors) if code != 0: errors.append(f"pytest collection exited {code}") ids = [test_id(nodeid) for nodeid in recorder.nodeids] if len(set(ids)) != len(ids): errors.append("pytest collected duplicate test IDs") return recorder.nodeids, errors def run(nodeids): if not nodeids: return [], [] recorder = Recorder() code = invoke(nodeids, recorder) errors = list(recorder.errors) if recorder.nodeids != nodeids: errors.append("pytest execution collection changed from the selected IDs") if code not in (0, 1): errors.append(f"pytest execution exited {code}") return list(recorder.records.values()), errors