{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Текст: .str\n",
    "\n",
    "Методы строк для целого столбца: убрать пробелы, выровнять регистр, заменить часть текста, найти подстроку, разбить на слова. Невидимые пробелы и разница между str.replace и replace.\n",
    "\n",
    "Курс pandas · урок `pd-strings` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/pandas/strings).\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",
    "for _name, _min in {\"numpy\":\"2.0\",\"pandas\":\"3.0\"}.items():\n",
    "    try:\n",
    "        _have = __import__(_name).__version__\n",
    "    except ImportError:\n",
    "        print(f\"Нет пакета {_name}: выполните %pip install {_name} и перезапустите ядро\")\n",
    "        continue\n",
    "    if tuple(int(x) for x in _have.split(\".\")[:2]) < tuple(int(x) for x in _min.split(\".\")):\n",
    "        print(f\"Урок написан для {_name} {_min} и новее, у вас {_have}: выполните %pip install -U {_name} и перезапустите ядро\")\n",
    "\n",
    "_DATA = {\n",
    "    \"supplier_prices.csv\": \"\"\"\n",
    "eNp9lF1O20AUhd9ZxSzggmbGP0l2wQ4qlEQVqtogCA99swlVKpEKov6AKFUpVKISlRp+XEISmy2cuwVWUl3baew05cEPY8397pl7\n",
    "zszGZquxXW8/W2/Qq7WXTaqvtZvPW5uvaWNzvd6kemurTVvtVv0FbW801trNxtKqNsTvOMQDB4g45BDJMoeYpIueMgoj3CjCMRLe\n",
    "RURGeb5Wjzsx1awmI5+3os2K1dZfWtU23YkxdzDBFYa8r6ynFW5mhEpe72pNvkvGKZQ7ImYXQyR4KBQX+ledvNrXRMYWal1CH6c4\n",
    "wCGO8BlH+CXqj/G+KN6pZOVVL2vuFwDe340KV4hwJ2dP1xgixuC/J0GMiHcUbjFAjJj3+A15tfLJfFIKF9znQDbgXnEXA9wrozPm\n",
    "haxoKs/4mox2ybgFRIVwiAhj7j+FcG2OqArClBFVwnlqTjxX5dWyKlvRVJ3rWyOccYABrnkfMRLpHOIKCQfKK0JsDjFGk3EqpQEY\n",
    "LQP4gURmijEGuFW4EQb3MMIQ94Rv2X8kHPIOhmSmPC1eF6wyhvAVEXcRIeaeGJbgmsNcX7QAlVslQ3GqZWVWlJ2kHouDY+4pXCt+\n",
    "W9S6iOl4s0lXbVmgk3olUebg30prnw5OIdbGJYw44A5+Y4SBkqaYYKxkAsLrSDX5duafTL9I8Ih35XYj5u7y7KLPEazS/nTcrqZy\n",
    "fI0vGehwkGqQ1wG3ciV4fx4yDZJxrZhWZFTSKyBJQYwhRgp3EioMOEDC3SzUZZ5Rrsl4j8EH8ku5NFXh5XcRk9QpUSfDElqm7gAj\n",
    "DrMDc0da8R45ys2ts9pokjwUuTXC9/QJ+YkznOJSoY9znOAAn/AFl/i4mGr9WVo9XTq61YQzRBykMgt2LiYZZfNXztfiZSmvfwDH\n",
    "5s7A\n",
    "\"\"\",\n",
    "}\n",
    "os.makedirs(\"data\", exist_ok=True)\n",
    "for _name, _packed in _DATA.items():\n",
    "    with open(os.path.join(\"data\", _name), \"wb\") as _f:\n",
    "        _f.write(zlib.decompress(base64.b64decode(_packed)))\n",
    "print(\"Файлы данных:\", \", \".join(sorted(_DATA)))\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": [
    "Текстовые столбцы — главный источник грязи в данных. Один человек пишет «Москва», другой «москва», третий\n",
    "ставит пробел в конце. Для человека это одно и то же, для программы — три разных значения. Продолжим чистить\n",
    "прайс поставщика: возьмёмся за названия."
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Названия как есть\n",
    "import pandas as pd\n",
    "\n",
    "supplier = pd.read_csv(\"data/supplier_prices.csv\")\n",
    "supplier[\"name\"].head(6).tolist()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "load"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Лишние пробелы в начале и в конце, строчные и заглавные буквы вперемешку.\n",
    "\n",
    "## Методы строк — через .str\n",
    "\n",
    "У строк Python есть методы `lower`, `strip`, `replace` и другие. Но столбец — не строка, и вызвать их у него\n",
    "напрямую нельзя:"
   ],
   "id": "text-16"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# У Series нет метода lower\n",
    "# Эта ячейка показывает ошибку AttributeError: она перехвачена, чтобы «Выполнить всё» не останавливалось.\n",
    "try:\n",
    "    supplier[\"name\"].lower()\n",
    "except Exception as error:\n",
    "    print(f\"{type(error).__name__}: {error}\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "no-str"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Методы строк у столбца живут за приставкой `.str`. Она значит: «применить к **каждому значению** столбца»:"
   ],
   "id": "text-25"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Всё строчными\n",
    "supplier[\"name\"].str.lower().head(4)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "lower"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Через `.str` доступны знакомые методы строк и ещё несколько. Например, `len` — длина каждого значения. Длина\n",
    "выдаёт пробелы, которых не видно в таблице:"
   ],
   "id": "text-29"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Длина до и после strip\n",
    "print(supplier[\"name\"].str.len().head(6).tolist())\n",
    "print(supplier[\"name\"].str.strip().str.len().head(6).tolist())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "len"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`strip` убирает пробелы в начале и в конце строки. Там, где длины отличаются, они были.\n",
    "\n",
    "## Невидимые пробелы\n",
    "\n",
    "Лишний пробел не виден на экране, но ломает сравнение:"
   ],
   "id": "text-34"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Ищем горький шоколад\n",
    "print((supplier[\"name\"] == \"Шоколад горький\").sum())\n",
    "print((supplier[\"name\"].str.strip() == \"Шоколад горький\").sum())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "invisible"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В файле он записан как `\"  Шоколад горький\"` — с двумя пробелами в начале. Точное сравнение его не находит."
   ],
   "id": "text-42"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Типичная ошибка: фильтр ничего не нашёл, хотя значение есть.**\n",
    ">\n",
    "> Если `таблица[таблица[\"столбец\"] == \"значение\"]` возвращает пустую таблицу, а значение вы видите глазами, —\n",
    "> проверьте пробелы и регистр. Помогает `таблица[\"столбец\"].unique()`: в списке значений кавычки показывают,\n",
    "> где строка начинается и кончается."
   ],
   "id": "component-45"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Результат метода `.str` — снова Series, поэтому методы можно вызывать цепочкой; приставку `.str` пишут перед\n",
    "каждым. Регистр выравнивают четыре метода: `lower` — всё строчными, `upper` — всё заглавными, `title` — каждое\n",
    "слово с заглавной буквы, `capitalize` — заглавная только первая буква строки, остальные строчные."
   ],
   "id": "text-50"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Убрать пробелы и выровнять регистр: upper и title\n",
    "stripped = supplier[\"name\"].str.strip()\n",
    "print(stripped.str.upper().head(3).tolist())\n",
    "print(stripped.str.title().head(3).tolist())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "chain"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Названия в порядок\n",
    "\n",
    "Приведите столбец `name` таблицы `supplier` к единому виду: без пробелов в начале и в конце, первая буква\n",
    "заглавная, остальные строчные. Результат запишите в тот же столбец.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`supplier[\"name\"].str.strip().str.capitalize()`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Методы возвращают новый Series — запишите его обратно: `supplier[\"name\"] = ...`.\n",
    "\n",
    "</details>"
   ],
   "id": "names-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# приведите в порядок столбец name таблицы supplier"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "names"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_names():\n",
    "    \"названия без лишних пробелов, с заглавной буквы\"\n",
    "    names = supplier[\"name\"].tolist()\n",
    "    assert all(isinstance(n, str) for n in names), \"в столбце name должен остаться текст\"\n",
    "    assert all(n == n.strip() for n in names), \"в названиях остались пробелы в начале или в конце. Результат нужно записать обратно в столбец name\"\n",
    "    assert \"БРАЗИЛИЯ 1 КГ\" not in names, \"остались названия заглавными буквами\"\n",
    "    assert \"Бразилия 1 Кг\" not in names, \"каждое слово стало с заглавной (title), а нужна заглавной только первая буква названия\"\n",
    "    assert \"эфиопия 250 г\" not in names, \"остались названия со строчной буквы\"\n",
    "    assert names[:4] == [\"Эспрессо-смесь 1 кг\", \"Колумбия 250 г\", \"Эфиопия 250 г\", \"Бразилия 1 кг\"], f\"первые названия сейчас {names[:4]}\"\n",
    "    assert supplier[\"name\"].nunique() == 20 and names[13] == \"Кружка 350 мл\", \"не все названия приведены к виду «Кружка 350 мл»\"\n",
    "\n",
    "_check(test_names)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "names-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "supplier[\"name\"] = supplier[\"name\"].str.strip().str.capitalize()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "names-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Заменить часть текста\n",
    "\n",
    "`.str.replace(что, на что)` заменяет **часть** текста внутри каждого значения:"
   ],
   "id": "text-64"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Убираем знак рубля\n",
    "supplier[\"price\"].str.replace(\" ₽\", \"\").head(3).tolist()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "replace"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Не путайте с методом `replace` без `.str` из прошлого модуля: тот заменяет **значение целиком**. Значения\n",
    "`\" ₽\"` в столбце нет, и он ничего не меняет:"
   ],
   "id": "text-70"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# replace без .str — значение целиком\n",
    "supplier[\"price\"].replace(\" ₽\", \"\").head(3).tolist()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "replace-whole"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Теперь можно довести до конца то, что не получилось в прошлом уроке, — сделать цены числами. Мешают знак\n",
    "рубля и пробел между тысячами."
   ],
   "id": "text-75"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Цены в числа\n",
    "\n",
    "Превратите столбец `price` таблицы `supplier` из текста вида `1 560 ₽` в целые числа и запишите в тот же\n",
    "столбец. В `price_total` сохраните сумму цен.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Две замены подряд: `.str.replace(\" ₽\", \"\")`, затем `.str.replace(\" \", \"\")`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "После замен остаются строки из цифр — их переводит `astype(\"int64\")`.\n",
    "\n",
    "</details>"
   ],
   "id": "price-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# превратите столбец price таблицы supplier в числа\n",
    "price_total = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "price"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_price():\n",
    "    \"price — целые числа\"\n",
    "    assert str(supplier[\"price\"].dtype) != \"str\", \"столбец price всё ещё текстовый: после замен поменяйте тип и запишите результат обратно в столбец\"\n",
    "    assert supplier[\"price\"].isna().sum() == 0, \"в price появились пропуски: сначала уберите из текста пробелы и знак ₽, потом меняйте тип\"\n",
    "    assert str(supplier[\"price\"].dtype) in (\"int64\", \"int32\"), f\"тип price — {supplier['price'].dtype}, а нужен целый\"\n",
    "    assert supplier[\"price\"].tolist()[:4] == [1560, 760, 830, 1370], f\"первые цены — {supplier['price'].tolist()[:4]}: уберите пробелы и знак ₽ и переведите в целые\"\n",
    "\n",
    "\n",
    "def test_total():\n",
    "    \"price_total — сумма цен\"\n",
    "    assert not isinstance(price_total, str), \"price_total — склеенная строка: сумму считают после перевода в числа\"\n",
    "    assert price_total == 19490, f\"price_total = {price_total!r} — это не сумма цен\"\n",
    "\n",
    "_check(test_price, test_total, names=[\"price_total\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "price-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "supplier[\"price\"] = supplier[\"price\"].str.replace(\" ₽\", \"\").str.replace(\" \", \"\").astype(\"int64\")\n",
    "price_total = supplier[\"price\"].sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "price-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Найти подстроку\n",
    "\n",
    "`.str.contains(текст)` возвращает маску: есть ли текст внутри значения."
   ],
   "id": "text-86"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# В названии есть «кофе»\n",
    "supplier[supplier[\"name\"].str.contains(\"кофе\")]"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "contains"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Нашёлся только «Кофе без кофеина» — и то благодаря слову «кофеина»: поиск учитывает регистр, а «Кофемолка»\n",
    "пишется с заглавной. Параметр `case=False` отключает учёт регистра:"
   ],
   "id": "text-92"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Без учёта регистра\n",
    "supplier.loc[supplier[\"name\"].str.contains(\"кофе\", case=False), \"name\"].tolist()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "contains-case"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Есть и родственные методы: `.str.startswith(текст)` — начинается с текста, `.str.endswith(текст)` —\n",
    "заканчивается им."
   ],
   "id": "text-97"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> **Точка, скобки и другие знаки.** `contains` понимает текст как **шаблон** (регулярное выражение), в котором точка, скобки, `+`, `?` и некоторые\n",
    "> другие знаки — служебные: точка, например, значит «любой символ». Чтобы искать текст с такими знаками\n",
    "> буквально, добавьте параметр `regex=False`."
   ],
   "id": "component-101"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Вопрос.** Что выведет `print(pd.Series([\" кофе\", \"кофе \", \"Кофе\"]).str.strip().nunique())`?\n",
    "\n",
    "- `1`\n",
    "- `2`\n",
    "- `3`\n",
    "\n",
    "<details><summary>Ответ</summary>\n",
    "\n",
    "`2`\n",
    "\n",
    "`strip` убрал пробелы: первые два значения стали одинаковыми — «кофе». Третье отличается регистром, а регистр `strip` не трогает. Разных значений два. Одно осталось бы после `.str.strip().str.lower()`.\n",
    "\n",
    "</details>"
   ],
   "id": "quiz-107"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Чай и не только\n",
    "\n",
    "Сохраните в `with_tea` товары, в названии которых встречается «чай» в любом регистре. В `real_tea` —\n",
    "товары категории «Чай».\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`supplier[\"name\"].str.contains(\"чай\", case=False)` — маска.\n",
    "\n",
    "</details>"
   ],
   "id": "tea-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "with_tea = ...\n",
    "real_tea = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "tea"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def test_with_tea():\n",
    "    \"with_tea — товары со словом «чай» в названии\"\n",
    "    assert isinstance(with_tea, pd.DataFrame), f\"with_tea — это {type(with_tea).__name__}, а нужна таблица\"\n",
    "    assert len(with_tea) != 2, \"найдено два товара: «Чайник» пишется с заглавной буквы — поиск должен не учитывать регистр\"\n",
    "    assert len(with_tea) == 3, f\"в with_tea {len(with_tea)} строк: ищите «чай» без учёта регистра\"\n",
    "    assert \"Чайник заварочный\" in with_tea[\"name\"].tolist(), \"в with_tea должен попасть и заварочный чайник — в его названии тоже есть «чай»\"\n",
    "\n",
    "\n",
    "def test_real_tea():\n",
    "    \"real_tea — товары категории «Чай»\"\n",
    "    assert isinstance(real_tea, pd.DataFrame), f\"real_tea — это {type(real_tea).__name__}, а нужна таблица\"\n",
    "    assert len(real_tea) == 4 and (real_tea[\"category\"] == \"Чай\").all(), f\"в real_tea {len(real_tea)} строк: нужны товары категории «Чай»\"\n",
    "\n",
    "_check(test_with_tea, test_real_tea, names=[\"with_tea\",\"real_tea\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "tea-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "with_tea = supplier[supplier[\"name\"].str.contains(\"чай\", case=False)]\n",
    "real_tea = supplier[supplier[\"category\"] == \"Чай\"]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "tea-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В `with_tea` попал заварочный чайник, а травяной сбор — нет, хотя он чай. Поиск по подстроке удобен, но слеп\n",
    "к смыслу: проверяйте глазами, что именно нашлось.\n",
    "\n",
    "## Разбить на части\n",
    "\n",
    "`.str.split()` разбивает каждое значение на слова по пробелам. В ячейке оказывается список:"
   ],
   "id": "text-123"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Названия по словам\n",
    "words = supplier[\"name\"].str.split()\n",
    "words.head(3).tolist()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "split"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Элемент списка достают через `.str[номер]` — как индекс у обычного списка:"
   ],
   "id": "text-132"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Первое и последнее слово\n",
    "print(words.str[0].head(3).tolist())\n",
    "print(words.str[-1].head(3).tolist())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "split-item"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Для самих строк `.str[...]` работает как срез — берёт символы:"
   ],
   "id": "text-136"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Первые два символа даты — день\n",
    "print(supplier[\"updated\"].head(3).tolist())\n",
    "print(supplier[\"updated\"].str[:2].head(3).tolist())"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "slice"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Текстовые столбцы можно складывать друг с другом и со строками — получается склейка:"
   ],
   "id": "text-140"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Код и название одной строкой\n",
    "(supplier[\"product_id\"] + \": \" + supplier[\"name\"]).head(3).tolist()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "concat"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Единицы измерения\n",
    "\n",
    "У весовых товаров название заканчивается единицей: «250 г», «1 кг». Добавьте в `supplier` столбец `unit` —\n",
    "последнее слово названия. В `n_grams` сохраните, сколько товаров продаётся в граммах (единица — ровно «г»).\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`supplier[\"name\"].str.split().str[-1]`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Число — сумма маски `supplier[\"unit\"] == \"г\"`.\n",
    "\n",
    "</details>"
   ],
   "id": "units-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# добавьте в supplier столбец unit — последнее слово названия\n",
    "n_grams = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "units"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_unit():\n",
    "    \"unit — последнее слово названия\"\n",
    "    assert \"unit\" in supplier.columns, \"в supplier нет столбца unit\"\n",
    "    assert supplier.loc[0, \"unit\"] != \"Эспрессо-смесь\", \"в unit попало первое слово, а нужно последнее\"\n",
    "    assert isinstance(supplier.loc[0, \"unit\"], str), \"в unit лежат списки слов: достаньте из списка последний элемент\"\n",
    "    assert supplier[\"unit\"].tolist()[:4] == [\"кг\", \"г\", \"г\", \"кг\"], f\"первые значения unit — {supplier['unit'].tolist()[:4]}\"\n",
    "    assert supplier.loc[12, \"unit\"] == \"Зефир\", \"у названия из одного слова последнее слово — оно само\"\n",
    "\n",
    "\n",
    "def test_grams():\n",
    "    \"n_grams — сколько товаров продаётся в граммах\"\n",
    "    assert n_grams != 9, \"9 — названия, которые заканчиваются на букву «г», включая «кг». Сравнивайте слово целиком\"\n",
    "    assert n_grams == 7, f\"n_grams = {n_grams!r} — это не число товаров в граммах\"\n",
    "\n",
    "_check(test_unit, test_grams, names=[\"n_grams\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "units-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "supplier[\"unit\"] = supplier[\"name\"].str.split().str[-1]\n",
    "n_grams = (supplier[\"unit\"] == \"г\").sum()\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "units-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итоги\n",
    "\n",
    "- Методы строк вызывают через `.str`: `столбец.str.lower()`; в цепочке `.str` пишут перед каждым методом.\n",
    "- `strip` — убрать пробелы по краям, `lower` / `upper` / `title` / `capitalize` — регистр, `len` — длина.\n",
    "- `.str.replace` меняет часть текста; `replace` без `.str` — значение целиком.\n",
    "- `.str.contains(текст, case=False)` — маска «текст встречается».\n",
    "- `.str.split()` — список слов, `.str[i]` — элемент списка или символы строки."
   ],
   "id": "text-152"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Подробнее в справочнике:** [pandas/strings](https://ds-edu-trainer.vercel.app/reference/pandas/strings) · [pandas/replace](https://ds-edu-trainer.vercel.app/reference/pandas/replace)"
   ],
   "id": "more"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
