Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 49 additions & 7 deletions app/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ def visit_Attribute(self, node: ast.Attribute) -> None:
else:
actual = eval(test['call'], namespace, namespace)
checks.append({'passed': actual == test['expected'], 'actual': repr(actual), 'expected': repr(test['expected'])})
print(json.dumps({'ok': all(item['passed'] for item in checks), 'checks': checks}, ensure_ascii=False))
print(json.dumps({'ok': all(item['passed'] for item in checks), 'checks': checks, 'stdout': output.getvalue()}, ensure_ascii=False))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape captured stdout before emitting runner JSON

When valid learner code prints a lone surrogate escape such as print('\ud800'), placing the raw captured value in an ensure_ascii=False JSON document makes the runner's final print raise UnicodeEncodeError; the exception handler then repeats the same failing serialization, and the parent reports a generic checker failure with no stdout. This also regresses ordinary code checks because raw stdout was not previously included in the payload; serialize with surrogate-safe escaping or sanitize the captured text.

Useful? React with 👍 / 👎.

except Exception as error:
print(json.dumps({'ok': False, 'error': f'{type(error).__name__}: {error}', 'checks': []}, ensure_ascii=False))
print(json.dumps({'ok': False, 'error': f'{type(error).__name__}: {error}', 'checks': [], 'stdout': output.getvalue()}, ensure_ascii=False))
"""


Expand All @@ -87,11 +87,25 @@ def normalize(value: Any) -> str:
def run_code(source: str, tests: list[dict]) -> dict:
"""Выполняет небольшой фрагмент в отдельном Python-процессе с лимитом времени."""
if len(source) > 5_000:
return {"correct": False, "message": "Решение слишком длинное для этого задания."}
return {
"correct": False,
"message": "Решение слишком длинное для этого задания.",
"stdout": "",
"stderr": "",
"error": "Решение слишком длинное для этого задания.",
"timed_out": False,
}
try:
SafetyVisitor().visit(ast.parse(source))
except (SyntaxError, ValueError) as error:
return {"correct": False, "message": str(error)}
return {
"correct": False,
"message": str(error),
"stdout": "",
"stderr": "",
"error": str(error),
"timed_out": False,
}

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

try:
payload = json.loads(result.stdout.strip().splitlines()[-1])
except (json.JSONDecodeError, IndexError):
return {
"correct": False,
"message": "Не удалось проверить решение. Попробуй упростить код.",
"stdout": "",
"stderr": result.stderr,
"error": "Не удалось проверить решение. Попробуй упростить код.",
"timed_out": False,
}

run_result = {
"stdout": payload.get("stdout", ""),
"stderr": result.stderr,
"error": payload.get("error"),
"timed_out": False,
}
if payload.get("error"):
return {"correct": False, "message": f"Почти: {payload['error']}", "checks": []}
return {
"correct": False,
"message": f"Почти: {payload['error']}",
"checks": [],
**run_result,
}
if payload.get("ok"):
return {"correct": True, "message": "Все тесты пройдены!", "checks": payload["checks"]}
return {
"correct": True,
"message": "Все тесты пройдены!",
"checks": payload["checks"],
**run_result,
}
return {
"correct": False,
"message": "Не все тесты прошли. Сверь результат с условием.",
"checks": payload.get("checks", []),
**run_result,
}


Expand Down
16 changes: 15 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
public_question,
)
from app.db import DATABASE_PATH, connection, init_db, record_attempt, save_exam, save_lesson, state
from app.evaluator import evaluate
from app.evaluator import evaluate, run_code

APP_DIR = Path(__file__).parent
STATIC_DIR = APP_DIR / "static"
Expand Down Expand Up @@ -52,6 +52,11 @@ class CodeCheck(BaseModel):
answer: str


class CodeRun(BaseModel):
code: str
question_id: str | None = None


def status_for(lesson: dict, saved: dict) -> dict:
index = next(index for index, item in enumerate(LESSONS) if item["id"] == lesson["id"])
completed = lesson["id"] in saved
Expand Down Expand Up @@ -252,6 +257,15 @@ def check_code(payload: CodeCheck) -> dict:
return evaluate(question, payload.answer)


@app.post("/api/code/run")
def run_code_endpoint(payload: CodeRun) -> dict:
result = run_code(payload.code, [])
return {
key: result.get(key, default)
for key, default in {"stdout": "", "stderr": "", "error": None, "timed_out": False}.items()
}


@app.get("/api/exams/{module_id}")
def get_exam(module_id: str) -> dict:
exam = EXAMS.get(module_id)
Expand Down
28 changes: 27 additions & 1 deletion app/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ function questionTemplate(question, number) {
field = `<input class="answer-input" data-answer-q="${question.id}" placeholder="${esc(question.placeholder || 'Введите ответ')}" autocomplete="off" />`;
} else {
field = `<textarea class="code-editor" data-answer-q="${question.id}" spellcheck="false">${esc(question.starter)}</textarea>
<div class="code-actions"><button class="button blue" type="button" data-check-code="${question.id}">▷ Проверить код</button></div>
<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>
<section class="code-output" aria-live="polite"><b>Вывод</b><pre id="output-${question.id}">—</pre></section>
<details class="hint"><summary>Нужна подсказка?</summary><p>${esc(question.hint)}</p></details>`;
}
return `<article class="question-card card" data-question="${question.id}">${head}${field}<div class="inline-result" id="result-${question.id}"></div></article>`;
Expand Down Expand Up @@ -139,6 +140,31 @@ function bindQuestionControls(scope) {
button.textContent = '▷ Проверить код';
});
});
scope.querySelectorAll('[data-run-code]').forEach((button) => {
button.addEventListener('click', async () => {
const questionId = button.dataset.runCode;
const editor = scope.querySelector(`[data-answer-q="${questionId}"]`);
button.disabled = true;
button.textContent = 'Запускаем…';
try {
showCodeOutput(questionId, await api('/api/code/run', { method: 'POST', body: JSON.stringify({ question_id: questionId, code: editor.value }) }));
} catch (error) { showCodeOutput(questionId, { error: error.message }); }
button.disabled = false;
button.textContent = '▶ Запустить';
});
});
}

function showCodeOutput(questionId, result) {
const node = document.querySelector(`#output-${questionId}`);
if (!node) return;
const output = [
result.stdout,
result.stderr,
result.error,
result.timed_out && 'Превышено время выполнения.',
].filter(Boolean);
node.textContent = output.join('\n') || 'Нет вывода.';
}

function showInline(questionId, correct, message, checks = []) {
Expand Down
2 changes: 1 addition & 1 deletion app/static/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 87 additions & 0 deletions docs/superpowers/plans/2026-07-28-run-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Run Button Implementation Plan

> **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.

**Goal:** Run code from the lesson editor and display its output without changing runner security limits.

**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.

**Tech Stack:** FastAPI, Pydantic, SQLite app, vanilla JavaScript, CSS, pytest.

## Global Constraints

- Work only in `/root/projects/python-path` on `feat/run-button`; never commit directly to `main`.
- Keep the current AST safety filter, isolated subprocess, 5,000-character source cap, and 2-second timeout unchanged.
- Do not add dependencies or delete project files.

---

### Task 1: Add API regression tests

**Files:**
- Modify: `tests/test_api.py`

**Interfaces:**
- Consumes: `POST /api/code/run` JSON `{ "code": str, "question_id": str | null }`.
- Produces: assertions for `{stdout, stderr, error, timed_out}`.

- [ ] **Step 1: Write failing tests**

```python
response = client.post("/api/code/run", json={"code": "print('hi')"})
assert response.json()["stdout"] == "hi\n"

response = client.post("/api/code/run", json={"code": "import os"})
assert response.json()["error"]
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_api.py -q`
Expected: FAIL because `/api/code/run` does not exist.

### Task 2: Implement and render code execution

**Files:**
- Modify: `app/evaluator.py`
- Modify: `app/main.py`
- Modify: `app/static/app.js`
- Modify: `app/static/styles.css`

**Interfaces:**
- Consumes: `run_code(source, tests)` with `tests=[]`.
- Produces: `POST /api/code/run` response `{stdout, stderr, error, timed_out}` and a `.code-output` block.

- [ ] **Step 1: Preserve runner data**

```python
return {"stdout": payload.get("stdout", ""), "stderr": result.stderr,
"error": payload.get("error"), "timed_out": False}
```

- [ ] **Step 2: Add endpoint and UI event handler**

```python
@app.post("/api/code/run")
def run_code_endpoint(payload: CodeRun) -> dict:
result = run_code(payload.code, [])
return {
"stdout": result.get("stdout", ""),
"stderr": result.get("stderr", ""),
"error": result.get("error"),
"timed_out": result.get("timed_out", False),
}
```

- [ ] **Step 3: Run focused tests then full suite**

Run: `uv run pytest tests/test_api.py -q && uv run pytest`
Expected: PASS.

- [ ] **Step 4: Commit and push**

```bash
git add -A
git commit -m "feat: run button with stdout output (P0.1)"
git push -u origin feat/run-button
```
24 changes: 24 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@
from app.main import app


def test_run_code_returns_stdout_and_blocks_imports() -> None:
with TestClient(app) as client:
response = client.post("/api/code/run", json={"code": "print('hi')"})
assert response.status_code == 200
assert response.json()["stdout"] == "hi\n"
assert response.json()["stderr"] == ""
assert response.json()["error"] is None
assert response.json()["timed_out"] is False

blocked = client.post("/api/code/run", json={"code": "import os"})
assert blocked.status_code == 200
assert blocked.json()["error"]

failed = client.post("/api/code/run", json={"code": "print('a')\n1 / 0"})
assert failed.json()["stdout"] == "a\n"
assert failed.json()["error"]

timed_out = client.post("/api/code/run", json={"code": "while True: pass"})
assert timed_out.json()["timed_out"] is True

too_long = client.post("/api/code/run", json={"code": "#" * 5_001})
assert too_long.json()["error"]


def test_lesson_unlocking_and_progression() -> None:
with TestClient(app) as client:
client.post("/api/reset")
Expand Down
Loading