| """run_tests tool — pytest invocation inside the ingested repo (read-only).""" |
| from __future__ import annotations |
| import subprocess |
| import sys |
| from pathlib import Path |
|
|
| from .base import ToolResult, ToolSpec |
|
|
|
|
| def make_tool(repo_root: str | Path, timeout: int = 120) -> ToolSpec: |
| root = Path(repo_root).resolve() |
|
|
| def run(test_path: str = "", k_expression: str = "", max_lines: int = 200) -> ToolResult: |
| target = (root / test_path).resolve() if test_path else root |
| try: |
| target.relative_to(root) |
| except ValueError: |
| return ToolResult(ok=False, output="", error=f"path outside repo: {test_path}") |
|
|
| cmd = [sys.executable, "-m", "pytest", "-x", "--tb=short", "-q", str(target)] |
| if k_expression: |
| cmd += ["-k", k_expression] |
| try: |
| proc = subprocess.run( |
| cmd, capture_output=True, text=True, cwd=str(root), timeout=timeout, |
| ) |
| except subprocess.TimeoutExpired: |
| return ToolResult(ok=False, output="", error=f"pytest timeout after {timeout}s") |
| except FileNotFoundError: |
| return ToolResult(ok=False, output="", error="pytest not installed") |
|
|
| lines = (proc.stdout or "").splitlines()[-max_lines:] |
| out = "\n".join(lines) |
| err = (proc.stderr or "").strip() |
| if proc.returncode == 0: |
| return ToolResult(ok=True, output=out or "(all passed)", extra={"returncode": 0}) |
| return ToolResult( |
| ok=False, output=out, error=err or f"pytest exit {proc.returncode}", |
| extra={"returncode": proc.returncode}, |
| ) |
|
|
| return ToolSpec( |
| name="run_tests", |
| description="Run pytest on the ingested repo (or a sub-path). Read-only.", |
| parameters={ |
| "type": "object", |
| "properties": { |
| "test_path": {"type": "string", "default": ""}, |
| "k_expression": {"type": "string", "default": "", "description": "pytest -k expression"}, |
| "max_lines": {"type": "integer", "default": 200}, |
| }, |
| }, |
| runner=run, |
| ) |
|
|