csci4130projectdemo / evaluate.py
SennPiee's picture
add files
2280f0f
Raw
History Blame Contribute Delete
11.2 kB
"""
evaluate.py
===========
Evaluates a single function + test pair.
Can be run standalone or imported by pipeline.py.
Standalone usage:
python evaluate.py --csv functions.csv --out evaluation_results.json
pip install coverage radon pytest
"""
import csv, json, sys, ast, copy, subprocess, tempfile, argparse
from pathlib import Path
ASSERT_METHODS = {
"assertEqual", "assertNotEqual", "assertTrue", "assertFalse",
"assertIn", "assertAlmostEqual", "assertRaises", "assertGreater",
"assertLess", "assertIsNone", "assertIsNotNone", "assertNotIn"
}
AOR = {ast.Add: ast.Sub, ast.Sub: ast.Add, ast.Mult: ast.Div, ast.Div: ast.Mult, ast.Mod: ast.Add}
ROR = {ast.Eq: ast.NotEq, ast.NotEq: ast.Eq, ast.Lt: ast.Gt, ast.Gt: ast.Lt, ast.LtE: ast.GtE, ast.GtE: ast.LtE}
LCR = {ast.And: ast.Or, ast.Or: ast.And}
def make_mutants(fn_code):
tree = ast.parse(fn_code)
mutants = []
def mutate(match_fn, replace_fn):
for node in ast.walk(copy.deepcopy(tree)):
if not match_fn(node):
continue
m = copy.deepcopy(tree)
for n in ast.walk(m):
if match_fn(n) and getattr(n, "lineno", -1) == getattr(node, "lineno", -2):
replace_fn(n)
break
ast.fix_missing_locations(m)
try:
mutants.append(ast.unparse(m))
except Exception:
pass
mutate(lambda n: isinstance(n, ast.BinOp) and type(n.op) in AOR,
lambda n: setattr(n, "op", AOR[type(n.op)]()))
mutate(lambda n: isinstance(n, ast.Compare) and n.ops and type(n.ops[0]) in ROR,
lambda n: n.ops.__setitem__(0, ROR[type(n.ops[0])]()))
mutate(lambda n: isinstance(n, ast.BoolOp) and type(n.op) in LCR,
lambda n: setattr(n, "op", LCR[type(n.op)]()))
mutate(lambda n: isinstance(n, ast.Return) and n.value is not None,
lambda n: setattr(n, "value", ast.Constant(value=None)))
mutate(lambda n: isinstance(n, ast.Constant) and n.value in (0, 1),
lambda n: setattr(n, "value", 1 - n.value))
return mutants
def run_mutant(mutant_src, test_code, timeout=5):
script = f"""
import sys, unittest, io, types
mod = types.ModuleType("target")
exec(compile({repr(mutant_src)}, "<t>", "exec"), mod.__dict__)
sys.modules["target"] = mod
tm = types.ModuleType("_tm")
exec(compile({repr(test_code)}, "<tc>", "exec"), tm.__dict__)
sys.modules["_tm"] = tm
suite = unittest.TestLoader().loadTestsFromModule(tm)
r = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print("KILLED" if (r.failures or r.errors) else "SURVIVED")
"""
try:
r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=timeout)
if "KILLED" in r.stdout: return "killed"
if "SURVIVED" in r.stdout: return "survived"
return "killed"
except subprocess.TimeoutExpired:
return "timeout"
def evaluate_one(fn_code, test_code):
"""
Evaluate a single function + test pair.
Returns (cov, mut, sta, score).
Importable by pipeline.py.
"""
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, "target.py").write_text(fn_code, encoding="utf-8")
Path(tmp, "test_target.py").write_text(test_code, encoding="utf-8")
# coverage
subprocess.run(
[sys.executable, "-m", "coverage", "run", "--branch",
"--include=target.py", "-m", "pytest", "test_target.py", "-q", "--tb=no"],
capture_output=True, text=True, cwd=tmp
)
r2 = subprocess.run(
[sys.executable, "-m", "coverage", "json", "-o", "cov.json"],
capture_output=True, text=True, cwd=tmp
)
cov = {}
cov_file = Path(tmp, "cov.json")
if not cov_file.exists():
cov["error"] = r2.stderr.strip() or "coverage failed"
else:
data = json.loads(cov_file.read_text())
fd = next((v for k, v in data["files"].items() if k.endswith("target.py")), None)
if fd is None:
cov["error"] = "target.py not in coverage report"
else:
s = fd["summary"]
missing_br = fd.get("missing_branches", [])
total_br = s.get("num_branches", 0)
branch_pct = round(100 * (total_br - len(missing_br)) / total_br, 1) if total_br else 100.0
cov = {
"line_coverage_pct": round(s.get("percent_covered", 0), 1),
"branch_coverage_pct": branch_pct,
"missing_lines": fd.get("missing_lines", []),
"missing_branches": missing_br,
}
# mutation
mutants = make_mutants(fn_code)
killed, survived_list = 0, []
for mutant in mutants:
outcome = run_mutant(mutant, test_code)
if outcome in ("killed", "timeout"):
killed += 1
else:
survived_list.append(mutant)
mut = {
"total_mutants": len(mutants),
"killed": killed,
"survived": len(mutants) - killed,
"mutation_score": round(100 * killed / len(mutants), 1) if mutants else 100.0,
"survived_mutants": survived_list,
}
# static
fn_tree = ast.parse(fn_code)
fn_defs = [n for n in ast.walk(fn_tree) if isinstance(n, ast.FunctionDef)]
cc_out = subprocess.run(
[sys.executable, "-m", "radon", "cc", str(Path(tmp, "target.py")), "-j", "-s"],
capture_output=True, text=True
)
mi_out = subprocess.run(
[sys.executable, "-m", "radon", "mi", str(Path(tmp, "target.py")), "-j"],
capture_output=True, text=True
)
cc = rank = mi = None
try:
entries = next(iter(json.loads(cc_out.stdout).values()), [])
if entries:
cc, rank = entries[0]["complexity"], entries[0]["rank"]
except Exception:
pass
try:
val = next(iter(json.loads(mi_out.stdout).values()), {})
mi = round(val.get("mi", 0), 1)
except Exception:
pass
if cc is None:
BRANCH_TYPES = (ast.If, ast.For, ast.While, ast.ExceptHandler, ast.comprehension)
cc = 1
for fn in fn_defs:
for n in ast.walk(fn):
if isinstance(n, BRANCH_TYPES):
cc += 1
elif isinstance(n, ast.BoolOp):
cc += len(n.values) - 1
rank = "A" if cc <= 5 else "B" if cc <= 10 else "C"
test_tree = ast.parse(test_code)
test_fns = [n for n in ast.walk(test_tree)
if isinstance(n, ast.FunctionDef) and n.name.startswith("test")]
assertions = {}
for fn in test_fns:
count = 0
for n in ast.walk(fn):
if isinstance(n, ast.Assert):
count += 1
elif isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute):
if n.func.attr in ASSERT_METHODS:
count += 1
assertions[fn.name] = count
total_asrt = sum(assertions.values())
sta = {
"cyclomatic_complexity": cc,
"complexity_rank": rank,
"num_parameters": len(fn_defs[0].args.args) if fn_defs else 0,
"num_raises": sum(1 for n in ast.walk(fn_tree) if isinstance(n, ast.Raise)),
"num_loops": sum(1 for n in ast.walk(fn_tree) if isinstance(n, (ast.For, ast.While))),
"total_test_functions": len(test_fns),
"total_assertions": total_asrt,
"assertion_density": round(total_asrt / len(test_fns), 2) if test_fns else 0.0,
"assertions_per_test": assertions,
"dead_tests": [name for name, count in assertions.items() if count == 0],
"coverage_adequacy_ratio": round(len(test_fns) / cc, 2) if cc else 1.0,
}
if mi is not None:
sta["maintainability_index"] = mi
# composite score: line 35% + branch 35% + mutation 20% + assertion density 10%
line_pct = cov.get("line_coverage_pct", 0) if "error" not in cov else 0
branch_pct = cov.get("branch_coverage_pct", 0) if "error" not in cov else 0
score = round(
line_pct * 0.35 +
branch_pct * 0.35 +
mut["mutation_score"] * 0.20 +
min(sta["assertion_density"] * 10, 10),
1
)
return cov, mut, sta, score
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--csv", default="functions.csv")
parser.add_argument("--out", default="evaluation_results.json")
args = parser.parse_args()
rows = []
with open(args.csv, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
rows.append({
"function_code": row["function_code"].strip(),
"test_code": row["test_code"].strip(),
})
print(f"Loaded {len(rows)} functions from {args.csv}\n")
results = []
for i, row in enumerate(rows):
fn_code = row["function_code"]
test_code = row["test_code"]
try:
fn_name = next(n.name for n in ast.walk(ast.parse(fn_code)) if isinstance(n, ast.FunctionDef))
except Exception:
fn_name = f"function_{i + 1}"
print(f"[{i+1}/{len(rows)}] {fn_name}")
cov, mut, sta, score = evaluate_one(fn_code, test_code)
if "error" in cov:
print(f" coverage : ERROR - {cov['error'][:80]}")
else:
print(f" coverage : line={cov['line_coverage_pct']}% branch={cov['branch_coverage_pct']}%")
print(f" mutation : {mut['mutation_score']}% ({mut['killed']}/{mut['total_mutants']} killed)")
print(f" static : CC={sta['cyclomatic_complexity']} rank={sta['complexity_rank']}")
print(f" score : {score}/100\n")
results.append({
"function_name": fn_name,
"function_code": fn_code,
"test_code": test_code,
"coverage": cov,
"mutation": mut,
"static": sta,
"score": score,
})
Path(args.out).write_text(json.dumps(results, indent=2))
print(f"Saved -> {args.out}")
print("\n" + "=" * 65)
print(f" {'function':<22} {'line%':>6} {'branch%':>8} {'mut%':>6} {'score':>6}")
print(" " + "-" * 63)
for r in results:
c = r["coverage"]
line_pct = c.get("line_coverage_pct", "ERR") if "error" not in c else "ERR"
branch_pct = c.get("branch_coverage_pct", "ERR") if "error" not in c else "ERR"
print(f" {r['function_name']:<22}"
f" {line_pct:>5}%"
f" {branch_pct:>6}%"
f" {r['mutation']['mutation_score']:>5}%"
f" {r['score']:>5}/100")
print("=" * 65)