From 0b9c7e653802b46c8e7254ea3a7be6ea3a2939eb Mon Sep 17 00:00:00 2001 From: Oleg Date: Sun, 22 Feb 2026 11:20:55 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B7=D0=B2=D0=BB=D0=B5=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B8=20=D0=B8=D1=85=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B2?= =?UTF-8?q?=20=D0=B1=D0=BE=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/dispatcher.py | 71 ++++---- maxapi/utils/commands.py | 38 +++++ tests/test_dispatcher.py | 183 ++++++++++++++++++++- tests/test_dispatcher_register_handlers.py | 0 tests/test_utils.py | 144 ---------------- tests/test_utils/__init__.py | 0 tests/test_utils/test_commands.py | 140 ++++++++++++++++ tests/test_utils/test_keyboard_builder.py | 149 +++++++++++++++++ tests/{ => test_utils}/test_time_utils.py | 0 9 files changed, 535 insertions(+), 190 deletions(-) create mode 100644 maxapi/utils/commands.py create mode 100644 tests/test_dispatcher_register_handlers.py delete mode 100644 tests/test_utils.py create mode 100644 tests/test_utils/__init__.py create mode 100644 tests/test_utils/test_commands.py create mode 100644 tests/test_utils/test_keyboard_builder.py rename tests/{ => test_utils}/test_time_utils.py (100%) diff --git a/maxapi/dispatcher.py b/maxapi/dispatcher.py index 0fe3abc..f567cb4 100644 --- a/maxapi/dispatcher.py +++ b/maxapi/dispatcher.py @@ -5,7 +5,6 @@ import warnings from asyncio.exceptions import TimeoutError as AsyncioTimeoutError from datetime import datetime -from re import DOTALL, search from typing import TYPE_CHECKING, Any, Literal from aiohttp import ClientConnectorError @@ -15,7 +14,6 @@ from .exceptions.dispatcher import HandlerException, MiddlewareException from .exceptions.max import InvalidToken, MaxApiError, MaxConnection from .filters import filter_attrs -from .filters.command import CommandsInfo from .filters.handler import Handler from .loggers import logger_dp from .methods.types.getted_updates import ( @@ -24,6 +22,7 @@ ) from .types.bot_mixin import BotMixin from .types.updates import UNKNOWN_UPDATE_DISCLAIMER, UpdateUnion +from .utils.commands import extract_commands from .utils.time import from_ms, to_ms try: @@ -53,7 +52,6 @@ CONNECTION_RETRY_DELAY = 30 GET_UPDATES_RETRY_DELAY = 5 -COMMANDS_INFO_PATTERN = r"commands_info:\s*(.*?)(?=\n|$)" DEFAULT_HOST = "localhost" DEFAULT_PORT = 8080 @@ -249,7 +247,7 @@ def filter(self, base_filter: BaseFilter) -> None: async def __ready(self, bot: Bot) -> None: """ - Подготавливает диспетчер: сохраняет бота, регистрирует + Подготавливает диспетчер: сохраняет бота, подготавливает обработчики, вызывает on_started. Args: @@ -259,58 +257,47 @@ async def __ready(self, bot: Bot) -> None: self.bot = bot self.bot.dispatcher = self - if self.polling and self.bot.auto_check_subscriptions: - response = await self.bot.get_subscriptions() - - if response.subscriptions: - logger_subscriptions_text = ", ".join( - [s.url for s in response.subscriptions] - ) - logger_dp.warning( - "БОТ ИГНОРИРУЕТ POLLING! " - "Обнаружены установленные подписки: %s", - logger_subscriptions_text, - ) + if self.polling and bot.auto_check_subscriptions: + await self._check_subscriptions(bot) await self.check_me() self.routers += [self] + self._prepare_handlers(bot) - for router in self.routers: - router.bot = bot - - for handler in router.event_handlers: - if handler.base_filters is None: - continue - - for base_filter in handler.base_filters: - commands = getattr(base_filter, "commands", None) + if self.on_started_func: + await self.on_started_func() - if commands and type(commands) is list: - handler_doc = handler.func_event.__doc__ - extracted_info = None + def _prepare_handlers(self, bot: Bot) -> None: + """Подготовить обработчики событий.""" - if handler_doc: - from_pattern = search( - COMMANDS_INFO_PATTERN, handler_doc, DOTALL - ) - if from_pattern: - extracted_info = from_pattern.group(1).strip() + handlers_count = 0 - self.bot.commands.append( - CommandsInfo(commands, extracted_info) - ) + for router in self.routers: + router.bot = bot - handlers_count = sum( - len(router.event_handlers) for router in self.routers - ) + for handler in router.event_handlers: + handlers_count += 1 + extract_commands(handler, bot) logger_dp.info( f"Зарегистрировано {handlers_count} обработчиков событий" ) - if self.on_started_func: - await self.on_started_func() + @staticmethod + async def _check_subscriptions(bot: Bot) -> None: + """Проверить наличие подписок при запуске polling.""" + response = await bot.get_subscriptions() + + if subscriptions := response.subscriptions: + logger_subscriptions_text = ", ".join( + [s.url for s in subscriptions] + ) + logger_dp.warning( + "БОТ ИГНОРИРУЕТ POLLING! " + "Обнаружены установленные подписки: %s", + logger_subscriptions_text, + ) def __get_context( self, chat_id: int | None, user_id: int | None diff --git a/maxapi/utils/commands.py b/maxapi/utils/commands.py new file mode 100644 index 0000000..818bca1 --- /dev/null +++ b/maxapi/utils/commands.py @@ -0,0 +1,38 @@ +from re import DOTALL, search + +from maxapi.bot import Bot +from maxapi.filters.command import CommandsInfo +from maxapi.filters.handler import Handler + +COMMANDS_INFO_PATTERN = r"commands_info:\s*(.*?)(?=\n|$)" + + +def extract_commands(handler: Handler, bot: Bot) -> None: + """Извлечь команды из обработчика и добавить их в бота.""" + if handler.base_filters is None: + return + + handler_info = get_handler_info(handler) + + for base_filter in handler.base_filters: + commands = getattr(base_filter, "commands", None) + + if commands and isinstance(commands, list): + command = CommandsInfo(commands=commands, info=handler_info) + bot.commands.append(command) + + +def get_handler_info(handler: Handler) -> str | None: + """Получить описание обработчика.""" + handler_doc = handler.func_event.__doc__ + if not handler_doc: + return None + + from_pattern = search( + pattern=COMMANDS_INFO_PATTERN, string=handler_doc, flags=DOTALL + ) + if not from_pattern: + return None + + info = from_pattern.group(1).strip() + return info or None diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 59233f5..e7aece5 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -3,12 +3,13 @@ from unittest.mock import AsyncMock, Mock, patch import pytest - -# Core Stuff -from maxapi import Dispatcher, F +from maxapi.bot import Bot from maxapi.context import MemoryContext -from maxapi.dispatcher import Event, Router +from maxapi.dispatcher import Dispatcher, Event, Router from maxapi.enums.update import UpdateType +from maxapi.filters import F +from maxapi.filters.command import Command, CommandsInfo +from maxapi.filters.handler import Handler from maxapi.types.updates.bot_started import BotStarted from maxapi.types.updates.message_created import MessageCreated @@ -112,6 +113,83 @@ async def on_started(): assert dispatcher.on_started_func is not None +class TestPrepareHandlers: + @staticmethod + def make_handler_with_doc(commands, info): + def func(): ... + + func.__doc__ = f""" + commands_info: {info} + """ + return Handler( + Command(commands), + func_event=func, + update_type=UpdateType.ON_STARTED, + ) + + def test_prepare_handlers_assigns_bot_and_extracts_commands(self): + bot = Bot(token="test") + dp = Dispatcher() + router = Router("r1") + + handler = self.make_handler_with_doc("start", "Запустить бота") + router.event_handlers.append(handler) + + dp.routers.append(router) + + # до подготовки bot ещё не присвоен + assert router.bot is None + assert bot.commands == [] + + dp._prepare_handlers(bot) + + # после подготовки router должен иметь ссылку на bot + assert router.bot is bot + + # команды из handler должны быть добавлены в bot.commands + assert bot.commands == [ + CommandsInfo(commands=["start"], info="Запустить бота") + ] + + def test_prepare_handlers_multiple_routers_and_handlers(self): + bot = Bot(token="test") + dp = Dispatcher() + + r1 = Router("r1") + r2 = Router("r2") + + h1 = self.make_handler_with_doc("a", "info1") + h2 = self.make_handler_with_doc(["b", "c"], "info2") + + r1.event_handlers.append(h1) + r2.event_handlers.append(h2) + + dp.routers.extend([r1, r2]) + + dp._prepare_handlers(bot) + + assert r1.bot is bot + assert r2.bot is bot + + # порядок добавления соответствует обходу роутеров и обработчиков + assert bot.commands == [ + CommandsInfo(commands=["a"], info="info1"), + CommandsInfo(commands=["b", "c"], info="info2"), + ] + + def test_prepare_handlers_with_no_event_handlers_does_nothing(self): + bot = Bot(token="test") + dp = Dispatcher() + router = Router("r1") + + dp.routers.append(router) + + dp._prepare_handlers(bot) + + assert router.bot is bot + assert bot.commands == [] + + class TestDispatcherRouters: """Тесты работы с роутерами.""" @@ -321,3 +399,100 @@ async def __call__(self, event): ) assert result is False + + +class TestDispatcherSubscriptions: + async def test_check_subscriptions_no_subscriptions( + self, dispatcher, bot, caplog + ): + """Если подписок нет, предупреждение не логируется.""" + dispatcher.bot = bot + bot.get_subscriptions = AsyncMock(return_value=Mock(subscriptions=[])) + + caplog.set_level("WARNING") + await dispatcher._check_subscriptions(bot) + + # Проверяем, что предупреждение с ключевой фразой не встречается + assert not any( + ( + record.levelname == "WARNING" + and "БОТ ИГНОРИРУЕТ POLLING!" in record.getMessage() + ) + for record in caplog.records + ) + + async def test_check_subscriptions_warns_when_subscriptions( + self, dispatcher, bot, caplog + ): + """Если подписки есть, логируется предупреждение с URL'ами.""" + dispatcher.bot = bot + subs = [Mock(url="https://a"), Mock(url="https://b")] + bot.get_subscriptions = AsyncMock( + return_value=Mock(subscriptions=subs) + ) + + caplog.set_level("WARNING") + await dispatcher._check_subscriptions(bot) + + warns = [r for r in caplog.records if r.levelname == "WARNING"] + assert warns, "Ожидалось предупреждение при найденных подписках" + + # Проверяем текст предупреждения и наличие URL'ов + assert any("БОТ ИГНОРИРУЕТ POLLING!" in r.getMessage() for r in warns) + joined = ", ".join([s.url for s in subs]) + assert any(joined in r.getMessage() for r in warns) + + +class TestDispatcherReady: + async def test_ready_triggers_subscriptions_and_prepare_and_on_started( + self, dispatcher, bot + ): + """Если включён polling и auto_check_subscriptions, + вызываются проверки подписок, check_me, prepare и on_started. + """ + dispatcher.polling = True + bot.auto_check_subscriptions = True + + # Подменяем методы, чтобы отследить вызовы + dispatcher._check_subscriptions = AsyncMock() + dispatcher.check_me = AsyncMock() + dispatcher._prepare_handlers = Mock() + dispatcher.on_started_func = AsyncMock() + + # Вызов приватного метода __ready + await dispatcher._Dispatcher__ready(bot) + + # Убедимся, что бот присвоен и бот знает диспетчера + assert dispatcher.bot is bot + assert bot.dispatcher is dispatcher + + # Проверяем, что были вызваны ожидаемые методы + dispatcher._check_subscriptions.assert_called() + dispatcher.check_me.assert_called() + dispatcher._prepare_handlers.assert_called_once_with(bot) + dispatcher.on_started_func.assert_called() + + # Dispatcher должен добавить себя в список роутеров + assert dispatcher in dispatcher.routers + + async def test_ready_skips_subscriptions_when_polling_disabled( + self, dispatcher, bot + ): + """Если polling отключён, проверка подписок не выполняется.""" + dispatcher.polling = False + bot.auto_check_subscriptions = True + + dispatcher._check_subscriptions = AsyncMock() + dispatcher.check_me = AsyncMock() + dispatcher._prepare_handlers = Mock() + dispatcher.on_started_func = None + + await dispatcher._Dispatcher__ready(bot) + + # _check_subscriptions не должен вызываться + dispatcher._check_subscriptions.assert_not_called() + + # Остальные шаги должны выполниться + dispatcher.check_me.assert_called() + dispatcher._prepare_handlers.assert_called_once_with(bot) + assert dispatcher in dispatcher.routers diff --git a/tests/test_dispatcher_register_handlers.py b/tests/test_dispatcher_register_handlers.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index a6d74c8..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Тесты для утилит.""" - -# Core Stuff -from maxapi.enums.attachment import AttachmentType -from maxapi.types import ( - CallbackButton, - ChatButton, - LinkButton, - RequestContactButton, - RequestGeoLocationButton, -) -from maxapi.utils.inline_keyboard import InlineKeyboardBuilder - - -class TestInlineKeyboardBuilder: - """Тесты InlineKeyboardBuilder.""" - - def test_builder_init(self): - """Тест инициализации билдера.""" - builder = InlineKeyboardBuilder() - assert isinstance(builder.payload, list) - assert len(builder.payload) == 1 - assert builder.payload[0] == [] - - def test_add_button(self): - """Тест добавления кнопки.""" - builder = InlineKeyboardBuilder() - button = CallbackButton(text="Test", payload="test_payload") - - builder.add(button) - - assert len(builder.payload[0]) == 1 - assert builder.payload[0][0] == button - - def test_add_multiple_buttons(self): - """Тест добавления нескольких кнопок в один ряд.""" - builder = InlineKeyboardBuilder() - button1 = CallbackButton(text="Button 1", payload="payload1") - button2 = LinkButton(text="Button 2", url="https://example.com") - - builder.add(button1) - builder.add(button2) - - assert len(builder.payload[0]) == 2 - assert builder.payload[0][0] == button1 - assert builder.payload[0][1] == button2 - - def test_row_empty(self): - """Тест создания нового ряда.""" - builder = InlineKeyboardBuilder() - builder.add(CallbackButton(text="B1", payload="p1")) - builder.row() - - assert len(builder.payload) == 2 - assert len(builder.payload[0]) == 1 - assert builder.payload[1] == [] - - def test_row_with_buttons(self): - """Тест создания ряда с кнопками.""" - builder = InlineKeyboardBuilder() - button1 = CallbackButton(text="Button 1", payload="payload1") - button2 = CallbackButton(text="Button 2", payload="payload2") - - builder.row(button1, button2) - - assert len(builder.payload) == 1 - assert len(builder.payload[0]) == 2 - assert builder.payload[0][0] == button1 - assert builder.payload[0][1] == button2 - - def test_multiple_rows(self): - """Тест создания нескольких рядов.""" - builder = InlineKeyboardBuilder() - button1 = CallbackButton(text="Button 1", payload="payload1") - button2 = CallbackButton(text="Button 2", payload="payload2") - button3 = CallbackButton(text="Button 3", payload="payload3") - - builder.add(button1) - builder.row(button2) - builder.add(button3) - - assert len(builder.payload) == 2 - assert len(builder.payload[0]) == 1 # Первый ряд - assert len(builder.payload[1]) == 2 # Второй ряд - - def test_as_markup(self): - """Тест преобразования в markup.""" - builder = InlineKeyboardBuilder() - button = CallbackButton(text="Test", payload="test_payload") - builder.add(button) - - markup = builder.as_markup() - - assert markup.type == AttachmentType.INLINE_KEYBOARD - assert hasattr(markup, "payload") - assert markup.payload.buttons == builder.payload # type: ignore - - def test_complex_keyboard(self): - """Тест создания сложной клавиатуры.""" - builder = InlineKeyboardBuilder() - - # Первый ряд - builder.add(CallbackButton(text="Button 1", payload="1")) - builder.add(CallbackButton(text="Button 2", payload="2")) - - # Второй ряд - builder.row(LinkButton(text="Link", url="https://example.com")) - - # Третий ряд - builder.add(ChatButton(text="Chat", chat_title="Test Chat")) - builder.add(RequestContactButton(text="Contact")) - - markup = builder.as_markup() - - assert len(markup.payload.buttons) == 2 # type: ignore - assert len(markup.payload.buttons[0]) == 2 # type: ignore - assert len(markup.payload.buttons[1]) == 3 # type: ignore - - def test_all_button_types(self): - """Тест всех типов кнопок.""" - builder = InlineKeyboardBuilder() - - builder.add(CallbackButton(text="Callback", payload="payload")) - builder.row(LinkButton(text="Link", url="https://example.com")) - builder.add(ChatButton(text="Chat", chat_title="Test")) - builder.add(RequestContactButton(text="Contact")) - builder.add(RequestGeoLocationButton(text="Location")) - - # MessageButton и OpenAppButton требуют дополнительные параметры - # builder.add(MessageButton(...)) - # builder.add(OpenAppButton(...)) - - markup = builder.as_markup() - assert markup.type == AttachmentType.INLINE_KEYBOARD - - def test_empty_keyboard(self): - """Тест создания пустой клавиатуры.""" - builder = InlineKeyboardBuilder() - - markup = builder.as_markup() - - assert markup.type == AttachmentType.INLINE_KEYBOARD - assert len(markup.payload.buttons) == 1 # type: ignore - assert len(markup.payload.buttons[0]) == 0 # type: ignore diff --git a/tests/test_utils/__init__.py b/tests/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils/test_commands.py b/tests/test_utils/test_commands.py new file mode 100644 index 0000000..3965666 --- /dev/null +++ b/tests/test_utils/test_commands.py @@ -0,0 +1,140 @@ +from maxapi.bot import Bot +from maxapi.enums.update import UpdateType +from maxapi.filters.command import Command, CommandsInfo +from maxapi.filters.filter import BaseFilter +from maxapi.filters.handler import Handler +from maxapi.utils.commands import extract_commands, get_handler_info + + +class TestGetHandlerInfo: + @staticmethod + def make_handler(doc: str | None) -> Handler: + def func(): ... + + func.__doc__ = doc + return Handler(func_event=func, update_type=UpdateType.ON_STARTED) + + def test_no_doc_returns_none(self): + handler = self.make_handler(None) + assert get_handler_info(handler) is None + + def test_doc_without_pattern_returns_none(self): + doc = """ + This handler does something useful. + No commands info here. + """ + handler = self.make_handler(doc) + assert get_handler_info(handler) is None + + def test_single_line_commands_info(self): + doc = """ + Handler description + \n + commands_info: Запустить бота и показать приветствие + """ + handler = self.make_handler(doc) + assert ( + get_handler_info(handler) + == "Запустить бота и показать приветствие" + ) + + def test_commands_info_stops_at_newline(self): + doc = """ + Handler description + + commands_info: Первая строка\nВторая строка с подробностями + """ + handler = self.make_handler(doc) + assert get_handler_info(handler) == "Первая строка" + + def test_commands_info_with_trailing_spaces(self): + doc = """ + commands_info: Описание с пробелами \n + Дополнительный текст + """ + handler = self.make_handler(doc) + assert get_handler_info(handler) == "Описание с пробелами" + + def test_commands_info_label_at_end_returns_empty_string(self): + doc = """ + Some text + commands_info: + """ + handler = self.make_handler(doc) + + # Проверяем, что если после "commands_info:" нет текста, + # возвращается None, т.к. нет полезной информации + assert get_handler_info(handler) is None + + +class TestExtractCommands: + @staticmethod + def make_handler(*base_filters, doc: str | None = None) -> Handler: + def func(): ... + + func.__doc__ = doc + return Handler( + *base_filters, + func_event=func, + update_type=UpdateType.ON_STARTED, + ) + + def test_extract_commands_with_no_base_filters(self): + bot = Bot(token="test") + handler = self.make_handler() + + extract_commands(handler, bot) + + assert bot.commands == [] + + def test_extract_commands_with_base_filter_without_commands(self): + bot = Bot(token="test") + base = BaseFilter() + handler = self.make_handler(base) + + extract_commands(handler, bot) + + assert bot.commands == [] + + def test_extract_commands_with_command_and_info(self): + bot = Bot(token="test") + doc = """ + Some handler + + commands_info: Описание команды + """ + cmd = Command("start") + handler = self.make_handler(cmd, doc=doc) + + extract_commands(handler, bot) + + assert bot.commands == [ + CommandsInfo(commands=["start"], info="Описание команды") + ] + + def test_extract_commands_multiple_base_filters_and_info(self): + bot = Bot(token="test") + doc = """ + commands_info: Общая инфа + """ + cmd1 = Command("a") + cmd2 = Command(["b", "c"]) + handler = self.make_handler(cmd1, cmd2, doc=doc) + + extract_commands(handler, bot) + + assert bot.commands == [ + CommandsInfo(commands=["a"], info="Общая инфа"), + CommandsInfo(commands=["b", "c"], info="Общая инфа"), + ] + + def test_extract_commands_handler_base_filters_none_is_noop(self): + bot = Bot(token="test") + handler = self.make_handler() + + # симулируем необычное состояние, когда base_filters равно None + handler.base_filters = None + + extract_commands(handler, bot) + + assert bot.commands == [] diff --git a/tests/test_utils/test_keyboard_builder.py b/tests/test_utils/test_keyboard_builder.py new file mode 100644 index 0000000..5d16f11 --- /dev/null +++ b/tests/test_utils/test_keyboard_builder.py @@ -0,0 +1,149 @@ +"""Тесты InlineKeyboardBuilder.""" + +from maxapi.enums.attachment import AttachmentType +from maxapi.types import ( + CallbackButton, + ChatButton, + LinkButton, + RequestContactButton, + RequestGeoLocationButton, +) +from maxapi.utils.inline_keyboard import InlineKeyboardBuilder + + +def test_builder_init(): + """Тест инициализации билдера.""" + builder = InlineKeyboardBuilder() + assert isinstance(builder.payload, list) + assert len(builder.payload) == 1 + assert builder.payload[0] == [] + + +def test_add_button(): + """Тест добавления кнопки.""" + builder = InlineKeyboardBuilder() + button = CallbackButton(text="Test", payload="test_payload") + + builder.add(button) + + assert len(builder.payload[0]) == 1 + assert builder.payload[0][0] == button + + +def test_add_multiple_buttons(): + """Тест добавления нескольких кнопок в один ряд.""" + builder = InlineKeyboardBuilder() + button1 = CallbackButton(text="Button 1", payload="payload1") + button2 = LinkButton(text="Button 2", url="https://example.com") + + builder.add(button1) + builder.add(button2) + + assert len(builder.payload[0]) == 2 + assert builder.payload[0][0] == button1 + assert builder.payload[0][1] == button2 + + +def test_row_empty(): + """Тест создания нового ряда.""" + builder = InlineKeyboardBuilder() + builder.add(CallbackButton(text="B1", payload="p1")) + builder.row() + + assert len(builder.payload) == 2 + assert len(builder.payload[0]) == 1 + assert builder.payload[1] == [] + + +def test_row_with_buttons(): + """Тест создания ряда с кнопками.""" + builder = InlineKeyboardBuilder() + button1 = CallbackButton(text="Button 1", payload="payload1") + button2 = CallbackButton(text="Button 2", payload="payload2") + + builder.row(button1, button2) + + assert len(builder.payload) == 1 + assert len(builder.payload[0]) == 2 + assert builder.payload[0][0] == button1 + assert builder.payload[0][1] == button2 + + +def test_multiple_rows(): + """Тест создания нескольких рядов.""" + builder = InlineKeyboardBuilder() + button1 = CallbackButton(text="Button 1", payload="payload1") + button2 = CallbackButton(text="Button 2", payload="payload2") + button3 = CallbackButton(text="Button 3", payload="payload3") + + builder.add(button1) + builder.row(button2) + builder.add(button3) + + assert len(builder.payload) == 2 + assert len(builder.payload[0]) == 1 # Первый ряд + assert len(builder.payload[1]) == 2 # Второй ряд + + +def test_as_markup(): + """Тест преобразования в markup.""" + builder = InlineKeyboardBuilder() + button = CallbackButton(text="Test", payload="test_payload") + builder.add(button) + + markup = builder.as_markup() + + assert markup.type == AttachmentType.INLINE_KEYBOARD + assert hasattr(markup, "payload") + assert markup.payload.buttons == builder.payload # type: ignore + + +def test_complex_keyboard(): + """Тест создания сложной клавиатуры.""" + builder = InlineKeyboardBuilder() + + # Первый ряд + builder.add(CallbackButton(text="Button 1", payload="1")) + builder.add(CallbackButton(text="Button 2", payload="2")) + + # Второй ряд + builder.row(LinkButton(text="Link", url="https://example.com")) + + # Третий ряд + builder.add(ChatButton(text="Chat", chat_title="Test Chat")) + builder.add(RequestContactButton(text="Contact")) + + markup = builder.as_markup() + + assert len(markup.payload.buttons) == 2 # type: ignore + assert len(markup.payload.buttons[0]) == 2 # type: ignore + assert len(markup.payload.buttons[1]) == 3 # type: ignore + + +def test_all_button_types(): + """Тест всех типов кнопок.""" + builder = InlineKeyboardBuilder() + + builder.add(CallbackButton(text="Callback", payload="payload")) + builder.row(LinkButton(text="Link", url="https://example.com")) + builder.add(ChatButton(text="Chat", chat_title="Test")) + builder.add(RequestContactButton(text="Contact")) + builder.add(RequestGeoLocationButton(text="Location")) + + # MessageButton и OpenAppButton требуют дополнительные параметры + # builder.add(MessageButton(...)) + # builder.add(OpenAppButton(...)) + + markup = builder.as_markup() + assert markup.type == AttachmentType.INLINE_KEYBOARD + + +def test_empty_keyboard(): + """Тест создания пустой клавиатуры.""" + builder = InlineKeyboardBuilder() + + markup = builder.as_markup() + + assert markup.type == AttachmentType.INLINE_KEYBOARD + assert len(markup.payload.buttons) == 1 # type: ignore + assert len(markup.payload.buttons[0]) == 0 # type: ignore diff --git a/tests/test_time_utils.py b/tests/test_utils/test_time_utils.py similarity index 100% rename from tests/test_time_utils.py rename to tests/test_utils/test_time_utils.py From 76946aa811c24d397f114aa45424b20940bc5c18 Mon Sep 17 00:00:00 2001 From: Oleg Date: Sun, 22 Feb 2026 11:36:25 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=92=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20mccabe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1ec0d83..6440ba0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,11 +107,10 @@ target-version = "py310" [tool.ruff.lint] -# TODO: добавить BLE C90 N PGH PLR TRY +# TODO: добавить BLE N PGH PLR TRY select = ["ALL"] ignore = [ "BLE", # TODO: требует рефакторинга - "C90", # TODO: требует рефакторинга "G004", # TODO: требует рефакторинга "ERA", # TODO: требует рефакторинга "N", # TODO: требует рефакторинга @@ -153,6 +152,19 @@ flake8-type-checking.runtime-evaluated-base-classes = [ ] "dispatcher.py" = [ "B023", # TODO: требует рефакторинга dispatcher + "C90", # TODO: требует рефакторинга dispatcher +] +"maxapi/utils/updates.py" = [ + "C90", # TODO: требует рефакторинга +] +"maxapi/methods/send_message.py" = [ + "C90", # TODO: требует рефакторинга +] +"maxapi/methods/edit_message.py" = [ + "C90", # TODO: требует рефакторинга +] +"maxapi/connection/base.py" = [ + "C90", # TODO: требует рефакторинга ] [tool.ruff.format] From c704f202eae7ad3d2b1b5d71ebcacf5afcbff927 Mon Sep 17 00:00:00 2001 From: "Oleg A." Date: Sun, 22 Feb 2026 13:03:07 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BD=D0=B0=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_utils/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_utils/test_commands.py b/tests/test_utils/test_commands.py index 3965666..99d42de 100644 --- a/tests/test_utils/test_commands.py +++ b/tests/test_utils/test_commands.py @@ -55,7 +55,7 @@ def test_commands_info_with_trailing_spaces(self): handler = self.make_handler(doc) assert get_handler_info(handler) == "Описание с пробелами" - def test_commands_info_label_at_end_returns_empty_string(self): + def test_commands_info_label_at_end_returns_none(self): doc = """ Some text commands_info: From a524cece0c76aeed536ba48b00b7aca41bcb715c Mon Sep 17 00:00:00 2001 From: Oleg Date: Sun, 22 Feb 2026 13:05:29 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B4=D0=BE=D0=BA=D1=81=D1=82=D1=80=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/love-apples/maxapi/pull/51#discussion_r2837381606 --- tests/test_utils/test_commands.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_utils/test_commands.py b/tests/test_utils/test_commands.py index 99d42de..995b3d7 100644 --- a/tests/test_utils/test_commands.py +++ b/tests/test_utils/test_commands.py @@ -1,3 +1,5 @@ +"""Тесты для утилит, связанных с командами бота.""" + from maxapi.bot import Bot from maxapi.enums.update import UpdateType from maxapi.filters.command import Command, CommandsInfo