Compare commits
21 Commits
cef53197b1
...
90547df975
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90547df975 | ||
|
|
1ab3205333 | ||
|
|
f825be866b | ||
|
|
fb42a90bec | ||
|
|
3982d26c95 | ||
|
|
5eb4184d63 | ||
|
|
3a2c388adf | ||
|
|
88ed1a6aa1 | ||
|
|
6b0a6cd8a6 | ||
|
|
d94b9c16ab | ||
|
|
efee713301 | ||
|
|
49cb1a430b | ||
|
|
b22b02d65e | ||
|
|
e957446063 | ||
|
|
048b9a3a91 | ||
|
|
bc37362959 | ||
|
|
27eaa677fd | ||
|
|
e2289e5092 | ||
|
|
c5c6a51cd2 | ||
|
|
1fa6deaf67 | ||
|
|
b5cbeecb61 |
@ -16,6 +16,7 @@ RUN pip install --no-cache-dir -r requirements.txt && \
|
|||||||
|
|
||||||
# Копируем только нужные файлы (оптимизация размера образа)
|
# Копируем только нужные файлы (оптимизация размера образа)
|
||||||
COPY bot.py .
|
COPY bot.py .
|
||||||
|
COPY conftest.py .
|
||||||
COPY commands/ commands/
|
COPY commands/ commands/
|
||||||
COPY utils/ utils/
|
COPY utils/ utils/
|
||||||
# .env передаётся через docker-compose environment:
|
# .env передаётся через docker-compose environment:
|
||||||
|
|||||||
27
ISSUES.md
27
ISSUES.md
@ -1,5 +1,7 @@
|
|||||||
# ISSUES — Задачи и баг-трекер
|
# ISSUES — Задачи и баг-трекер
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Исправлено
|
## Исправлено
|
||||||
|
|
||||||
- [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py`
|
- [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py`
|
||||||
@ -10,19 +12,30 @@
|
|||||||
- [x] ~~Отсутствует `.gitignore`~~ — файл существует
|
- [x] ~~Отсутствует `.gitignore`~~ — файл существует
|
||||||
- [x] ~~Отсутствует Dockerfile~~ — файл существует
|
- [x] ~~Отсутствует Dockerfile~~ — файл существует
|
||||||
- [x] ~~Отсутствует `.env.example`~~ — файл существует
|
- [x] ~~Отсутствует `.env.example`~~ — файл существует
|
||||||
|
|
||||||
## Средний приоритет
|
|
||||||
|
|
||||||
- [x] **Type hints в production-коде** — добавлены аннотации ко всем 20 функциям
|
- [x] **Type hints в production-коде** — добавлены аннотации ко всем 20 функциям
|
||||||
- [x] **`asyncio.iscoroutinefunction` deprecated** — monkey-patch в `bot.py` + `conftest.py`
|
- [x] **`asyncio.iscoroutinefunction` deprecated** — monkey-patch в `bot.py` + `conftest.py`
|
||||||
- [x] **Утечка корутины Scheduler в тестах** — mock `_start_scheduler` вместо `asyncio.create_task`
|
- [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] **Пустой `__init__` в Morning** — удалён
|
||||||
- [x] **Global RateLimiter на модульном уровне** — добавлены factory-функции `make_*_limiter()`
|
- [x] **Global RateLimiter на модульном уровне** — добавлены factory-функции `make_*_limiter()`
|
||||||
|
|
||||||
## Низкий приоритет
|
|
||||||
|
|
||||||
- [x] **Type hints в тестах** — добавлены `-> None` ко всем 140 test-функциям
|
- [x] **Type hints в тестах** — добавлены `-> None` ко всем 140 test-функциям
|
||||||
- [x] **`import` внутри функций в тестах** — вынесены наверх модулей (35 вхождений)
|
- [x] **`import` внутри функций в тестах** — вынесены наверх модулей (35 вхождений)
|
||||||
- [x] **Добавить pre-commit хуки** — `.pre-commit-config.yaml` (ruff + ruff-format), `requirements-dev.txt`
|
- [x] **Добавить pre-commit хуки** — `.pre-commit-config.yaml` (ruff + ruff-format), `requirements-dev.txt`
|
||||||
- [x] **Интеграционные тесты** — 9 тестов загрузок когов, команд и утилит
|
- [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 с сообщением
|
||||||
|
|||||||
@ -55,7 +55,7 @@ commands/ # Discord команды (cogs)
|
|||||||
stats.py # !stats — серверы, каналы, пользователи
|
stats.py # !stats — серверы, каналы, пользователи
|
||||||
utils/ # Утилиты (API-клиенты, конвертации)
|
utils/ # Утилиты (API-клиенты, конвертации)
|
||||||
__init__.py # __all__ — публичный 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()
|
news.py # fetch_rss(), format_articles(), truncate_title()
|
||||||
cat.py # fetch_cat()
|
cat.py # fetch_cat()
|
||||||
rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры
|
rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры
|
||||||
@ -267,7 +267,7 @@ ruff>=0.8.0
|
|||||||
| `translate_weather()` | Перевод погодных описаний на русский язык |
|
| `translate_weather()` | Перевод погодных описаний на русский язык |
|
||||||
| `pressure_to_mmhg()` | Конвертация давления из hPa в мм рт. ст. |
|
| `pressure_to_mmhg()` | Конвертация давления из hPa в мм рт. ст. |
|
||||||
| `format_weather_data_for_console()` | Форматирование данных погоды для вывода в консоль |
|
| `format_weather_data_for_console()` | Форматирование данных погоды для вывода в консоль |
|
||||||
| `format_weather_for_embed()` | Форматирование погоды для Discord embed (с заголовком) |
|
| `format_weather_for_message()` | Форматирование погоды для plain text сообщения (с заголовком) |
|
||||||
|
|
||||||
### utils/news.py
|
### utils/news.py
|
||||||
|
|
||||||
|
|||||||
15
bot.py
15
bot.py
@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@ -9,7 +8,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||||
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||||
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
from utils import compat # noqa: F401
|
||||||
|
|
||||||
import discord # noqa: E402
|
import discord # noqa: E402
|
||||||
from discord.ext import commands # 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():
|
for cog_or_none, cog_commands in mapping.items():
|
||||||
if cog_or_none is None:
|
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
|
continue
|
||||||
for command in cog_commands:
|
for command in cog_commands:
|
||||||
if not command.hidden:
|
if not command.hidden:
|
||||||
@ -175,10 +178,14 @@ class BotRunner:
|
|||||||
pass # Бот не может писать в канал — игнорируем
|
pass # Бот не может писать в канал — игнорируем
|
||||||
|
|
||||||
def _on_shutdown(self) -> None:
|
def _on_shutdown(self) -> None:
|
||||||
"""Остановить планировщик при завершении работы бота."""
|
"""Остановить планировщик и закрыть сетевые сессии."""
|
||||||
if self.scheduler:
|
if self.scheduler:
|
||||||
self.scheduler.stop()
|
self.scheduler.stop()
|
||||||
logger.info("Планировщик остановлен")
|
logger.info("Планировщик остановлен")
|
||||||
|
from utils import close_all_sessions
|
||||||
|
|
||||||
|
close_all_sessions()
|
||||||
|
logger.info("Сетевые сессии закрыты")
|
||||||
|
|
||||||
async def _on_shutdown_async(self) -> None:
|
async def _on_shutdown_async(self) -> None:
|
||||||
"""Асинхронный хук завершения (discord.py on_shutdown)."""
|
"""Асинхронный хук завершения (discord.py on_shutdown)."""
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from utils.news import (
|
from utils.news import (
|
||||||
@ -17,29 +18,31 @@ class News(commands.Cog):
|
|||||||
@commands.command(name="nw")
|
@commands.command(name="nw")
|
||||||
async def nw(self, ctx: commands.Context) -> None:
|
async def nw(self, ctx: commands.Context) -> None:
|
||||||
"""Топ-5 свежих статей и новостей по AI с Habr"""
|
"""Топ-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:
|
if articles is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
|
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
|
||||||
)
|
)
|
||||||
await ctx.send("Не удалось получить новости. Попробуйте позже.")
|
parts.append("Не удалось получить статьи.")
|
||||||
return
|
elif articles:
|
||||||
|
|
||||||
if not articles:
|
|
||||||
logger.info("%s: !nw — статей нет в RSS", ctx.author)
|
|
||||||
await ctx.send("Новостей пока нет.")
|
|
||||||
return
|
|
||||||
|
|
||||||
articles_text = format_articles(
|
articles_text = format_articles(
|
||||||
articles,
|
articles,
|
||||||
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
|
"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:
|
if posts is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
|
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
|
||||||
@ -61,6 +64,6 @@ class News(commands.Cog):
|
|||||||
logger.info(
|
logger.info(
|
||||||
"%s: !nw выполнена (статей: %d, постов: %d)",
|
"%s: !nw выполнена (статей: %d, постов: %d)",
|
||||||
ctx.author,
|
ctx.author,
|
||||||
len(articles),
|
len(articles) if articles else 0,
|
||||||
len(posts) if posts else 0,
|
len(posts) if posts else 0,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
"""Pytest configuration — применяется до импорта тестов."""
|
"""Pytest configuration — применяется до импорта тестов."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||||
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||||
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
from utils import compat # noqa: F401
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
discord.py>=2.3.2
|
discord.py~=2.7.1
|
||||||
python-dotenv>=1.0.0
|
python-dotenv~=1.2.2
|
||||||
requests>=2.31.0
|
requests~=2.34.2
|
||||||
defusedxml>=0.7.0
|
defusedxml~=0.7.1
|
||||||
|
|||||||
@ -151,11 +151,11 @@ class TestPgCommand:
|
|||||||
await cog.pg.callback(cog, ctx)
|
await cog.pg.callback(cog, ctx)
|
||||||
|
|
||||||
args = ctx.send.call_args[0][0]
|
args = ctx.send.call_args[0][0]
|
||||||
# dict.get(key, default) возвращает None, если ключ есть, но значение None
|
# None значения корректно заменяются на "—"
|
||||||
assert "Температура: None°C" in args
|
assert "Температура: —°C" in args
|
||||||
assert "ощущается как None°C" in args
|
assert "ощущается как —°C" in args
|
||||||
assert "Описание: —" in args
|
assert "Описание: —" in args
|
||||||
assert "Влажность: None%" in args
|
assert "Влажность: —%" in args
|
||||||
assert "Давление: — мм рт. ст." in args
|
assert "Давление: — мм рт. ст." in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -48,9 +48,10 @@ class TestParseDate:
|
|||||||
[
|
[
|
||||||
("Mon, 28 May 2026 10:00:00 +0000", "28.05.2026"),
|
("Mon, 28 May 2026 10:00:00 +0000", "28.05.2026"),
|
||||||
("Mon, 28 May 2026 10:00:00 GMT", "28.05.2026"),
|
("Mon, 28 May 2026 10:00:00 GMT", "28.05.2026"),
|
||||||
("2026-05-28T10:00:00Z", "2026.05.28"),
|
("2026-05-28T10:00:00Z", "28.05.2026"),
|
||||||
("2026-12-31T23:59:59Z", "2026.12.31"),
|
("2026-12-31T23:59:59Z", "31.12.2026"),
|
||||||
("2026-01-01T00:00:00Z", "2026.01.01"),
|
("2026-01-01T00:00:00Z", "01.01.2026"),
|
||||||
|
("2026-05-28", "28.05.2026"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_parse_date_known(self, pub_date, expected) -> None:
|
def test_parse_date_known(self, pub_date, expected) -> None:
|
||||||
@ -69,11 +70,9 @@ class TestParseDate:
|
|||||||
assert _parse_date(pub_date) == expected
|
assert _parse_date(pub_date) == expected
|
||||||
|
|
||||||
def test_parse_date_invalid(self) -> None:
|
def test_parse_date_invalid(self) -> None:
|
||||||
"""Невалидная дата должна вернуть первые 10 символов."""
|
"""Невалидная дата должна вернуть пустую строку."""
|
||||||
result = _parse_date("invalid-date-string")
|
result = _parse_date("invalid-date-string")
|
||||||
assert (
|
assert result == ""
|
||||||
result == "invalid.da"
|
|
||||||
) # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
|
||||||
|
|
||||||
|
|
||||||
class TestFormatArticles:
|
class TestFormatArticles:
|
||||||
@ -126,9 +125,10 @@ class TestFormatArticles:
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
def test_format_articles_none(self) -> None:
|
def test_format_articles_none(self) -> None:
|
||||||
"""None должен вызвать TypeError (articles[:5] на None)."""
|
"""None должен вернуть graceful fallback вместо TypeError."""
|
||||||
with pytest.raises(TypeError):
|
result = format_articles(None, "Заголовок", "https://habr.com/feed")
|
||||||
format_articles(None, "Заголовок", "https://habr.com/feed")
|
assert len(result) == 2
|
||||||
|
assert "Не удалось загрузить статьи." in result[1]
|
||||||
|
|
||||||
def test_format_articles_single_article(self) -> None:
|
def test_format_articles_single_article(self) -> None:
|
||||||
"""Одна статья должна быть корректно отформатирована."""
|
"""Одна статья должна быть корректно отформатирована."""
|
||||||
@ -205,7 +205,7 @@ class TestFormatArticles:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
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:
|
def test_format_articles_empty_date(self) -> None:
|
||||||
"""Пустая дата должна быть пустой строкой."""
|
"""Пустая дата должна быть пустой строкой."""
|
||||||
|
|||||||
@ -94,12 +94,12 @@ class TestSendBotHelp:
|
|||||||
assert "hidden_cmd" not in message
|
assert "hidden_cmd" not in message
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_bot_help_skips_none_cog(self, help_command) -> None:
|
async def test_send_bot_help_shows_none_cog(self, help_command) -> None:
|
||||||
"""Команды без cog (None) должны пропускаться."""
|
"""Команды без cog (None) должны показываться, если не hidden."""
|
||||||
cmd = MagicMock()
|
cmd = MagicMock()
|
||||||
cmd.name = "built_in"
|
cmd.name = "standalone"
|
||||||
cmd.hidden = False
|
cmd.hidden = False
|
||||||
cmd.short_doc = "Встроенная"
|
cmd.short_doc = "Самостоятельная"
|
||||||
|
|
||||||
destination = MagicMock()
|
destination = MagicMock()
|
||||||
destination.send = AsyncMock(return_value=None)
|
destination.send = AsyncMock(return_value=None)
|
||||||
@ -110,7 +110,27 @@ class TestSendBotHelp:
|
|||||||
|
|
||||||
destination.send.assert_awaited_once()
|
destination.send.assert_awaited_once()
|
||||||
message = destination.send.call_args[0][0]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_bot_help_empty_doc(self, help_command) -> None:
|
async def test_send_bot_help_empty_doc(self, help_command) -> None:
|
||||||
|
|||||||
@ -163,11 +163,11 @@ class TestTranslateWeather:
|
|||||||
[
|
[
|
||||||
("", "—"),
|
("", "—"),
|
||||||
(None, "—"),
|
(None, "—"),
|
||||||
(" ", " "), # пробелы не считаются пустыми
|
(" ", "—"), # строка из пробелов тоже считается пустой
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_translate_empty(self, input_value, expected) -> None:
|
def test_translate_empty(self, input_value, expected) -> None:
|
||||||
"""Пустой или None ввод должен возвращать '—'."""
|
"""Пустой, None или только пробелы должен возвращать '—'."""
|
||||||
assert translate_weather(input_value) == expected
|
assert translate_weather(input_value) == expected
|
||||||
|
|
||||||
def test_translate_unknown_returns_original(self) -> None:
|
def test_translate_unknown_returns_original(self) -> None:
|
||||||
@ -208,7 +208,6 @@ class TestPressureToMMHG:
|
|||||||
(1000, 750.1),
|
(1000, 750.1),
|
||||||
(980, 735.1),
|
(980, 735.1),
|
||||||
(1030, 772.6),
|
(1030, 772.6),
|
||||||
# (0, "—"), # 0 — falsy, возвращается '—' (баг)
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_pressure_valid(self, mb, expected) -> None:
|
def test_pressure_valid(self, mb, expected) -> None:
|
||||||
|
|||||||
@ -1,45 +1,80 @@
|
|||||||
"""Тесты для utils/rate_limiter.py — проверка логики токен-бакета."""
|
"""Тесты для utils/rate_limiter.py — проверка логики токен-бакета."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from utils.rate_limiter import RateLimiter
|
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:
|
async def test_initial_tokens_full() -> None:
|
||||||
"""Бакет заполнен до burst при создании."""
|
"""Бакет заполнен до burst при создании."""
|
||||||
limiter = RateLimiter(rate=2.0, burst=5)
|
limiter, _ = _make_time()
|
||||||
assert limiter.tokens == 5.0
|
assert limiter.tokens == 5.0
|
||||||
|
|
||||||
|
|
||||||
async def test_acquire_consumes_token() -> None:
|
async def test_acquire_consumes_token() -> None:
|
||||||
"""acquire() уменьшает количество токенов."""
|
"""acquire() уменьшает количество токенов."""
|
||||||
limiter = RateLimiter(rate=1.0, burst=3)
|
limiter, _ = _make_time()
|
||||||
await limiter.acquire()
|
await limiter.acquire()
|
||||||
assert limiter.tokens == 2.0
|
assert limiter.tokens == 4.0
|
||||||
|
|
||||||
|
|
||||||
async def test_acquire_waits_when_empty() -> None:
|
async def test_acquire_waits_when_empty() -> None:
|
||||||
"""acquire() ждёт, когда токены закончились."""
|
"""acquire() ждёт, когда токены закончились (контролируемое время)."""
|
||||||
limiter = RateLimiter(rate=10.0, burst=1) # 10 токенов/сек
|
limiter, times = _make_time()
|
||||||
await limiter.acquire() # бакет пуст
|
# Потратить все 5 токенов
|
||||||
start = time.monotonic()
|
for _ in range(5):
|
||||||
await limiter.acquire() # должен ждать ~0.1 сек
|
await limiter.acquire()
|
||||||
elapsed = time.monotonic() - start
|
assert limiter.tokens < 1.0
|
||||||
assert elapsed >= 0.05 # допускаем погрешность
|
|
||||||
|
# Пропустить 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:
|
async def test_burst_cap() -> None:
|
||||||
"""Токены не превышают burst после долгого простоя."""
|
"""Токены не превышают burst после долгого простоя."""
|
||||||
limiter = RateLimiter(rate=100.0, burst=3)
|
limiter, times = _make_time()
|
||||||
await asyncio.sleep(0.1) # теоретически +10 токенов, но cap = 3
|
times[0] = 10.0 # теоретически +100 токенов, но cap = 5
|
||||||
async with limiter.lock:
|
async with limiter.lock:
|
||||||
limiter._refill()
|
limiter._refill()
|
||||||
assert limiter.tokens == 3.0
|
assert limiter.tokens == 5.0
|
||||||
|
|
||||||
|
|
||||||
async def test_multiple_acquire() -> None:
|
async def test_multiple_acquire() -> None:
|
||||||
"""Можно забрать несколько токенов за раз."""
|
"""Можно забрать несколько токенов за раз."""
|
||||||
limiter = RateLimiter(rate=1.0, burst=10)
|
limiter, _ = _make_time()
|
||||||
await limiter.acquire(token=5)
|
await limiter.acquire(token=3)
|
||||||
assert limiter.tokens == 5.0
|
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
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from .pogoda import (
|
from .pogoda import (
|
||||||
API_URL_WEATHER,
|
API_URL_WEATHER,
|
||||||
|
_session as _weather_session,
|
||||||
fetch_weather,
|
fetch_weather,
|
||||||
fetch_open_meteo,
|
fetch_open_meteo,
|
||||||
format_weather_data_for_console,
|
format_weather_data_for_console,
|
||||||
@ -8,14 +9,18 @@ from .pogoda import (
|
|||||||
translate_weather,
|
translate_weather,
|
||||||
wmo_to_russian,
|
wmo_to_russian,
|
||||||
)
|
)
|
||||||
from .news import (
|
from .news import ( # noqa: E402
|
||||||
|
_session as _news_session,
|
||||||
RSS_URL_ARTICLES,
|
RSS_URL_ARTICLES,
|
||||||
RSS_URL_POSTS,
|
RSS_URL_POSTS,
|
||||||
fetch_rss,
|
fetch_rss,
|
||||||
format_articles,
|
format_articles,
|
||||||
truncate_title,
|
truncate_title,
|
||||||
)
|
)
|
||||||
from .cat import fetch_cat
|
from .cat import ( # noqa: E402
|
||||||
|
_session as _cat_session,
|
||||||
|
fetch_cat,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Погода
|
# Погода
|
||||||
@ -35,4 +40,20 @@ __all__ = [
|
|||||||
"truncate_title",
|
"truncate_title",
|
||||||
# Котики
|
# Котики
|
||||||
"fetch_cat",
|
"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
7
utils/compat.py
Normal 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]
|
||||||
@ -223,6 +223,15 @@ class Scheduler:
|
|||||||
async def _run_morning(self):
|
async def _run_morning(self):
|
||||||
logger.info("Выполняю morning в %s", self.morning_time)
|
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:
|
if self._target_channel_id:
|
||||||
# fetch_channel — API-запрос, не зависит от кэша
|
# fetch_channel — API-запрос, не зависит от кэша
|
||||||
@ -242,17 +251,27 @@ class Scheduler:
|
|||||||
"Канал с ID %s не текстовый — fallback", self._target_channel_id
|
"Канал с 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
|
sent = False
|
||||||
for channel in self.bot.get_all_channels():
|
for guild in guilds_to_check:
|
||||||
if isinstance(channel, discord.TextChannel):
|
for channel in guild.text_channels:
|
||||||
if channel.permissions_for(channel.guild.me).send_messages:
|
if channel.permissions_for(guild.me).send_messages:
|
||||||
try:
|
try:
|
||||||
await run_morning(self.bot, channel)
|
await run_morning(self.bot, channel)
|
||||||
sent = True
|
sent = True
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка отправки в #%s: %s", channel.name, e)
|
logger.error(
|
||||||
|
"Ошибка отправки в #%s (%s): %s",
|
||||||
|
channel.name,
|
||||||
|
guild.name,
|
||||||
|
e,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
if not sent:
|
if not sent:
|
||||||
logger.error("Не удалось найти канал для отправки morning-дайджеста")
|
logger.error("Не удалось найти канал для отправки morning-дайджеста")
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
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:
|
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:
|
if not pub_date:
|
||||||
return ""
|
return ""
|
||||||
try:
|
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")
|
dt = datetime.strptime(d, "%a, %d %b %Y %H:%M:%S %z")
|
||||||
return dt.strftime("%d.%m.%Y")
|
return dt.strftime("%d.%m.%Y")
|
||||||
except ValueError:
|
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:
|
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]:
|
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}>"]
|
lines = [f"{title}\n<{link}>"]
|
||||||
for i, article in enumerate(articles[:5], 1):
|
for i, article in enumerate(articles[:5], 1):
|
||||||
date_str = _parse_date(article["pub_date"])
|
date_str = _parse_date(article["pub_date"])
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
@ -90,9 +90,7 @@ async def fetch_open_meteo(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def wmo_to_russian(code: Optional[int]) -> str:
|
_WMO_MAPPING: dict[int, str] = {
|
||||||
"""Перевод WMO weather code в русский."""
|
|
||||||
mapping = {
|
|
||||||
0: "Ясно",
|
0: "Ясно",
|
||||||
1: "Ясно",
|
1: "Ясно",
|
||||||
2: "Переменная облачность",
|
2: "Переменная облачность",
|
||||||
@ -121,55 +119,62 @@ def wmo_to_russian(code: Optional[int]) -> str:
|
|||||||
95: "Гроза",
|
95: "Гроза",
|
||||||
96: "Гроза с градом",
|
96: "Гроза с градом",
|
||||||
99: "Сильная гроза с градом",
|
99: "Сильная гроза с градом",
|
||||||
}
|
}
|
||||||
return mapping.get(code, "Неизвестно")
|
|
||||||
|
|
||||||
|
def wmo_to_russian(code: Optional[int]) -> str:
|
||||||
|
"""Перевод WMO weather code в русский."""
|
||||||
|
return _WMO_MAPPING.get(code, "Неизвестно")
|
||||||
|
|
||||||
|
|
||||||
_WEATHER_MAPPING = [
|
_WEATHER_MAPPING = [
|
||||||
|
# Отсортировано по убыванию длины ключа для корректного substring-matching:
|
||||||
|
# более длинные фразы проверяются первыми, чтобы "Light rain" не совпал
|
||||||
|
# раньше "Light rain shower".
|
||||||
("Moderate or heavy freezing rain at a distance", "Ледяной дождь"),
|
("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 freezing rain in area", "Ледяной дождь"),
|
||||||
|
("Moderate or heavy sleet at a distance", "Слякоть"),
|
||||||
("Moderate or heavy sleet 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 snow in area", "Снег"),
|
||||||
("Moderate or heavy rain in area", "Дождь"),
|
("Moderate or heavy rain in area", "Дождь"),
|
||||||
("Thundery outbreaks in nearby", "Гроза вблизи"),
|
("Thundery outbreaks in nearby", "Гроза вблизи"),
|
||||||
("Moderate or heavy rain at times", "Дождь"),
|
("Moderate or light sleet", "Слякоть"),
|
||||||
("Moderate or heavy snow at times", "Снег"),
|
("Moderate rain at times", "Умеренный дождь"),
|
||||||
("Patchy light drizzle", "Местами лёгкая морось"),
|
("Patchy light drizzle", "Местами лёгкая морось"),
|
||||||
("Patchy light snow", "Местами лёгкий снег"),
|
|
||||||
("Heavy freezing rain", "Сильный ледяной дождь"),
|
("Heavy freezing rain", "Сильный ледяной дождь"),
|
||||||
("Light freezing rain", "Лёгкий ледяной дождь"),
|
("Light freezing rain", "Лёгкий ледяной дождь"),
|
||||||
("Light rain shower", "Небольшой дождь"),
|
("Patchy sleet nearby", "Местами слякоть"),
|
||||||
("Heavy rain shower", "Сильный дождь"),
|
("Heavy rain at times", "Сильный дождь"),
|
||||||
("Moderate or light sleet", "Слякоть"),
|
|
||||||
("Patchy rain nearby", "Местами дождь"),
|
("Patchy rain nearby", "Местами дождь"),
|
||||||
("Patchy snow nearby", "Местами снег"),
|
("Patchy snow nearby", "Местами снег"),
|
||||||
("Patchy sleet nearby", "Местами слякоть"),
|
("Patchy light snow", "Местами лёгкий снег"),
|
||||||
("Moderate rain at times", "Умеренный дождь"),
|
("Light rain shower", "Небольшой дождь"),
|
||||||
("Heavy rain at times", "Сильный дождь"),
|
("Heavy rain shower", "Сильный дождь"),
|
||||||
("Moderate rain", "Умеренный дождь"),
|
("Moderate rain", "Умеренный дождь"),
|
||||||
|
("Moderate snow", "Умеренный снег"),
|
||||||
|
("Partly cloudy", "Переменная облачность"),
|
||||||
|
("Blowing snow", "Метель"),
|
||||||
("Light rain", "Небольшой дождь"),
|
("Light rain", "Небольшой дождь"),
|
||||||
("Heavy rain", "Сильный дождь"),
|
("Heavy rain", "Сильный дождь"),
|
||||||
("Light snow", "Небольшой снег"),
|
("Light snow", "Небольшой снег"),
|
||||||
("Moderate snow", "Умеренный снег"),
|
|
||||||
("Heavy snow", "Сильный снег"),
|
("Heavy snow", "Сильный снег"),
|
||||||
("Blowing snow", "Метель"),
|
|
||||||
("Partly cloudy", "Переменная облачность"),
|
|
||||||
("Overcast", "Пасмурно"),
|
("Overcast", "Пасмурно"),
|
||||||
("Cloudy", "Облачно"),
|
("Cloudy", "Облачно"),
|
||||||
("Foggy", "Туманно"),
|
("Foggy", "Туманно"),
|
||||||
("Fog", "Туман"),
|
|
||||||
("Mist", "Туман"),
|
|
||||||
("Haze", "Дымка"),
|
|
||||||
("Clear", "Ясно"),
|
("Clear", "Ясно"),
|
||||||
("Sunny", "Ясно"),
|
("Sunny", "Ясно"),
|
||||||
|
("Mist", "Туман"),
|
||||||
|
("Haze", "Дымка"),
|
||||||
|
("Fog", "Туман"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def translate_weather(en: Optional[str]) -> str:
|
def translate_weather(en: Optional[str]) -> str:
|
||||||
if not en:
|
if not en or not en.strip():
|
||||||
return "—"
|
return "—"
|
||||||
en_lower = en.lower()
|
en_lower = en.strip().lower()
|
||||||
for key, value in _WEATHER_MAPPING:
|
for key, value in _WEATHER_MAPPING:
|
||||||
if key.lower() in en_lower:
|
if key.lower() in en_lower:
|
||||||
return value
|
return value
|
||||||
@ -192,13 +197,24 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
|
|||||||
if not current:
|
if not current:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
temp = current.get("temp_C", "—")
|
# dict.get() возвращает None если ключ существует со значением null;
|
||||||
feels_like = current.get("FeelsLikeC", "—")
|
# используем проверку на None для корректного fallback
|
||||||
description = translate_weather(
|
temp = current.get("temp_C")
|
||||||
current.get("weatherDesc", [{}])[0].get("value", "—")
|
if temp is None:
|
||||||
)
|
temp = "—"
|
||||||
humidity = current.get("humidity", "—")
|
feels_like = current.get("FeelsLikeC")
|
||||||
wind_kmh = current.get("windspeedKmph", "—")
|
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:
|
try:
|
||||||
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "—" else "—"
|
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "—" else "—"
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@ -225,7 +241,7 @@ def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
|
|||||||
return "Погода в Магнитогорске:\n" + "\n".join(lines)
|
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 == "":
|
if mb == "—" or mb is None or mb == "":
|
||||||
return "—"
|
return "—"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Final
|
from typing import Callable, Final
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -18,21 +18,28 @@ logger = logging.getLogger(__name__)
|
|||||||
class RateLimiter:
|
class RateLimiter:
|
||||||
"""Токен-бакет: заполняется со скоростью rate токенов/сек, максимум burst."""
|
"""Токен-бакет: заполняется со скоростью 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:
|
Args:
|
||||||
rate: Скорость пополнения токенов (токенов в секунду).
|
rate: Скорость пополнения токенов (токенов в секунду).
|
||||||
burst: Максимальный размер бакета.
|
burst: Максимальный размер бакета.
|
||||||
|
_time_func: Функция получения времени (для тестов). По умолчанию time.monotonic.
|
||||||
"""
|
"""
|
||||||
self.rate: float = rate
|
self.rate: float = rate
|
||||||
self.burst: int = burst
|
self.burst: int = burst
|
||||||
self.tokens: float = float(burst)
|
self.tokens: float = float(burst)
|
||||||
self.lock: asyncio.Lock = asyncio.Lock()
|
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:
|
def _refill(self) -> None:
|
||||||
"""Пополнить токены за прошедшее время."""
|
"""Пополнить токены за прошедшее время."""
|
||||||
now: float = time.monotonic()
|
now: float = self._time_func()
|
||||||
elapsed: float = now - self._last_refill
|
elapsed: float = now - self._last_refill
|
||||||
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
||||||
self._last_refill = now
|
self._last_refill = now
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user