Files

302 lines
8.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run selected numbered testcases through the IR backend and IRCompiler."""
from __future__ import annotations
import argparse
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class ExpectedOutput:
stdout: bytes
returncode: int
@dataclass(frozen=True)
class Testcase:
number: int
source: Path
input_file: Path | None
output_file: Path | None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Run numbered testcases like 1-5,7 with "
"`cargo run -- -I -S -D -o output.ir testcase.c`, then "
"`tools/IRCompiler -R output.ir`, and compare with testcase.out."
)
)
parser.add_argument("numbers", help="testcase numbers, for example: 1-5,7")
parser.add_argument(
"--testcases",
type=Path,
default=Path("testcases"),
help="testcase directory (default: testcases)",
)
parser.add_argument(
"--ir",
type=Path,
default=Path("output.ir"),
help="IR output path reused for each testcase (default: output.ir)",
)
parser.add_argument(
"--ircompiler",
type=Path,
default=Path("tools/IRCompiler"),
help="IRCompiler path (default: tools/IRCompiler)",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="print compiler/interpreter stderr for passing cases too",
)
return parser.parse_args()
def parse_numbers(spec: str) -> list[int]:
numbers: list[int] = []
seen: set[int] = set()
for raw_part in spec.split(","):
part = raw_part.strip()
if not part:
continue
if "-" in part:
bounds = part.split("-", 1)
if len(bounds) != 2 or not bounds[0] or not bounds[1]:
raise ValueError(f"invalid range: {part!r}")
start, end = int(bounds[0]), int(bounds[1])
step = 1 if start <= end else -1
values = range(start, end + step, step)
else:
values = (int(part),)
for value in values:
if value <= 0:
raise ValueError(f"testcase number must be positive: {value}")
if value not in seen:
seen.add(value)
numbers.append(value)
if not numbers:
raise ValueError("no testcase numbers specified")
return numbers
def find_testcase(testcases_dir: Path, number: int) -> Testcase:
prefix = f"{number:03d}_"
matches = sorted(testcases_dir.glob(f"{prefix}*.c"))
if not matches:
raise FileNotFoundError(f"no C testcase found for {number} ({prefix}*.c)")
if len(matches) > 1:
joined = ", ".join(str(path) for path in matches)
raise RuntimeError(f"multiple C testcases found for {number}: {joined}")
source = matches[0]
stem = source.with_suffix("")
input_file = stem.with_suffix(".in")
output_file = stem.with_suffix(".out")
return Testcase(
number=number,
source=source,
input_file=input_file if input_file.is_file() else None,
output_file=output_file if output_file.is_file() else None,
)
def parse_expected(output_file: Path) -> ExpectedOutput:
data = output_file.read_bytes()
lines = data.splitlines(keepends=True)
if not lines:
raise ValueError(f"empty expected output file: {output_file}")
returncode_line = lines[-1].strip()
try:
returncode = int(returncode_line)
except ValueError as err:
raise ValueError(
f"last line of {output_file} is not an integer return code: "
f"{returncode_line!r}"
) from err
return ExpectedOutput(stdout=b"".join(lines[:-1]), returncode=returncode)
def normalize_returncode(returncode: int) -> int:
return returncode & 255
def strip_trailing_newlines(data: bytes) -> bytes:
return data.rstrip(b"\r\n")
def run_command(
command: list[str],
*,
stdin: bytes | None = None,
) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
command,
input=stdin,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def format_bytes(data: bytes) -> str:
if not data:
return "<empty>"
try:
return data.decode()
except UnicodeDecodeError:
return repr(data)
def format_bytes_debug(data: bytes) -> str:
return f"{data!r} ({len(data)} bytes)"
def print_failure_detail(
testcase: Testcase,
*,
expected: ExpectedOutput | None,
actual_stdout: bytes,
actual_returncode: int | None,
stderr: bytes,
verbose: bool,
) -> None:
print(f" source: {testcase.source}")
if testcase.input_file is not None:
print(f" input: {testcase.input_file}")
if expected is not None:
print(" expected stdout:")
print(format_bytes(expected.stdout), end="" if expected.stdout.endswith(b"\n") else "\n")
print(f" expected stdout bytes: {format_bytes_debug(expected.stdout)}")
print(f" expected returncode: {expected.returncode}")
print(" actual stdout:")
print(format_bytes(actual_stdout), end="" if actual_stdout.endswith(b"\n") else "\n")
print(f" actual stdout bytes: {format_bytes_debug(actual_stdout)}")
if actual_returncode is not None:
print(f" actual returncode: {actual_returncode}")
if stderr and (verbose or expected is None):
print(" stderr:")
print(format_bytes(stderr), end="" if stderr.endswith(b"\n") else "\n")
def run_testcase(
testcase: Testcase,
*,
ir_file: Path,
ircompiler: Path,
verbose: bool,
) -> bool:
compile_cmd = [
"cargo",
"run",
"--",
"-I",
"-S",
"-D",
"-o",
str(ir_file),
str(testcase.source),
]
compile_result = run_command(compile_cmd)
if compile_result.returncode != 0:
print(f"[FAIL] {testcase.number:03d} compile failed")
print_failure_detail(
testcase,
expected=None,
actual_stdout=compile_result.stdout,
actual_returncode=compile_result.returncode,
stderr=compile_result.stderr,
verbose=True,
)
return False
stdin = testcase.input_file.read_bytes() if testcase.input_file is not None else None
run_result = run_command([str(ircompiler), "-R", str(ir_file)], stdin=stdin)
if testcase.output_file is None:
print(f"[SKIP] {testcase.number:03d} missing .out: {testcase.source.name}")
print_failure_detail(
testcase,
expected=None,
actual_stdout=run_result.stdout,
actual_returncode=run_result.returncode,
stderr=run_result.stderr,
verbose=verbose,
)
return False
expected = parse_expected(testcase.output_file)
actual_returncode = normalize_returncode(run_result.returncode)
ok = (
strip_trailing_newlines(run_result.stdout)
== strip_trailing_newlines(expected.stdout)
and actual_returncode == expected.returncode
)
status = "PASS" if ok else "FAIL"
print(f"[{status}] {testcase.number:03d} {testcase.source.name}")
if not ok or (verbose and (compile_result.stderr or run_result.stderr)):
if verbose and compile_result.stderr:
print(" compiler stderr:")
print(
format_bytes(compile_result.stderr),
end="" if compile_result.stderr.endswith(b"\n") else "\n",
)
print_failure_detail(
testcase,
expected=expected,
actual_stdout=run_result.stdout,
actual_returncode=actual_returncode,
stderr=run_result.stderr,
verbose=verbose,
)
return ok
def main() -> int:
args = parse_args()
try:
numbers = parse_numbers(args.numbers)
testcases = [find_testcase(args.testcases, number) for number in numbers]
except (OSError, RuntimeError, ValueError) as err:
print(f"error: {err}", file=sys.stderr)
return 2
passed = 0
for testcase in testcases:
try:
if run_testcase(
testcase,
ir_file=args.ir,
ircompiler=args.ircompiler,
verbose=args.verbose,
):
passed += 1
except OSError as err:
print(f"[FAIL] {testcase.number:03d} failed to run command: {err}")
except ValueError as err:
print(f"[FAIL] {testcase.number:03d} invalid expected output: {err}")
total = len(testcases)
failed = total - passed
print(f"\nsummary: {passed}/{total} passed, {failed} failed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())