{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Настройки dataclass\n",
    "\n",
    "__post_init__ проверяет поля после __init__, frozen=True делает экземпляры неизменяемыми и хешируемыми, order=True — сравнимыми, а replace создаёт изменённую копию.\n",
    "\n",
    "Курс ООП в Python · урок `oop-dataclass-options` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/oop/dataclass-options).\n",
    "\n",
    "Выполняйте ячейки по порядку (Shift+Enter), начиная с первой — она готовит урок. После каждого упражнения идёт\n",
    "ячейка проверки: она печатает ✓ / ✗ и итог."
   ],
   "id": "intro"
  },
  {
   "cell_type": "code",
   "metadata": {
    "cellView": "form",
    "jupyter": {
     "source_hidden": true
    }
   },
   "source": [
    "# @title Подготовка урока — выполните эту ячейку первой\n",
    "# Проверяет версии пакетов, записывает файлы данных в папку data/ и определяет _check — проверку упражнений.\n",
    "import base64, os, zlib\n",
    "\n",
    "import sys\n",
    "\n",
    "if sys.version_info < (3, 10):\n",
    "    print(f\"Урок написан для Python 3.10 и новее, у вас {sys.version.split()[0]} — часть кода может не работать\")\n",
    "\n",
    "# ─── Проверка упражнений: тот же раннер, что у задач на сайте ───\n",
    "# ─── Запуск тестов ───────────────────────────────────────────────────────────\n",
    "\n",
    "_TIMEOUT = 2.0  # секунд на тест; отдельный тест может задать свой: test_x.timeout = 10\n",
    "\n",
    "\n",
    "def _call_with_timeout(target, seconds: float) -> bool:\n",
    "    \"\"\"Запускает target() в отдельном потоке. Возвращает False, если за seconds он не завершился.\"\"\"\n",
    "    import sys\n",
    "    import threading\n",
    "\n",
    "    if sys.platform == \"emscripten\":  # Python в браузере: потоков нет, таймаут обеспечивает страница\n",
    "        target()\n",
    "        return True\n",
    "    # Большой стек нужен, чтобы глубокая рекурсия давала RecursionError, а не аварийное завершение Python.\n",
    "    for megabytes in (256, 64, 0):\n",
    "        try:\n",
    "            threading.stack_size(megabytes * 1024 * 1024)\n",
    "            worker = threading.Thread(target=target, daemon=True)\n",
    "            worker.start()\n",
    "        except (ValueError, RuntimeError, MemoryError):\n",
    "            continue\n",
    "        worker.join(seconds)\n",
    "        return not worker.is_alive()\n",
    "    target()  # потоки недоступны — запускаем без таймаута\n",
    "    return True\n",
    "\n",
    "\n",
    "def _test_info(fn) -> dict:\n",
    "    \"\"\"Описание теста, его лимит времени и сообщение на случай зависания.\"\"\"\n",
    "    limit = getattr(fn, \"timeout\", _TIMEOUT)\n",
    "    hint = getattr(fn, \"timeout_hint\", \"\")\n",
    "    message = (\n",
    "        f\"тест не завершился за {limit:g} с — возможно, бесконечный цикл или рекурсия: \"\n",
    "        \"проверьте, что границы/аргументы изменяются на каждом шаге\" + (f\". {hint}\" if hint else \"\")\n",
    "    )\n",
    "    return {\"title\": (fn.__doc__ or fn.__name__).strip(), \"timeout\": limit, \"timeout_message\": message}\n",
    "\n",
    "\n",
    "def _error_result(title: str, error: BaseException, user_file: str | None) -> dict:\n",
    "    \"\"\"Результат упавшего теста: статус, сообщение, тип исключения и строка в коде решения.\"\"\"\n",
    "    line = None\n",
    "    if user_file:  # самый глубокий кадр в коде решения; ошибка внутри самих тестов — без номера строки\n",
    "        tb = error.__traceback__\n",
    "        while tb is not None:\n",
    "            if tb.tb_frame.f_code.co_filename == user_file:\n",
    "                line = tb.tb_lineno\n",
    "            tb = tb.tb_next\n",
    "    status, message = \"error\", f\"{type(error).__name__}: {error}\"\n",
    "    if isinstance(error, NotImplementedError):\n",
    "        status, message, line = \"not_written\", \"функция ещё не написана\", None\n",
    "    elif isinstance(error, AssertionError):\n",
    "        status, message = \"failed\", str(error) or \"проверка не прошла\"\n",
    "    elif isinstance(error, RecursionError):\n",
    "        message = \"RecursionError: слишком глубокая рекурсия — проверьте базовый случай и то, что задача уменьшается\"\n",
    "    elif isinstance(error, MemoryError) or (isinstance(error, OverflowError) and \"index-sized\" in str(error)):\n",
    "        message = f\"{type(error).__name__}: не хватило памяти — возможно, создаётся слишком большая структура данных\"\n",
    "    return {\"title\": title, \"status\": status, \"message\": message, \"error_type\": type(error).__name__, \"line\": line}\n",
    "\n",
    "\n",
    "def _run_tests(verbose: bool = True, on_start=None, on_result=None, user_file: str | None = None) -> list[dict]:\n",
    "    \"\"\"Запускает тесты задачи (_TESTS — функции из tests.py) по порядку, каждый — с таймаутом.\n",
    "\n",
    "    Возвращает список результатов: {\"title\", \"status\", \"message\", \"error_type\", \"line\"}, где status —\n",
    "    passed · failed · error · not_written · timeout · not_run. Если тест завис, при verbose=True печатает\n",
    "    итог и завершает процесс: зависший поток остановить нельзя. on_start(i) и on_result(i, результат)\n",
    "    вызываются по мере выполнения; user_file — имя файла с кодом решения для номеров строк в ошибках.\n",
    "    \"\"\"\n",
    "    import os\n",
    "    import sys\n",
    "\n",
    "    if verbose:\n",
    "        try:  # ✓ и кириллица в консоли Windows со старой кодировкой\n",
    "            sys.stdout.reconfigure(encoding=\"utf-8\")\n",
    "        except Exception:  # noqa: BLE001 — reconfigure может не быть (перенаправленный вывод, браузер)\n",
    "            pass\n",
    "\n",
    "    tests = list(_TESTS)\n",
    "    results: list[dict] = []\n",
    "    not_written = hung = False\n",
    "    for index, fn in enumerate(tests):\n",
    "        info = _test_info(fn)\n",
    "        outcome: dict = {}\n",
    "\n",
    "        def target(fn=fn, outcome=outcome):\n",
    "            try:\n",
    "                fn()\n",
    "            except BaseException as e:\n",
    "                outcome[\"error\"] = e\n",
    "\n",
    "        if on_start:\n",
    "            on_start(index)\n",
    "        if not _call_with_timeout(target, info[\"timeout\"]):\n",
    "            results.append({\"title\": info[\"title\"], \"status\": \"timeout\", \"message\": info[\"timeout_message\"], \"error_type\": None, \"line\": None})\n",
    "            for other in tests[index + 1:]:\n",
    "                results.append({\"title\": _test_info(other)[\"title\"], \"status\": \"not_run\", \"message\": \"не запускался: предыдущий тест завис\", \"error_type\": None, \"line\": None})\n",
    "            hung = True\n",
    "            break\n",
    "        error = outcome.get(\"error\")\n",
    "        if error is None:\n",
    "            result = {\"title\": info[\"title\"], \"status\": \"passed\", \"message\": \"\", \"error_type\": None, \"line\": None}\n",
    "        else:\n",
    "            result = _error_result(info[\"title\"], error, user_file)\n",
    "            not_written = not_written or result[\"status\"] == \"not_written\"\n",
    "        results.append(result)\n",
    "        if on_result:\n",
    "            on_result(index, result)\n",
    "\n",
    "    if not_written:  # пока функция не написана, «даром» пройденные тесты не засчитываются\n",
    "        for r in results:\n",
    "            if r[\"status\"] == \"passed\":\n",
    "                r.update(status=\"not_written\", message=\"не засчитан, пока функция не написана\")\n",
    "    for r in results:\n",
    "        if len(r[\"message\"]) > 300:\n",
    "            r[\"message\"] = r[\"message\"][:300] + \"…\"\n",
    "\n",
    "    if verbose:\n",
    "        if not_written:\n",
    "            print(\"Функция ещё не написана — замените raise NotImplementedError своим кодом.\\n\")\n",
    "        for r in results:\n",
    "            mark = {\"passed\": \"✓\", \"not_run\": \"–\"}.get(r[\"status\"], \"✗\")\n",
    "            print(f\"{mark} {r['title']}\")\n",
    "            if r[\"message\"]:\n",
    "                print(f\"    {r['message']}\")\n",
    "        passed = sum(r[\"status\"] == \"passed\" for r in results)\n",
    "        print(\"─\" * 40)\n",
    "        print(f\"Прошло {passed} из {len(results)}\" + (\" — всё верно!\" if passed == len(results) else \"\"))\n",
    "        if hung:\n",
    "            print(\"Выполнение остановлено: один из тестов завис.\")\n",
    "            sys.stdout.flush()\n",
    "            os._exit(1)\n",
    "    return results\n",
    "\n",
    "\n",
    "def _check(*tests, names=()):\n",
    "    \"\"\"Запускает проверки упражнения и печатает ✓ / ✗ и итог — как на сайте.\"\"\"\n",
    "    for name in names:\n",
    "        if \".\" in name:  # «Класс.метод» из заготовки: класс должен быть объявлен\n",
    "            if not isinstance(globals().get(name.split(\".\")[0]), type):\n",
    "                print(f\"✗ класс {name.split('.')[0]} не объявлен — выполните ячейку упражнения\")\n",
    "                return\n",
    "            continue\n",
    "        if name not in globals():\n",
    "            print(f\"✗ переменная {name} не создана — выполните ячейку упражнения\")\n",
    "            return\n",
    "        if globals()[name] is Ellipsis:\n",
    "            print(f\"✗ {name} пока равна ... — замените многоточие своим кодом\")\n",
    "            return\n",
    "    globals()[\"_TESTS\"] = list(tests)\n",
    "    _run_tests()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "setup"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`@dataclass` сам пишет `__init__` — а где тогда проверять значения, как сеттер в модуле 3? И как сделать\n",
    "объект, который нельзя изменить, чтобы он не «пропадал» из `set`? У декоратора есть настройки на эти случаи.\n",
    "\n",
    "## `__post_init__`: проверка после создания\n",
    "\n",
    "Если в dataclass объявить метод `__post_init__`, сгенерированный `__init__` вызовет его последним действием —\n",
    "когда все поля уже заполнены. Это место для проверок и для приведения значений к нужному виду:"
   ],
   "id": "text-9"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка объёма и название с большой буквы\n",
    "from dataclasses import dataclass\n",
    "\n",
    "\n",
    "@dataclass\n",
    "class Cup:\n",
    "    name: str\n",
    "    volume: int\n",
    "\n",
    "    def __post_init__(self):  # вызывается в конце __init__\n",
    "        self.name = self.name.title()\n",
    "        if self.volume < 50:\n",
    "            raise ValueError(f\"объём {self.volume} мл — меньше 50 мл не бывает\")\n",
    "\n",
    "\n",
    "print(Cup(\"капучино\", 300))\n",
    "try:\n",
    "    Cup(\"эспрессо\", 30)\n",
    "except ValueError as error:\n",
    "    print(\"ValueError:\", error)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "post-init"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Учтите: `__post_init__` проверяет значения **только при создании**. Присваивание позже, `cup.volume = 10`,\n",
    "пройдёт без проверки — сеттер из модуля 3 такое ловил. Если поле нельзя испортить и потом, сделайте экземпляр\n",
    "неизменяемым (`frozen=True`, ниже) или верните свойство с сеттером."
   ],
   "id": "text-19"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Товар с проверками\n",
    "\n",
    "Допишите dataclass `Product` (поля `name` и `price`): метод `__post_init__` убирает пробелы по краям\n",
    "названия, а если название после этого пустое или цена не больше нуля — выбрасывает `ValueError`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Порядок важен: сначала уберите пробелы, потом проверяйте — иначе название из одних пробелов пройдёт.\n",
    "\n",
    "</details>"
   ],
   "id": "product-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "from dataclasses import dataclass\n",
    "\n",
    "\n",
    "@dataclass\n",
    "class Product:\n",
    "    name: str\n",
    "    price: int\n",
    "\n",
    "    def __post_init__(self):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "product"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def _value_error(*args):\n",
    "    try:\n",
    "        Product(*args)\n",
    "    except ValueError:\n",
    "        return True\n",
    "    except Exception as e:\n",
    "        assert False, f\"Product{args} выбросил {type(e).__name__}, а нужен ValueError\"\n",
    "    return False\n",
    "\n",
    "\n",
    "def test_strip():\n",
    "    \"пробелы по краям убираются\"\n",
    "    got = Product(\"  Латте \", 220)\n",
    "    assert got.name == \"Латте\", f\"название {got.name!r}, а пробелы по краям нужно убрать: self.name.strip()\"\n",
    "    assert got.price == 220\n",
    "\n",
    "\n",
    "def test_invalid():\n",
    "    \"пустое название и цена ≤ 0 — ValueError\"\n",
    "    assert _value_error(\"   \", 220), \"Product(\\\"   \\\", 220) создался — название из одних пробелов должно давать ValueError\"\n",
    "    assert _value_error(\"Чай\", 0), \"Product(\\\"Чай\\\", 0) создался — цена должна быть больше нуля\"\n",
    "    assert _value_error(\"Чай\", -5), \"Product(\\\"Чай\\\", -5) создался — цена должна быть больше нуля\"\n",
    "\n",
    "_check(test_strip, test_invalid, names=[\"Product.__post_init__\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "product-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "from dataclasses import dataclass\n",
    "\n",
    "\n",
    "@dataclass\n",
    "class Product:\n",
    "    name: str\n",
    "    price: int\n",
    "\n",
    "    def __post_init__(self):\n",
    "        self.name = self.name.strip()\n",
    "        if not self.name:\n",
    "            raise ValueError(\"пустое название\")\n",
    "        if self.price <= 0:\n",
    "            raise ValueError(\"цена должна быть больше нуля\")\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "product-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## `frozen=True`: неизменяемый экземпляр\n",
    "\n",
    "`@dataclass(frozen=True)` запрещает менять поля после создания: присваивание выбрасывает\n",
    "`FrozenInstanceError` (это подкласс `AttributeError`). Зато такой экземпляр **хешируемый**. Обычный\n",
    "dataclass — нет: он пишет `__eq__` и, как в уроке о равенстве, отключает хеш. А с `frozen=True` dataclass\n",
    "сам пишет `__hash__` из полей, и объект можно класть в `set` и делать ключом `dict` — не боясь, что он\n",
    "«потеряется» после изменения, как в уроке о равенстве. Запрет действует везде, даже в `__post_init__`:\n",
    "`self.name = self.name.strip()` в неизменяемом классе выбросит `FrozenInstanceError` — такой класс только\n",
    "проверяет поля, а не исправляет их."
   ],
   "id": "text-30"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Неизменяемый купон\n",
    "@dataclass(frozen=True)\n",
    "class Coupon:\n",
    "    code: str\n",
    "    percent: int\n",
    "\n",
    "\n",
    "autumn = Coupon(\"ОСЕНЬ\", 10)\n",
    "try:\n",
    "    autumn.percent = 50\n",
    "except AttributeError as error:\n",
    "    print(type(error).__name__, error)\n",
    "used = {autumn, Coupon(\"ОСЕНЬ\", 10), Coupon(\"ЗИМА\", 15)}\n",
    "print(len(used), Coupon(\"ОСЕНЬ\", 10) in used)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "frozen"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Деньги — неизменяемые\n",
    "\n",
    "Объявите dataclass `Money` с одним полем `rubles` (целое), неизменяемый. Проверьте себя: экземпляр не даёт\n",
    "менять `rubles` и годится как ключ словаря.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Неизменяемость включается параметром декоратора — он был в демонстрации с купоном.\n",
    "\n",
    "</details>"
   ],
   "id": "money-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "from dataclasses import dataclass\n",
    "\n",
    "\n",
    "class Money:\n",
    "    ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "money"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "from dataclasses import fields, is_dataclass\n",
    "\n",
    "\n",
    "def test_fields():\n",
    "    \"dataclass с полем rubles\"\n",
    "    assert is_dataclass(Money), \"Money — не dataclass: поставьте над классом @dataclass(frozen=True)\"\n",
    "    assert [f.name for f in fields(Money)] == [\"rubles\"], f\"поля: {[f.name for f in fields(Money)]}, а нужно одно поле rubles\"\n",
    "    assert Money(220) == Money(220), \"одинаковые суммы должны быть равны\"\n",
    "\n",
    "\n",
    "def test_frozen():\n",
    "    \"rubles нельзя изменить\"\n",
    "    assert is_dataclass(Money), \"сначала сделайте Money dataclass'ом\"\n",
    "    m = Money(220)\n",
    "    try:\n",
    "        m.rubles = 0\n",
    "    except AttributeError:\n",
    "        pass\n",
    "    else:\n",
    "        assert False, \"поле rubles удалось изменить — нужен @dataclass(frozen=True)\"\n",
    "\n",
    "\n",
    "def test_hashable():\n",
    "    \"Money — ключ словаря\"\n",
    "    assert is_dataclass(Money), \"сначала сделайте Money dataclass'ом\"\n",
    "    try:\n",
    "        prices = {Money(220): \"латте\"}\n",
    "    except TypeError:\n",
    "        assert False, \"Money нельзя сделать ключом словаря — с frozen=True dataclass сам пишет __hash__\"\n",
    "    assert prices[Money(220)] == \"латте\"\n",
    "\n",
    "_check(test_fields, test_frozen, test_hashable)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "money-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "from dataclasses import dataclass\n",
    "\n",
    "\n",
    "@dataclass(frozen=True)\n",
    "class Money:\n",
    "    rubles: int\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "money-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## `replace`: изменённая копия\n",
    "\n",
    "Как «поменять» цену неизменяемого товара? Создать новый экземпляр с другой ценой. Функция `replace` из модуля\n",
    "`dataclasses` делает это коротко: копирует все поля, кроме названных:"
   ],
   "id": "text-49"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Тот же купон, другой процент\n",
    "from dataclasses import replace\n",
    "\n",
    "winter = replace(autumn, code=\"ЗИМА\", percent=15)\n",
    "print(autumn)\n",
    "print(winter)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "replace"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## `order=True`: сравнение и сортировка\n",
    "\n",
    "`@dataclass(order=True)` пишет `__lt__`, `__le__`, `__gt__` и `__ge__`: экземпляры сравниваются как кортежи\n",
    "своих полей — по первому полю, при равенстве — по второму и так далее. Поэтому порядок полей становится\n",
    "важен: сначала поле, по которому сортируют."
   ],
   "id": "text-56"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Брони по времени\n",
    "\n",
    "Объявите dataclass `Booking` с полями `time` (строка «ЧЧ:ММ») и `name` (имя гостя) так, чтобы брони\n",
    "сортировались по времени, а при одинаковом времени — по имени. Затем сохраните в `ordered` список имён из\n",
    "броней `bookings`, отсортированных по порядку.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Сравнение включается параметром декоратора, а порядок сравнения задаёт порядок полей.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Отсортируйте брони встроенной `sorted` и соберите имена генератором списка.\n",
    "\n",
    "</details>"
   ],
   "id": "booking-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "from dataclasses import dataclass\n",
    "\n",
    "\n",
    "class Booking:\n",
    "    ...\n",
    "\n",
    "\n",
    "bookings = [Booking(\"18:30\", \"Вера\"), Booking(\"09:30\", \"Борис\"), Booking(\"18:30\", \"Анна\")]\n",
    "ordered = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "booking"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "from dataclasses import fields, is_dataclass\n",
    "\n",
    "\n",
    "def test_class():\n",
    "    \"Booking — сортируемый dataclass\"\n",
    "    assert is_dataclass(Booking), \"Booking — не dataclass: поставьте @dataclass(order=True)\"\n",
    "    assert [f.name for f in fields(Booking)] == [\"time\", \"name\"], f\"поля: {[f.name for f in fields(Booking)]} — первым должно идти time: по нему сортируют\"\n",
    "    try:\n",
    "        less = Booking(\"09:30\", \"Борис\") < Booking(\"18:30\", \"Анна\")\n",
    "    except TypeError:\n",
    "        assert False, \"брони не сравниваются — нужен @dataclass(order=True)\"\n",
    "    assert less, \"бронь на 09:30 должна быть раньше брони на 18:30\"\n",
    "\n",
    "\n",
    "def test_ordered():\n",
    "    \"ordered — имена по времени и имени\"\n",
    "    assert ordered == [\"Борис\", \"Анна\", \"Вера\"], f\"ordered = {ordered}, а нужно ['Борис', 'Анна', 'Вера']\"\n",
    "\n",
    "_check(test_class, test_ordered, names=[\"ordered\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "booking-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "from dataclasses import dataclass\n",
    "\n",
    "\n",
    "@dataclass(order=True)\n",
    "class Booking:\n",
    "    time: str\n",
    "    name: str\n",
    "\n",
    "\n",
    "bookings = [Booking(\"18:30\", \"Вера\"), Booking(\"09:30\", \"Борис\"), Booking(\"18:30\", \"Анна\")]\n",
    "ordered = [b.name for b in sorted(bookings)]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "booking-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Подорожание без изменения\n",
    "\n",
    "Товары `menu` — неизменяемые (`Price` из заготовки). Сохраните в `new_menu` новый список, где у каждого товара\n",
    "цена выше на 10 %, округлённая до целых. Исходный список и его товары не меняются.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Для каждого товара — `replace` с новой ценой; округлите её `round`.\n",
    "\n",
    "</details>"
   ],
   "id": "price-change-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "from dataclasses import dataclass, replace\n",
    "\n",
    "\n",
    "@dataclass(frozen=True)\n",
    "class Price:\n",
    "    name: str\n",
    "    price: int\n",
    "\n",
    "\n",
    "menu = [Price(\"Латте\", 220), Price(\"Чай\", 120), Price(\"Какао\", 190), Price(\"Маффин\", 133)]\n",
    "new_menu = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "price-change"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_new():\n",
    "    \"new_menu — цены выше на 10 %\"\n",
    "    assert isinstance(new_menu, list) and all(isinstance(p, Price) for p in new_menu), \"new_menu — список товаров Price\"\n",
    "    got = [(p.name, p.price) for p in new_menu]\n",
    "    assert got == [(\"Латте\", 242), (\"Чай\", 132), (\"Какао\", 209), (\"Маффин\", 146)], f\"new_menu: {got}\"\n",
    "\n",
    "\n",
    "def test_old():\n",
    "    \"старое меню не изменилось\"\n",
    "    assert [p.price for p in menu] == [220, 120, 190, 133], \"исходное меню изменилось\"\n",
    "    assert all(a is not b for a, b in zip(menu, new_menu)), \"в new_menu должны быть новые объекты — replace создаёт копию\"\n",
    "\n",
    "_check(test_new, test_old, names=[\"new_menu\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "price-change-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "from dataclasses import dataclass, replace\n",
    "\n",
    "\n",
    "@dataclass(frozen=True)\n",
    "class Price:\n",
    "    name: str\n",
    "    price: int\n",
    "\n",
    "\n",
    "menu = [Price(\"Латте\", 220), Price(\"Чай\", 120), Price(\"Какао\", 190), Price(\"Маффин\", 133)]\n",
    "new_menu = [replace(p, price=round(p.price * 110 / 100)) for p in menu]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "price-change-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Вопрос.** Что верно для `@dataclass(frozen=True)`?\n",
    "\n",
    "- экземпляр нельзя положить в `set`\n",
    "- у экземпляра нельзя менять поля, но можно получить изменённую копию через `replace`\n",
    "- поля можно менять, но только методами класса\n",
    "\n",
    "<details><summary>Ответ</summary>\n",
    "\n",
    "у экземпляра нельзя менять поля, но можно получить изменённую копию через `replace`\n",
    "\n",
    "`frozen=True` запрещает любое присваивание полям, даже из методов. Именно поэтому экземпляр хешируемый: его хеш никогда не изменится. «Изменить» его можно только созданием нового объекта — `replace` делает это за одну строку.\n",
    "\n",
    "</details>"
   ],
   "id": "quiz-79"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
