diff --git a/.dockerignore b/.dockerignore index 546c45d..ea158e2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,6 +26,8 @@ deploy .gitea tests docs +scripts +Makefile *.md Dockerfile docker-compose*.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 35d315f..22d6653 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -6,6 +6,11 @@ on: branches: [master] workflow_dispatch: +# Отменяем предыдущий незавершённый прогон при новом пуше. +concurrency: + group: ci + cancel-in-progress: true + env: REGISTRY: git.mpakosrv.ru IMAGE_NAME: mpakobecex/dispelevilbot-bot @@ -20,12 +25,10 @@ jobs: python-version: "3.12" - name: Установка зависимостей run: pip install -r requirements-dev.txt - # В Forgejo 1.21 `continue-on-error` игнорируется, поэтому неблокирующий - # режим для black сделан через shell. ruff обязателен. - name: Ruff run: ruff check . - name: Black - run: black --check . || echo "::warning::black нашёл отличия в форматировании (пока не блокирует)" + run: black --check . - name: Pytest run: pytest -q @@ -43,7 +46,9 @@ jobs: IMAGE="$REGISTRY/$IMAGE_NAME" SHA="${GITHUB_SHA:-unknown}" SHORT_SHA="$(echo "$SHA" | cut -c1-12)" + VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)" TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest" + if [ -n "$VERSION" ]; then TAGS="$TAGS $IMAGE:$VERSION"; fi ARGS="" for t in $TAGS; do ARGS="$ARGS -t $t"; done docker build $ARGS . diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb0bcf2 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +# Управление контурами test/prod. +# Пример: make up E=test +# +# Используем .RECIPEPREFIX, чтобы не зависеть от табов (рецепты начинаются с ">"). +E ?= test +FILE := env/$(E).env +COMPOSE := docker compose -p $(E) --env-file $(FILE) -f docker-compose.prod.yml + +.DEFAULT_GOAL := help +.RECIPEPREFIX := > + +.PHONY: help up pull down restart logs ps images check psql + +help: +> @echo "Контур задаётся через E=test|prod (по умолчанию test):" +> @echo " make up E=test поднять контур" +> @echo " make pull E=test подтянуть образ из registry" +> @echo " make down E=test остановить (данные сохраняются)" +> @echo " make restart E=test пересоздать контейнеры" +> @echo " make logs E=test стримить логи" +> @echo " make ps E=test статус сервисов" +> @echo " make images E=test образы контура" +> @echo " make check E=test проверка контура (скрипт)" +> @echo " make psql E=test psql в БД контура" + +up: +> $(COMPOSE) up -d + +pull: +> $(COMPOSE) pull + +down: +> $(COMPOSE) down + +restart: +> $(COMPOSE) up -d --force-recreate + +logs: +> $(COMPOSE) logs -f + +ps: +> $(COMPOSE) ps + +images: +> $(COMPOSE) images + +check: +> ./scripts/check_contour.sh $(E) + +psql: +> $(COMPOSE) exec postgres sh -lc 'psql -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"' diff --git a/README.md b/README.md index 24caa0b..ad37202 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ pytest ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2.5`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/bot/handlers/commands.py b/bot/handlers/commands.py index 70c929b..d663b5a 100644 --- a/bot/handlers/commands.py +++ b/bot/handlers/commands.py @@ -32,9 +32,7 @@ async def set_nick_handler(message: Message) -> None: nick = parts[1] try: await users_service.set_nick(message.from_user.id, nick) - await message.answer( - render(system.MSG_NICK_SET, {"username": message.from_user.username, "nick": nick}) - ) + await message.answer(render(system.MSG_NICK_SET, {"username": message.from_user.username, "nick": nick})) await message.delete() except Exception: logger.exception("Не удалось установить ник") diff --git a/bot/services/notifications.py b/bot/services/notifications.py index a085e30..3dd068d 100644 --- a/bot/services/notifications.py +++ b/bot/services/notifications.py @@ -54,9 +54,7 @@ SCHEDULES: tuple[ScheduledMessage, ...] = ( async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None: - chunks = [ - subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE) - ] or [[]] + chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]] for chunk in chunks: text = render(template, {"usernames": format_usernames(chunk)}) await bot.send_message(chat_id=get_settings().chat_id, text=text) diff --git a/bot/services/shop.py b/bot/services/shop.py index f61ba27..aca31b4 100644 --- a/bot/services/shop.py +++ b/bot/services/shop.py @@ -8,18 +8,12 @@ from common.repositories import shop_repo async def list_titles() -> list[Title]: rows = await shop_repo.get_titles_list() - return [ - Title(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) - for row in rows - ] + return [Title(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) for row in rows] async def list_donats() -> list[Donat]: rows = await shop_repo.get_donats_list() - return [ - Donat(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) - for row in rows - ] + return [Donat(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) for row in rows] async def buy_title(id_user: int, id_title: int) -> bool: diff --git a/bot/services/stats.py b/bot/services/stats.py index d4731ca..0c182c5 100644 --- a/bot/services/stats.py +++ b/bot/services/stats.py @@ -9,6 +9,5 @@ from common.repositories import stats_repo async def top_users() -> list[TopEntry]: rows = await stats_repo.get_top_list() return [ - TopEntry(username=row.get("username"), nick=row.get("nick"), count=int(row.get("count") or 0)) - for row in rows + TopEntry(username=row.get("username"), nick=row.get("nick"), count=int(row.get("count") or 0)) for row in rows ] diff --git a/common/config.py b/common/config.py index 042f477..b1ddd30 100644 --- a/common/config.py +++ b/common/config.py @@ -87,10 +87,7 @@ class Settings(BaseSettings): """Строка подключения psycopg для async-пула (креды URL-экранируются).""" user = quote_plus(self.db_user) password = quote_plus(self.db_password) - return ( - f"postgresql://{user}:{password}" - f"@{self.db_host}:{self.db_port}/{self.db_name}" - ) + return f"postgresql://{user}:{password}" f"@{self.db_host}:{self.db_port}/{self.db_name}" @lru_cache diff --git a/common/repositories/economy_repo.py b/common/repositories/economy_repo.py index d9a7457..dc25db8 100644 --- a/common/repositories/economy_repo.py +++ b/common/repositories/economy_repo.py @@ -9,40 +9,35 @@ from common.db.pool import get_pool async def money_action(id_user: int, amount: int, id_action: int = 1) -> None: """Начислить/списать деньги (запись в журнал money).""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("CALL bot.money_action(%s, %s, %s)", (id_user, amount, id_action)) + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("CALL bot.money_action(%s, %s, %s)", (id_user, amount, id_action)) async def get_money(id_user: int) -> int: """Текущий баланс пользователя (SUM по журналу).""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT bot.get_money(%s) AS money", (id_user,)) - row = await cur.fetchone() - return int(row["money"] or 0) if row else 0 + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT bot.get_money(%s) AS money", (id_user,)) + row = await cur.fetchone() + return int(row["money"] or 0) if row else 0 async def get_eggs(id_user: int) -> int: """Количество яичек пользователя.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT bot.get_eggs(%s) AS eggs", (id_user,)) - row = await cur.fetchone() - return int(row["eggs"] or 0) if row else 0 + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT bot.get_eggs(%s) AS eggs", (id_user,)) + row = await cur.fetchone() + return int(row["eggs"] or 0) if row else 0 async def purification(id_user: int) -> dict[str, Any] | None: """Выполнить ежедневное очищение.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT * FROM bot.purification(%s)", (id_user,)) - return await cur.fetchone() + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT * FROM bot.purification(%s)", (id_user,)) + return await cur.fetchone() async def add_eggs(id_user: int) -> dict[str, Any] | None: """Получить яички (раз в сутки).""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT * FROM bot.add_eggs(%s)", (id_user,)) - return await cur.fetchone() + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT * FROM bot.add_eggs(%s)", (id_user,)) + return await cur.fetchone() diff --git a/common/repositories/shop_repo.py b/common/repositories/shop_repo.py index 7348409..c2689f1 100644 --- a/common/repositories/shop_repo.py +++ b/common/repositories/shop_repo.py @@ -9,33 +9,29 @@ from common.db.pool import get_pool async def get_titles_list() -> list[dict[str, Any]]: """Доступные к покупке звания (id_title, title, price).""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT * FROM bot.get_titles_list()") - return await cur.fetchall() + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT * FROM bot.get_titles_list()") + return await cur.fetchall() async def get_donats_list() -> list[dict[str, Any]]: """Доступные к покупке донаты (id_donat, title, price).""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT * FROM bot.get_donats_list()") - return await cur.fetchall() + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT * FROM bot.get_donats_list()") + return await cur.fetchall() async def buy_title(id_user: int, id_title: int) -> int: """Купить звание. Возвращает 1 при успехе, -1 если не хватает денег.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT bot.buy_title(%s, %s) AS res", (id_user, id_title)) - row = await cur.fetchone() - return int(row["res"]) if row else -1 + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT bot.buy_title(%s, %s) AS res", (id_user, id_title)) + row = await cur.fetchone() + return int(row["res"]) if row else -1 async def buy_donat(id_user: int, id_donat: int) -> str: """Купить донат. Возвращает промокод или '-1' при неуспехе.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT bot.buy_donat(%s, %s) AS res", (id_user, id_donat)) - row = await cur.fetchone() - return str(row["res"]) if row else "-1" + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT bot.buy_donat(%s, %s) AS res", (id_user, id_donat)) + row = await cur.fetchone() + return str(row["res"]) if row else "-1" diff --git a/common/repositories/stats_repo.py b/common/repositories/stats_repo.py index e736c97..be71b2b 100644 --- a/common/repositories/stats_repo.py +++ b/common/repositories/stats_repo.py @@ -9,7 +9,6 @@ from common.db.pool import get_pool async def get_top_list() -> list[dict[str, Any]]: """Топ-10 пользователей по количеству сообщений (username, nick, count).""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT u AS username, n AS nick, c AS count FROM bot.get_top_list()") - return await cur.fetchall() + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT u AS username, n AS nick, c AS count FROM bot.get_top_list()") + return await cur.fetchall() diff --git a/common/repositories/subscriptions_repo.py b/common/repositories/subscriptions_repo.py index 2ba29b2..68b9912 100644 --- a/common/repositories/subscriptions_repo.py +++ b/common/repositories/subscriptions_repo.py @@ -7,13 +7,11 @@ from common.db.pool import get_pool async def subscribe(id_user: int) -> None: """Подписать пользователя на рассылки.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("CALL bot.subscribe(%s)", (id_user,)) + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("CALL bot.subscribe(%s)", (id_user,)) async def unsubscribe(id_user: int) -> None: """Отписать пользователя от рассылок.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("CALL bot.unsubscribe(%s)", (id_user,)) + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("CALL bot.unsubscribe(%s)", (id_user,)) diff --git a/common/repositories/users_repo.py b/common/repositories/users_repo.py index e33ec50..0968001 100644 --- a/common/repositories/users_repo.py +++ b/common/repositories/users_repo.py @@ -9,47 +9,41 @@ from common.db.pool import get_pool async def add_user(id_user: int, username: str, first_name: str, last_name: str) -> None: """Создать/обновить пользователя и добавить запись о сообщении.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute( - "CALL bot.add_user(%s, %s, %s, %s)", - (id_user, username, first_name, last_name), - ) + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute( + "CALL bot.add_user(%s, %s, %s, %s)", + (id_user, username, first_name, last_name), + ) async def add_nick(id_user: int, nick: str) -> None: """Установить ник пользователя.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick)) + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick)) async def get_nick_list() -> list[dict[str, Any]]: """Список всех игроков (username, nick), отсортированный по нику.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT username, nick FROM bot.get_nick_list()") - return await cur.fetchall() + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT username, nick FROM bot.get_nick_list()") + return await cur.fetchall() async def get_nick_list_subscribe() -> list[dict[str, Any]]: """Список подписанных на рассылки (username, nick).""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT username, nick FROM bot.get_nick_list_subscribe()") - return await cur.fetchall() + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT username, nick FROM bot.get_nick_list_subscribe()") + return await cur.fetchall() async def get_info_by_id(id_user: int) -> dict[str, Any] | None: """Сводная информация о пользователе.""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("SELECT * FROM bot.get_info_by_id(%s)", (id_user,)) - return await cur.fetchone() + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("SELECT * FROM bot.get_info_by_id(%s)", (id_user,)) + return await cur.fetchone() async def del_user_by_id(id_user: int) -> None: """Удалить пользователя (при выходе из чата).""" - async with get_pool().connection() as conn: - async with conn.cursor() as cur: - await cur.execute("CALL bot.del_user_by_id(%s)", (id_user,)) + async with get_pool().connection() as conn, conn.cursor() as cur: + await cur.execute("CALL bot.del_user_by_id(%s)", (id_user,)) diff --git a/common/texts/render.py b/common/texts/render.py index f580473..5855ef4 100644 --- a/common/texts/render.py +++ b/common/texts/render.py @@ -18,9 +18,7 @@ from typing import Any _TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}") #: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже). -RAW_TAGS: frozenset[str] = frozenset( - {"usernames", "users_list", "users_list_count", "tops"} -) +RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops"}) #: Полный реестр поддерживаемых тегов: имя -> короткое описание. TAG_REGISTRY: dict[str, str] = { diff --git a/common/version.py b/common/version.py index ac6dc75..2003d97 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.2" +__version__ = "0.2.5" diff --git a/env/prod.env.example b/env/prod.env.example index 0dbd53a..30f0427 100644 --- a/env/prod.env.example +++ b/env/prod.env.example @@ -7,9 +7,9 @@ ENV_FILE=env/prod.env APP_ENV=prod -# Образ в реестре +# Образ в реестре (latest = последняя сборка из master; можно закрепить или версию, напр. 0.2.5) REGISTRY=git.mpakosrv.ru -IMAGE_TAG=v0.2 +IMAGE_TAG=latest BOT_TOKEN= diff --git a/env/test.env.example b/env/test.env.example index 65a15b6..2090426 100644 --- a/env/test.env.example +++ b/env/test.env.example @@ -6,9 +6,9 @@ ENV_FILE=env/test.env APP_ENV=dev -# Образ в реестре +# Образ в реестре (latest = последняя сборка из master; можно закрепить или версию, напр. 0.2.5) REGISTRY=git.mpakosrv.ru -IMAGE_TAG=v0.2 +IMAGE_TAG=latest BOT_TOKEN= diff --git a/pyproject.toml b/pyproject.toml index f4a0acf..757224b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ line-length = 120 target-version = "py312" [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "W"] +select = ["E", "F", "I", "UP", "B", "W", "C4", "SIM", "PIE"] ignore = ["E501"] [tool.black] diff --git a/scripts/check_contour.sh b/scripts/check_contour.sh new file mode 100644 index 0000000..4097ea6 --- /dev/null +++ b/scripts/check_contour.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Проверка контура: сервисы запущены, образ бота — ожидаемый, миграции применены. +# Использование: ./scripts/check_contour.sh [test|prod] +set -euo pipefail + +E="${1:-test}" +FILE="env/${E}.env" + +if [ ! -f "$FILE" ]; then + echo "Нет файла окружения: $FILE" >&2 + exit 1 +fi + +COMPOSE="docker compose -p ${E} --env-file ${FILE} -f docker-compose.prod.yml" + +echo "== контур: ${E} ==" +$COMPOSE ps + +echo +echo "== образы ==" +$COMPOSE images + +echo +echo "== проверки ==" + +running="$($COMPOSE ps --services --status running || true)" +fail=0 +for svc in postgres bot; do + if echo "$running" | grep -qx "$svc"; then + echo "OK ${svc}: running" + else + echo "FAIL ${svc}: не запущен" >&2 + fail=1 + fi +done + +# миграции: сервис migrate должен завершиться успешно (Exited (0)) +if $COMPOSE ps -a --format '{{.Service}} {{.Status}}' | grep -q '^migrate Exited (0)'; then + echo "OK migrate: завершён успешно" +else + echo "WARN migrate: нет подтверждённого успешного завершения (см. 'logs migrate')" >&2 +fi + +# БД отвечает +if $COMPOSE exec -T postgres sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then + echo "OK postgres: принимает соединения" +else + echo "FAIL postgres: не отвечает" >&2 + fail=1 +fi + +exit $fail diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..aeb1624 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,60 @@ +"""Тесты разбора переменных окружения (без БД и сети). + +Проверяем граничные случаи, которые уже ломали запуск: +- ``ADMIN_IDS`` пустой / через запятую / как JSON-массив; +- пустые целочисленные поля ``CHAT_ID``/``ALERT_CHAT_ID``; +- обязательный ``BOT_TOKEN``. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from common.config import Settings + +#: Переменные, которые чистим перед каждым тестом, чтобы окружение не влияло. +_ENV_KEYS = ("BOT_TOKEN", "APP_ENV", "ADMIN_IDS", "CHAT_ID", "ALERT_CHAT_ID") + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in _ENV_KEYS: + monkeypatch.delenv(key, raising=False) + + +def _settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> Settings: + for key, value in env.items(): + monkeypatch.setenv(key, value) + return Settings(_env_file=None) + + +def test_admin_ids_empty(monkeypatch: pytest.MonkeyPatch) -> None: + assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="").admin_ids == [] + + +def test_admin_ids_not_set(monkeypatch: pytest.MonkeyPatch) -> None: + assert _settings(monkeypatch, BOT_TOKEN="1:a").admin_ids == [] + + +def test_admin_ids_comma(monkeypatch: pytest.MonkeyPatch) -> None: + assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="1,2, 3").admin_ids == [1, 2, 3] + + +def test_admin_ids_json_like(monkeypatch: pytest.MonkeyPatch) -> None: + assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="[7, 8]").admin_ids == [7, 8] + + +def test_chat_ids_empty_are_zero(monkeypatch: pytest.MonkeyPatch) -> None: + settings = _settings(monkeypatch, BOT_TOKEN="1:a", CHAT_ID="", ALERT_CHAT_ID="") + assert settings.chat_id == 0 + assert settings.alert_chat_id == 0 + + +def test_chat_id_value(monkeypatch: pytest.MonkeyPatch) -> None: + assert _settings(monkeypatch, BOT_TOKEN="1:a", CHAT_ID="-1001234567890").chat_id == -1001234567890 + + +def test_bot_token_required(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises(ValidationError): + Settings(_env_file=None) diff --git a/tests/test_version.py b/tests/test_version.py index 74f39fa..1015e50 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.2" + assert __version__ == "0.2.5"