Переименовать команду погоды: pogoda -> pg, удалить docker-audit.md

This commit is contained in:
deadzilla 2026-05-31 23:13:23 +05:00
parent 6e31a7e6ff
commit 0d605eea5a
6 changed files with 56 additions and 127 deletions

View File

@ -25,7 +25,7 @@ python bot.py
| Команда | Описание | Формат вывода |
|---------|----------|---------------|
| `!pogoda` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление |
| `!pg` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление |
| `!news` | Топ-5 статей и топ-5 новостей AI с Habr | Два блока: статьи и новости |
| `!morning` | Погода + топ-5 статей + топ-5 новостей + котик | Embed: котик thumbnail, погода, статьи, новости |
| `!cat` | Случайный котик | Embed с изображением |
@ -50,7 +50,7 @@ python bot.py
## API и внешние сервисы
### Погода (!pogoda, !morning)
### Погода (!pg, !morning)
- **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа)
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
@ -89,7 +89,7 @@ requests>=2.31.0
```
## Структура данных погоды
Команда `!pogoda` возвращает:
Команда `!pg` возвращает:
```
Температура: X°C (ощущается как Y°C)
Описание: Z

View File

@ -56,7 +56,7 @@ python bot.py
bot.py # Точка входа, инициализация бота, console_input()
commands/ # Discord команды (cogs)
__init__.py # ALL_COMMANDS — явные импорты
pogoda.py # !pogoda — погода с retry + fallback
pg.py # !pg — погода с retry + fallback
news.py # !news — новости с Habr
cat.py # !cat — случайный котик
morning.py # !morning — утренний дайджест
@ -77,7 +77,7 @@ tests/ # pytest-тесты
test_fetch_rss.py # fetch_rss
test_fetch_weather.py # fetch_weather, fetch_open_meteo
test_format_articles.py # truncate_title, parse_date, format_articles
test_commands_pogoda.py # Pogoda cog
test_commands_pg.py # Pg cog
ISSUES.md # Задачи и баг-трекер проекта
pytest.ini # Конфигурация pytest
```
@ -109,13 +109,13 @@ python -m pytest tests/ -v
| `test_fetch_rss.py` | `fetch_rss()` | 20 |
| `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 20 |
| `test_format_articles.py` | `truncate_title()`, `parse_date()`, `format_articles()` | 24 |
| `test_commands_pogoda.py` | `Pogoda` cog, команда `!pogoda` | 13 |
| `test_commands_pg.py` | `Pg` cog, команда `!pg` | 13 |
**Итого: 180 тестов.**
## API и внешние сервисы
### Погода (!pogoda, !morning)
### Погода (!pg, !morning)
- **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа)
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
@ -140,7 +140,7 @@ python -m pytest tests/ -v
## Структура данных погоды
Команда `!pogoda` возвращает:
Команда `!pg` возвращает:
```
Температура: X°C (ощущается как Y°C)

View File

@ -1,6 +1,6 @@
from .pogoda import Pogoda
from .pg import Pg
from .news import News
from .cat import Cat
from .morning import Morning
ALL_COMMANDS = [Pogoda, News, Cat, Morning]
ALL_COMMANDS = [Pg, News, Cat, Morning]

View File

@ -3,14 +3,14 @@ from discord.ext import commands
from utils.pogoda import fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg
class Pogoda(commands.Cog):
"""Команда !pogoda — прогноз погоды для Магнитогорска"""
class Pg(commands.Cog):
"""Команда !pg — прогноз погоды для Магнитогорска"""
def __init__(self):
self.api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
@commands.command(name="pogoda")
async def pogoda(self, ctx):
@commands.command(name="pg")
async def pg(self, ctx):
data = await fetch_weather(self.api_url)
if data is None:
return

View File

@ -1,71 +0,0 @@
# Docker Audit — Discord Bot
## ✅ Что уже есть
| Файл | Статус |
|--------------------|---------|
| Dockerfile | ✅ Есть |
| docker-compose.yml | ✅ Есть |
| .dockerignore | ✅ Есть |
| requirements.txt | ✅ Есть |
| .env.example | ✅ Есть |
## ⚠️ Что можно улучшить
### 1. Dockerfile копирует весь проект целиком
Включая тесты, `console_commands/`, `.git`, `.pytest_cache` и прочее.
Стоит копировать только нужное: `bot.py`, `commands/`, `utils/`.
### 2. Версия Python
Dockerfile использует `python:3.12-slim`, но локально Python 3.14.
Discord.py 2.3.2+ поддерживает 3.12+, но стоит убедиться, что зависимости совместимы.
Обновить базовый образ до `python:3.14-slim`.
### 3. Нет .env в контейнере
docker-compose передаёт переменные через `environment:`, это работает,
но бот при запуске читает `.env` через `python-dotenv`.
Если запустить контейнер без `.env` на хосте, `DISCORD_TOKEN` не подтянется.
Решение: копировать `.env` в образ или убедиться, что `environment:` в compose передаёт все нужные переменные.
### 4. Нет healthcheck
Нет способа проверить, жив ли бот через Docker.
### 5. Нет ARG для тега образа
Нет возможности указать версию Python при сборке.
---
## 📋 План улучшений
### 1. Dockerfile
- Обновить базовый образ до `python:3.14-slim`
- Добавить `ARG PYTHON_VERSION` для гибкости
- Оптимизировать COPY: копировать только `bot.py`, `commands/`, `utils/`
- Добавить `.env` в образ (или убедиться, что env vars передаются корректно)
- Добавить healthcheck
- Добавить `.dockerenv` или аналог для предотвращения копирования лишних файлов
### 2. docker-compose.yml
- Добавить healthcheck для сервиса бота
- Добавить volumes для логов
- Убедиться, что все переменные из `.env` передаются в контейнер
- Добавить restart policy
### 3. .dockerignore
- Добавить `.vscode/`
- Добавить `__pycache__/` (уже есть, проверить)
- Добавить `.pytest_cache/`
- Добавить `node_modules/`
- Добавить `*.pyc`
---
## 🚀 Приоритет
| Приоритет | Изменение | Почему |
|-----------|-----------|--------|
| 🔴 Высокий | Оптимизировать COPY | Уменьшает размер образа, ускоряет сборку |
| 🔴 Высокий | Добавить healthcheck | Мониторинг состояния бота |
| 🟡 Средний | Обновить Python до 3.14 | Актуальность, но не критично |
| 🟡 Средний | Добавить volumes для логов | Удобство отладки |
| 🟢 Низкий | Добавить ARG для тега | Удобство, но не обязательно |

View File

@ -1,23 +1,23 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from commands.pogoda import Pogoda
from commands.pg import Pg
class TestPogodaInit:
"""Тесты инициализации Cog Pogoda."""
class TestPgInit:
"""Тесты инициализации Cog Pg."""
def test_init_sets_api_url(self):
"""__init__ должен устанавливать api_url."""
cog = Pogoda()
cog = Pg()
assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
class TestPogodaCommand:
class TestPgCommand:
"""Тесты команды !pogoda."""
def _make_cog(self):
return Pogoda()
return Pg()
def _make_ctx(self, send_return=None):
ctx = MagicMock()
@ -42,14 +42,14 @@ class TestPogodaCommand:
return defaults
@pytest.mark.asyncio
async def test_pogoda_success(self):
async def test_pg_success(self):
"""Успешный запрос погоды должен отправить embed с данными."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data()
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
ctx.send.assert_called_once()
args = ctx.send.call_args[0][0]
@ -61,80 +61,80 @@ class TestPogodaCommand:
assert "Давление: 759.8 мм рт. ст." in args
@pytest.mark.asyncio
async def test_pogoda_fetch_returns_none(self):
async def test_pg_fetch_returns_none(self):
"""fetch_weather вернул None — бот должен ничего не отправить."""
cog = self._make_cog()
ctx = self._make_ctx()
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=None)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=None)):
await cog.pg.callback(cog, ctx)
ctx.send.assert_not_called()
@pytest.mark.asyncio
async def test_pogoda_empty_current_condition(self):
async def test_pg_empty_current_condition(self):
"""current_condition пустой список — код выбрасывает IndexError (баг в коде)."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = {"current_condition": []}
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
with pytest.raises(IndexError):
await cog.pogoda.callback(cog, ctx)
await cog.pg.callback(cog, ctx)
@pytest.mark.asyncio
async def test_pogoda_current_condition_none(self):
async def test_pg_current_condition_none(self):
"""current_condition — пустой dict — бот должен сообщить об ошибке."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = {"current_condition": [{}]}
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
@pytest.mark.asyncio
async def test_pogoda_wind_non_numeric(self):
async def test_pg_wind_non_numeric(self):
"""windspeedKmph — не число — wind должен быть ''."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph="abc")
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Ветер: — м/с" in args
@pytest.mark.asyncio
async def test_pogoda_wind_none(self):
async def test_pg_wind_none(self):
"""windspeedKmph отсутствует — wind должен быть ''."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph=None)
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Ветер: — м/с" in args
@pytest.mark.asyncio
async def test_pogoda_zero_wind(self):
async def test_pg_zero_wind(self):
"""windspeedKmph = 0 — wind должен быть 0.0."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph="0")
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Ветер: 0.0 м/с" in args
@pytest.mark.asyncio
async def test_pogoda_default_values(self):
async def test_pg_default_values(self):
"""Поля с отсутствующими значениями должны давать ''."""
cog = self._make_cog()
ctx = self._make_ctx()
@ -146,8 +146,8 @@ class TestPogodaCommand:
pressure=None,
)
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
# dict.get(key, default) возвращает None, если ключ есть, но значение None
@ -158,53 +158,53 @@ class TestPogodaCommand:
assert "Давление: — мм рт. ст." in args
@pytest.mark.asyncio
async def test_pogoda_translate_unknown_weather(self):
async def test_pg_translate_unknown_weather(self):
"""Неизвестное описание погоды должно возвращать оригинал."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(weatherDesc=[{"value": "UnknownXYZ"}])
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Описание: UnknownXYZ" in args
@pytest.mark.asyncio
async def test_pogoda_russian_weather_description(self):
async def test_pg_russian_weather_description(self):
"""Описание погоды на русском должно корректно переводиться."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}])
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Описание: Переменная облачность" in args
@pytest.mark.asyncio
async def test_pogoda_negative_pressure(self):
async def test_pg_negative_pressure(self):
"""Отрицательное давление должно конвертироваться."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(pressure="-50")
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Давление: -37.5 мм рт. ст." in args
@pytest.mark.asyncio
async def test_pogoda_high_wind(self):
async def test_pg_high_wind(self):
"""Большая скорость ветра должна корректно округляться."""
cog = self._make_cog()
ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph="123")
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
assert "Ветер: 34.2 м/с" in args