litellm/tests/rust-python-harness/strategies/unit_tests/python_runner.py
yujonglee 2c30fe16b0
Merge pull request #38765 from BerriAI/litellm_ocr_sdk_parity_tests
test(harness): add OCR parity with migration strategy runners
2026-09-03 10:16:35 -07:00

208 lines
7.4 KiB
Python

from __future__ import annotations
import argparse
import ast
import importlib
import os
import subprocess
import sys
import tempfile
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Final, Literal, cast
import pytest
from pydantic import BaseModel, ConfigDict
Backend = Literal["python", "rust"]
class PythonReport(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
backend: Backend
verified: bool
tests: tuple[str, ...] = ()
outcomes: tuple[tuple[str, str, str], ...] = ()
exit_code: int
problems: tuple[str, ...] = ()
class BackendSpec(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
environment_variable: str
probe: str
def ocr_backend() -> Backend:
from litellm.rust_bridge import native_bridge_available
from litellm.rust_bridge.configuration import rust_ocr_enabled
if not rust_ocr_enabled():
return "python"
if not native_bridge_available():
raise RuntimeError("Rust OCR was enabled but the native extension is unavailable")
return "rust"
class ResultPlugin:
def __init__(self, backend: Backend, probe: Callable[[], object]) -> None:
self.backend: Final = backend
self.probe: Final = probe
self.tests: tuple[str, ...] = ()
self.outcomes: tuple[tuple[str, str, str], ...] = ()
self.problems: tuple[str, ...] = ()
def verify(self) -> None:
if self.probe() != self.backend:
raise RuntimeError(f"backend probe did not select {self.backend}")
def pytest_collection_finish(self, session: pytest.Session) -> None:
self.tests = tuple(item.nodeid for item in session.items)
@pytest.hookimpl(tryfirst=True)
def pytest_runtest_call(self, item: pytest.Item) -> None:
del item
self.verify()
def pytest_collectreport(self, report: pytest.CollectReport) -> None:
if report.failed:
self.problems = (*self.problems, str(report.longrepr))
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
self.outcomes = (*self.outcomes, (report.nodeid, report.when, report.outcome))
if report.failed:
self.problems = (*self.problems, str(report.longrepr))
def run_python_tests(
selectors: Sequence[str],
repo_root: Path,
backend: Backend,
spec: BackendSpec,
pytest_args: Sequence[str] = (),
) -> PythonReport:
with tempfile.TemporaryDirectory(prefix="litellm-unit-tests-") as directory:
output: Final = Path(directory) / "report.json"
command: Final = (
sys.executable,
"-m",
__name__,
"--backend",
backend,
"--probe",
spec.probe,
"--output",
str(output),
"--",
*selectors,
*pytest_args,
)
env: Final = {
**os.environ,
spec.environment_variable: "1" if backend == "rust" else "0",
"PYTHONPATH": os.pathsep.join((str(repo_root), os.environ.get("PYTHONPATH", ""))),
}
try:
result: Final = subprocess.run(
command, cwd=repo_root, env=env, capture_output=True, text=True, timeout=600, check=False
)
except (OSError, subprocess.TimeoutExpired) as error:
return PythonReport(backend=backend, verified=False, exit_code=1, problems=(str(error),))
if not output.exists():
return PythonReport(
backend=backend,
verified=False,
exit_code=result.returncode or 1,
problems=(result.stdout + result.stderr,),
)
report: Final = PythonReport.model_validate_json(output.read_text())
if report.exit_code != result.returncode:
return report.model_copy(
update={
"exit_code": result.returncode or 1,
"problems": (*report.problems, "worker exit code differs from report"),
}
)
return report
def compare_python_runs(python: PythonReport, rust: PythonReport) -> tuple[str, ...]:
return (
*(("backend selection was not verified",) if not python.verified or not rust.verified else ()),
*(("Python run used the wrong backend",) if python.backend != "python" else ()),
*(("Rust run used the wrong backend",) if rust.backend != "rust" else ()),
*(("Python/Rust test inventories differ",) if python.tests != rust.tests else ()),
*(("Python/Rust test outcomes differ",) if sorted(python.outcomes) != sorted(rust.outcomes) else ()),
*(("no Python tests collected",) if not python.tests else ()),
*(
("Python tests did not all pass",)
if set(python.tests)
!= {node for node, phase, status in python.outcomes if phase == "call" and status == "passed"}
else ()
),
*(
("Rust-enabled Python tests did not all pass",)
if set(rust.tests)
!= {node for node, phase, status in rust.outcomes if phase == "call" and status == "passed"}
else ()
),
*(("Python test run failed",) if python.exit_code else ()),
*(("Rust-enabled Python test run failed",) if rust.exit_code else ()),
*python.problems,
*rust.problems,
)
def main(argv: Sequence[str] | None = None) -> int:
parser: Final = argparse.ArgumentParser()
parser.add_argument("--backend", required=True, choices=("python", "rust"))
parser.add_argument("--probe", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("pytest_args", nargs=argparse.REMAINDER)
args: Final = parser.parse_args(argv)
try:
module, name = args.probe.rsplit(":", 1)
probe: Final = cast(Callable[[], object], getattr(importlib.import_module(module), name))
plugin: Final = ResultPlugin(args.backend, probe)
plugin.verify()
code: Final = int(
pytest.main(["-o", "consider_namespace_packages=true", *args.pytest_args[1:]], plugins=[plugin])
)
report: Final = PythonReport(
backend=args.backend,
verified=True,
tests=plugin.tests,
outcomes=plugin.outcomes,
exit_code=code,
problems=plugin.problems,
)
except Exception as error:
failure: Final = PythonReport(backend=args.backend, verified=False, exit_code=1, problems=(str(error),))
args.output.write_text(failure.model_dump_json())
return 1
args.output.write_text(report.model_dump_json())
return report.exit_code
def enumerate_python_tests(repo_root: Path, relative_path: str) -> frozenset[str]:
source = (repo_root / relative_path).read_text(encoding="utf-8")
tree = ast.parse(source, filename=relative_path)
module_level: list[str] = []
for node in ast.iter_child_nodes(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
module_level.append(node.name)
elif isinstance(node, ast.ClassDef):
for child in ast.iter_child_nodes(node):
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith(
"test_"
):
module_level.append(f"{node.name}::{child.name}")
return frozenset(module_level)
if __name__ == "__main__":
raise SystemExit(main())