mirror of
https://github.com/aiogram/aiogram.git
synced 2026-04-08 16:37:47 +00:00
Added tests for KeyboardBuilder and initial docs
This commit is contained in:
parent
99062abf68
commit
d556b4f27f
5 changed files with 275 additions and 146 deletions
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ Utils
|
|||
.. toctree::
|
||||
|
||||
i18n
|
||||
keyboard
|
||||
|
|
|
|||
24
docs/utils/keyboard.rst
Normal file
24
docs/utils/keyboard.rst
Normal file
|
|
@ -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
|
||||
226
tests/test_utils/test_keyboard.py
Normal file
226
tests/test_utils/test_keyboard.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue