Skip to content

Commit e48fb11

Browse files
author
Hermes Agent
committed
Merge remote-tracking branch 'origin/main' into dependabot/pip/ruff-gte-0.15.21
2 parents b2a8d9f + 7a4e1ab commit e48fb11

14 files changed

Lines changed: 1760 additions & 85 deletions

app/content.py

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from app.extended_curriculum import EXTRA_EXAMS, EXTRA_LESSONS, EXTRA_MODULES
6+
from app.lessons_13_25 import LESSONS_13_25
67

78

89
def theory(title: str, text: str, example: str, tip: str = "") -> dict:
@@ -285,20 +286,17 @@ def lesson(
285286
),
286287
text(
287288
"if-result",
288-
"Что выведет `if 3 > 5: print('да') else: print('нет')`?",
289+
"Что выведет этот код?\n\nif 3 > 5:\n print('да')\nelse:\n print('нет')",
289290
["нет"],
290291
"3 не больше 5, поэтому выполняется ветка else.",
291292
),
292293
code(
293294
"if-code",
294-
"Напиши `is_adult(age)`, возвращающую True при возрасте 18 и больше.",
295-
"def is_adult(age):\n # твой код\n pass\n",
296-
[
297-
{"kind": "call", "call": "is_adult(18)", "expected": True},
298-
{"kind": "call", "call": "is_adult(17)", "expected": False},
299-
],
300-
"Условие внутри функции помогает вернуть нужный логический результат.",
301-
"Сравни age с 18 и верни результат сравнения либо используй if/else.",
295+
"Дано `temperature = 22`. Если температура >= 25, выведи `Надень шорты`, иначе — `Возьми куртку`.",
296+
"temperature = 22\n# твой код\n",
297+
[{"kind": "stdout", "expected": "Возьми куртку"}],
298+
"Условие выбирает одежду по температуре.",
299+
"Проверь temperature >= 25: в одной ветке выведи про шорты, в другой — про куртку.",
302300
),
303301
],
304302
),
@@ -338,14 +336,11 @@ def lesson(
338336
text("for-sum", "Чему равна сумма чисел в `range(1, 4)`?", ["6"], "Это 1 + 2 + 3 = 6."),
339337
code(
340338
"for-code",
341-
"Напиши `sum_to(number)`, возвращающую сумму от 1 до number включительно.",
342-
"def sum_to(number):\n # твой код\n pass\n",
343-
[
344-
{"kind": "call", "call": "sum_to(1)", "expected": 1},
345-
{"kind": "call", "call": "sum_to(5)", "expected": 15},
346-
],
347-
"Цикл избавляет от ручного сложения каждого числа.",
348-
"Создай total = 0 и пройди range(1, number + 1).",
339+
"Выведи числа от 1 до 5, каждое с новой строки.",
340+
"# Напиши цикл\n",
341+
[{"kind": "stdout", "expected": "1\n2\n3\n4\n5"}],
342+
"Цикл for и range позволяют вывести числа без повторения print().",
343+
"Пройди по range(1, 6) и выведи каждое число через print().",
349344
),
350345
],
351346
),
@@ -391,14 +386,11 @@ def lesson(
391386
),
392387
code(
393388
"while-code",
394-
"Напиши `countdown(start)`, возвращающую список от start до 1 через while.",
395-
"def countdown(start):\n # твой код\n pass\n",
396-
[
397-
{"kind": "call", "call": "countdown(1)", "expected": [1]},
398-
{"kind": "call", "call": "countdown(4)", "expected": [4, 3, 2, 1]},
399-
],
389+
"Дано `start = 5`. Выведи обратный отсчёт до 1, каждое число с новой строки, затем `Пуск!`.",
390+
"start = 5\n# твой код\n",
391+
[{"kind": "stdout", "expected": "5\n4\n3\n2\n1\nПуск!"}],
400392
"while хорош, когда счётчик меняется до достижения границы.",
401-
"Начни с result = [] и уменьшай start после добавления в список.",
393+
"Пока start больше 0, выводи его и уменьшай на 1. После цикла выведи «Пуск!».",
402394
),
403395
],
404396
),
@@ -728,6 +720,7 @@ def lesson(
728720

729721

730722
MODULES.extend(EXTRA_MODULES)
723+
LESSONS.extend(LESSONS_13_25)
731724
LESSONS.extend(EXTRA_LESSONS)
732725

733726

@@ -767,7 +760,9 @@ def lesson(
767760

768761
def public_question(question: dict) -> dict:
769762
return {
770-
key: value for key, value in question.items() if key not in {"answer", "answers", "tests"}
763+
key: value
764+
for key, value in question.items()
765+
if key not in {"answer", "answers", "tests", "reference"}
771766
}
772767

773768

app/error_hints.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Короткие подсказки к распространённым ошибкам Python."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
8+
def translate_error(error_text: str) -> dict[str, str]:
9+
"""Возвращает понятную новичку подсказку, сохраняя исходную ошибку."""
10+
text = error_text or ""
11+
lower = text.casefold()
12+
title, hint = (
13+
"Неизвестная ошибка",
14+
"Прочитай текст ошибки и проверь последнюю изменённую строку кода.",
15+
)
16+
17+
if "unexpected eof while parsing" in lower:
18+
title, hint = "Незаконченный код", "Проверь, закрыты ли скобки, кавычки и все блоки кода."
19+
elif "eol while scanning string literal" in lower or "unterminated string literal" in lower:
20+
title, hint = "Незакрытая строка", "Проверь, есть ли закрывающая кавычка у строки."
21+
elif "invalid character" in lower:
22+
title, hint = (
23+
"Недопустимый символ",
24+
"Проверь строку: в код случайно мог попасть необычный символ.",
25+
)
26+
elif "syntaxerror" in lower:
27+
title, hint = (
28+
"Синтаксическая ошибка",
29+
"Проверь знаки препинания, скобки, кавычки и написание команды.",
30+
)
31+
elif "expected an indented block" in lower or "unindent does not match" in lower:
32+
title, hint = (
33+
"Ошибка отступа",
34+
"После двоеточия добавь отступ в 4 пробела и выровняй строки блока.",
35+
)
36+
elif "taberror" in lower:
37+
title, hint = (
38+
"Смешаны табы и пробелы",
39+
"Используй для отступов только пробелы — по 4 на каждый уровень.",
40+
)
41+
elif "nameerror" in lower:
42+
name = re.search(r"name ['\"](.+?)['\"] is not defined", text)
43+
variable = f" «{name.group(1)}»" if name else ""
44+
title, hint = (
45+
"Неизвестное имя",
46+
f"Проверь написание{variable}: переменную нужно объявить до использования.",
47+
)
48+
elif "typeerror" in lower:
49+
title, hint = (
50+
"Неподходящий тип данных",
51+
"Проверь, какие значения участвуют в операции: строку и число нельзя сложить напрямую.",
52+
)
53+
elif "valueerror" in lower:
54+
title, hint = (
55+
"Некорректное значение",
56+
"Проверь формат значения и то, подходит ли оно для этой операции.",
57+
)
58+
elif "indexerror" in lower:
59+
title, hint = (
60+
"Индекс вне диапазона",
61+
"Проверь номер элемента: отсчёт в списке начинается с 0.",
62+
)
63+
elif "keyerror" in lower:
64+
title, hint = "Ключ не найден", "Проверь название ключа и убедись, что он есть в словаре."
65+
elif "attributeerror" in lower:
66+
title, hint = (
67+
"Нет такого свойства или метода",
68+
"Проверь название метода и подходит ли он для этого типа данных.",
69+
)
70+
elif "zerodivisionerror" in lower:
71+
title, hint = "Деление на ноль", "Перед делением убедись, что делитель не равен нулю."
72+
elif "modulenotfounderror" in lower:
73+
title, hint = (
74+
"Модуль не найден",
75+
"Проверь название модуля и его доступность в учебном редакторе.",
76+
)
77+
elif "importerror" in lower:
78+
title, hint = (
79+
"Ошибка импорта",
80+
"Проверь название модуля или объекта, который хочешь импортировать.",
81+
)
82+
elif "timeouterror" in lower or "слишком долго" in lower:
83+
title, hint = (
84+
"Время выполнения истекло",
85+
"Проверь условие цикла: оно должно когда-нибудь становиться ложным.",
86+
)
87+
88+
return {"title": title, "hint": hint, "original": text}

app/evaluator.py

Lines changed: 120 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,29 +74,109 @@ 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

8383
def normalize(value: Any) -> str:
8484
return str(value or "").strip().casefold()
8585

8686

87+
def _check_source_requirements(tree: ast.AST, tests: list[dict]) -> str | None:
88+
for test in tests:
89+
if test.get("kind") != "source":
90+
continue
91+
if test.get("requires") == "unpacking":
92+
name = test.get("name")
93+
function = test.get("function")
94+
scope: ast.AST = tree
95+
if function:
96+
target_function = next(
97+
(
98+
node
99+
for node in ast.walk(tree)
100+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
101+
and node.name == function
102+
),
103+
None,
104+
)
105+
scope = ast.Module(
106+
body=target_function.body if target_function else [], type_ignores=[]
107+
)
108+
has_unpacking = any(
109+
isinstance(node, ast.Assign)
110+
and isinstance(node.value, ast.Name)
111+
and node.value.id == name
112+
and any(isinstance(target, (ast.Tuple, ast.List)) for target in node.targets)
113+
for node in ast.walk(scope)
114+
)
115+
uses_index = any(isinstance(node, ast.Subscript) for node in ast.walk(scope))
116+
if function:
117+
unpacking_values = {
118+
id(node.value)
119+
for node in ast.walk(scope)
120+
if isinstance(node, ast.Assign)
121+
and isinstance(node.value, ast.Name)
122+
and node.value.id == name
123+
and any(isinstance(target, (ast.Tuple, ast.List)) for target in node.targets)
124+
}
125+
uses_parameter_elsewhere = any(
126+
isinstance(node, ast.Name)
127+
and node.id == name
128+
and id(node) not in unpacking_values
129+
for node in ast.walk(scope)
130+
)
131+
else:
132+
uses_parameter_elsewhere = False
133+
if not has_unpacking or uses_index or uses_parameter_elsewhere:
134+
return "В этом задании нужна распаковка последовательности без индексов."
135+
return None
136+
137+
87138
def run_code(source: str, tests: list[dict]) -> dict:
88139
"""Выполняет небольшой фрагмент в отдельном Python-процессе с лимитом времени."""
89140
if len(source) > 5_000:
90-
return {"correct": False, "message": "Решение слишком длинное для этого задания."}
141+
return {
142+
"correct": False,
143+
"message": "Решение слишком длинное для этого задания.",
144+
"stdout": "",
145+
"stderr": "",
146+
"error": "Решение слишком длинное для этого задания.",
147+
"timed_out": False,
148+
}
91149
try:
92-
SafetyVisitor().visit(ast.parse(source))
150+
tree = ast.parse(source)
151+
SafetyVisitor().visit(tree)
93152
except (SyntaxError, ValueError) as error:
94-
return {"correct": False, "message": str(error)}
153+
error_text = f"{type(error).__name__}: {error}"
154+
return {
155+
"correct": False,
156+
"message": str(error),
157+
"stdout": "",
158+
"stderr": "",
159+
"error": error_text,
160+
"timed_out": False,
161+
}
162+
163+
requirement_error = _check_source_requirements(tree, tests)
164+
if requirement_error:
165+
return {
166+
"correct": False,
167+
"message": requirement_error,
168+
"checks": [],
169+
"stdout": "",
170+
"stderr": "",
171+
"error": requirement_error,
172+
"timed_out": False,
173+
}
95174

175+
runtime_tests = [test for test in tests if test.get("kind") != "source"]
96176
encoded_code = base64.b64encode(source.encode("utf-8")).decode("ascii")
97-
encoded_tests = base64.b64encode(json.dumps(tests, ensure_ascii=False).encode("utf-8")).decode(
98-
"ascii"
99-
)
177+
encoded_tests = base64.b64encode(
178+
json.dumps(runtime_tests, ensure_ascii=False).encode("utf-8")
179+
).decode("ascii")
100180
program = RUNNER.replace("CODE", repr(encoded_code)).replace("TESTS", repr(encoded_tests))
101181
try:
102182
result = subprocess.run(
@@ -107,24 +187,53 @@ def run_code(source: str, tests: list[dict]) -> dict:
107187
check=False,
108188
)
109189
except subprocess.TimeoutExpired:
110-
return {"correct": False, "message": "Код выполнялся слишком долго. Проверь условие цикла."}
190+
error_text = "TimeoutError: Код выполнялся слишком долго. Проверь условие цикла."
191+
return {
192+
"correct": False,
193+
"message": error_text,
194+
"stdout": "",
195+
"stderr": "",
196+
"error": error_text,
197+
"timed_out": True,
198+
}
111199

112200
try:
113201
payload = json.loads(result.stdout.strip().splitlines()[-1])
114202
except (json.JSONDecodeError, IndexError):
115203
return {
116204
"correct": False,
117205
"message": "Не удалось проверить решение. Попробуй упростить код.",
206+
"stdout": "",
207+
"stderr": result.stderr,
208+
"error": "Не удалось проверить решение. Попробуй упростить код.",
209+
"timed_out": False,
118210
}
119211

212+
run_result = {
213+
"stdout": payload.get("stdout", ""),
214+
"stderr": result.stderr,
215+
"error": payload.get("error"),
216+
"timed_out": False,
217+
}
120218
if payload.get("error"):
121-
return {"correct": False, "message": f"Почти: {payload['error']}", "checks": []}
219+
return {
220+
"correct": False,
221+
"message": f"Почти: {payload['error']}",
222+
"checks": [],
223+
**run_result,
224+
}
122225
if payload.get("ok"):
123-
return {"correct": True, "message": "Все тесты пройдены!", "checks": payload["checks"]}
226+
return {
227+
"correct": True,
228+
"message": "Все тесты пройдены!",
229+
"checks": payload["checks"],
230+
**run_result,
231+
}
124232
return {
125233
"correct": False,
126234
"message": "Не все тесты прошли. Сверь результат с условием.",
127235
"checks": payload.get("checks", []),
236+
**run_result,
128237
}
129238

130239

0 commit comments

Comments
 (0)