diff --git a/app/evaluator.py b/app/evaluator.py
index 498c541..ca71c18 100644
--- a/app/evaluator.py
+++ b/app/evaluator.py
@@ -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))
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))
"""
@@ -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(
@@ -107,7 +121,14 @@ 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])
@@ -115,16 +136,37 @@ def run_code(source: str, tests: list[dict]) -> dict:
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,
}
diff --git a/app/main.py b/app/main.py
index 147ba52..326d9fb 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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"
@@ -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
@@ -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)
diff --git a/app/static/app.js b/app/static/app.js
index c792984..14c5b8f 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -102,7 +102,8 @@ function questionTemplate(question, number) {
field = ``;
} else {
field = `
-
+
+
Нужна подсказка?
${esc(question.hint)}
`;
}
return `${head}${field}`;
@@ -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 = []) {
diff --git a/app/static/styles.css b/app/static/styles.css
index 96e1401..59bf280 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -68,7 +68,7 @@ h3 { margin: 0; font-size: 17px; }
.lesson-head { display: flex; justify-content: space-between; align-items: start; gap: 24px; margin-bottom: 25px; }.back-link { color: var(--muted); display: inline-flex; gap: 6px; align-items: center; font-size: 13px; font-weight: 900; margin-bottom: 14px; }.back-link:hover { color: var(--green-dark); }.lesson-meta { color: var(--muted); margin-top: 10px; font-size: 13px; font-weight: 800; }.lesson-meta span { margin-right: 12px; }
.theory-card { margin: 14px 0; padding: 22px; }.theory-card p { color: #4e5a71; line-height: 1.55; margin: 10px 0; }.code-example { overflow-x: auto; margin: 14px 0 0; padding: 14px; border-radius: 11px; background: #202b42; color: #e8f5ff; font: 13px/1.55 "Source Code Pro", monospace; white-space: pre; }.tip { margin: 14px 0 0; color: #688151; background: #eff9e7; padding: 10px 12px; border-radius: 10px; font-size: 12px; font-weight: 700; }.tip:before { content: "💡 "; }
.practice-title { margin-top: 34px; }.question-card { padding: 22px; margin: 14px 0; }.question-number { color: var(--green-dark); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; font-weight: 900; }.question-prompt { margin: 8px 0 17px; font-size: 17px; line-height: 1.4; font-weight: 800; }.inline-code { font-family: "Source Code Pro", monospace; font-size: .89em; background: #eff3f8; padding: 1px 5px; border-radius: 5px; }.options { display: grid; gap: 9px; }.option { padding: 13px 14px; border: 2px solid var(--line); background: white; color: var(--ink); border-radius: 12px; text-align: left; font-weight: 800; }.option:hover { border-color: #b7e89a; background: #f7fff1; }.option.selected { border-color: var(--green); color: var(--green-dark); background: #effbe7; }.answer-input, .code-editor { width: 100%; border: 2px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; transition: border-color .15s; }.answer-input { padding: 13px; }.answer-input:focus, .code-editor:focus { border-color: var(--blue); }.code-editor { min-height: 145px; padding: 15px; resize: vertical; background: #202b42; color: #eaf6ff; border: 0; font: 13px/1.55 "Source Code Pro", monospace; }.code-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 11px; }.code-actions .button { padding: 9px 13px; font-size: 12px; }.hint { margin-top: 10px; color: var(--muted); font-size: 12px; }.hint summary { cursor: pointer; font-weight: 900; }.hint p { margin: 7px 0 0; }.inline-result { display: none; margin-top: 12px; padding: 11px 13px; border-radius: 10px; font-size: 13px; font-weight: 800; }.inline-result.visible { display: block; }.inline-result.ok { background: #eaf9df; color: #438413; }.inline-result.no { background: #fff0f1; color: #c33e51; }.submit-row { display: flex; align-items: center; gap: 15px; margin-top: 24px; }.submit-row .button { min-width: 190px; }.submit-note { color: var(--muted); font-size: 12px; font-weight: 700; }
-.result-panel { padding: 22px; margin: 22px 0; border-radius: 17px; border: 2px solid #bdeaa1; background: #f4ffec; }.result-panel.fail { border-color: #ffc2cb; background: #fff5f6; }.result-panel h2 { margin-bottom: 8px; }.result-panel p { margin: 0; color: #526142; line-height: 1.45; }.result-list { display: grid; gap: 7px; margin-top: 16px; }.result-item { padding: 10px; border-radius: 9px; background: rgba(255,255,255,.67); font-size: 12px; }.result-item b { margin-right: 5px; }.result-item.ok b { color: var(--green-dark); }.result-item.no b { color: var(--red); }
+.code-output { margin-top: 12px; padding: 11px 13px; border-radius: 10px; background: #202b42; color: #eaf6ff; font: 13px/1.55 "Source Code Pro", monospace; }.code-output b { display: block; margin-bottom: 5px; color: #b7e89a; }.code-output pre { margin: 0; white-space: pre-wrap; }.result-panel { padding: 22px; margin: 22px 0; border-radius: 17px; border: 2px solid #bdeaa1; background: #f4ffec; }.result-panel.fail { border-color: #ffc2cb; background: #fff5f6; }.result-panel h2 { margin-bottom: 8px; }.result-panel p { margin: 0; color: #526142; line-height: 1.45; }.result-list { display: grid; gap: 7px; margin-top: 16px; }.result-item { padding: 10px; border-radius: 9px; background: rgba(255,255,255,.67); font-size: 12px; }.result-item b { margin-right: 5px; }.result-item.ok b { color: var(--green-dark); }.result-item.no b { color: var(--red); }
.practice-wrap, .exam-wrap { max-width: 760px; }.practice-hero, .exam-hero { padding: 26px; margin-bottom: 22px; }.practice-hero { background: linear-gradient(120deg, #e9f9ff, #f6fcff); border-color: #cceaf7; }.exam-hero { background: linear-gradient(120deg, #fff8de, #fffdf6); border-color: #ffe8a3; }.empty { text-align: center; padding: 55px 20px; }.empty-icon { font-size: 48px; }.empty p { color: var(--muted); }.loading { color: var(--muted); padding: 70px; text-align: center; font-weight: 800; }.loading i { width: 8px; height: 8px; display: inline-block; background: var(--green); border-radius: 99px; margin: 0 2px; animation: bounce .9s infinite alternate; }.loading i:nth-child(2) { animation-delay: .2s; }.loading i:nth-child(3) { animation-delay: .4s; }@keyframes bounce { to { transform: translateY(-7px); opacity: .55; } }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 30; padding: 13px 16px; color: white; background: #28354e; box-shadow: 0 8px 25px rgba(30,45,75,.25); border-radius: 12px; font-weight: 800; font-size: 13px; animation: in .2s ease-out; }@keyframes in { from { transform: translateY(15px); opacity: 0; } }
diff --git a/docs/superpowers/plans/2026-07-28-run-button.md b/docs/superpowers/plans/2026-07-28-run-button.md
new file mode 100644
index 0000000..f7dc3a9
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-28-run-button.md
@@ -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
+```
diff --git a/tests/test_api.py b/tests/test_api.py
index 7595a19..2ac0302 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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")