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")