volsu-contests/backend/app/services/judge.py

294 lines
10 KiB
Python

import httpx
from app.config import settings
# Mapping language_id to Piston language name and version
# Synced with languages installed in entrypoint.sh
LANGUAGE_MAP = {
# Core languages
71: ("python", "3.12.0"),
50: ("c", "10.2.0"),
54: ("c++", "10.2.0"),
62: ("java", "15.0.2"),
63: ("javascript", "20.11.1"),
# Systems programming
60: ("go", "1.16.2"),
73: ("rust", "1.68.2"),
# JVM languages
78: ("kotlin", "1.8.20"),
81: ("scala", "3.2.2"),
# .NET
51: ("csharp", "5.0.201"),
# Scripting
72: ("ruby", "3.0.1"),
85: ("perl", "5.36.0"),
68: ("php", "8.2.3"),
64: ("lua", "5.4.4"),
# Functional
61: ("haskell", "9.0.1"),
65: ("ocaml", "4.12.0"),
90: ("elixir", "1.11.3"),
86: ("clojure", "1.10.3"),
95: ("racket", "8.3.0"),
# Other
67: ("pascal", "3.2.2"),
59: ("fortran", "10.2.0"),
83: ("swift", "5.3.3"),
74: ("typescript", "5.0.3"),
91: ("nim", "1.6.2"),
96: ("zig", "0.10.1"),
56: ("d", "10.2.0"),
69: ("prolog", "8.2.4"),
# Low-level
45: ("nasm", "2.15.5"),
46: ("bash", "5.1.0"),
}
class JudgeStatus:
"""Status codes compatible with Judge0 format"""
IN_QUEUE = 1
PROCESSING = 2
ACCEPTED = 3
WRONG_ANSWER = 4
TIME_LIMIT_EXCEEDED = 5
COMPILATION_ERROR = 6
RUNTIME_ERROR_SIGSEGV = 7
RUNTIME_ERROR_SIGXFSZ = 8
RUNTIME_ERROR_SIGFPE = 9
RUNTIME_ERROR_SIGABRT = 10
RUNTIME_ERROR_NZEC = 11
RUNTIME_ERROR_OTHER = 12
INTERNAL_ERROR = 13
EXEC_FORMAT_ERROR = 14
class JudgeService:
"""Code execution service using Piston API"""
def __init__(self):
self.base_url = settings.piston_url
async def get_languages(self) -> list[dict]:
"""Get list of supported languages from Piston"""
async with httpx.AsyncClient() as client:
response = await client.get(f"{self.base_url}/api/v2/runtimes")
response.raise_for_status()
runtimes = response.json()
# Create a set of available runtimes for quick lookup
available = {rt["language"] for rt in runtimes}
# Return only languages from LANGUAGE_MAP that are installed
result = []
seen_ids = set()
for lang_id, (piston_lang, piston_ver) in LANGUAGE_MAP.items():
if piston_lang in available and lang_id not in seen_ids:
seen_ids.add(lang_id)
# Find actual installed version
actual_version = piston_ver
for rt in runtimes:
if rt["language"] == piston_lang:
actual_version = rt["version"]
break
# Human-readable names
name_map = {
"python": "Python",
"c": "C (GCC)",
"c++": "C++ (GCC)",
"java": "Java",
"javascript": "JavaScript (Node.js)",
"go": "Go",
"rust": "Rust",
"kotlin": "Kotlin",
"scala": "Scala",
"csharp": "C#",
"ruby": "Ruby",
"perl": "Perl",
"php": "PHP",
"lua": "Lua",
"haskell": "Haskell",
"ocaml": "OCaml",
"elixir": "Elixir",
"clojure": "Clojure",
"racket": "Racket",
"pascal": "Pascal",
"fortran": "Fortran",
"swift": "Swift",
"typescript": "TypeScript",
"nim": "Nim",
"zig": "Zig",
"d": "D",
"prolog": "Prolog",
"nasm": "Assembly (NASM)",
"bash": "Bash",
}
display_name = name_map.get(piston_lang, piston_lang.title())
result.append({
"id": lang_id,
"name": f"{display_name} ({actual_version})",
})
return result
def _normalize_output(self, output: str) -> str:
"""
Normalize output for comparison:
- Strip trailing whitespace from each line
- Ensure single trailing newline (as print() adds)
"""
if not output:
return ""
lines = output.splitlines()
normalized = "\n".join(line.rstrip() for line in lines)
if normalized:
normalized += "\n"
return normalized
def _get_piston_language(self, language_id: int) -> tuple[str, str]:
"""Convert Judge0 language_id to Piston language name and version"""
if language_id in LANGUAGE_MAP:
return LANGUAGE_MAP[language_id]
# Default to Python if unknown
return ("python", "3.10.0")
async def submit(
self,
source_code: str,
language_id: int,
stdin: str = "",
expected_output: str = "",
cpu_time_limit: float = 1.0,
memory_limit: int = 262144,
) -> dict:
"""
Execute code using Piston and return result in Judge0-compatible format.
"""
language, version = self._get_piston_language(language_id)
normalized_expected = self._normalize_output(expected_output)
# Determine file extension
ext_map = {
"python": "py", "c": "c", "c++": "cpp", "java": "java",
"javascript": "js", "go": "go", "rust": "rs", "kotlin": "kt",
"scala": "scala", "csharp": "cs", "ruby": "rb", "perl": "pl",
"php": "php", "lua": "lua", "haskell": "hs", "ocaml": "ml",
"elixir": "exs", "clojure": "clj", "racket": "rkt", "pascal": "pas",
"fortran": "f90", "swift": "swift", "typescript": "ts", "nim": "nim",
"zig": "zig", "d": "d", "prolog": "pl", "nasm": "asm", "bash": "sh",
}
ext = ext_map.get(language, "txt")
filename = f"main.{ext}"
# For Java, extract class name from source
if language == "java":
import re
match = re.search(r'public\s+class\s+(\w+)', source_code)
if match:
filename = f"{match.group(1)}.java"
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
f"{self.base_url}/api/v2/execute",
json={
"language": language,
"version": version,
"files": [
{
"name": filename,
"content": source_code,
}
],
"stdin": stdin,
"run_timeout": int(cpu_time_limit * 1000), # Convert to ms
"compile_timeout": 10000, # 10 seconds for compilation
},
)
response.raise_for_status()
result = response.json()
except httpx.HTTPStatusError as e:
return {
"status": {"id": JudgeStatus.INTERNAL_ERROR, "description": "Internal Error"},
"stdout": "",
"stderr": str(e),
"compile_output": "",
"message": f"Piston API error: {e.response.status_code}",
"time": None,
"memory": None,
}
except Exception as e:
return {
"status": {"id": JudgeStatus.INTERNAL_ERROR, "description": "Internal Error"},
"stdout": "",
"stderr": str(e),
"compile_output": "",
"message": str(e),
"time": None,
"memory": None,
}
# Convert Piston response to Judge0-compatible format
run_result = result.get("run", {})
compile_result = result.get("compile", {})
stdout = run_result.get("stdout", "") or ""
stderr = run_result.get("stderr", "") or ""
compile_output = compile_result.get("output", "") or ""
exit_code = run_result.get("code", 0)
signal = run_result.get("signal")
# Determine status
if compile_result.get("code") is not None and compile_result.get("code") != 0:
# Compilation error
status_id = JudgeStatus.COMPILATION_ERROR
status_desc = "Compilation Error"
elif signal == "SIGKILL":
# Usually means timeout or memory limit
status_id = JudgeStatus.TIME_LIMIT_EXCEEDED
status_desc = "Time Limit Exceeded"
elif signal is not None:
# Runtime error with signal
signal_map = {
"SIGSEGV": JudgeStatus.RUNTIME_ERROR_SIGSEGV,
"SIGFPE": JudgeStatus.RUNTIME_ERROR_SIGFPE,
"SIGABRT": JudgeStatus.RUNTIME_ERROR_SIGABRT,
}
status_id = signal_map.get(signal, JudgeStatus.RUNTIME_ERROR_OTHER)
status_desc = f"Runtime Error ({signal})"
elif exit_code != 0:
# Non-zero exit code
status_id = JudgeStatus.RUNTIME_ERROR_NZEC
status_desc = "Runtime Error (NZEC)"
else:
# Execution successful - compare output
actual_output = self._normalize_output(stdout)
if actual_output == normalized_expected:
status_id = JudgeStatus.ACCEPTED
status_desc = "Accepted"
else:
status_id = JudgeStatus.WRONG_ANSWER
status_desc = "Wrong Answer"
return {
"status": {"id": status_id, "description": status_desc},
"stdout": stdout,
"stderr": stderr,
"compile_output": compile_output,
"message": "",
"time": None, # Piston doesn't provide execution time in same format
"memory": None, # Piston doesn't provide memory usage
}
judge_service = JudgeService()