From 46d2cfae5e225a3b1e1efa54483d9eb9b3982eb1 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 01:59:18 +0300 Subject: [PATCH 1/3] =?UTF-8?q?zavod=20#46,=20=D0=BA=D1=80=D1=83=D0=B3=201?= =?UTF-8?q?:=20=D0=A0=D1=83=D0=BA=D0=BE=D0=BF=D0=B8=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=83=D1=80=D0=BE=D0=BA=D0=B8=2049=E2=80=9356?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_handwritten_iterators_modules.py | 355 ++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 tests/test_handwritten_iterators_modules.py diff --git a/tests/test_handwritten_iterators_modules.py b/tests/test_handwritten_iterators_modules.py new file mode 100644 index 0000000..cde20e2 --- /dev/null +++ b/tests/test_handwritten_iterators_modules.py @@ -0,0 +1,355 @@ +import ast +import importlib +from pathlib import Path +from typing import Any + +import pytest + +from app.evaluator import evaluate, normalize, run_code + +APP_DIR = Path(__file__).resolve().parents[1] / "app" +MODULE_NAME = "app.handwritten.lessons_iterators_modules" +MODULE_PATH = APP_DIR / "handwritten" / "lessons_iterators_modules.py" +LESSON_SPECS = { + "iterators-iterable": (49, "Iterable и iterator"), + "iterators-stop-iteration": (50, "Конец итерации"), + "iterators-yield": (51, "Генераторы с yield"), + "iterators-generator-expression": (52, "Генераторное выражение"), + "modules-imports": (53, "Импорт модулей"), + "modules-main-guard": (54, "Точка входа"), + "modules-packages": (55, "Пакеты"), + "modules-stdlib-imports": (56, "Стандартная библиотека"), +} +LESSON_IDS = list(LESSON_SPECS) +FORBIDDEN_PHRASES = ( + "Когда это применять", + "Сформулируй правило своими словами", + "Ориентир:", + "относится к теме", +) +OUTPUT_MARKER = "# Вывод:" +UNDERSTANDING_WORDS = ( + "вывод", + "выведет", + "вернёт", + "напечатает", + "будет", + "ошиб", + "выбери", + "найди", + "какой код", +) +OUTPUT_QUESTION_WORDS = ("выведет", "напечатает", "вывод") +MAIN_GUARDS = ("__name__ == '__main__'", "'__main__' == __name__") + + +def _lessons_by_id() -> dict[str, dict[str, Any]]: + assert MODULE_PATH.is_file(), f"нет файла {MODULE_PATH.name} в app/handwritten" + lessons = getattr(importlib.import_module(MODULE_NAME), "LESSONS", None) + assert isinstance(lessons, list), "LESSONS должен быть списком уроков" + for lesson in lessons: + assert isinstance(lesson, dict), lesson + return {lesson.get("id"): lesson for lesson in lessons} + + +def _lesson(lesson_id: str) -> dict[str, Any]: + lessons = _lessons_by_id() + assert lesson_id in lessons, f"в {MODULE_NAME} нет урока {lesson_id}" + return lessons[lesson_id] + + +def _strings(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return [text for item in value.values() for text in _strings(item)] + if isinstance(value, list | tuple): + return [text for item in value for text in _strings(item)] + return [] + + +def _expected_output(example: str) -> list[str] | None: + """Возвращает строки вывода из комментария «# Вывод:» или None, если его нет.""" + lines = example.splitlines() + for index, line in enumerate(lines): + if not line.strip().startswith(OUTPUT_MARKER): + continue + expected: list[str] = [] + inline = line.strip()[len(OUTPUT_MARKER) :].strip() + if inline: + expected.append(inline) + for comment in lines[index + 1 :]: + stripped = comment.strip() + if not stripped.startswith("#"): + break + expected.append(stripped[1:].strip()) + return expected + return None + + +def _function(tree: ast.AST, name: str) -> ast.FunctionDef | None: + return next( + ( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == name + ), + None, + ) + + +def _param_names(function: ast.FunctionDef) -> list[str]: + return [arg.arg for arg in function.args.args] + + +def _nodes(tree: ast.AST, node_type: type) -> list[Any]: + return [node for node in ast.walk(tree) if isinstance(node, node_type)] + + +def _callee(call: ast.Call) -> str | None: + return call.func.id if isinstance(call.func, ast.Name) else None + + +def _check_code_task( + lesson_id: str, function_name: str, params: list[str], tests: list[dict] +) -> ast.Module: + code = _lesson(lesson_id)["questions"][2] + assert code["id"] == f"{lesson_id}-code", lesson_id + assert code["kind"] == "code", lesson_id + + reference_tree = ast.parse(code["reference"]) + reference_function = _function(reference_tree, function_name) + assert reference_function is not None, (lesson_id, function_name) + assert _param_names(reference_function) == params, lesson_id + + starter_function = _function(ast.parse(code["starter"]), function_name) + assert starter_function is not None, (lesson_id, function_name) + assert _param_names(starter_function) == params, lesson_id + assert _nodes(starter_function, ast.Pass), (lesson_id, "starter без pass") + + result = run_code(code["reference"], tests) + assert result["error"] is None, (lesson_id, result["error"]) + assert result["correct"] is True, (lesson_id, result["message"], result.get("checks")) + return reference_tree + + +def test_c1_module_declares_the_eight_lessons() -> None: + lessons = _lessons_by_id() + assert sorted(lessons) == sorted(LESSON_IDS) + + +def test_c1_registry_and_course_expose_lessons_in_order() -> None: + from app.content import LESSON_BY_ID, LESSONS + + registry = importlib.import_module("app.handwritten").HANDWRITTEN + for lesson_id, (order, title) in LESSON_SPECS.items(): + assert lesson_id in registry, lesson_id + assert lesson_id in LESSON_BY_ID, lesson_id + lesson = LESSON_BY_ID[lesson_id] + assert lesson["order"] == order, lesson_id + assert lesson["title"] == title, lesson_id + assert len(LESSONS) == 120 + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c2_lesson_passes_handwritten_contract(lesson_id: str) -> None: + lesson = _lesson(lesson_id) + assert lesson["id"] == lesson_id + + theory = lesson["theory"] + assert isinstance(theory, list) and len(theory) == 3, lesson_id + for card in theory: + for key in ("title", "text", "example"): + assert isinstance(card.get(key), str) and card[key].strip(), (lesson_id, key) + if card.get("run") is False: + continue + result = run_code(card["example"], []) + assert result["error"] is None, (lesson_id, card["title"], result["error"]) + + questions = lesson["questions"] + assert [question["id"] for question in questions] == [ + f"{lesson_id}-choice", + f"{lesson_id}-term", + f"{lesson_id}-code", + ] + assert [question["kind"] for question in questions] == ["choice", "input", "code"] + choice, term, code = questions + + assert len(set(choice["options"])) >= 3, lesson_id + assert choice["answer"] in choice["options"], lesson_id + assert choice["explanation"].strip(), lesson_id + + assert isinstance(term["answers"], list) and term["answers"], lesson_id + assert all(isinstance(answer, str) and answer.strip() for answer in term["answers"]) + assert all(len(answer) <= 80 for answer in term["answers"]), (lesson_id, "ответ не короткий") + assert term["explanation"].strip(), lesson_id + + assert len(code["tests"]) >= 2, lesson_id + assert code["hint"].strip(), lesson_id + assert code["explanation"].strip(), lesson_id + assert evaluate(code, code["reference"])["correct"] is True, lesson_id + assert evaluate(code, code["starter"])["correct"] is False, lesson_id + + for text in _strings(lesson): + for phrase in FORBIDDEN_PHRASES: + assert phrase not in text, (lesson_id, phrase) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c3_choice_checks_understanding(lesson_id: str) -> None: + choice = _lesson(lesson_id)["questions"][0] + prompt = choice["prompt"].casefold() + assert any(word in prompt for word in UNDERSTANDING_WORDS), (lesson_id, choice["prompt"]) + + options = choice["options"] + assert 3 <= len(options) <= 4, lesson_id + + has_ticks = ["`" in option for option in options] + assert all(has_ticks) or not any(has_ticks), (lesson_id, "бэктики только у части вариантов") + + lengths = [len(option) for option in options] + assert max(lengths) <= 1.6 * min(lengths), (lesson_id, "варианты разной длины") + other_lengths = [len(option) for option in options if option != choice["answer"]] + assert min(other_lengths) <= len(choice["answer"]) <= max(other_lengths), lesson_id + + if any(word in prompt for word in OUTPUT_QUESTION_WORDS): + code = choice["prompt"].partition("\n")[2] + result = run_code(code, []) + if result["error"] is None: + assert normalize(result["stdout"]) == normalize(choice["answer"]), ( + lesson_id, + result["stdout"], + ) + else: + answer = choice["answer"].casefold() + assert "ошиб" in answer or "error" in answer, (lesson_id, choice["answer"]) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c3_examples_have_real_output_and_card_order(lesson_id: str) -> None: + theory = _lesson(lesson_id)["theory"] + + typical_error = theory[2] + assert "ошиб" in f"{typical_error['title']} {typical_error['text']}".casefold(), lesson_id + + for card in theory: + expected = _expected_output(card["example"]) + assert expected is not None, (lesson_id, card["title"], "нет комментария «# Вывод:»") + if card.get("run") is False: + continue + result = run_code(card["example"], []) + actual = [line.rstrip() for line in result["stdout"].splitlines()] + assert actual == [line.rstrip() for line in expected], (lesson_id, card["title"]) + + +@pytest.mark.parametrize("lesson_id", LESSON_IDS) +def test_c4_run_flag_matches_runner_capability(lesson_id: str) -> None: + for card in _lesson(lesson_id)["theory"]: + assert "run" not in card or card["run"] is False, (lesson_id, card["title"]) + result = run_code(card["example"], []) + if card.get("run") is False: + assert result["error"] is not None, (lesson_id, card["title"], "пример исполняется") + else: + assert result["error"] is None, (lesson_id, card["title"], result["error"]) + + +def test_k49_take_reads_first_items_with_iter_and_next() -> None: + tests = [ + {"kind": "call", "call": "take('abc', 2)", "expected": ["a", "b"]}, + {"kind": "call", "call": "take((c for c in 'abc'), 2)", "expected": ["a", "b"]}, + {"kind": "call", "call": "take(range(10), 3)", "expected": [0, 1, 2]}, + {"kind": "call", "call": "take([1], 5)", "expected": [1]}, + ] + tree = _check_code_task("iterators-iterable", "take", ["iterable", "n"], tests) + function = _function(tree, "take") + assert function is not None + called = {_callee(call) for call in _nodes(function, ast.Call)} + assert {"iter", "next"} <= called, "нужны iter() и next()" + assert not _nodes(function, ast.Slice), "срезы в этом задании запрещены" + + +def test_k50_safe_next_returns_default_on_stop_iteration() -> None: + tests = [ + { + "kind": "call", + "call": "(lambda it: [safe_next(it, 0), safe_next(it, 0)])(iter([1]))", + "expected": [1, 0], + }, + ] + _check_code_task("iterators-stop-iteration", "safe_next", ["iterator", "default"], tests) + + +def test_k51_countdown_is_generator_with_yield() -> None: + tests = [ + {"kind": "call", "call": "list(countdown(3))", "expected": [3, 2, 1]}, + {"kind": "call", "call": "list(countdown(0))", "expected": []}, + ] + tree = _check_code_task("iterators-yield", "countdown", ["n"], tests) + function = _function(tree, "countdown") + assert function is not None + assert _nodes(function, ast.Yield), "countdown должен использовать yield" + + +def test_k52_total_length_sums_generator_expression() -> None: + tests = [ + {"kind": "call", "call": "total_length(['ab', 'cde'])", "expected": 5}, + {"kind": "call", "call": "total_length([])", "expected": 0}, + ] + tree = _check_code_task("iterators-generator-expression", "total_length", ["words"], tests) + function = _function(tree, "total_length") + assert function is not None + sums = [call for call in _nodes(function, ast.Call) if _callee(call) == "sum"] + assert any(call.args and isinstance(call.args[0], ast.GeneratorExp) for call in sums), ( + "нужен sum по генераторному выражению" + ) + + +def test_k53_circle_area_imports_math_and_rounds() -> None: + tests = [ + {"kind": "call", "call": "circle_area(1)", "expected": 3.14}, + {"kind": "call", "call": "circle_area(2)", "expected": 12.57}, + ] + tree = _check_code_task("modules-imports", "circle_area", ["r"], tests) + imported = [alias.name for node in _nodes(tree, ast.Import) for alias in node.names] + assert "math" in imported, "нужен import math" + + +def test_k54_main_is_called_only_under_main_guard() -> None: + tests = [ + {"kind": "call", "call": "main()", "expected": "Готово"}, + {"kind": "stdout", "expected": ""}, + ] + tree = _check_code_task("modules-main-guard", "main", [], tests) + + top_level_calls = [ + stmt.value + for stmt in tree.body + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call) + ] + assert not any(_callee(call) == "main" for call in top_level_calls), ( + "main() не должен вызываться вне if __name__" + ) + + guards = [node for node in _nodes(tree, ast.If) if ast.unparse(node.test) in MAIN_GUARDS] + assert any(_callee(call) == "main" for guard in guards for call in _nodes(guard, ast.Call)), ( + "main() должен стоять под if __name__ == '__main__'" + ) + + +def test_k55_module_path_maps_dotted_name_to_file() -> None: + tests = [ + {"kind": "call", "call": "module_path('app.utils.text')", "expected": "app/utils/text.py"}, + {"kind": "call", "call": "module_path('main')", "expected": "main.py"}, + ] + _check_code_task("modules-packages", "module_path", ["dotted"], tests) + + +def test_k56_most_common_word_uses_counter() -> None: + tests = [ + {"kind": "call", "call": "most_common_word('кот Кот пёс')", "expected": "кот"}, + ] + tree = _check_code_task("modules-stdlib-imports", "most_common_word", ["text"], tests) + imported = [ + (node.module, alias.name) for node in _nodes(tree, ast.ImportFrom) for alias in node.names + ] + assert ("collections", "Counter") in imported, "нужен from collections import Counter" From c5221af9934c04625de7e239fd170d3e1cdf037a Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:03:19 +0300 Subject: [PATCH 2/3] =?UTF-8?q?zavod=20#46,=20=D0=BA=D1=80=D1=83=D0=B3=201?= =?UTF-8?q?:=20=D0=A0=D1=83=D0=BA=D0=BE=D0=BF=D0=B8=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=83=D1=80=D0=BE=D0=BA=D0=B8=2049=E2=80=9356?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handwritten/lessons_iterators_modules.py | 774 +++++++++++++++++++ 1 file changed, 774 insertions(+) create mode 100644 app/handwritten/lessons_iterators_modules.py diff --git a/app/handwritten/lessons_iterators_modules.py b/app/handwritten/lessons_iterators_modules.py new file mode 100644 index 0000000..4685349 --- /dev/null +++ b/app/handwritten/lessons_iterators_modules.py @@ -0,0 +1,774 @@ +"""Рукописные уроки 49–56: итераторы, генераторы, модули и пакеты.""" + +from __future__ import annotations + +from typing import Any + + +def _card(title: str, text: str, example: str, tip: str = "", run: bool = True) -> dict[str, Any]: + card: dict[str, Any] = {"title": title, "text": text, "example": example, "tip": tip} + if not run: + card["run"] = False + return card + + +def _choice( + lesson_id: str, + prompt: str, + options: list[str], + answer: str, + explanation: str, +) -> dict[str, Any]: + return { + "id": f"{lesson_id}-choice", + "kind": "choice", + "prompt": prompt, + "options": options, + "answer": answer, + "explanation": explanation, + } + + +def _term( + lesson_id: str, + prompt: str, + answers: list[str], + explanation: str, +) -> dict[str, Any]: + return { + "id": f"{lesson_id}-term", + "kind": "input", + "prompt": prompt, + "answers": answers, + "placeholder": "Введите ответ", + "explanation": explanation, + } + + +def _code( + lesson_id: str, + prompt: str, + starter: str, + tests: list[dict[str, Any]], + reference: str, + explanation: str, + hint: str, +) -> dict[str, Any]: + return { + "id": f"{lesson_id}-code", + "kind": "code", + "prompt": prompt, + "starter": starter, + "tests": tests, + "reference": reference, + "explanation": explanation, + "hint": hint, + } + + +def _lesson( + lesson_id: str, + subtitle: str, + theory: list[dict[str, str]], + questions: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "id": lesson_id, + "subtitle": subtitle, + "theory": theory, + "questions": questions, + } + + +LESSONS: list[dict[str, Any]] = [ + _lesson( + "iterators-iterable", + "Кто хранит данные, а кто выдаёт следующий элемент", + [ + _card( + "Iterable хранит, iterator выдаёт", + "Список, строка и range — это iterable: по ним можно пройти циклом. " + "Но сам список не помнит, на каком месте ты остановился. Итератор — " + "отдельный объект, который выдаёт по одному элементу и хранит текущую " + "позицию. Его создаёт встроенная функция iter(), а next() забирает " + "следующий элемент.", + "numbers = [10, 20]\n" + "iterator = iter(numbers)\n" + "print(next(iterator))\n" + "# Вывод:\n" + "# 10", + "iter() не копирует список, а создаёт отдельный объект чтения.", + ), + _card( + "Итератор помнит, где остановился", + "Когда итератор создан, он живёт отдельно от списка. Каждый next() " + "сдвигает его вперёд, а цикл for забирает остаток. Поэтому после одного " + "next() цикл начнётся со второго элемента, а не с первого. Так удобно " + "прочитать заголовок, а потом обработать остальные строки.", + "letters = iter('abc')\n" + "print(next(letters))\n" + "for letter in letters:\n" + " print(letter)\n" + "# Вывод:\n" + "# a\n" + "# b\n" + "# c", + "Один и тот же итератор нельзя пройти дважды: позиция не сбрасывается.", + ), + _card( + "Типичная ошибка: next() на списке", + "next() работает только с итератором. Если передать ему список, Python " + "бросит TypeError: 'list' object is not an iterator. Ошибка заметна " + "сразу по тексту исключения. Исправление простое: сначала вызови " + "iter(numbers), а уже потом next().", + "numbers = [10, 20]\n" + "try:\n" + " next(numbers)\n" + "except TypeError:\n" + " print('нужен iter(numbers)')\n" + "# Вывод:\n" + "# нужен iter(numbers)", + "Список — iterable, но не iterator: ему нужен iter().", + ), + ], + [ + _choice( + "iterators-iterable", + "Что выведет этот код?\n" + "numbers = [1, 2, 3]\n" + "iterator = iter(numbers)\n" + "print(next(iterator), next(iterator))", + ["1 2", "2 3", "1 3", "3 2"], + "1 2", + "Первый next() вернул 1, второй сдвинул итератор и вернул 2.", + ), + _term( + "iterators-iterable", + "Какая встроенная функция возвращает итератор из списка?", + ["iter", "iter()", "функция iter"], + "iter(numbers) создаёт итератор, у которого работает next().", + ), + _code( + "iterators-iterable", + "Напиши `take(iterable, n)`: верни список из первых n элементов. Бери " + "элементы через `iter()` и `next()`, без срезов. Если элементов меньше, " + "верни сколько есть. Работать должно и с генератором.", + "def take(iterable, n):\n # верни первые n элементов\n pass\n", + [ + {"kind": "call", "call": "take('abc', 2)", "expected": ["a", "b"]}, + { + "kind": "call", + "call": "take((c for c in 'abc'), 2)", + "expected": ["a", "b"], + }, + {"kind": "call", "call": "take(range(10), 3)", "expected": [0, 1, 2]}, + {"kind": "call", "call": "take([1], 5)", "expected": [1]}, + ], + "def take(iterable, n):\n" + " iterator = iter(iterable)\n" + " result = []\n" + " for _ in range(n):\n" + " try:\n" + " result.append(next(iterator))\n" + " except StopIteration:\n" + " break\n" + " return result\n", + "Верно: iter() создаёт итератор, next() выдаёт элементы, а " + "StopIteration завершает короткую последовательность.", + "Заведи пустой список и цикл range(n); внутри вызывай next() и лови StopIteration.", + ), + ], + ), + _lesson( + "iterators-stop-iteration", + "StopIteration и запасное значение next", + [ + _card( + "StopIteration сообщает о конце", + "Итератор не знает заранее, когда закончится. Когда элементы кончились, " + "next() бросает исключение StopIteration. Это не поломка, а сигнал " + "«больше нет». Цикл for ловит его сам и поэтому останавливается без " + "ошибки.", + "iterator = iter(['яблоко'])\n" + "print(next(iterator))\n" + "try:\n" + " next(iterator)\n" + "except StopIteration:\n" + " print('элементы кончились')\n" + "# Вывод:\n" + "# яблоко\n" + "# элементы кончились", + "StopIteration — штатный конец итератора, а не сбой программы.", + ), + _card( + "У next() есть запасное значение", + "Второй аргумент next() задаёт значение по умолчанию. Если элементы " + "кончились, next(iterator, default) вернёт default и не бросит " + "StopIteration. Это удобно, когда отсутствие элемента — нормальная " + "ситуация. Например, так читают необязательный заголовок.", + "iterator = iter([])\n" + "print(next(iterator, 'пусто'))\n" + "print(next(iter([1, 2]), 0))\n" + "# Вывод:\n" + "# пусто\n" + "# 1", + "Значение по умолчанию спасает от лишнего try/except.", + ), + _card( + "Типичная ошибка: next() без обработки", + "Если вызывать next() в цикле while и не ловить StopIteration, программа " + "упадёт с traceback на последнем шаге. Ошибка видна по строке " + "StopIteration в конце вывода. Либо используй for, либо возьми " + "next(iterator, default), либо оберни вызов в try/except.", + "iterator = iter([1])\n" + "try:\n" + " while True:\n" + " print(next(iterator))\n" + "except StopIteration:\n" + " print('готово')\n" + "# Вывод:\n" + "# 1\n" + "# готово", + "После последнего элемента next() бросает StopIteration.", + ), + ], + [ + _choice( + "iterators-stop-iteration", + "Что выведет этот код?\n" + "iterator = iter([7])\n" + "print(next(iterator, 0))\n" + "print(next(iterator, 0))", + ["7\n0", "7\n7", "0\n7", "0\n0"], + "7\n0", + "Первый вызов вернул 7, второй упёрся в конец и вернул запасной 0.", + ), + _term( + "iterators-stop-iteration", + "Какое исключение бросает next(), когда элементы закончились?", + ["StopIteration", "StopIteration()", "исключение StopIteration"], + "StopIteration — сигнал конца итератора; for перехватывает его сам.", + ), + _code( + "iterators-stop-iteration", + "Напиши `safe_next(iterator, default)`: верни следующий элемент, а если " + "итератор закончился — default. Используй try/except StopIteration.", + "def safe_next(iterator, default):\n # верни next или default\n pass\n", + [ + { + "kind": "call", + "call": "(lambda it: [safe_next(it, 0), safe_next(it, 0)])(iter([1]))", + "expected": [1, 0], + }, + { + "kind": "call", + "call": "safe_next(iter([]), 'пусто')", + "expected": "пусто", + }, + ], + "def safe_next(iterator, default):\n" + " try:\n" + " return next(iterator)\n" + " except StopIteration:\n" + " return default\n", + "Верно: первый next() отдаёт элемент, второй ловит StopIteration и " + "возвращает default.", + "Оберни next(iterator) в try, а в except StopIteration верни default.", + ), + ], + ), + _lesson( + "iterators-yield", + "Функция, которая выдаёт значения по одному", + [ + _card( + "yield превращает функцию в генератор", + "Функция с yield не выполняется сразу целиком. При вызове она возвращает " + "генератор: объект, который выдаёт значения по одному. Каждый next() " + "запускает функцию до следующего yield и приостанавливает её. Так можно " + "описать последовательность, не строя весь список в памяти.", + "def count_up(limit):\n" + " for value in range(1, limit + 1):\n" + " yield value\n" + "\n" + "print(list(count_up(3)))\n" + "# Вывод:\n" + "# [1, 2, 3]", + "list() забирает из генератора все значения до конца.", + ), + _card( + "Генератор выдаёт значения по требованию", + "Генератор удобно читать через for: цикл сам вызывает next() и " + "останавливается, когда функция закончилась. Можно и вручную: первый " + "next() вернёт первое значение и замрёт на yield. Второй next() продолжит " + "с того же места. Поэтому генератор подходит для больших потоков данных.", + "def letters():\n" + " yield 'a'\n" + " yield 'b'\n" + "\n" + "generator = letters()\n" + "print(next(generator))\n" + "print(next(generator))\n" + "# Вывод:\n" + "# a\n" + "# b", + "Генератор не хранит все значения: он помнит место остановки.", + ), + _card( + "Типичная ошибка: генератор исчерпывается", + "Генератор одноразовый: когда значения закончились, повторный проход " + "ничего не даст. Если сохранить generator и дважды вызвать " + "list(generator), второй список будет пустым. Ошибка не падает, поэтому " + "её легко не заметить. Нужен повторный проход — создай новый генератор " + "вызовом функции.", + "def numbers():\n" + " yield 1\n" + " yield 2\n" + "\n" + "generator = numbers()\n" + "print(list(generator))\n" + "print(list(generator))\n" + "# Вывод:\n" + "# [1, 2]\n" + "# []", + "Новый проход — новый вызов функции-генератора.", + ), + ], + [ + _choice( + "iterators-yield", + "Что выведет этот код?\n" + "def countdown(n):\n" + " while n > 0:\n" + " yield n\n" + " n -= 1\n" + "\n" + "print(list(countdown(2)))", + ["[2, 1]", "[1, 2]", "[2, 1, 0]", "[1, 2, 3]"], + "[2, 1]", + "Генератор начинает с n = 2, выдаёт 2, затем 1 и завершается.", + ), + _term( + "iterators-yield", + "Какое ключевое слово делает функцию генератором?", + ["yield", "yield()", "ключевое слово yield"], + "yield приостанавливает функцию и отдаёт очередное значение.", + ), + _code( + "iterators-yield", + "Напиши генератор `countdown(n)`: он выдаёт числа от n до 1 через " + "`yield`. Для n = 0 не выдаёт ничего.", + "def countdown(n):\n # выдавай числа от n до 1\n pass\n", + [ + {"kind": "call", "call": "list(countdown(3))", "expected": [3, 2, 1]}, + {"kind": "call", "call": "list(countdown(0))", "expected": []}, + {"kind": "call", "call": "list(countdown(1))", "expected": [1]}, + ], + "def countdown(n):\n while n > 0:\n yield n\n n -= 1\n", + "Верно: yield отдаёт текущее n и возвращает управление на следующем " + "next(), а условие while завершает генератор на нуле.", + "Уменьшай n после yield, иначе генератор зациклится.", + ), + ], + ), + _lesson( + "iterators-generator-expression", + "Ленивая версия list comprehension", + [ + _card( + "Круглые скобки создают поток", + "Круглые скобки вместо квадратных создают генераторное выражение. Оно не " + "строит список, а вычисляет следующий элемент по запросу. Поэтому его " + "удобно передавать в sum(), min() или any(): функция сама вытянет " + "значения. Память тратится на один элемент, а не на всю " + "последовательность.", + "squares = (n * n for n in range(4))\n" + "print(list(squares))\n" + "# Вывод:\n" + "# [0, 1, 4, 9]", + "list() нужен только чтобы увидеть все значения сразу.", + ), + _card( + "Генераторное выражение внутри sum()", + "Когда генераторное выражение — единственный аргумент функции, вторые " + "скобки можно не писать: sum(n * 2 for n in numbers). Это читается как " + "«сумма удвоенных чисел». В отличие от [n * 2 for n in numbers], список " + "целиком не создаётся. Для больших данных это заметно экономит память.", + "numbers = [1, 2, 3]\nprint(sum(n * 2 for n in numbers))\n# Вывод:\n# 12", + "sum() отлично работает с ленивым потоком значений.", + ), + _card( + "Типичная ошибка: генератор вместо списка", + "Генератор не поддерживает индексы и len(): у него нет готовых элементов. " + "Выражение lengths[0] бросит TypeError: 'generator' object is not " + "subscriptable. Ошибка заметна по слову generator в тексте. Если нужен " + "доступ по индексу, сначала собери список: list(lengths).", + "words = ['ab', 'cde']\n" + "lengths = (len(word) for word in words)\n" + "print(next(lengths))\n" + "print(next(lengths))\n" + "# Вывод:\n" + "# 2\n" + "# 3", + "Для индексов и len() нужен список, а не генератор.", + ), + ], + [ + _choice( + "iterators-generator-expression", + "Что выведет этот код?\nnumbers = [1, 2, 3]\nprint(sum(n + 1 for n in numbers))", + ["9", "6", "7", "8"], + "9", + "К каждому числу прибавилась единица: 2 + 3 + 4 = 9.", + ), + _term( + "iterators-generator-expression", + "Как называется выражение (x * x for x in numbers)?", + ["генераторное выражение", "генератор", "generator expression"], + "Круглые скобки дают генераторное выражение — ленивый поток значений.", + ), + _code( + "iterators-generator-expression", + "Напиши `total_length(words)`: верни суммарную длину слов. Используй " + "`sum` и генераторное выражение, без промежуточного списка.", + "def total_length(words):\n # сумма длин слов\n pass\n", + [ + { + "kind": "call", + "call": "total_length(['ab', 'cde'])", + "expected": 5, + }, + {"kind": "call", "call": "total_length([])", "expected": 0}, + {"kind": "call", "call": "total_length(['a'])", "expected": 1}, + ], + "def total_length(words):\n return sum(len(word) for word in words)\n", + "Верно: sum() обходит генераторное выражение и складывает длины, не " + "создавая промежуточный список.", + "Передай в sum() выражение len(word) for word in words.", + ), + ], + ), + _lesson( + "modules-imports", + "Подключаем готовый код из другого файла", + [ + _card( + "Модуль — это файл с готовым кодом", + "Модуль — обычный Python-файл, код которого можно подключить в другой " + "файл. Команда import math подключает модуль стандартной библиотеки и " + "даёт доступ к его функциям через точку: math.sqrt(16). Имя модуля " + "становится именем в твоей программе. Так один и тот же код не " + "приходится копировать.", + "import math\n\nprint(math.sqrt(16))\n# Вывод:\n# 4.0", + "После import обращайся к функциям через имя модуля и точку.", + ), + _card( + "from ... import берёт конкретное имя", + "Если нужна одна функция, её можно импортировать напрямую: from math " + "import floor. Тогда пиши floor(3.9), без префикса math. Это короче, но " + "по имени уже не видно, откуда оно. Для ясности крупные модули чаще " + "импортируют целиком, а короткие имена берут через from.", + "from math import floor\n\nprint(floor(3.9))\n# Вывод:\n# 3", + "from удобен для одного-двух имён, но не для всего модуля.", + ), + _card( + "Типичная ошибка: затереть имя модуля", + "Если назвать переменную так же, как модуль, переменная заменит модуль в " + "твоём файле. После math = 'строка' вызов math.sqrt упадёт с " + "AttributeError: 'str' object has no attribute 'sqrt'. Ошибку видно по " + "тому, что имя модуля стало строкой. Не используй имена модулей для " + "своих переменных.", + "import math\n\nmath = 'это уже строка'\nprint(math)\n# Вывод:\n# это уже строка", + "Проверь, не занято ли имя модуля твоей переменной.", + ), + ], + [ + _choice( + "modules-imports", + "Что выведет этот код?\nimport math\nprint(math.ceil(2.1))", + ["3", "2", "4", "1"], + "3", + "ceil округляет вверх: ближайшее целое не меньше 2.1 — это 3.", + ), + _term( + "modules-imports", + "Какой модуль стандартной библиотеки даёт sqrt, ceil и pi?", + ["math", "модуль math"], + "math — модуль с математическими функциями и константами.", + ), + _code( + "modules-imports", + "Напиши `circle_area(r)`: верни площадь круга с округлением до двух " + "знаков. Используй `import math` и `math.pi`.", + "def circle_area(r):\n # площадь круга\n pass\n", + [ + {"kind": "call", "call": "circle_area(1)", "expected": 3.14}, + {"kind": "call", "call": "circle_area(2)", "expected": 12.57}, + {"kind": "call", "call": "circle_area(0)", "expected": 0.0}, + ], + "import math\n\n\ndef circle_area(r):\n return round(math.pi * r ** 2, 2)\n", + "Верно: math.pi даёт точное число пи, а round(..., 2) оставляет два " + "знака после запятой.", + "Формула площади: pi умножить на радиус в квадрате.", + ), + ], + ), + _lesson( + "modules-main-guard", + "Запускаем файл, но не мешаем импорту", + [ + _card( + "main() собирает запуск в функцию", + "Сценарий запуска удобно собрать в функцию main(). Тогда весь код " + "программы не разбросан по файлу, а вызывается в одном месте. Функцию " + "можно импортировать и вызвать в тесте, не запуская всё заново. Это " + "первый шаг к аккуратной точке входа.", + "def main():\n print('Готово')\n\nmain()\n# Вывод:\n# Готово", + "main() — обычная функция, её можно вызвать и проверить отдельно.", + ), + _card( + "Защитный блок запускает только напрямую", + "При прямом запуске файла Python ставит переменной __name__ значение " + "'__main__'. При импорте там будет имя модуля. Поэтому блок " + "if __name__ == '__main__': main() выполняет main() только при запуске " + "файла. Импорт просто подключает функции: блок пропускается, а остальной " + "код модуля продолжает выполняться.", + "def main():\n" + " print('Готово')\n" + "\n" + "if __name__ == '__main__':\n" + " main()\n" + "\n" + "print('строка после блока выполнилась')\n" + "# Вывод:\n" + "# строка после блока выполнилась", + "Под защитой main() не сработает при импорте модуля.", + ), + _card( + "Типичная ошибка: main() без защитного блока", + "Если оставить вызов main() на верхнем уровне, он сработает и при " + "импорте модуля. Тогда тест или другой файл получит лишний запуск и " + "неожиданный вывод. Ошибка видна по тому, что импорт печатает «Готово». " + "Прячь запуск под if __name__ == '__main__'.", + "def main():\n print('Готово')\n\nmain()\n# Вывод:\n# Готово", + "Вызов на верхнем уровне выполняется всегда, даже при импорте.", + ), + ], + [ + _choice( + "modules-main-guard", + "Какой код запустит main() только при прямом запуске файла?", + [ + "if __name__ == '__main__': main()", + "if __name__ = '__main__': main()", + "if name == '__main__': main()", + "if __name__ == '__learner__': main()", + ], + "if __name__ == '__main__': main()", + "Сравнивать нужно именно __name__ с '__main__': тогда импорт блок пропустит.", + ), + _term( + "modules-main-guard", + "Какое значение получает __name__ у файла, запущенного напрямую?", + ["__main__", "'__main__'", "main"], + "При прямом запуске __name__ равен '__main__', при импорте — имени модуля.", + ), + _code( + "modules-main-guard", + "Напиши `main()`, которая возвращает строку 'Готово'. Вызови её под " + "защитным блоком if __name__ == '__main__'. При импорте ничего не " + "должно печататься.", + "def main():\n pass\n", + [ + {"kind": "call", "call": "main()", "expected": "Готово"}, + {"kind": "stdout", "expected": ""}, + ], + "def main():\n return 'Готово'\n\n\nif __name__ == '__main__':\n main()\n", + "Верно: main() возвращает строку, а защитный блок не выполняет её при " + "импорте, поэтому stdout остаётся пустым.", + "Верни строку из main() и помести вызов под проверку __name__.", + ), + ], + ), + _lesson( + "modules-packages", + "Каталог с модулями и общим именем", + [ + _card( + "Пакет — каталог с модулями", + "Пакет — это каталог, в котором лежат модули и файл __init__.py. Точки в " + "имени показывают вложенность: app.utils.text означает файл text.py " + "внутри пакета utils внутри пакета app. Такая структура помогает " + "раскладывать код по смыслу. Путь к файлу получается заменой точек на " + "слеши.", + "parts = 'app.utils.text'.split('.')\n" + "print('/'.join(parts) + '.py')\n" + "# Вывод:\n" + "# app/utils/text.py", + "Каждая точка в имени модуля — ещё один уровень каталога.", + ), + _card( + "Импорт между файлами пакета", + "Внутри пакета модули импортируют друг друга по полному имени: from " + "app.utils.text import clean. Python находит пакет по структуре каталогов " + "от корня проекта. Так функция clean живёт в одном файле, а пользуются " + "ею в main.py. Это и есть переиспользование кода.", + "# app/utils/text.py\n" + "def clean(text):\n" + " return text.strip()\n" + "\n" + "# main.py\n" + "from app.utils.text import clean\n" + "\n" + "print(clean(' привет '))\n" + "# Вывод:\n" + "# привет", + "Абсолютный импорт начинается от корня проекта, а не от текущего файла.", + run=False, + ), + _card( + "Типичная ошибка: относительный импорт в скрипте", + "Относительный импорт from .helpers import clean работает только внутри " + "пакета. Если запустить файл напрямую, Python не знает родительский пакет " + "и падает с ImportError: attempted relative import with no known parent " + "package. Ошибка видна по слову relative import. Запускай такой файл как " + "модуль: python -m app.utils.text.", + "# файл app/utils/text.py\n" + "from .helpers import clean\n" + "\n" + "print(clean(' привет '))\n" + "# Вывод:\n" + "# привет", + "Точка в начале импорта означает «рядом со мной внутри пакета».", + run=False, + ), + ], + [ + _choice( + "modules-packages", + "Что выведет этот код?\n" + "dotted = 'app.utils.text'\n" + "print(dotted.replace('.', '/') + '.py')", + ["app/utils/text.py", "app.utils/text.py", "app/utils.py", "app/utils/text"], + "app/utils/text.py", + "Точки заменяются на слеши, а расширение .py добавляется в конце.", + ), + _term( + "modules-packages", + "Как называется каталог с __init__.py, который объединяет модули?", + ["пакет", "package", "python-пакет"], + "Пакет — каталог модулей с общим пространством имён.", + ), + _code( + "modules-packages", + "Напиши `module_path(dotted)`: преврати точечное имя модуля в путь к " + "файлу. module_path('app.utils.text') должен вернуть 'app/utils/text.py', " + "а module_path('main') — 'main.py'.", + "def module_path(dotted):\n # имя модуля -> путь к файлу\n pass\n", + [ + { + "kind": "call", + "call": "module_path('app.utils.text')", + "expected": "app/utils/text.py", + }, + {"kind": "call", "call": "module_path('main')", "expected": "main.py"}, + {"kind": "call", "call": "module_path('a.b')", "expected": "a/b.py"}, + ], + "def module_path(dotted):\n return dotted.replace('.', '/') + '.py'\n", + "Верно: replace() меняет все точки на слеши, а '.py' завершает путь к файлу.", + "Замени точки на '/' и добавь '.py' в конце.", + ), + ], + ), + _lesson( + "modules-stdlib-imports", + "Готовые инструменты вместо своих велосипедов", + [ + _card( + "Стандартная библиотека уже под рукой", + "Стандартная библиотека — набор модулей, которые идут вместе с Python. В " + "ней есть математика, даты, JSON, регулярные выражения и коллекции. Не " + "нужно писать своё, если готовая функция уже решает задачу. Подключай " + "модуль через import и читай его документацию.", + "from collections import Counter\n" + "\n" + "print(Counter('ааб').most_common())\n" + "# Вывод:\n" + "# [('а', 2), ('б', 1)]", + "Counter сразу считает повторы за тебя.", + ), + _card( + "Разные задачи — разные модули", + "Модули стандартной библиотеки подключаются так же, как math. Например, " + "datetime хранит даты, json разбирает JSON, а re ищет по шаблону. Импорт " + "не замедляет программу заметно: Python загружает модуль один раз. " + "Проверь стандартную библиотеку до того, как писать свой велосипед.", + "from datetime import date\n\nprint(date(2026, 1, 1).year)\n# Вывод:\n# 2026", + "У date есть готовые атрибуты вроде year, month и day.", + ), + _card( + "Типичная ошибка: most_common возвращает пары", + "Метод most_common(1) возвращает список кортежей, а не слово. Если сразу " + "вывести результат, получится [('кот', 2)], и сравнение со строкой не " + "сработает. Ошибка заметна по скобкам вокруг пары. Чтобы достать само " + "слово, возьми top[0][0].", + "from collections import Counter\n" + "\n" + "words = 'кот пёс кот'.split()\n" + "top = Counter(words).most_common(1)\n" + "print(top)\n" + "# Вывод:\n" + "# [('кот', 2)]", + "Сначала выбери пару, потом её первый элемент — слово.", + ), + ], + [ + _choice( + "modules-stdlib-imports", + "Что выведет этот код?\n" + "from collections import Counter\n" + "print(Counter('ааб').most_common(1))", + ["[('а', 2)]", "[('б', 1)]", "[('а', 1)]", "('а', 2)"], + "[('а', 2)]", + "most_common(1) возвращает список с одной парой «а» и её количеством 2.", + ), + _term( + "modules-stdlib-imports", + "Какая коллекция из collections считает повторы элементов?", + ["Counter", "counter", "счётчик"], + "Counter — словарь, где ключом является элемент, а значением — число повторов.", + ), + _code( + "modules-stdlib-imports", + "Напиши `most_common_word(text)`: верни самое частое слово в нижнем " + "регистре. Используй `from collections import Counter`. Если текст пуст, " + "верни пустую строку.", + "def most_common_word(text):\n # самое частое слово\n pass\n", + [ + { + "kind": "call", + "call": "most_common_word('кот Кот пёс')", + "expected": "кот", + }, + {"kind": "call", "call": "most_common_word('')", "expected": ""}, + { + "kind": "call", + "call": "most_common_word('a b b c')", + "expected": "b", + }, + ], + "from collections import Counter\n" + "\n" + "\n" + "def most_common_word(text):\n" + " words = text.lower().split()\n" + " if not words:\n" + " return ''\n" + " return Counter(words).most_common(1)[0][0]\n", + "Верно: lower() приводит слова к одному регистру, Counter считает " + "повторы, а most_common(1)[0][0] достаёт само слово.", + "Приведи текст к нижнему регистру, разбей на слова и посчитай Counter.", + ), + ], + ), +] From 804c539d4ee2cecd62e7f328cc7ffe587027c8cb Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:05:58 +0300 Subject: [PATCH 3/3] =?UTF-8?q?zavod=20#46,=20=D0=BA=D1=80=D1=83=D0=B3=202?= =?UTF-8?q?:=20=D0=A0=D1=83=D0=BA=D0=BE=D0=BF=D0=B8=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=83=D1=80=D0=BE=D0=BA=D0=B8=2049=E2=80=9356?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handwritten/lessons_iterators_modules.py | 52 +++++++++++++++----- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/app/handwritten/lessons_iterators_modules.py b/app/handwritten/lessons_iterators_modules.py index 4685349..a54f49f 100644 --- a/app/handwritten/lessons_iterators_modules.py +++ b/app/handwritten/lessons_iterators_modules.py @@ -611,13 +611,22 @@ def _lesson( "Импорт между файлами пакета", "Внутри пакета модули импортируют друг друга по полному имени: from " "app.utils.text import clean. Python находит пакет по структуре каталогов " - "от корня проекта. Так функция clean живёт в одном файле, а пользуются " - "ею в main.py. Это и есть переиспользование кода.", - "# app/utils/text.py\n" - "def clean(text):\n" - " return text.strip()\n" + "от корня проекта. В примере ниже файлы пакета создаются во временном " + "каталоге: так видно, что импорту нужны настоящие файлы. В твоём проекте " + "они уже лежат на диске, и строка import остаётся такой же.", + "import sys\n" + "import tempfile\n" + "from pathlib import Path\n" + "\n" + "root = Path(tempfile.mkdtemp())\n" + "(root / 'app' / 'utils').mkdir(parents=True)\n" + "(root / 'app' / '__init__.py').write_text('', encoding='utf-8')\n" + "(root / 'app' / 'utils' / '__init__.py').write_text('', encoding='utf-8')\n" + "(root / 'app' / 'utils' / 'text.py').write_text(\n" + " 'def clean(text):\\n return text.strip()\\n', encoding='utf-8'\n" + ")\n" + "sys.path.insert(0, str(root))\n" "\n" - "# main.py\n" "from app.utils.text import clean\n" "\n" "print(clean(' привет '))\n" @@ -632,13 +641,34 @@ def _lesson( "пакета. Если запустить файл напрямую, Python не знает родительский пакет " "и падает с ImportError: attempted relative import with no known parent " "package. Ошибка видна по слову relative import. Запускай такой файл как " - "модуль: python -m app.utils.text.", - "# файл app/utils/text.py\n" - "from .helpers import clean\n" + "модуль из корня проекта: python -m app.utils.text. Пример сам создаёт " + "пакет и запускает файл напрямую, поэтому в выводе именно эта ошибка.", + "import subprocess\n" + "import sys\n" + "import tempfile\n" + "from pathlib import Path\n" "\n" - "print(clean(' привет '))\n" + "root = Path(tempfile.mkdtemp())\n" + "module_dir = root / 'app' / 'utils'\n" + "module_dir.mkdir(parents=True)\n" + "(root / 'app' / '__init__.py').write_text('', encoding='utf-8')\n" + "(module_dir / '__init__.py').write_text('', encoding='utf-8')\n" + "(module_dir / 'helpers.py').write_text(\n" + " 'def clean(text):\\n return text.strip()\\n', encoding='utf-8'\n" + ")\n" + "(module_dir / 'text.py').write_text(\n" + " \"from .helpers import clean\\n\\nprint(clean(' привет '))\\n\",\n" + " encoding='utf-8',\n" + ")\n" + "\n" + "result = subprocess.run(\n" + " [sys.executable, str(module_dir / 'text.py')],\n" + " capture_output=True,\n" + " text=True,\n" + ")\n" + "print(result.stderr.strip().splitlines()[-1])\n" "# Вывод:\n" - "# привет", + "# ImportError: attempted relative import with no known parent package", "Точка в начале импорта означает «рядом со мной внутри пакета».", run=False, ),