NexusCoder / nexus /tools /code_coverage.py
AdminReal's picture
Import NexusCoder from github.com/mhieuhonda/NexusCoder
eca5751 verified
Raw
History Blame Contribute Delete
7.61 kB
"""
Code Coverage Tool - Run tests với coverage bằng coverage.py.
Author: Hieu Louis (2026)
Lazy import `coverage` package. Trả về coverage %, uncovered lines per file.
Hỗ trợ 2 report formats: text (default, parseable) và json (structured).
DANGEROUS (executes tests), requires_confirmation.
"""
from __future__ import annotations
import io
import json
import os
import shutil
import subprocess
from typing import Any, Dict, List, Optional
from .base import Tool, ToolResult, ToolContext, ToolCategory, ToolSafety
class CodeCoverageTool(Tool):
"""Run tests với coverage.py. Trả về coverage % và uncovered lines."""
category = ToolCategory.EXEC
safety = ToolSafety.DANGEROUS # executes tests
requires_confirmation = True
@property
def name(self) -> str:
return "code_coverage"
@property
def description(self) -> str:
return (
"Run tests với coverage.py. Trả về coverage %, uncovered lines per file. "
"Yêu cầu `coverage` package và pytest. Hỗ trợ text/json report."
)
@property
def parameters(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Thư mục source để đo coverage"},
"module": {
"type": "string",
"description": "Test module/path (vd 'tests/' hoặc 'test_x.py'). Mặc định 'tests/'.",
},
"source": {
"type": "string",
"description": "Package để đo coverage (mặc định = path)",
},
"report_format": {
"type": "string",
"enum": ["text", "json"],
"default": "text",
"description": "Report format",
},
},
"required": ["path"],
}
def validate_args(self, args: Dict[str, Any]) -> Optional[str]:
if not args.get("path"):
return "Missing required arg: path"
fmt = args.get("report_format", "text")
if fmt not in {"text", "json"}:
return f"Unsupported report_format: {fmt}. Chọn: ['text', 'json']"
return None
def execute(self, args: Dict[str, Any], context: ToolContext) -> ToolResult:
path: str = args["path"]
module: str = args.get("module", "tests/")
source: str = args.get("source", path)
report_format: str = args.get("report_format", "text")
if not os.path.exists(path):
return ToolResult(success=False, error=f"Path không tồn tại: {path}", return_code=1)
# Lazy import coverage
try:
import coverage # type: ignore
except ImportError as e:
return ToolResult(
success=False,
error=f"coverage not installed: {e}. Cài: pip install coverage",
return_code=127,
)
if context.dry_run:
return ToolResult(
success=True,
output=(
f"[dry-run] Sẽ chạy: coverage run --source={source} -m pytest {module}, "
f"sau đó report (format={report_format})"
),
metadata={"dry_run": True, "source": source, "module": module, "format": report_format},
)
# Locate pytest executable
pytest_cmd = shutil.which("pytest")
if not pytest_cmd:
pytest_cmd = shutil.which("python3")
if not pytest_cmd:
return ToolResult(
success=False,
error="pytest không tìm thấy trong PATH. Cài: pip install pytest",
return_code=127,
)
# Build pytest command
full_cmd: List[str] = [pytest_cmd]
if pytest_cmd.endswith("python3"):
full_cmd += ["-m", "pytest"]
full_cmd += ["-q", module]
# Run tests under coverage
cov = coverage.Coverage(source=[source])
cov.start()
try:
try:
cp = subprocess.run(
full_cmd,
capture_output=True,
text=True,
timeout=context.timeout,
cwd=context.working_dir or path,
env={**os.environ, **context.env},
)
except subprocess.TimeoutExpired:
# finally block below sẽ chạy cov.stop()/cov.save()
return ToolResult(
success=False,
error=f"Test timeout ({context.timeout}s)",
return_code=124,
metadata={"command": " ".join(full_cmd)},
)
finally:
cov.stop()
cov.save()
# Build report
try:
if report_format == "json":
buf = io.StringIO()
# cov.json_report writes to a file or stdout-like stream
cov.json_report(outfile=buf) # type: ignore[arg-type]
report_str = buf.getvalue()
try:
report_data = json.loads(report_str)
except json.JSONDecodeError:
return ToolResult(
success=False,
error="Không parse được JSON coverage report",
return_code=1,
)
totals = report_data.get("totals", {})
pct = float(totals.get("percent_covered", 0.0))
files_info: List[Dict[str, Any]] = []
for fname, finfo in report_data.get("files", {}).items():
summary = finfo.get("summary", {})
files_info.append({
"file": fname,
"covered": summary.get("covered_lines", 0),
"missing": summary.get("missing_lines", 0),
"percent": round(summary.get("percent_covered", 0.0), 2),
"missing_lines": finfo.get("missing_lines", []),
})
output = json.dumps(
{"totals": totals, "files": files_info},
indent=2, ensure_ascii=False,
)
else:
buf = io.StringIO()
cov.report(file=buf)
output = buf.getvalue()
# Parse TOTAL line for summary %
pct = 0.0
for line in output.splitlines():
if line.strip().startswith("TOTAL"):
parts = line.split()
if parts:
try:
pct = float(parts[-1].rstrip("%"))
except ValueError:
pass
except Exception as e:
return ToolResult(success=False, error=f"Report lỗi: {e}", return_code=1)
return ToolResult(
success=(cp.returncode == 0),
output=output,
error=cp.stderr if cp.stderr else None,
return_code=cp.returncode,
metadata={
"command": " ".join(full_cmd),
"source": source,
"module": module,
"coverage_pct": round(pct, 2),
"report_format": report_format,
"test_returncode": cp.returncode,
},
)