Skip to content

Commit 2848b0a

Browse files
committed
Add free Python sandbox
1 parent 852cdbc commit 2848b0a

9 files changed

Lines changed: 103 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
6060
- Ошибки из практики автоматически попадают в отдельный мягкий режим повторения.
6161
- Код проверяется в изолированном Python-процессе с AST-фильтром и двухсекундным лимитом.
6262
- `input()` работает через безопасный симулятор: ответы вводятся под редактором, по одной строке для каждого вызова.
63+
- Вкладка «Песочница» даёт свободный редактор с историей запусков и консолью: можно экспериментировать без проверки задания.
6364
- После сдачи урока и серии практики показывается реальный консольный вывод выполненного кода.
6465
- В разделе «Проекты» есть шесть мини-инструментов: от именной открытки до карточки контакта; они открываются постепенно и сохраняют прохождение.
6566
- Один пользователь, локальный SQLite: можно заниматься без регистрации и облака.

‎app/evaluator.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ def visit_Attribute(self, node: ast.Attribute) -> None:
5454
5555
SAFE_BUILTINS = {
5656
'__build_class__': builtins.__build_class__, 'abs': abs, 'all': all, 'any': any,
57-
'bool': bool, 'dict': dict, 'enumerate': enumerate, 'float': float, 'int': int,
58-
'len': len, 'list': list, 'max': max, 'min': min, 'print': print, 'range': range,
59-
'round': round, 'set': set, 'sorted': sorted, 'str': str, 'sum': sum, 'tuple': tuple,
60-
'zip': zip,
57+
'bool': bool, 'chr': chr, 'dict': dict, 'enumerate': enumerate, 'float': float,
58+
'format': format, 'int': int, 'isinstance': isinstance, 'len': len, 'list': list,
59+
'map': map, 'max': max, 'min': min, 'ord': ord, 'pow': pow, 'print': print,
60+
'range': range, 'repr': repr, 'reversed': reversed, 'round': round, 'set': set,
61+
'slice': slice, 'sorted': sorted, 'str': str, 'sum': sum, 'tuple': tuple,
62+
'type': type, 'zip': zip,
6163
}
6264
source = base64.b64decode(CODE).decode('utf-8')
6365
tests = json.loads(base64.b64decode(TESTS).decode('utf-8'))

‎app/main.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ class ProjectRun(ProjectSubmission):
7676
inputs: list[str] = []
7777

7878

79+
class SandboxRun(ProjectRun):
80+
"""Свободный запуск в той же безопасной среде, что задания и проекты."""
81+
82+
7983
def status_for(lesson: dict, saved: dict) -> dict:
8084
index = next(index for index, item in enumerate(LESSONS) if item["id"] == lesson["id"])
8185
completed = lesson["id"] in saved
@@ -476,6 +480,11 @@ def run_editor_code(payload: CodeRun) -> dict:
476480
return run_code(payload.answer, [], payload.inputs)
477481

478482

483+
@app.post("/api/sandbox/run")
484+
def run_sandbox(payload: SandboxRun) -> dict:
485+
return run_code(payload.answer, [], payload.inputs)
486+
487+
479488
@app.get("/api/exams/{module_id}")
480489
def get_exam(module_id: str) -> dict:
481490
exam = EXAMS.get(module_id)

‎app/static/app.js‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
const view = document.querySelector('#view');
22
let dashboard = null;
3+
const sandboxHistory = [];
4+
const sandboxSamples = [
5+
{ label: 'Вывод текста', code: "print('Привет, Python!')\n", inputs: [] },
6+
{ label: 'Небольшой расчёт', code: 'price = 120\ncount = 3\nprint(price * count)\n', inputs: [] },
7+
{ label: 'Ввод имени', code: "name = input('Имя: ')\nprint(f'Привет, {name}!')\n", inputs: ['Аня'] },
8+
];
39

410
const esc = (value = '') => String(value)
511
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
@@ -36,7 +42,7 @@ function route() {
3642
}
3743

3844
function setActiveNavigation(current) {
39-
const active = current.startsWith('/projects') ? '/projects' : current.startsWith('/practice') ? '/practice' : '/';
45+
const active = current.startsWith('/sandbox') ? '/sandbox' : current.startsWith('/projects') ? '/projects' : current.startsWith('/practice') ? '/practice' : '/';
4046
document.querySelectorAll('[data-nav]').forEach((node) => node.classList.toggle('active', node.dataset.nav === active));
4147
}
4248

@@ -110,7 +116,7 @@ function questionTemplate(question, number, stageLabel = '') {
110116
const exampleInputs = (question.input_example || []).join('\n');
111117
field = `<textarea class="code-editor" data-answer-q="${question.id}" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">${esc(question.starter)}</textarea>
112118
<details class="code-input-panel" ${exampleInputs ? 'open' : ''}><summary>⌨️ Данные для input() <small>необязательно</small></summary><p>Одна строка — один ответ. Они подставятся по порядку при запуске.</p><textarea class="code-stdin" data-input-q="${question.id}" placeholder="Например: Аня" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">${esc(exampleInputs)}</textarea></details>
113-
<div class="code-actions"><button class="button ghost" type="button" data-run-code="${question.id}">▷ Запустить</button><button class="button blue" type="button" data-check-code="${question.id}">✓ Проверить по заданию</button></div>
119+
<div class="code-actions"><button class="button ghost" type="button" data-run-code="${question.id}">▷ Запустить</button><button class="button blue" type="button" data-check-code="${question.id}">✓ Проверить по заданию</button><a class="button ghost sandbox-link" href="#/sandbox">⌨️ Песочница</a></div>
114120
<details class="hint"><summary>Нужна подсказка?</summary><p>${esc(question.hint)}</p></details>`;
115121
}
116122
return `<article class="question-card card" data-question="${question.id}">${head}${guide}${field}<div class="inline-result" id="result-${question.id}"></div></article>`;
@@ -302,6 +308,54 @@ async function renderPractice(mode = 'guided', moduleId = '') {
302308
renderStep();
303309
}
304310

311+
function renderSandboxHistory() {
312+
const node = document.querySelector('#sandbox-history');
313+
if (!node) return;
314+
if (!sandboxHistory.length) {
315+
node.innerHTML = '<p class="sandbox-empty">Запуски появятся здесь. Не бойся менять код и запускать снова.</p>';
316+
return;
317+
}
318+
node.innerHTML = sandboxHistory.map((item, index) => `<article class="sandbox-run ${item.correct ? '' : 'failed'}"><div><strong>Запуск ${sandboxHistory.length - index}</strong><span>${item.correct ? 'Код выполнился' : esc(item.message)}</span></div><pre class="execution-source">${esc(item.source)}</pre><span>Консоль</span><pre class="code-output">${esc(item.output || '— программа ничего не вывела —')}</pre></article>`).join('');
319+
}
320+
321+
function renderSandbox() {
322+
const sample = sandboxSamples[2];
323+
view.innerHTML = `<section class="sandbox-wrap"><a class="back-link" href="#/">← К маршруту</a><article class="sandbox-hero card"><p class="eyebrow">Свободная практика</p><h1>Песочница Python</h1><p class="lead">Пиши обычный код, меняй его и запускай сколько угодно раз. Здесь нет оценки и нет единственного правильного ответа — есть код, консоль и эксперимент.</p></article><aside class="learning-roadmap"><strong>Рабочий цикл</strong><span>1. Напиши идею</span><span>2. Запусти код</span><span>3. Прочитай вывод</span><p>Можно использовать базовые функции Python, условия, циклы, функции и input(). Импорты, файлы и системные команды остаются закрыты для безопасности.</p></aside><section class="sandbox-card card"><div class="sandbox-toolbar"><strong>Быстрый старт</strong><div>${sandboxSamples.map((item, index) => `<button class="sandbox-sample" type="button" data-sandbox-sample="${index}">${esc(item.label)}</button>`).join('')}</div></div><textarea id="sandbox-code" class="code-editor sandbox-editor" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">${esc(sample.code)}</textarea><details class="code-input-panel" open><summary>⌨️ Данные для input() <small>необязательно</small></summary><p>Одна строка — один ответ. Если код вызывает input() дважды, напиши два ответа в разных строках.</p><textarea id="sandbox-input" class="code-stdin" placeholder="Например: Аня" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">${esc(sample.inputs.join('\n'))}</textarea></details><div class="code-actions"><button class="button blue" type="button" id="sandbox-run">▷ Запустить код</button><button class="button ghost" type="button" id="sandbox-reset">↺ Очистить</button></div></section><section class="sandbox-console card"><div class="sandbox-console-head"><div><p class="eyebrow">История</p><h2>Консоль запусков</h2></div><span>Каждый запуск сохраняется, пока открыта страница</span></div><div id="sandbox-history"></div></section></section>`;
324+
const code = document.querySelector('#sandbox-code');
325+
const input = document.querySelector('#sandbox-input');
326+
const runButton = document.querySelector('#sandbox-run');
327+
document.querySelectorAll('[data-sandbox-sample]').forEach((button) => {
328+
button.addEventListener('click', () => {
329+
const selected = sandboxSamples[Number(button.dataset.sandboxSample)];
330+
code.value = selected.code;
331+
input.value = selected.inputs.join('\n');
332+
code.focus();
333+
});
334+
});
335+
document.querySelector('#sandbox-reset').addEventListener('click', () => {
336+
code.value = '# Здесь можно писать и запускать любой учебный код\n';
337+
input.value = '';
338+
sandboxHistory.length = 0;
339+
renderSandboxHistory();
340+
code.focus();
341+
});
342+
runButton.addEventListener('click', async () => {
343+
runButton.disabled = true;
344+
runButton.textContent = 'Запускаем…';
345+
try {
346+
const result = await api('/api/sandbox/run', { method: 'POST', body: JSON.stringify({ answer: code.value, inputs: input.value ? input.value.replaceAll('\r\n', '\n').split('\n') : [] }) });
347+
sandboxHistory.unshift({ source: code.value, output: result.output, correct: result.correct, message: result.message });
348+
renderSandboxHistory();
349+
} catch (error) {
350+
sandboxHistory.unshift({ source: code.value, output: '', correct: false, message: error.message });
351+
renderSandboxHistory();
352+
}
353+
runButton.disabled = false;
354+
runButton.textContent = '▷ Запустить код';
355+
});
356+
renderSandboxHistory();
357+
}
358+
305359
async function renderProjects() {
306360
loading();
307361
const data = await api('/api/projects');
@@ -380,6 +434,7 @@ async function render() {
380434
try {
381435
if (current === '/') { loading(); renderDashboard(await refreshDashboard()); }
382436
else if (current === '/practice') await renderPractice();
437+
else if (current === '/sandbox') renderSandbox();
383438
else if (current === '/projects') await renderProjects();
384439
else if (current.startsWith('/projects/')) await renderProject(current.split('/')[2]);
385440
else if (current.startsWith('/lesson/')) await renderLesson(current.split('/')[2]);

‎app/static/index.html‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<a class="nav-link" data-nav="/" href="#/"><span>⌂</span> Главная</a>
2626
<a class="nav-link" data-nav="/practice" href="#/practice"><span>🎯</span> Практика</a>
2727
<a class="nav-link" data-nav="/projects" href="#/projects"><span>🛠️</span> Проекты</a>
28+
<a class="nav-link" data-nav="/sandbox" href="#/sandbox"><span>⌨️</span> Песочница</a>
2829
<button class="nav-link nav-reset" id="reset-button" type="button"><span>↺</span> Сбросить прогресс</button>
2930
<div class="sidebar-tip"><span>💡</span><p>15 минут в день дают устойчивый прогресс.</p></div>
3031
</aside>
@@ -34,6 +35,7 @@
3435
<a data-nav="/" href="#/"><span>⌂</span>Главная</a>
3536
<a data-nav="/practice" href="#/practice"><span>🎯</span>Практика</a>
3637
<a data-nav="/projects" href="#/projects"><span>🛠️</span>Проекты</a>
38+
<a data-nav="/sandbox" href="#/sandbox"><span>⌨️</span>Код</a>
3739
</nav>
3840
<script src="/static/app.js" defer></script>
3941
</body>

0 commit comments

Comments
 (0)