Compare commits

..

No commits in common. "90547df975b46283348ccf283e0086e14570d1ed" and "cef53197b15a2931010eb5adf23b0bfad86ed99f" have entirely different histories.

18 changed files with 151 additions and 310 deletions

View File

@ -16,7 +16,6 @@ RUN pip install --no-cache-dir -r requirements.txt && \
# Копируем только нужные файлы (оптимизация размера образа)
COPY bot.py .
COPY conftest.py .
COPY commands/ commands/
COPY utils/ utils/
# .env передаётся через docker-compose environment:

View File

@ -1,7 +1,5 @@
# ISSUES — Задачи и баг-трекер
---
## Исправлено
- [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py`
@ -12,30 +10,19 @@
- [x] ~~Отсутствует `.gitignore`~~ — файл существует
- [x] ~~Отсутствует Dockerfile~~ — файл существует
- [x] ~~Отсутствует `.env.example`~~ — файл существует
## Средний приоритет
- [x] **Type hints в production-коде** — добавлены аннотации ко всем 20 функциям
- [x] **`asyncio.iscoroutinefunction` deprecated** — monkey-patch в `bot.py` + `conftest.py`
- [x] **Утечка корутины Scheduler в тестах** — mock `_start_scheduler` вместо `asyncio.create_task`
- [x] **Graceful shutdown (SIGTERM)** — реализован через `on_shutdown` listener + `async with self.bot`
- [x] **Graceful shutdown (SIGTERM)**уже реализован через `on_shutdown` listener + `async with self.bot`
- [x] **Пустой `__init__` в Morning** — удалён
- [x] **Global RateLimiter на модульном уровне** — добавлены factory-функции `make_*_limiter()`
## Низкий приоритет
- [x] **Type hints в тестах** — добавлены `-> None` ко всем 140 test-функциям
- [x] **`import` внутри функций в тестах** — вынесены наверх модулей (35 вхождений)
- [x] **Добавить pre-commit хуки**`.pre-commit-config.yaml` (ruff + ruff-format), `requirements-dev.txt`
- [x] **Интеграционные тесты** — 9 тестов загрузок когов, команд и утилит
- [x] **`format_weather_for_embed` отсутствует в коде** — заменено на `format_weather_for_message` в README.md (`README.md`)
- [x] **`_WEATHER_MAPPING` не отсортирован по убыванию длины ключей** — отсортирован список по убыванию `len(key)`, добавлен комментарий (`utils/pogoda.py`)
- [x] **Глобальные `requests.Session` не закрываются** — добавлена `close_all_sessions()` в `utils/__init__.py`, вызов из `_on_shutdown` в `bot.py`
- [x] **`run_morning` fallback может отправить дайджест в несколько каналов** — fallback перебирает каналы целевого сервера вместо `bot.get_all_channels()` (`utils/morning_runner.py`)
- [x] **`!nw`: последовательные вызовы API вместо параллельных** — заменено на `asyncio.gather()` (`commands/news.py`)
- [x] **`format_weather_data_for_console` выводит `None` в текст** — добавлена явная проверка `is None` для всех полей (`utils/pogoda.py`), обновлён тест (`tests/test_commands_pg.py`)
- [x] **`_parse_date` хрупкий fallback** — валидация формата через regex, ISO даты конвертируются в DD.MM.YYYY (`utils/news.py`)
- [x] **`wmo_to_russian` создаёт dict на каждый вызов** — вынесен в константу `_WMO_MAPPING` (`utils/pogoda.py`)
- [x] **`TextHelpCommand` пропускает команды без cog** — standalone-команды показываются если не hidden (`bot.py`)
- [x] **`translate_weather(" ")` возвращает пробелы** — добавлен `.strip()` перед проверкой (`utils/pogoda.py`)
- [x] **`pressure_to_mmhg(mb: Any)`** — заменён на `float | int | str | None` (`utils/pogoda.py`)
- [x] **`conftest.py` и `bot.py` дублируют monkey-patch** — вынесен в `utils/compat.py`, оба файла импортируют оттуда
- [x] **`RateLimiter` тесты зависят от реального времени** — добавлен `_time_func` параметр, все тесты используют контролируемую функцию времени
- [x] **`Dockerfile` не копирует `conftest.py`** — добавлен `COPY conftest.py .`
- [x] **`requirements.txt` без пиннинга версий** — `>=` заменён на `~=` (compatible release)
- [x] **Закомментированный тест `pressure_to_mmhg(0)`** — удалена устаревшая закомментированная строка
- [x] **`format_articles(None)` бросает `TypeError`** — добавлена валидация `None`, graceful fallback с сообщением

View File

@ -55,7 +55,7 @@ commands/ # Discord команды (cogs)
stats.py # !stats — серверы, каналы, пользователи
utils/ # Утилиты (API-клиенты, конвертации)
__init__.py # __all__ — публичный API утилит
pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_message()
pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_embed()
news.py # fetch_rss(), format_articles(), truncate_title()
cat.py # fetch_cat()
rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры
@ -267,7 +267,7 @@ ruff>=0.8.0
| `translate_weather()` | Перевод погодных описаний на русский язык |
| `pressure_to_mmhg()` | Конвертация давления из hPa в мм рт. ст. |
| `format_weather_data_for_console()` | Форматирование данных погоды для вывода в консоль |
| `format_weather_for_message()` | Форматирование погоды для plain text сообщения (с заголовком) |
| `format_weather_for_embed()` | Форматирование погоды для Discord embed (с заголовком) |
### utils/news.py

15
bot.py
View File

@ -1,4 +1,5 @@
import asyncio
import inspect
import logging
import os
import sys
@ -8,7 +9,7 @@ from typing import TYPE_CHECKING
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
from utils import compat # noqa: F401
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
import discord # noqa: E402
from discord.ext import commands # noqa: E402
@ -37,11 +38,7 @@ class TextHelpCommand(commands.HelpCommand):
for cog_or_none, cog_commands in mapping.items():
if cog_or_none is None:
# Standalone-команды (без cog) — показываем если не hidden
for command in cog_commands:
if not command.hidden:
desc = command.short_doc or ""
lines.append(f" !{command.name} - {desc}")
# Команды без cog (встроенная help) — пропускаем
continue
for command in cog_commands:
if not command.hidden:
@ -178,14 +175,10 @@ class BotRunner:
pass # Бот не может писать в канал — игнорируем
def _on_shutdown(self) -> None:
"""Остановить планировщик и закрыть сетевые сессии."""
"""Остановить планировщик при завершении работы бота."""
if self.scheduler:
self.scheduler.stop()
logger.info("Планировщик остановлен")
from utils import close_all_sessions
close_all_sessions()
logger.info("Сетевые сессии закрыты")
async def _on_shutdown_async(self) -> None:
"""Асинхронный хук завершения (discord.py on_shutdown)."""

View File

@ -1,4 +1,3 @@
import asyncio
import logging
from discord.ext import commands
from utils.news import (
@ -18,31 +17,29 @@ class News(commands.Cog):
@commands.command(name="nw")
async def nw(self, ctx: commands.Context) -> None:
"""Топ-5 свежих статей и новостей по AI с Habr"""
articles, posts = await asyncio.gather(
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
)
parts: list[str] = []
# --- Статьи ---
articles = await fetch_rss(RSS_URL_ARTICLES)
if articles is None:
logger.warning(
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
)
parts.append("Не удалось получить статьи.")
elif articles:
await ctx.send("Не удалось получить новости. Попробуйте позже.")
return
if not articles:
logger.info("%s: !nw — статей нет в RSS", ctx.author)
await ctx.send("Новостей пока нет.")
return
articles_text = format_articles(
articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
)
parts.append("\n".join(articles_text))
else:
logger.info("%s: !nw — статей нет в RSS", ctx.author)
parts.append("Статей пока нет.")
# --- Посты ---
posts = await fetch_rss(RSS_URL_POSTS)
parts = ["\n".join(articles_text)]
if posts is None:
logger.warning(
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
@ -64,6 +61,6 @@ class News(commands.Cog):
logger.info(
"%s: !nw выполнена (статей: %d, постов: %d)",
ctx.author,
len(articles) if articles else 0,
len(articles),
len(posts) if posts else 0,
)

View File

@ -1,5 +1,8 @@
"""Pytest configuration — применяется до импорта тестов."""
import asyncio
import inspect
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
from utils import compat # noqa: F401
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]

View File

@ -1,4 +1,4 @@
discord.py~=2.7.1
python-dotenv~=1.2.2
requests~=2.34.2
defusedxml~=0.7.1
discord.py>=2.3.2
python-dotenv>=1.0.0
requests>=2.31.0
defusedxml>=0.7.0

View File

@ -151,11 +151,11 @@ class TestPgCommand:
await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0]
# None значения корректно заменяются на "—"
assert "Температура: °C" in args
assert "ощущается как °C" in args
# dict.get(key, default) возвращает None, если ключ есть, но значение None
assert "Температура: None°C" in args
assert "ощущается как None°C" in args
assert "Описание: —" in args
assert "Влажность: %" in args
assert "Влажность: None%" in args
assert "Давление: — мм рт. ст." in args
@pytest.mark.asyncio

View File

@ -48,10 +48,9 @@ class TestParseDate:
[
("Mon, 28 May 2026 10:00:00 +0000", "28.05.2026"),
("Mon, 28 May 2026 10:00:00 GMT", "28.05.2026"),
("2026-05-28T10:00:00Z", "28.05.2026"),
("2026-12-31T23:59:59Z", "31.12.2026"),
("2026-01-01T00:00:00Z", "01.01.2026"),
("2026-05-28", "28.05.2026"),
("2026-05-28T10:00:00Z", "2026.05.28"),
("2026-12-31T23:59:59Z", "2026.12.31"),
("2026-01-01T00:00:00Z", "2026.01.01"),
],
)
def test_parse_date_known(self, pub_date, expected) -> None:
@ -70,9 +69,11 @@ class TestParseDate:
assert _parse_date(pub_date) == expected
def test_parse_date_invalid(self) -> None:
"""Невалидная дата должна вернуть пустую строку."""
"""Невалидная дата должна вернуть первые 10 символов."""
result = _parse_date("invalid-date-string")
assert result == ""
assert (
result == "invalid.da"
) # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
class TestFormatArticles:
@ -125,10 +126,9 @@ class TestFormatArticles:
assert len(result) == 1
def test_format_articles_none(self) -> None:
"""None должен вернуть graceful fallback вместо TypeError."""
result = format_articles(None, "Заголовок", "https://habr.com/feed")
assert len(result) == 2
assert "Не удалось загрузить статьи." in result[1]
"""None должен вызвать TypeError (articles[:5] на None)."""
with pytest.raises(TypeError):
format_articles(None, "Заголовок", "https://habr.com/feed")
def test_format_articles_single_article(self) -> None:
"""Одна статья должна быть корректно отформатирована."""
@ -205,7 +205,7 @@ class TestFormatArticles:
},
]
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert result[1] == "Статья\n28.05.2026 <https://habr.com/1>"
assert result[1] == "Статья\n2026.05.28 <https://habr.com/1>"
def test_format_articles_empty_date(self) -> None:
"""Пустая дата должна быть пустой строкой."""

View File

@ -94,12 +94,12 @@ class TestSendBotHelp:
assert "hidden_cmd" not in message
@pytest.mark.asyncio
async def test_send_bot_help_shows_none_cog(self, help_command) -> None:
"""Команды без cog (None) должны показываться, если не hidden."""
async def test_send_bot_help_skips_none_cog(self, help_command) -> None:
"""Команды без cog (None) должны пропускаться."""
cmd = MagicMock()
cmd.name = "standalone"
cmd.name = "built_in"
cmd.hidden = False
cmd.short_doc = "Самостоятельная"
cmd.short_doc = "Встроенная"
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
@ -110,27 +110,7 @@ class TestSendBotHelp:
destination.send.assert_awaited_once()
message = destination.send.call_args[0][0]
assert "standalone" in message
assert "Самостоятельная" in message
@pytest.mark.asyncio
async def test_send_bot_help_hides_none_cog_hidden_cmd(self, help_command) -> None:
"""Hidden команды без cog не должны показываться."""
cmd = MagicMock()
cmd.name = "hidden_standalone"
cmd.hidden = True
cmd.short_doc = "Скрытая"
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
with patch.object(help_command, "get_destination", return_value=destination):
mapping = {None: [cmd]}
await help_command.send_bot_help(mapping)
destination.send.assert_awaited_once()
message = destination.send.call_args[0][0]
assert "hidden_standalone" not in message
assert "built_in" not in message
@pytest.mark.asyncio
async def test_send_bot_help_empty_doc(self, help_command) -> None:

View File

@ -163,11 +163,11 @@ class TestTranslateWeather:
[
("", ""),
(None, ""),
(" ", ""), # строка из пробелов тоже считается пустой
(" ", " "), # пробелы не считаются пустыми
],
)
def test_translate_empty(self, input_value, expected) -> None:
"""Пустой, None или только пробелы должен возвращать ''."""
"""Пустой или None ввод должен возвращать ''."""
assert translate_weather(input_value) == expected
def test_translate_unknown_returns_original(self) -> None:
@ -208,6 +208,7 @@ class TestPressureToMMHG:
(1000, 750.1),
(980, 735.1),
(1030, 772.6),
# (0, "—"), # 0 — falsy, возвращается '—' (баг)
],
)
def test_pressure_valid(self, mb, expected) -> None:

View File

@ -1,80 +1,45 @@
"""Тесты для utils/rate_limiter.py — проверка логики токен-бакета."""
import asyncio
from unittest.mock import MagicMock
import time
from utils.rate_limiter import RateLimiter
def _make_time() -> tuple[RateLimiter, list[float]]:
"""Создать RateLimiter с контролируемой временной функцией."""
times: list[float] = [0.0]
def controlled_time() -> float:
return times[0]
limiter = RateLimiter(rate=10.0, burst=5, _time_func=controlled_time)
return limiter, times
async def test_initial_tokens_full() -> None:
"""Бакет заполнен до burst при создании."""
limiter, _ = _make_time()
limiter = RateLimiter(rate=2.0, burst=5)
assert limiter.tokens == 5.0
async def test_acquire_consumes_token() -> None:
"""acquire() уменьшает количество токенов."""
limiter, _ = _make_time()
limiter = RateLimiter(rate=1.0, burst=3)
await limiter.acquire()
assert limiter.tokens == 4.0
assert limiter.tokens == 2.0
async def test_acquire_waits_when_empty() -> None:
"""acquire() ждёт, когда токены закончились (контролируемое время)."""
limiter, times = _make_time()
# Потратить все 5 токенов
for _ in range(5):
await limiter.acquire()
assert limiter.tokens < 1.0
# Пропустить 0.2 сек -> должно пополниться 2 токена (rate=10)
times[0] = 0.2
async with limiter.lock:
limiter._refill()
assert limiter.tokens >= 2.0
"""acquire() ждёт, когда токены закончились."""
limiter = RateLimiter(rate=10.0, burst=1) # 10 токенов/сек
await limiter.acquire() # бакет пуст
start = time.monotonic()
await limiter.acquire() # должен ждать ~0.1 сек
elapsed = time.monotonic() - start
assert elapsed >= 0.05 # допускаем погрешность
async def test_burst_cap() -> None:
"""Токены не превышают burst после долгого простоя."""
limiter, times = _make_time()
times[0] = 10.0 # теоретически +100 токенов, но cap = 5
limiter = RateLimiter(rate=100.0, burst=3)
await asyncio.sleep(0.1) # теоретически +10 токенов, но cap = 3
async with limiter.lock:
limiter._refill()
assert limiter.tokens == 5.0
assert limiter.tokens == 3.0
async def test_multiple_acquire() -> None:
"""Можно забрать несколько токенов за раз."""
limiter, _ = _make_time()
await limiter.acquire(token=3)
assert limiter.tokens == 2.0
async def test_refill_partial() -> None:
"""Пополнение за малый интервал времени."""
limiter, times = _make_time()
times[0] = 0.1 # 10 токенов/сек * 0.1 сек = 1 токен
async with limiter.lock:
limiter._refill()
assert limiter.tokens == 5.0 # был 5 + 1 = 6, но cap = 5
async def test_refill_exact() -> None:
"""Точное пополнение при частичном бакете."""
limiter, times = _make_time()
await limiter.acquire(token=3) # осталось 2
times[0] = 0.1 # +1 токен
async with limiter.lock:
limiter._refill()
assert limiter.tokens == 3.0 # 2 + 1 = 3
limiter = RateLimiter(rate=1.0, burst=10)
await limiter.acquire(token=5)
assert limiter.tokens == 5.0

View File

@ -1,6 +1,5 @@
from .pogoda import (
API_URL_WEATHER,
_session as _weather_session,
fetch_weather,
fetch_open_meteo,
format_weather_data_for_console,
@ -9,18 +8,14 @@ from .pogoda import (
translate_weather,
wmo_to_russian,
)
from .news import ( # noqa: E402
_session as _news_session,
from .news import (
RSS_URL_ARTICLES,
RSS_URL_POSTS,
fetch_rss,
format_articles,
truncate_title,
)
from .cat import ( # noqa: E402
_session as _cat_session,
fetch_cat,
)
from .cat import fetch_cat
__all__ = [
# Погода
@ -40,20 +35,4 @@ __all__ = [
"truncate_title",
# Котики
"fetch_cat",
# Lifecycle
"close_all_sessions",
]
def close_all_sessions() -> None:
"""Закрыть все requests.Session для освобождения сокетов."""
for session in (
_weather_session,
_news_session,
_cat_session,
):
try:
session.close()
except Exception:
pass # Cleanup — игнорируем ошибки

View File

@ -1,7 +0,0 @@
"""Совместимость с Python 3.14+."""
import asyncio
import inspect
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]

View File

@ -223,15 +223,6 @@ class Scheduler:
async def _run_morning(self):
logger.info("Выполняю morning в %s", self.morning_time)
# Определяем целевой сервер для fallback
target_guild: discord.Guild | None = None
if self._target_channel_id:
guild = self.bot.get_guild(
(await self.bot.fetch_channel(self._target_channel_id)).guild.id
)
if guild:
target_guild = guild
# Если задан конкретный канал — отправляем туда
if self._target_channel_id:
# fetch_channel — API-запрос, не зависит от кэша
@ -251,27 +242,17 @@ class Scheduler:
"Канал с ID %s не текстовый — fallback", self._target_channel_id
)
# Fallback: первый текстовый канал целевого сервера с правами send_messages
guilds_to_check: list[discord.Guild] = []
if target_guild:
guilds_to_check.append(target_guild)
guilds_to_check.extend(self.bot.guilds)
# Fallback: первый канал с правами send_messages
sent = False
for guild in guilds_to_check:
for channel in guild.text_channels:
if channel.permissions_for(guild.me).send_messages:
for channel in self.bot.get_all_channels():
if isinstance(channel, discord.TextChannel):
if channel.permissions_for(channel.guild.me).send_messages:
try:
await run_morning(self.bot, channel)
sent = True
return
except Exception as e:
logger.error(
"Ошибка отправки в #%s (%s): %s",
channel.name,
guild.name,
e,
)
logger.error("Ошибка отправки в #%s: %s", channel.name, e)
continue
if not sent:
logger.error("Не удалось найти канал для отправки morning-дайджеста")

View File

@ -1,6 +1,5 @@
import asyncio
import logging
import re
from datetime import datetime
from typing import Optional
@ -83,13 +82,7 @@ async def fetch_rss(url: str) -> Optional[list[dict]]:
def _parse_date(pub_date: Optional[str]) -> str:
"""Парсить дату из RSS в строку 'дд.мм.гггг'.
Поддерживает:
- RFC 822: "Mon, 01 Jan 2024 12:00:00 GMT"
- ISO 8601: "2024-01-01T12:00:00+00:00" или "2024-01-01"
Возвращает пустую строку, если формат не распознан.
"""
"""Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты."""
if not pub_date:
return ""
try:
@ -97,12 +90,7 @@ def _parse_date(pub_date: Optional[str]) -> str:
dt = datetime.strptime(d, "%a, %d %b %Y %H:%M:%S %z")
return dt.strftime("%d.%m.%Y")
except ValueError:
pass
# Fallback: YYYY-MM-DD или YYYY-MM-DDT...
match = re.match(r"(\d{4})-(\d{2})-(\d{2})", pub_date)
if match:
return f"{match.group(3)}.{match.group(2)}.{match.group(1)}"
return ""
return pub_date[:10].replace("-", ".")
def truncate_title(title: str, max_len: int = 60) -> str:
@ -135,8 +123,6 @@ def truncate_message(text: str, max_len: int = 2000) -> str:
def format_articles(articles: list[dict], title: str, link: str) -> list[str]:
"""Сформировать список строк для вывода статей/постов."""
if articles is None:
return [f"{title}\n<{link}>", "Не удалось загрузить статьи."]
lines = [f"{title}\n<{link}>"]
for i, article in enumerate(articles[:5], 1):
date_str = _parse_date(article["pub_date"])

View File

@ -1,6 +1,6 @@
import asyncio
import logging
from typing import Optional
from typing import Any, Optional
import requests
from requests.exceptions import ConnectionError, Timeout, SSLError
@ -90,7 +90,9 @@ async def fetch_open_meteo(
return None
_WMO_MAPPING: dict[int, str] = {
def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский."""
mapping = {
0: "Ясно",
1: "Ясно",
2: "Переменная облачность",
@ -120,61 +122,54 @@ _WMO_MAPPING: dict[int, str] = {
96: "Гроза с градом",
99: "Сильная гроза с градом",
}
def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский."""
return _WMO_MAPPING.get(code, "Неизвестно")
return mapping.get(code, "Неизвестно")
_WEATHER_MAPPING = [
# Отсортировано по убыванию длины ключа для корректного substring-matching:
# более длинные фразы проверяются первыми, чтобы "Light rain" не совпал
# раньше "Light rain shower".
("Moderate or heavy freezing rain at a distance", "Ледяной дождь"),
("Moderate or heavy freezing rain in area", "Ледяной дождь"),
("Moderate or heavy sleet at a distance", "Слякоть"),
("Moderate or heavy freezing rain in area", "Ледяной дождь"),
("Moderate or heavy sleet in area", "Слякоть"),
("Moderate or heavy rain at times", "Дождь"),
("Moderate or heavy snow at times", "Снег"),
("Moderate or heavy snow in area", "Снег"),
("Moderate or heavy rain in area", "Дождь"),
("Thundery outbreaks in nearby", "Гроза вблизи"),
("Moderate or light sleet", "Слякоть"),
("Moderate rain at times", "Умеренный дождь"),
("Moderate or heavy rain at times", "Дождь"),
("Moderate or heavy snow at times", "Снег"),
("Patchy light drizzle", "Местами лёгкая морось"),
("Patchy light snow", "Местами лёгкий снег"),
("Heavy freezing rain", "Сильный ледяной дождь"),
("Light freezing rain", "Лёгкий ледяной дождь"),
("Patchy sleet nearby", "Местами слякоть"),
("Heavy rain at times", "Сильный дождь"),
("Patchy rain nearby", "Местами дождь"),
("Patchy snow nearby", "Местами снег"),
("Patchy light snow", "Местами лёгкий снег"),
("Light rain shower", "Небольшой дождь"),
("Heavy rain shower", "Сильный дождь"),
("Moderate or light sleet", "Слякоть"),
("Patchy rain nearby", "Местами дождь"),
("Patchy snow nearby", "Местами снег"),
("Patchy sleet nearby", "Местами слякоть"),
("Moderate rain at times", "Умеренный дождь"),
("Heavy rain at times", "Сильный дождь"),
("Moderate rain", "Умеренный дождь"),
("Moderate snow", "Умеренный снег"),
("Partly cloudy", "Переменная облачность"),
("Blowing snow", "Метель"),
("Light rain", "Небольшой дождь"),
("Heavy rain", "Сильный дождь"),
("Light snow", "Небольшой снег"),
("Moderate snow", "Умеренный снег"),
("Heavy snow", "Сильный снег"),
("Blowing snow", "Метель"),
("Partly cloudy", "Переменная облачность"),
("Overcast", "Пасмурно"),
("Cloudy", "Облачно"),
("Foggy", "Туманно"),
("Clear", "Ясно"),
("Sunny", "Ясно"),
("Fog", "Туман"),
("Mist", "Туман"),
("Haze", "Дымка"),
("Fog", "Туман"),
("Clear", "Ясно"),
("Sunny", "Ясно"),
]
def translate_weather(en: Optional[str]) -> str:
if not en or not en.strip():
if not en:
return ""
en_lower = en.strip().lower()
en_lower = en.lower()
for key, value in _WEATHER_MAPPING:
if key.lower() in en_lower:
return value
@ -197,24 +192,13 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
if not current:
return None
# dict.get() возвращает None если ключ существует со значением null;
# используем проверку на None для корректного fallback
temp = current.get("temp_C")
if temp is None:
temp = ""
feels_like = current.get("FeelsLikeC")
if feels_like is None:
feels_like = ""
weather_desc = current.get("weatherDesc", [{}])[0].get("value")
if weather_desc is None:
weather_desc = ""
description = translate_weather(weather_desc)
humidity = current.get("humidity")
if humidity is None:
humidity = ""
wind_kmh = current.get("windspeedKmph")
if wind_kmh is None:
wind_kmh = ""
temp = current.get("temp_C", "")
feels_like = current.get("FeelsLikeC", "")
description = translate_weather(
current.get("weatherDesc", [{}])[0].get("value", "")
)
humidity = current.get("humidity", "")
wind_kmh = current.get("windspeedKmph", "")
try:
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "" else ""
except (ValueError, TypeError):
@ -241,7 +225,7 @@ def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
return "Погода в Магнитогорске:\n" + "\n".join(lines)
def pressure_to_mmhg(mb: float | int | str | None) -> float | str:
def pressure_to_mmhg(mb: Any) -> float | str:
if mb == "" or mb is None or mb == "":
return ""
try:

View File

@ -10,7 +10,7 @@ import asyncio
import logging
import os
import time
from typing import Callable, Final
from typing import Final
logger = logging.getLogger(__name__)
@ -18,28 +18,21 @@ logger = logging.getLogger(__name__)
class RateLimiter:
"""Токен-бакет: заполняется со скоростью rate токенов/сек, максимум burst."""
def __init__(
self,
rate: float,
burst: int,
_time_func: Callable[[], float] | None = None,
) -> None:
def __init__(self, rate: float, burst: int) -> None:
"""
Args:
rate: Скорость пополнения токенов (токенов в секунду).
burst: Максимальный размер бакета.
_time_func: Функция получения времени (для тестов). По умолчанию time.monotonic.
"""
self.rate: float = rate
self.burst: int = burst
self.tokens: float = float(burst)
self.lock: asyncio.Lock = asyncio.Lock()
self._time_func = _time_func or time.monotonic
self._last_refill: float = self._time_func()
self._last_refill: float = time.monotonic()
def _refill(self) -> None:
"""Пополнить токены за прошедшее время."""
now: float = self._time_func()
now: float = time.monotonic()
elapsed: float = now - self._last_refill
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
self._last_refill = now