From d556b4f27f3ec4e46b6726e11202fe27e1d6b6ef Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Wed, 22 Sep 2021 00:17:47 +0300 Subject: [PATCH] Added tests for KeyboardBuilder and initial docs --- aiogram/utils/deprecated.py | 140 ------------------ aiogram/utils/keyboard.py | 30 +++- docs/utils/index.rst | 1 + docs/utils/keyboard.rst | 24 ++++ tests/test_utils/test_keyboard.py | 226 ++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 146 deletions(-) delete mode 100644 aiogram/utils/deprecated.py create mode 100644 docs/utils/keyboard.rst create mode 100644 tests/test_utils/test_keyboard.py diff --git a/aiogram/utils/deprecated.py b/aiogram/utils/deprecated.py deleted file mode 100644 index 149440b4..00000000 --- a/aiogram/utils/deprecated.py +++ /dev/null @@ -1,140 +0,0 @@ -import asyncio -import functools -import inspect -import warnings -from typing import Any, Callable, Type - - -def deprecated(reason: str, stacklevel: int = 2) -> Callable[..., Any]: - """ - This is a decorator which can be used to mark functions - as deprecated. It will result in a warning being emitted - when the function is used. - - Source: https://stackoverflow.com/questions/2536307/decorators-in-the-python-standard-lib-deprecated-specifically - """ - - if isinstance(reason, str): - - # The @deprecated is used with a 'reason'. - # - # .. code-block:: python - # - # @deprecated("please, use another function") - # def old_function(x, y): - # pass - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - - if inspect.isclass(func): - msg = "Call to deprecated class {name} ({reason})." - else: - msg = "Call to deprecated function {name} ({reason})." - - @functools.wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: - warn_deprecated( - msg.format(name=func.__name__, reason=reason), stacklevel=stacklevel - ) - warnings.simplefilter("default", DeprecationWarning) - return func(*args, **kwargs) - - return wrapper - - return decorator - - if inspect.isclass(reason) or inspect.isfunction(reason): - - # The @deprecated is used without any 'reason'. - # - # .. code-block:: python - # - # @deprecated - # def old_function(x, y): - # pass - - func1 = reason - - if inspect.isclass(func1): - msg1 = "Call to deprecated class {name}." - else: - msg1 = "Call to deprecated function {name}." - - @functools.wraps(func1) - def wrapper1(*args, **kwargs): - warn_deprecated(msg1.format(name=func1.__name__), stacklevel=stacklevel) - return func1(*args, **kwargs) - - return wrapper1 - - raise TypeError(repr(type(reason))) - - -def warn_deprecated( - message: str, warning: Type[Warning] = DeprecationWarning, stacklevel: int = 2 -) -> None: - warnings.simplefilter("always", warning) - warnings.warn(message, category=warning, stacklevel=stacklevel) - warnings.simplefilter("default", warning) - - -def renamed_argument( - old_name: str, new_name: str, until_version: str, stacklevel: int = 3 -) -> Callable[..., Any]: - """ - A meta-decorator to mark an argument as deprecated. - - .. code-block:: python3 - - @renamed_argument("chat", "chat_id", "3.0") # stacklevel=3 by default - @renamed_argument("user", "user_id", "3.0", stacklevel=4) - def some_function(user_id, chat_id=None): - print(f"user_id={user_id}, chat_id={chat_id}") - - some_function(user=123) # prints 'user_id=123, chat_id=None' with warning - some_function(123) # prints 'user_id=123, chat_id=None' without warning - some_function(user_id=123) # prints 'user_id=123, chat_id=None' without warning - - - :param old_name: - :param new_name: - :param until_version: the version in which the argument is scheduled to be removed - :param stacklevel: leave it to default if it's the first decorator used. - Increment with any new decorator used. - :return: decorator - """ - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - if asyncio.iscoroutinefunction(func): - - @functools.wraps(func) - async def wrapped(*args: Any, **kwargs: Any) -> Any: - if old_name in kwargs: - warn_deprecated( - f"In coroutine '{func.__name__}' argument '{old_name}' " - f"is renamed to '{new_name}' " - f"and will be removed in aiogram {until_version}", - stacklevel=stacklevel, - ) - kwargs.update({new_name: kwargs[old_name]}) - kwargs.pop(old_name) - return await func(*args, **kwargs) - - else: - - @functools.wraps(func) - def wrapped(*args: Any, **kwargs: Any) -> Any: - if old_name in kwargs: - warn_deprecated( - f"In function `{func.__name__}` argument `{old_name}` " - f"is renamed to `{new_name}` " - f"and will be removed in aiogram {until_version}", - stacklevel=stacklevel, - ) - kwargs.update({new_name: kwargs[old_name]}) - kwargs.pop(old_name) - return func(*args, **kwargs) - - return wrapped - - return decorator diff --git a/aiogram/utils/keyboard.py b/aiogram/utils/keyboard.py index 1ea9e7fe..34d660b0 100644 --- a/aiogram/utils/keyboard.py +++ b/aiogram/utils/keyboard.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy from itertools import chain from itertools import cycle as repeat_all from typing import ( @@ -149,7 +150,7 @@ class KeyboardBuilder(Generic[ButtonType]): :return: """ - return self._markup.copy() + return deepcopy(self._markup) def add(self, *buttons: ButtonType) -> "KeyboardBuilder[ButtonType]": """ @@ -241,7 +242,8 @@ def repeat_last(items: Iterable[T]) -> Generator[T, None, None]: items_iter = iter(items) try: value = next(items_iter) - except StopIteration: + except StopIteration: # pragma: no cover + # Possible case but not in place where this function is used return yield value finished = False @@ -275,8 +277,16 @@ class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]): def as_markup(self, **kwargs: Any) -> InlineKeyboardMarkup: ... - def __init__(self) -> None: - super().__init__(InlineKeyboardButton) + def __init__(self, markup: Optional[List[List[InlineKeyboardButton]]] = None) -> None: + super().__init__(button_type=InlineKeyboardButton, markup=markup) + + def copy(self: "InlineKeyboardBuilder") -> "InlineKeyboardBuilder": + """ + Make full copy of current builder with markup + + :return: + """ + return InlineKeyboardBuilder(markup=self.export()) class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]): @@ -296,5 +306,13 @@ class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]): def as_markup(self, **kwargs: Any) -> ReplyKeyboardMarkup: ... - def __init__(self) -> None: - super().__init__(KeyboardButton) + def __init__(self, markup: Optional[List[List[KeyboardButton]]] = None) -> None: + super().__init__(button_type=KeyboardButton, markup=markup) + + def copy(self: "ReplyKeyboardBuilder") -> "ReplyKeyboardBuilder": + """ + Make full copy of current builder with markup + + :return: + """ + return ReplyKeyboardBuilder(markup=self.export()) diff --git a/docs/utils/index.rst b/docs/utils/index.rst index a362e9f8..424ac0d9 100644 --- a/docs/utils/index.rst +++ b/docs/utils/index.rst @@ -5,3 +5,4 @@ Utils .. toctree:: i18n + keyboard diff --git a/docs/utils/keyboard.rst b/docs/utils/keyboard.rst new file mode 100644 index 00000000..98681e9f --- /dev/null +++ b/docs/utils/keyboard.rst @@ -0,0 +1,24 @@ +================ +Keyboard builder +================ + +Inline Keyboard +=============== + +.. autoclass:: aiogram.utils.keyboard.InlineKeyboardBuilder + :members: __init__, buttons, copy, export, add, row, adjust, button, as_markup + :undoc-members: True + +Reply Keyboard +============== + +.. autoclass:: aiogram.utils.keyboard.ReplyKeyboardBuilder + :members: __init__, buttons, copy, export, add, row, adjust, button, as_markup + :undoc-members: True + + +Base builder +============ +.. autoclass:: aiogram.utils.keyboard.ReplyKeyboardBuilder + :members: __init__, buttons, copy, export, add, row, adjust, button, as_markup + :undoc-members: True diff --git a/tests/test_utils/test_keyboard.py b/tests/test_utils/test_keyboard.py new file mode 100644 index 00000000..9923064f --- /dev/null +++ b/tests/test_utils/test_keyboard.py @@ -0,0 +1,226 @@ +import pytest + +from aiogram.dispatcher.filters.callback_data import CallbackData +from aiogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + ReplyKeyboardMarkup, +) +from aiogram.utils.keyboard import InlineKeyboardBuilder, KeyboardBuilder, ReplyKeyboardBuilder + + +class MyCallback(CallbackData, prefix="test"): + value: str + + +class TestKeyboardBuilder: + def test_init(self): + with pytest.raises(ValueError): + KeyboardBuilder(button_type=object) + + def test_init_success(self): + builder = KeyboardBuilder(button_type=KeyboardButton) + assert builder._button_type is KeyboardButton + builder = InlineKeyboardBuilder() + assert builder._button_type is InlineKeyboardButton + builder = ReplyKeyboardBuilder() + assert builder._button_type is KeyboardButton + + def test_validate_button(self): + builder = InlineKeyboardBuilder() + with pytest.raises(ValueError): + builder._validate_button(button=object()) + with pytest.raises(ValueError): + builder._validate_button(button=KeyboardButton(text="test")) + assert builder._validate_button( + button=InlineKeyboardButton(text="test", callback_data="callback") + ) + + def test_validate_buttons(self): + builder = InlineKeyboardBuilder() + with pytest.raises(ValueError): + builder._validate_buttons(object(), object()) + with pytest.raises(ValueError): + builder._validate_buttons(KeyboardButton(text="test")) + with pytest.raises(ValueError): + builder._validate_buttons( + InlineKeyboardButton(text="test", callback_data="callback"), + KeyboardButton(text="test"), + ) + assert builder._validate_button( + InlineKeyboardButton(text="test", callback_data="callback") + ) + + def test_validate_row(self): + builder = ReplyKeyboardBuilder() + + with pytest.raises(ValueError): + assert builder._validate_row( + row=(KeyboardButton(text=f"test {index}") for index in range(10)) + ) + + with pytest.raises(ValueError): + assert builder._validate_row( + row=[KeyboardButton(text=f"test {index}") for index in range(10)] + ) + + for count in range(9): + assert builder._validate_row( + row=[KeyboardButton(text=f"test {index}") for index in range(count)] + ) + + def test_validate_markup(self): + builder = ReplyKeyboardBuilder() + + with pytest.raises(ValueError): + builder._validate_markup(markup=()) + + with pytest.raises(ValueError): + builder._validate_markup( + markup=[ + [KeyboardButton(text=f"{row}.{col}") for col in range(8)] for row in range(15) + ] + ) + + assert builder._validate_markup( + markup=[[KeyboardButton(text=f"{row}.{col}") for col in range(8)] for row in range(8)] + ) + + def test_validate_size(self): + builder = ReplyKeyboardBuilder() + with pytest.raises(ValueError): + builder._validate_size(None) + with pytest.raises(ValueError): + builder._validate_size(2.0) + + with pytest.raises(ValueError): + builder._validate_size(0) + + with pytest.raises(ValueError): + builder._validate_size(10) + for size in range(1, 9): + builder._validate_size(size) + + def test_export(self): + builder = ReplyKeyboardBuilder(markup=[[KeyboardButton(text="test")]]) + markup = builder.export() + assert id(builder._markup) != id(markup) + + markup.clear() + assert len(builder._markup) == 1 + assert len(markup) == 0 + + @pytest.mark.parametrize( + "builder,button", + [ + [ + ReplyKeyboardBuilder(markup=[[KeyboardButton(text="test")]]), + KeyboardButton(text="test2"), + ], + [ + InlineKeyboardBuilder(markup=[[InlineKeyboardButton(text="test")]]), + InlineKeyboardButton(text="test2"), + ], + [ + KeyboardBuilder( + button_type=InlineKeyboardButton, markup=[[InlineKeyboardButton(text="test")]] + ), + InlineKeyboardButton(text="test2"), + ], + ], + ) + def test_copy(self, builder, button): + builder1 = builder + builder2 = builder1.copy() + assert builder1 != builder2 + + builder1.add(button) + builder2.row(button) + + markup1 = builder1.export() + markup2 = builder2.export() + assert markup1 != markup2 + + assert len(markup1) == 1 + assert len(markup2) == 2 + assert len(markup1[0]) == 2 + assert len(markup2[0]) == 1 + + @pytest.mark.parametrize( + "count,rows,last_columns", + [[0, 0, 0], [3, 1, 3], [8, 1, 8], [9, 2, 1], [16, 2, 8], [19, 3, 3]], + ) + def test_add(self, count: int, rows: int, last_columns: int): + builder = ReplyKeyboardBuilder() + + for index in range(count): + builder.add(KeyboardButton(text=f"btn-{index}")) + markup = builder.export() + + assert len(list(builder.buttons)) == count + assert len(markup) == rows + if last_columns: + assert len(markup[-1]) == last_columns + + def test_row( + self, + ): + builder = ReplyKeyboardBuilder(markup=[[KeyboardButton(text="test")]]) + builder.row(*(KeyboardButton(text=f"test-{index}") for index in range(10)), width=3) + markup = builder.export() + assert len(markup) == 5 + + @pytest.mark.parametrize( + "count,repeat,sizes,shape", + [ + [0, False, [], []], + [0, False, [2], []], + [1, False, [2], [1]], + [3, False, [2], [2, 1]], + [10, False, [], [8, 2]], + [10, False, [3, 2, 1], [3, 2, 1, 1, 1, 1, 1]], + [12, True, [3, 2, 1], [3, 2, 1, 3, 2, 1]], + ], + ) + def test_adjust(self, count, repeat, sizes, shape): + builder = ReplyKeyboardBuilder() + builder.row(*(KeyboardButton(text=f"test-{index}") for index in range(count))) + builder.adjust(*sizes, repeat=repeat) + markup = builder.export() + + assert len(markup) == len(shape) + for row, expected_size in zip(markup, shape): + assert len(row) == expected_size + + @pytest.mark.parametrize( + "builder_type,kwargs,expected", + [ + [ReplyKeyboardBuilder, dict(text="test"), KeyboardButton(text="test")], + [ + InlineKeyboardBuilder, + dict(text="test", callback_data="callback"), + InlineKeyboardButton(text="test", callback_data="callback"), + ], + [ + InlineKeyboardBuilder, + dict(text="test", callback_data=MyCallback(value="test")), + InlineKeyboardButton(text="test", callback_data="test:test"), + ], + ], + ) + def test_button(self, builder_type, kwargs, expected): + builder = builder_type() + builder.button(**kwargs) + markup = builder.export() + assert markup[0][0] == expected + + @pytest.mark.parametrize( + "builder,expected", + [ + [ReplyKeyboardBuilder(), ReplyKeyboardMarkup], + [InlineKeyboardBuilder(), InlineKeyboardMarkup], + ], + ) + def test_as_markup(self, builder, expected): + assert isinstance(builder.as_markup(), expected)