diff --git a/main.py b/main.py index 299bd94..d1f8265 100644 --- a/main.py +++ b/main.py @@ -11,10 +11,13 @@ from schemas import ( DebugRequest, FormatRequest, FormatResponse, + RunRequest, + RunResponse, ) from database import DatabaseService from debug_runner import DebugError, run_debug from formatter import format_code, FormatError +from runner import run_code, RunError from dotenv import load_dotenv @@ -155,6 +158,16 @@ async def format_code_endpoint(request: FormatRequest) -> FormatResponse: return FormatResponse(code=formatted) +@app.post("/run", response_model=RunResponse) +async def run_code_endpoint(request: RunRequest) -> RunResponse: + """在 go-judge 沙箱中编译运行 C/C++ 代码""" + try: + result = await run_code(request.language, request.code, request.stdin) + except RunError as e: + raise HTTPException(status_code=502, detail=str(e)) + return RunResponse(**result) + + if __name__ == "__main__": import uvicorn diff --git a/pyproject.toml b/pyproject.toml index bebe4b5..3f1b64d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "uvicorn[standard]>=0.38.0", "ruff>=0.15.17", "sqlparse>=0.5.5", + "httpx>=0.28.1", ] [dependency-groups] diff --git a/runner.py b/runner.py new file mode 100644 index 0000000..5c054e0 --- /dev/null +++ b/runner.py @@ -0,0 +1,117 @@ +import os + +import httpx + +# go-judge 沙箱地址,docker-compose 里是 code-judge 容器 +GOJUDGE_URL = os.getenv("GOJUDGE_URL", "http://code-judge:5050") + +# 状态码沿用前端(原 Judge0)的约定 +STATUS_ACCEPTED = 3 +STATUS_COMPILE_ERROR = 6 +STATUS_RUNTIME_ERROR = 11 + +SECOND = 1_000_000_000 # go-judge 的时间单位是纳秒 +MB = 1024 * 1024 +OUTPUT_MAX = 64 * 1024 + +LANGUAGES = { + "c": { + "source": "main.c", + "compile": ["/usr/bin/gcc", "main.c", "-o", "main", "-lm"], + }, + "cpp": { + "source": "main.cpp", + "compile": ["/usr/bin/g++", "main.cpp", "-o", "main"], + }, +} + +STATUS_MESSAGES = { + "Time Limit Exceeded": "运行超时", + "Memory Limit Exceeded": "内存超出限制", + "Output Limit Exceeded": "输出超出限制", + "Signalled": "程序异常终止", +} + + +class RunError(Exception): + """沙箱不可用等非用户代码导致的错误""" + + +def _cmd(args: list[str], stdin: str, cpu: int, memory: int, **extra) -> dict: + return { + "args": args, + "env": ["PATH=/usr/bin:/bin"], + "files": [ + {"content": stdin}, + {"name": "stdout", "max": OUTPUT_MAX}, + {"name": "stderr", "max": OUTPUT_MAX}, + ], + "cpuLimit": cpu * SECOND, + "clockLimit": cpu * 2 * SECOND, + "memoryLimit": memory * MB, + "procLimit": 50, + "copyOut": ["stdout", "stderr"], + **extra, + } + + +async def _run(client: httpx.AsyncClient, cmd: dict) -> dict: + try: + response = await client.post("/run", json={"cmd": [cmd]}) + response.raise_for_status() + except httpx.HTTPError as e: + raise RunError(f"判题沙箱不可用:{e}") + result = response.json()[0] + if result["status"] == "Internal Error": + raise RunError(result.get("error") or "判题沙箱内部错误") + return result + + +def _output(result: dict) -> str: + # 与原来 Judge0 的 redirect_stderr_to_stdout 效果接近:stdout 在前,stderr 在后 + files = result.get("files", {}) + return (files.get("stdout", "") + files.get("stderr", "")).strip() + + +async def run_code(language: str, code: str, stdin: str) -> dict: + config = LANGUAGES[language] + async with httpx.AsyncClient(base_url=GOJUDGE_URL, timeout=30) as client: + compiled = await _run( + client, + _cmd( + config["compile"], + "", + cpu=10, + memory=512, + copyIn={config["source"]: {"content": code}}, + copyOutCached=["main"], + ), + ) + if compiled["status"] != "Accepted": + return {"status": STATUS_COMPILE_ERROR, "output": _output(compiled)} + + file_id = compiled["fileIds"]["main"] + try: + result = await _run( + client, + _cmd( + ["main"], + stdin, + cpu=5, + memory=256, + copyIn={"main": {"fileId": file_id}}, + ), + ) + finally: + try: + await client.delete(f"/file/{file_id}") + except httpx.HTTPError: + pass + + output = _output(result) + if result["status"] == "Accepted": + return {"status": STATUS_ACCEPTED, "output": output} + message = STATUS_MESSAGES.get(result["status"]) + if message: + output = f"{output}\n{message}".strip() + return {"status": STATUS_RUNTIME_ERROR, "output": output} diff --git a/schemas.py b/schemas.py index e43b784..a700a21 100644 --- a/schemas.py +++ b/schemas.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import List, Dict, Any +from typing import List, Dict, Any, Literal class PresetCodeCreate(BaseModel): @@ -30,6 +30,7 @@ class AIAnalysisRequest(BaseModel): class DebugRequest(BaseModel): """调试请求模式,用于调试 Python 代码""" + code: str inputs: List[str] @@ -45,3 +46,18 @@ class FormatResponse(BaseModel): """格式化代码的响应模式""" code: str + + +class RunRequest(BaseModel): + """运行 C/C++ 代码的请求模式""" + + code: str + language: Literal["c", "cpp"] + stdin: str = "" + + +class RunResponse(BaseModel): + """运行结果,status 沿用 Judge0 的状态码:3 成功、6 编译错误、11 运行错误""" + + status: int + output: str diff --git a/uv.lock b/uv.lock index 298d4b9..9691eef 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [[package]] @@ -73,6 +73,7 @@ source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, { name = "fastapi" }, + { name = "httpx" }, { name = "openai" }, { name = "pydantic" }, { name = "python-dotenv" }, @@ -91,6 +92,7 @@ dev = [ requires-dist = [ { name = "aiosqlite", specifier = "==0.21" }, { name = "fastapi", specifier = ">=0.119.1" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "openai", specifier = ">=2.6.0" }, { name = "pydantic", specifier = ">=2.12.3" }, { name = "python-dotenv", specifier = ">=1.1.1" },