Skip to content

Commit 10f3e2e

Browse files
authored
Merge pull request #13 from gon7187/feat/run-button
feat: кнопка Запустить с выводом stdout (P0.1)
2 parents 009b1ad + 632de21 commit 10f3e2e

6 files changed

Lines changed: 203 additions & 10 deletions

File tree

‎app/evaluator.py‎

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ def visit_Attribute(self, node: ast.Attribute) -> None:
7474
else:
7575
actual = eval(test['call'], namespace, namespace)
7676
checks.append({'passed': actual == test['expected'], 'actual': repr(actual), 'expected': repr(test['expected'])})
77-
print(json.dumps({'ok': all(item['passed'] for item in checks), 'checks': checks}, ensure_ascii=False))
77+
print(json.dumps({'ok': all(item['passed'] for item in checks), 'checks': checks, 'stdout': output.getvalue()}, ensure_ascii=False))
7878
except Exception as error:
79-
print(json.dumps({'ok': False, 'error': f'{type(error).__name__}: {error}', 'checks': []}, ensure_ascii=False))
79+
print(json.dumps({'ok': False, 'error': f'{type(error).__name__}: {error}', 'checks': [], 'stdout': output.getvalue()}, ensure_ascii=False))
8080
"""
8181

8282

@@ -87,11 +87,25 @@ def normalize(value: Any) -> str:
8787
def run_code(source: str, tests: list[dict]) -> dict:
8888
"""Выполняет небольшой фрагмент в отдельном Python-процессе с лимитом времени."""
8989
if len(source) > 5_000:
90-
return {"correct": False, "message": "Решение слишком длинное для этого задания."}
90+
return {
91+
"correct": False,
92+
"message": "Решение слишком длинное для этого задания.",
93+
"stdout": "",
94+
"stderr": "",
95+
"error": "Решение слишком длинное для этого задания.",
96+
"timed_out": False,
97+
}
9198
try:
9299
SafetyVisitor().visit(ast.parse(source))
93100
except (SyntaxError, ValueError) as error:
94-
return {"correct": False, "message": str(error)}
101+
return {
102+
"correct": False,
103+
"message": str(error),
104+
"stdout": "",
105+
"stderr": "",
106+
"error": str(error),
107+
"timed_out": False,
108+
}
95109

96110
encoded_code = base64.b64encode(source.encode("utf-8")).decode("ascii")
97111
encoded_tests = base64.b64encode(json.dumps(tests, ensure_ascii=False).encode("utf-8")).decode(
@@ -107,24 +121,52 @@ def run_code(source: str, tests: list[dict]) -> dict:
107121
check=False,
108122
)
109123
except subprocess.TimeoutExpired:
110-
return {"correct": False, "message": "Код выполнялся слишком долго. Проверь условие цикла."}
124+
return {
125+
"correct": False,
126+
"message": "Код выполнялся слишком долго. Проверь условие цикла.",
127+
"stdout": "",
128+
"stderr": "",
129+
"error": "Код выполнялся слишком долго. Проверь условие цикла.",
130+
"timed_out": True,
131+
}
111132

112133
try:
113134
payload = json.loads(result.stdout.strip().splitlines()[-1])
114135
except (json.JSONDecodeError, IndexError):
115136
return {
116137
"correct": False,
117138
"message": "Не удалось проверить решение. Попробуй упростить код.",
139+
"stdout": "",
140+
"stderr": result.stderr,
141+
"error": "Не удалось проверить решение. Попробуй упростить код.",
142+
"timed_out": False,
118143
}
119144

145+
run_result = {
146+
"stdout": payload.get("stdout", ""),
147+
"stderr": result.stderr,
148+
"error": payload.get("error"),
149+
"timed_out": False,
150+
}
120151
if payload.get("error"):
121-
return {"correct": False, "message": f"Почти: {payload['error']}", "checks": []}
152+
return {
153+
"correct": False,
154+
"message": f"Почти: {payload['error']}",
155+
"checks": [],
156+
**run_result,
157+
}
122158
if payload.get("ok"):
123-
return {"correct": True, "message": "Все тесты пройдены!", "checks": payload["checks"]}
159+
return {
160+
"correct": True,
161+
"message": "Все тесты пройдены!",
162+
"checks": payload["checks"],
163+
**run_result,
164+
}
124165
return {
125166
"correct": False,
126167
"message": "Не все тесты прошли. Сверь результат с условием.",
127168
"checks": payload.get("checks", []),
169+
**run_result,
128170
}
129171

130172

‎app/main.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
public_question,
2323
)
2424
from app.db import DATABASE_PATH, connection, init_db, record_attempt, save_exam, save_lesson, state
25-
from app.evaluator import evaluate
25+
from app.evaluator import evaluate, run_code
2626

2727
APP_DIR = Path(__file__).parent
2828
STATIC_DIR = APP_DIR / "static"
@@ -52,6 +52,11 @@ class CodeCheck(BaseModel):
5252
answer: str
5353

5454

55+
class CodeRun(BaseModel):
56+
code: str
57+
question_id: str | None = None
58+
59+
5560
def status_for(lesson: dict, saved: dict) -> dict:
5661
index = next(index for index, item in enumerate(LESSONS) if item["id"] == lesson["id"])
5762
completed = lesson["id"] in saved
@@ -264,6 +269,15 @@ def check_code(payload: CodeCheck) -> dict:
264269
return evaluate(question, payload.answer)
265270

266271

272+
@app.post("/api/code/run")
273+
def run_code_endpoint(payload: CodeRun) -> dict:
274+
result = run_code(payload.code, [])
275+
return {
276+
key: result.get(key, default)
277+
for key, default in {"stdout": "", "stderr": "", "error": None, "timed_out": False}.items()
278+
}
279+
280+
267281
@app.get("/api/exams/{module_id}")
268282
def get_exam(module_id: str) -> dict:
269283
exam = EXAMS.get(module_id)

‎app/static/app.js‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ function questionTemplate(question, number) {
102102
field = `<input class="answer-input" data-answer-q="${question.id}" placeholder="${esc(question.placeholder || 'Введите ответ')}" autocomplete="off" />`;
103103
} else {
104104
field = `<textarea class="code-editor" data-answer-q="${question.id}" spellcheck="false">${esc(question.starter)}</textarea>
105-
<div class="code-actions"><button class="button blue" type="button" data-check-code="${question.id}">▷ Проверить код</button></div>
105+
<div class="code-actions"><button class="button blue" type="button" data-check-code="${question.id}">▷ Проверить код</button><button class="button ghost" type="button" data-run-code="${question.id}">▶ Запустить</button></div>
106+
<section class="code-output" aria-live="polite"><b>Вывод</b><pre id="output-${question.id}">—</pre></section>
106107
<details class="hint"><summary>Нужна подсказка?</summary><p>${esc(question.hint)}</p></details>`;
107108
}
108109
return `<article class="question-card card" data-question="${question.id}">${head}${field}<div class="inline-result" id="result-${question.id}"></div></article>`;
@@ -139,6 +140,31 @@ function bindQuestionControls(scope) {
139140
button.textContent = '▷ Проверить код';
140141
});
141142
});
143+
scope.querySelectorAll('[data-run-code]').forEach((button) => {
144+
button.addEventListener('click', async () => {
145+
const questionId = button.dataset.runCode;
146+
const editor = scope.querySelector(`[data-answer-q="${questionId}"]`);
147+
button.disabled = true;
148+
button.textContent = 'Запускаем…';
149+
try {
150+
showCodeOutput(questionId, await api('/api/code/run', { method: 'POST', body: JSON.stringify({ question_id: questionId, code: editor.value }) }));
151+
} catch (error) { showCodeOutput(questionId, { error: error.message }); }
152+
button.disabled = false;
153+
button.textContent = '▶ Запустить';
154+
});
155+
});
156+
}
157+
158+
function showCodeOutput(questionId, result) {
159+
const node = document.querySelector(`#output-${questionId}`);
160+
if (!node) return;
161+
const output = [
162+
result.stdout,
163+
result.stderr,
164+
result.error,
165+
result.timed_out && 'Превышено время выполнения.',
166+
].filter(Boolean);
167+
node.textContent = output.join('\n') || 'Нет вывода.';
142168
}
143169

144170
function showInline(questionId, correct, message, checks = []) {

‎app/static/styles.css‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Run Button Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Run code from the lesson editor and display its output without changing runner security limits.
6+
7+
**Architecture:** Add a small `POST /api/code/run` wrapper around `run_code(code, [])`. Preserve the runner's AST filter and two-second subprocess timeout, while returning its captured stdout and execution error. The existing editor sends to that endpoint and renders the returned output below the editor.
8+
9+
**Tech Stack:** FastAPI, Pydantic, SQLite app, vanilla JavaScript, CSS, pytest.
10+
11+
## Global Constraints
12+
13+
- Work only in `/root/projects/python-path` on `feat/run-button`; never commit directly to `main`.
14+
- Keep the current AST safety filter, isolated subprocess, 5,000-character source cap, and 2-second timeout unchanged.
15+
- Do not add dependencies or delete project files.
16+
17+
---
18+
19+
### Task 1: Add API regression tests
20+
21+
**Files:**
22+
- Modify: `tests/test_api.py`
23+
24+
**Interfaces:**
25+
- Consumes: `POST /api/code/run` JSON `{ "code": str, "question_id": str | null }`.
26+
- Produces: assertions for `{stdout, stderr, error, timed_out}`.
27+
28+
- [ ] **Step 1: Write failing tests**
29+
30+
```python
31+
response = client.post("/api/code/run", json={"code": "print('hi')"})
32+
assert response.json()["stdout"] == "hi\n"
33+
34+
response = client.post("/api/code/run", json={"code": "import os"})
35+
assert response.json()["error"]
36+
```
37+
38+
- [ ] **Step 2: Run test to verify it fails**
39+
40+
Run: `uv run pytest tests/test_api.py -q`
41+
Expected: FAIL because `/api/code/run` does not exist.
42+
43+
### Task 2: Implement and render code execution
44+
45+
**Files:**
46+
- Modify: `app/evaluator.py`
47+
- Modify: `app/main.py`
48+
- Modify: `app/static/app.js`
49+
- Modify: `app/static/styles.css`
50+
51+
**Interfaces:**
52+
- Consumes: `run_code(source, tests)` with `tests=[]`.
53+
- Produces: `POST /api/code/run` response `{stdout, stderr, error, timed_out}` and a `.code-output` block.
54+
55+
- [ ] **Step 1: Preserve runner data**
56+
57+
```python
58+
return {"stdout": payload.get("stdout", ""), "stderr": result.stderr,
59+
"error": payload.get("error"), "timed_out": False}
60+
```
61+
62+
- [ ] **Step 2: Add endpoint and UI event handler**
63+
64+
```python
65+
@app.post("/api/code/run")
66+
def run_code_endpoint(payload: CodeRun) -> dict:
67+
result = run_code(payload.code, [])
68+
return {
69+
"stdout": result.get("stdout", ""),
70+
"stderr": result.get("stderr", ""),
71+
"error": result.get("error"),
72+
"timed_out": result.get("timed_out", False),
73+
}
74+
```
75+
76+
- [ ] **Step 3: Run focused tests then full suite**
77+
78+
Run: `uv run pytest tests/test_api.py -q && uv run pytest`
79+
Expected: PASS.
80+
81+
- [ ] **Step 4: Commit and push**
82+
83+
```bash
84+
git add -A
85+
git commit -m "feat: run button with stdout output (P0.1)"
86+
git push -u origin feat/run-button
87+
```

‎tests/test_api.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@
55
from app.main import app
66

77

8+
def test_run_code_returns_stdout_and_blocks_imports() -> None:
9+
with TestClient(app) as client:
10+
response = client.post("/api/code/run", json={"code": "print('hi')"})
11+
assert response.status_code == 200
12+
assert response.json()["stdout"] == "hi\n"
13+
assert response.json()["stderr"] == ""
14+
assert response.json()["error"] is None
15+
assert response.json()["timed_out"] is False
16+
17+
blocked = client.post("/api/code/run", json={"code": "import os"})
18+
assert blocked.status_code == 200
19+
assert blocked.json()["error"]
20+
21+
failed = client.post("/api/code/run", json={"code": "print('a')\n1 / 0"})
22+
assert failed.json()["stdout"] == "a\n"
23+
assert failed.json()["error"]
24+
25+
timed_out = client.post("/api/code/run", json={"code": "while True: pass"})
26+
assert timed_out.json()["timed_out"] is True
27+
28+
too_long = client.post("/api/code/run", json={"code": "#" * 5_001})
29+
assert too_long.json()["error"]
30+
31+
832
def test_lesson_unlocking_and_progression() -> None:
933
with TestClient(app) as client:
1034
client.post("/api/reset")

0 commit comments

Comments
 (0)