Added tests for KeyboardBuilder and initial docs

This commit is contained in:
Alex Root Junior 2021-09-22 00:17:47 +03:00
parent 99062abf68
commit d556b4f27f
5 changed files with 275 additions and 146 deletions

View file

@ -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

View file

@ -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())

View file

@ -5,3 +5,4 @@ Utils
.. toctree::
i18n
keyboard

24
docs/utils/keyboard.rst Normal file
View 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

View 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)