Compare commits

...

21 Commits

Author SHA1 Message Date
deadzilla
90547df975 docs: обновлён ISSUES.md — все задачи (средний + низкий) перенесены в исправленные 2026-07-12 00:18:43 +05:00
deadzilla
1ab3205333 fix: format_articles(None) возвращает graceful fallback вместо TypeError 2026-07-12 00:11:54 +05:00
deadzilla
f825be866b chore: удалена закомментированная устаревшая строка теста pressure_to_mmhg(0) 2026-07-12 00:09:21 +05:00
deadzilla
fb42a90bec fix: requirements.txt использует ~= (compatible release) вместо >= для предсказуемости версий 2026-07-12 00:08:49 +05:00
deadzilla
3982d26c95 fix: Dockerfile копирует conftest.py для запуска тестов в контейнере 2026-07-12 00:08:29 +05:00
deadzilla
5eb4184d63 refactor: RateLimiter принимает _time_func для детерминированных тестов без зависимости от реального времени 2026-07-12 00:08:13 +05:00
deadzilla
3a2c388adf refactor: вынесен monkey-patch asyncio.iscoroutinefunction в utils/compat.py 2026-07-12 00:05:46 +05:00
deadzilla
88ed1a6aa1 docs: обновлён ISSUES.md — 5 задач среднего приоритета перенесены в исправленные 2026-07-12 00:04:10 +05:00
deadzilla
6b0a6cd8a6 refactor: pressure_to_mmhg заменяет Any на float | int | str | None 2026-07-12 00:03:39 +05:00
deadzilla
d94b9c16ab fix: translate_weather обрезает пробелы перед проверкой на пустоту 2026-07-12 00:02:13 +05:00
deadzilla
efee713301 fix: TextHelpCommand показывает standalone-команды (без cog) если они не hidden 2026-07-11 23:59:41 +05:00
deadzilla
49cb1a430b perf: wmo_to_russian использует модульную константу _WMO_MAPPING вместо создания dict при каждом вызове 2026-07-11 23:56:54 +05:00
deadzilla
b22b02d65e fix: _parse_date валидирует формат даты через regex вместо возврата мусора 2026-07-11 23:55:18 +05:00
deadzilla
e957446063 docs: обновлён ISSUES.md — 3 задачи высокого приоритета перенесены в исправленные 2026-07-11 23:50:40 +05:00
deadzilla
048b9a3a91 fix: format_weather_data_for_console заменяет None на эм-дэш вместо вывода 'None' в текст 2026-07-11 23:50:05 +05:00
deadzilla
bc37362959 fix: !nw использует asyncio.gather() для параллельной загрузки RSS вместо последовательной 2026-07-11 23:47:00 +05:00
deadzilla
27eaa677fd fix: fallback morning-дайджеста перебирает каналы целевого сервера вместо всех серверов 2026-07-11 23:45:25 +05:00
deadzilla
e2289e5092 docs: обновлён ISSUES.md — 3 критические задачи перенесены в исправленные 2026-07-11 23:31:27 +05:00
deadzilla
c5c6a51cd2 fix: добавлено закрытие requests.Session при graceful shutdown бота 2026-07-11 23:30:55 +05:00
deadzilla
1fa6deaf67 fix: отсортирован _WEATHER_MAPPING по убыванию длины ключей для корректного substring-matching 2026-07-11 23:27:17 +05:00
deadzilla
b5cbeecb61 docs: заменён format_weather_for_embed на format_weather_for_message в README 2026-07-11 23:23:19 +05:00
18 changed files with 309 additions and 150 deletions

View File

@ -16,6 +16,7 @@ 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,5 +1,7 @@
# ISSUES — Задачи и баг-трекер
---
## Исправлено
- [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py`
@ -10,19 +12,30 @@
- [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_embed()
pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_message()
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_embed()` | Форматирование погоды для Discord embed (с заголовком) |
| `format_weather_for_message()` | Форматирование погоды для plain text сообщения (с заголовком) |
### utils/news.py

15
bot.py
View File

@ -1,5 +1,4 @@
import asyncio
import inspect
import logging
import os
import sys
@ -9,7 +8,7 @@ from typing import TYPE_CHECKING
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
from utils import compat # noqa: F401
import discord # noqa: E402
from discord.ext import commands # noqa: E402
@ -38,7 +37,11 @@ class TextHelpCommand(commands.HelpCommand):
for cog_or_none, cog_commands in mapping.items():
if cog_or_none is None:
# Команды без cog (встроенная help) — пропускаем
# Standalone-команды (без cog) — показываем если не hidden
for command in cog_commands:
if not command.hidden:
desc = command.short_doc or ""
lines.append(f" !{command.name} - {desc}")
continue
for command in cog_commands:
if not command.hidden:
@ -175,10 +178,14 @@ 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,3 +1,4 @@
import asyncio
import logging
from discord.ext import commands
from utils.news import (
@ -17,29 +18,31 @@ class News(commands.Cog):
@commands.command(name="nw")
async def nw(self, ctx: commands.Context) -> None:
"""Топ-5 свежих статей и новостей по AI с Habr"""
articles = await fetch_rss(RSS_URL_ARTICLES)
articles, posts = await asyncio.gather(
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
)
parts: list[str] = []
# --- Статьи ---
if articles is None:
logger.warning(
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
)
await ctx.send("Не удалось получить новости. Попробуйте позже.")
return
if not articles:
logger.info("%s: !nw — статей нет в RSS", ctx.author)
await ctx.send("Новостей пока нет.")
return
parts.append("Не удалось получить статьи.")
elif articles:
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
@ -61,6 +64,6 @@ class News(commands.Cog):
logger.info(
"%s: !nw выполнена (статей: %d, постов: %d)",
ctx.author,
len(articles),
len(articles) if articles else 0,
len(posts) if posts else 0,
)

View File

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

View File

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

View File

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

View File

@ -48,9 +48,10 @@ 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", "2026.05.28"),
("2026-12-31T23:59:59Z", "2026.12.31"),
("2026-01-01T00:00:00Z", "2026.01.01"),
("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"),
],
)
def test_parse_date_known(self, pub_date, expected) -> None:
@ -69,11 +70,9 @@ class TestParseDate:
assert _parse_date(pub_date) == expected
def test_parse_date_invalid(self) -> None:
"""Невалидная дата должна вернуть первые 10 символов."""
"""Невалидная дата должна вернуть пустую строку."""
result = _parse_date("invalid-date-string")
assert (
result == "invalid.da"
) # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
assert result == ""
class TestFormatArticles:
@ -126,9 +125,10 @@ class TestFormatArticles:
assert len(result) == 1
def test_format_articles_none(self) -> None:
"""None должен вызвать TypeError (articles[:5] на None)."""
with pytest.raises(TypeError):
format_articles(None, "Заголовок", "https://habr.com/feed")
"""None должен вернуть graceful fallback вместо TypeError."""
result = format_articles(None, "Заголовок", "https://habr.com/feed")
assert len(result) == 2
assert "Не удалось загрузить статьи." in result[1]
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] == "Статья\n2026.05.28 <https://habr.com/1>"
assert result[1] == "Статья\n28.05.2026 <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_skips_none_cog(self, help_command) -> None:
"""Команды без cog (None) должны пропускаться."""
async def test_send_bot_help_shows_none_cog(self, help_command) -> None:
"""Команды без cog (None) должны показываться, если не hidden."""
cmd = MagicMock()
cmd.name = "built_in"
cmd.name = "standalone"
cmd.hidden = False
cmd.short_doc = "Встроенная"
cmd.short_doc = "Самостоятельная"
destination = MagicMock()
destination.send = AsyncMock(return_value=None)
@ -110,7 +110,27 @@ class TestSendBotHelp:
destination.send.assert_awaited_once()
message = destination.send.call_args[0][0]
assert "built_in" not in message
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
@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,7 +208,6 @@ 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,45 +1,80 @@
"""Тесты для utils/rate_limiter.py — проверка логики токен-бакета."""
import asyncio
import time
from unittest.mock import MagicMock
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 = RateLimiter(rate=2.0, burst=5)
limiter, _ = _make_time()
assert limiter.tokens == 5.0
async def test_acquire_consumes_token() -> None:
"""acquire() уменьшает количество токенов."""
limiter = RateLimiter(rate=1.0, burst=3)
limiter, _ = _make_time()
await limiter.acquire()
assert limiter.tokens == 2.0
assert limiter.tokens == 4.0
async def test_acquire_waits_when_empty() -> None:
"""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 # допускаем погрешность
"""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
async def test_burst_cap() -> None:
"""Токены не превышают burst после долгого простоя."""
limiter = RateLimiter(rate=100.0, burst=3)
await asyncio.sleep(0.1) # теоретически +10 токенов, но cap = 3
limiter, times = _make_time()
times[0] = 10.0 # теоретически +100 токенов, но cap = 5
async with limiter.lock:
limiter._refill()
assert limiter.tokens == 3.0
assert limiter.tokens == 5.0
async def test_multiple_acquire() -> None:
"""Можно забрать несколько токенов за раз."""
limiter = RateLimiter(rate=1.0, burst=10)
await limiter.acquire(token=5)
assert limiter.tokens == 5.0
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

View File

@ -1,5 +1,6 @@
from .pogoda import (
API_URL_WEATHER,
_session as _weather_session,
fetch_weather,
fetch_open_meteo,
format_weather_data_for_console,
@ -8,14 +9,18 @@ from .pogoda import (
translate_weather,
wmo_to_russian,
)
from .news import (
from .news import ( # noqa: E402
_session as _news_session,
RSS_URL_ARTICLES,
RSS_URL_POSTS,
fetch_rss,
format_articles,
truncate_title,
)
from .cat import fetch_cat
from .cat import ( # noqa: E402
_session as _cat_session,
fetch_cat,
)
__all__ = [
# Погода
@ -35,4 +40,20 @@ __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 — игнорируем ошибки

7
utils/compat.py Normal file
View File

@ -0,0 +1,7 @@
"""Совместимость с 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,6 +223,15 @@ 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-запрос, не зависит от кэша
@ -242,17 +251,27 @@ class Scheduler:
"Канал с ID %s не текстовый — fallback", self._target_channel_id
)
# Fallback: первый канал с правами send_messages
# 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)
sent = False
for channel in self.bot.get_all_channels():
if isinstance(channel, discord.TextChannel):
if channel.permissions_for(channel.guild.me).send_messages:
for guild in guilds_to_check:
for channel in guild.text_channels:
if channel.permissions_for(guild.me).send_messages:
try:
await run_morning(self.bot, channel)
sent = True
return
except Exception as e:
logger.error("Ошибка отправки в #%s: %s", channel.name, e)
logger.error(
"Ошибка отправки в #%s (%s): %s",
channel.name,
guild.name,
e,
)
continue
if not sent:
logger.error("Не удалось найти канал для отправки morning-дайджеста")

View File

@ -1,5 +1,6 @@
import asyncio
import logging
import re
from datetime import datetime
from typing import Optional
@ -82,7 +83,13 @@ async def fetch_rss(url: str) -> Optional[list[dict]]:
def _parse_date(pub_date: Optional[str]) -> str:
"""Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты."""
"""Парсить дату из RSS в строку 'дд.мм.гггг'.
Поддерживает:
- RFC 822: "Mon, 01 Jan 2024 12:00:00 GMT"
- ISO 8601: "2024-01-01T12:00:00+00:00" или "2024-01-01"
Возвращает пустую строку, если формат не распознан.
"""
if not pub_date:
return ""
try:
@ -90,7 +97,12 @@ 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:
return pub_date[:10].replace("-", ".")
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 ""
def truncate_title(title: str, max_len: int = 60) -> str:
@ -123,6 +135,8 @@ 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 Any, Optional
from typing import Optional
import requests
from requests.exceptions import ConnectionError, Timeout, SSLError
@ -90,9 +90,7 @@ async def fetch_open_meteo(
return None
def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский."""
mapping = {
_WMO_MAPPING: dict[int, str] = {
0: "Ясно",
1: "Ясно",
2: "Переменная облачность",
@ -122,54 +120,61 @@ def wmo_to_russian(code: Optional[int]) -> str:
96: "Гроза с градом",
99: "Сильная гроза с градом",
}
return mapping.get(code, "Неизвестно")
def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский."""
return _WMO_MAPPING.get(code, "Неизвестно")
_WEATHER_MAPPING = [
# Отсортировано по убыванию длины ключа для корректного substring-matching:
# более длинные фразы проверяются первыми, чтобы "Light rain" не совпал
# раньше "Light rain shower".
("Moderate or heavy freezing rain at a distance", "Ледяной дождь"),
("Moderate or heavy sleet at a distance", "Слякоть"),
("Moderate or heavy freezing rain in area", "Ледяной дождь"),
("Moderate or heavy sleet at a distance", "Слякоть"),
("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 heavy rain at times", "Дождь"),
("Moderate or heavy snow at times", "Снег"),
("Moderate or light sleet", "Слякоть"),
("Moderate rain at times", "Умеренный дождь"),
("Patchy light drizzle", "Местами лёгкая морось"),
("Patchy light snow", "Местами лёгкий снег"),
("Heavy freezing rain", "Сильный ледяной дождь"),
("Light freezing rain", "Лёгкий ледяной дождь"),
("Light rain shower", "Небольшой дождь"),
("Heavy rain shower", "Сильный дождь"),
("Moderate or light sleet", "Слякоть"),
("Patchy sleet nearby", "Местами слякоть"),
("Heavy rain at times", "Сильный дождь"),
("Patchy rain nearby", "Местами дождь"),
("Patchy snow nearby", "Местами снег"),
("Patchy sleet nearby", "Местами слякоть"),
("Moderate rain at times", "Умеренный дождь"),
("Heavy rain at times", "Сильный дождь"),
("Patchy light snow", "Местами лёгкий снег"),
("Light rain shower", "Небольшой дождь"),
("Heavy rain shower", "Сильный дождь"),
("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", "Туманно"),
("Fog", "Туман"),
("Mist", "Туман"),
("Haze", "Дымка"),
("Clear", "Ясно"),
("Sunny", "Ясно"),
("Mist", "Туман"),
("Haze", "Дымка"),
("Fog", "Туман"),
]
def translate_weather(en: Optional[str]) -> str:
if not en:
if not en or not en.strip():
return ""
en_lower = en.lower()
en_lower = en.strip().lower()
for key, value in _WEATHER_MAPPING:
if key.lower() in en_lower:
return value
@ -192,13 +197,24 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
if not current:
return None
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", "")
# 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 = ""
try:
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "" else ""
except (ValueError, TypeError):
@ -225,7 +241,7 @@ def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
return "Погода в Магнитогорске:\n" + "\n".join(lines)
def pressure_to_mmhg(mb: Any) -> float | str:
def pressure_to_mmhg(mb: float | int | str | None) -> 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 Final
from typing import Callable, Final
logger = logging.getLogger(__name__)
@ -18,21 +18,28 @@ logger = logging.getLogger(__name__)
class RateLimiter:
"""Токен-бакет: заполняется со скоростью rate токенов/сек, максимум burst."""
def __init__(self, rate: float, burst: int) -> None:
def __init__(
self,
rate: float,
burst: int,
_time_func: Callable[[], float] | None = None,
) -> 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._last_refill: float = time.monotonic()
self._time_func = _time_func or time.monotonic
self._last_refill: float = self._time_func()
def _refill(self) -> None:
"""Пополнить токены за прошедшее время."""
now: float = time.monotonic()
now: float = self._time_func()
elapsed: float = now - self._last_refill
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
self._last_refill = now