ai-code-review-agent / app /tools /pytest_runner.py
Padmanav's picture
fix: move success computation to end of run_tests and remove it from except block
decea2b
Raw
History Blame Contribute Delete
3.4 kB
"""
Runs pytest against a cloned repository inside a resource-limited sandbox.
"""
import os
import sys
import subprocess # nosec B404
from typing import Any
from app.core.logger import get_logger
from app.tools.sandbox import run_sandboxed
logger = get_logger(__name__)
# Hard cap on how many test output lines we forward to the LLM
_MAX_OUTPUT_LINES = 100
def run_tests(local_path: str) -> dict[str, Any]:
"""
Execute pytest in local_path inside the sandbox and return a summary.
Returns:
{
"passed": int,
"failed": int,
"errors": int,
"output": str, # truncated pytest stdout/stderr
"timed_out": bool,
"oom_killed": bool,
}
"""
summary: dict[str, Any] = {
"passed": 0,
"failed": 0,
"errors": 0,
"output": "",
"timed_out": False,
"oom_killed": False,
"success": False,
}
# Minimal safe env: inherit PATH so pytest is findable, but strip
# secrets that might be present in the parent environment
safe_env = {
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
"HOME": "/tmp", # nosec B108 — restrict home dir
"PYTHONDONTWRITEBYTECODE": "1",
}
try:
result = run_sandboxed(
cmd=[sys.executable, "-m", "pytest", "--tb=short", "-q", "--no-header"],
cwd=local_path,
timeout=120,
env=safe_env,
)
except subprocess.TimeoutExpired:
summary["output"] = "pytest timed out"
summary["timed_out"] = True
return summary
summary["timed_out"] = result["timed_out"]
summary["oom_killed"] = result["oom_killed"]
raw_output = (result["stdout"] + result["stderr"]).strip()
# Truncate to avoid flooding the LLM context
lines = raw_output.splitlines()
if len(lines) > _MAX_OUTPUT_LINES:
lines = lines[:_MAX_OUTPUT_LINES] + [f"... ({len(lines) - _MAX_OUTPUT_LINES} lines truncated)"]
summary["output"] = "\n".join(lines)
# Parse pytest's summary line: "3 passed, 1 failed, 2 errors"
for line in lines:
lower = line.lower()
if "passed" in lower or "failed" in lower or "error" in lower:
summary["passed"] += _parse_count(line, "passed")
summary["failed"] += _parse_count(line, "failed")
summary["errors"] += _parse_count(line, "error")
if result["timed_out"]:
logger.warning("pytest timed out", extra={"path": local_path})
elif result["oom_killed"]:
logger.warning("pytest was OOM-killed", extra={"path": local_path})
else:
logger.info(
"pytest complete",
extra={
"passed": summary["passed"],
"failed": summary["failed"],
"errors": summary["errors"],
},
)
summary["success"] = (
not summary["timed_out"]
and not summary["oom_killed"]
and summary["failed"] == 0
and summary["errors"] == 0
and summary["passed"] > 0
)
return summary
def _parse_count(line: str, keyword: str) -> int:
"""Extract the integer before `keyword` in a pytest summary line."""
import re
match = re.search(rf"(\d+)\s+{keyword}", line.lower())
return int(match.group(1)) if match else 0