Repository: aiogram/aiogram
Pull request #1399 (2024-01-27 16:26:56)

Update KeyboardBuilder utility, fixed type-hints for button method, a…
Update KeyboardBuilder utility, fixed type-hints for button method, adjusted limits of the different markup types to real world values.

github-actions[bot]: # :x: Changelog is required!

You need to add a brief description of the changes to the `CHANGES` directory.

Changes file should be named like `<issue or PR number>.<category>.rst`,
example `1234.bugfix.rst` where `1234` is the PR or issue number and `bugfix` is the category.

The content of the file should be a brief description of the changes in
the PR in the format of a description of what has been done.

Possible categories are: `feature`, `bugfix`, `doc`, `removal` and `misc`.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aiogram/aiogram/pull/1399?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
Attention: `1 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`eeafe46`)](https://app.codecov.io/gh/aiogram/aiogram/commit/eeafe4696c6c6f32c88293f197af95c9785a1045?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) 100.00% compared to head [(`241b3ce`)](https://app.codecov.io/gh/aiogram/aiogram/pull/1399?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) 99.98%.
> Report is 1 commits behind head on dev-3.x.

> :exclamation: Current head 241b3ce differs from pull request most recent head e30e89a. Consider uploading reports for the commit e30e89a to get more accurate results

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/aiogram/aiogram/pull/1399/graphs/tree.svg?width=650&height=150&src=pr&token=cFqEr3p9MX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)](https://app.codecov.io/gh/aiogram/aiogram/pull/1399?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)

```diff
@@             Coverage Diff             @@
##           dev-3.x    #1399      +/-   ##
===========================================
- Coverage   100.00%   99.98%   -0.02%     
===========================================
  Files          410      411       +1     
  Lines        10603    10620      +17     
===========================================
+ Hits         10603    10618      +15     
- Misses           0        2       +2     
```

| [Flag](https://app.codecov.io/gh/aiogram/aiogram/pull/1399/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/aiogram/aiogram/pull/1399/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | `99.98% <96.15%> (-0.02%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/aiogram/aiogram/pull/1399?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [aiogram/utils/keyboard.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1399?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS91dGlscy9rZXlib2FyZC5weQ==) | `98.70% <96.15%> (-1.30%)` | :arrow_down: |

... and [2 files with indirect coverage changes](https://app.codecov.io/gh/aiogram/aiogram/pull/1399/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)

</details>

Patch:
diff --git a/CHANGES/1399.bugfix.rst b/CHANGES/1399.bugfix.rst
new file mode 100644
index 00000000..89a36644
--- /dev/null
+++ b/CHANGES/1399.bugfix.rst
@@ -0,0 +1,1 @@
+Update KeyboardBuilder utility, fixed type-hints for button method, adjusted limits of the different markup types to real world values.
diff --git a/aiogram/utils/keyboard.py b/aiogram/utils/keyboard.py
index 932f5865..3457ec13 100644
--- a/aiogram/utils/keyboard.py
+++ b/aiogram/utils/keyboard.py
@@ -16,7 +16,6 @@ from typing import (
     TypeVar,
     Union,
     cast,
-    no_type_check,
 )
 
 from aiogram.filters.callback_data import CallbackData
@@ -26,6 +25,8 @@ from aiogram.types import (
     InlineKeyboardMarkup,
     KeyboardButton,
     KeyboardButtonPollType,
+    KeyboardButtonRequestChat,
+    KeyboardButtonRequestUsers,
     LoginUrl,
     ReplyKeyboardMarkup,
     SwitchInlineQueryChosenChat,
@@ -34,9 +35,6 @@ from aiogram.types import (
 
 ButtonType = TypeVar("ButtonType", InlineKeyboardButton, KeyboardButton)
 T = TypeVar("T")
-MAX_WIDTH = 8
-MIN_WIDTH = 1
-MAX_BUTTONS = 100
 
 
 class KeyboardBuilder(Generic[ButtonType], ABC):
@@ -46,6 +44,10 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
     Works both of InlineKeyboardMarkup and ReplyKeyboardMarkup.
     """
 
+    max_width: int = 0
+    min_width: int = 0
+    max_buttons: int = 0
+
     def __init__(
         self, button_type: Type[ButtonType], markup: Optional[List[List[ButtonType]]] = None
     ) -> None:
@@ -103,8 +105,8 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
                 f"Row {row!r} should be type 'List[{self._button_type.__name__}]' "
                 f"not type {type(row).__name__}"
             )
-        if len(row) > MAX_WIDTH:
-            raise ValueError(f"Row {row!r} is too long (MAX_WIDTH={MAX_WIDTH})")
+        if len(row) > self.max_width:
+            raise ValueError(f"Row {row!r} is too long (max width: {self.max_width})")
         self._validate_buttons(*row)
         return True
 
@@ -125,8 +127,8 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
         for row in markup:
             self._validate_row(row)
             count += len(row)
-        if count > MAX_BUTTONS:
-            raise ValueError(f"Too much buttons detected Max allowed count - {MAX_BUTTONS}")
+        if count > self.max_buttons:
+            raise ValueError(f"Too much buttons detected Max allowed count - {self.max_buttons}")
         return True
 
     def _validate_size(self, size: Any) -> int:
@@ -138,18 +140,12 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
         """
         if not isinstance(size, int):
             raise ValueError("Only int sizes are allowed")
-        if size not in range(MIN_WIDTH, MAX_WIDTH + 1):
-            raise ValueError(f"Row size {size} are not allowed")
+        if size not in range(self.min_width, self.max_width + 1):
+            raise ValueError(
+                f"Row size {size} is not allowed, range: [{self.min_width}, {self.max_width}]"
+            )
         return size
 
-    def copy(self: "KeyboardBuilder[ButtonType]") -> "KeyboardBuilder[ButtonType]":
-        """
-        Make full copy of current builder with markup
-
-        :return:
-        """
-        return self.__class__(self._button_type, markup=self.export())
-
     def export(self) -> List[List[ButtonType]]:
         """
         Export configured markup as list of lists of buttons
@@ -175,21 +171,23 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
         markup = self.export()
 
         # Try to add new buttons to the end of last row if it possible
-        if markup and len(markup[-1]) < MAX_WIDTH:
+        if markup and len(markup[-1]) < self.max_width:
             last_row = markup[-1]
-            pos = MAX_WIDTH - len(last_row)
+            pos = self.max_width - len(last_row)
             head, buttons = buttons[:pos], buttons[pos:]
             last_row.extend(head)
 
         # Separate buttons to exclusive rows with max possible row width
         while buttons:
-            row, buttons = buttons[:MAX_WIDTH], buttons[MAX_WIDTH:]
+            row, buttons = buttons[: self.max_width], buttons[self.max_width :]
             markup.append(list(row))
 
         self._markup = markup
         return self
 
-    def row(self, *buttons: ButtonType, width: int = MAX_WIDTH) -> "KeyboardBuilder[ButtonType]":
+    def row(
+        self, *buttons: ButtonType, width: Optional[int] = None
+    ) -> "KeyboardBuilder[ButtonType]":
         """
         Add row to markup
 
@@ -199,6 +197,9 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
         :param width:
         :return:
         """
+        if width is None:
+            width = self.max_width
+
         self._validate_size(width)
         self._validate_buttons(*buttons)
         self._markup.extend(
@@ -220,7 +221,7 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
         :return:
         """
         if not sizes:
-            sizes = (MAX_WIDTH,)
+            sizes = (self.max_width,)
 
         validated_sizes = map(self._validate_size, sizes)
         sizes_iter = repeat_all(validated_sizes) if repeat else repeat_last(validated_sizes)
@@ -239,7 +240,7 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
         self._markup = markup
         return self
 
-    def button(self, **kwargs: Any) -> "KeyboardBuilder[ButtonType]":
+    def _button(self, **kwargs: Any) -> "KeyboardBuilder[ButtonType]":
         """
         Add button to markup
 
@@ -293,25 +294,40 @@ class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]):
     Inline keyboard builder inherits all methods from generic builder
     """
 
-    if TYPE_CHECKING:
+    max_width: int = 8
+    min_width: int = 1
+    max_buttons: int = 100
+
+    def button(
+        self,
+        *,
+        text: str,
+        url: Optional[str] = None,
+        callback_data: Optional[Union[str, CallbackData]] = None,
+        web_app: Optional[WebAppInfo] = None,
+        login_url: Optional[LoginUrl] = None,
+        switch_inline_query: Optional[str] = None,
+        switch_inline_query_current_chat: Optional[str] = None,
+        switch_inline_query_chosen_chat: Optional[SwitchInlineQueryChosenChat] = None,
+        callback_game: Optional[CallbackGame] = None,
+        pay: Optional[bool] = None,
+        **kwargs: Any,
+    ) -> "KeyboardBuilder[InlineKeyboardButton]":
+        return self._button(
+            text=text,
+            url=url,
+            callback_data=callback_data,
+            web_app=web_app,
+            login_url=login_url,
+            switch_inline_query=switch_inline_query,
+            switch_inline_query_current_chat=switch_inline_query_current_chat,
+            switch_inline_query_chosen_chat=switch_inline_query_chosen_chat,
+            callback_game=callback_game,
+            pay=pay,
+            **kwargs,
+        )
 
-        @no_type_check
-        def button(
-            self,
-            *,
-            text: str,
-            url: Optional[str] = None,
-            callback_data: Optional[Union[str, CallbackData]] = None,
-            web_app: Optional[WebAppInfo] = None,
-            login_url: Optional[LoginUrl] = None,
-            switch_inline_query: Optional[str] = None,
-            switch_inline_query_current_chat: Optional[str] = None,
-            switch_inline_query_chosen_chat: Optional[SwitchInlineQueryChosenChat] = None,
-            callback_game: Optional[CallbackGame] = None,
-            pay: Optional[bool] = None,
-            **kwargs: Any,
-        ) -> "KeyboardBuilder[InlineKeyboardButton]":
-            ...
+    if TYPE_CHECKING:
 
         def as_markup(self, **kwargs: Any) -> InlineKeyboardMarkup:
             """Construct an InlineKeyboardMarkup"""
@@ -346,22 +362,34 @@ class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]):
     Reply keyboard builder inherits all methods from generic builder
     """
 
-    if TYPE_CHECKING:
+    max_width: int = 10
+    min_width: int = 1
+    max_buttons: int = 300
+
+    def button(
+        self,
+        *,
+        text: str,
+        request_users: Optional[KeyboardButtonRequestUsers] = None,
+        request_chat: Optional[KeyboardButtonRequestChat] = None,
+        request_contact: Optional[bool] = None,
+        request_location: Optional[bool] = None,
+        request_poll: Optional[KeyboardButtonPollType] = None,
+        web_app: Optional[WebAppInfo] = None,
+        **kwargs: Any,
+    ) -> "KeyboardBuilder[KeyboardButton]":
+        return self._button(
+            text=text,
+            request_users=request_users,
+            request_chat=request_chat,
+            request_contact=request_contact,
+            request_location=request_location,
+            request_poll=request_poll,
+            web_app=web_app,
+            **kwargs,
+        )
 
-        @no_type_check
-        def button(
-            self,
-            *,
-            text: str,
-            request_user: Optional[bool] = None,
-            request_chat: Optional[bool] = None,
-            request_contact: Optional[bool] = None,
-            request_location: Optional[bool] = None,
-            request_poll: Optional[KeyboardButtonPollType] = None,
-            web_app: Optional[WebAppInfo] = None,
-            **kwargs: Any,
-        ) -> "KeyboardBuilder[KeyboardButton]":
-            ...
+    if TYPE_CHECKING:
 
         def as_markup(self, **kwargs: Any) -> ReplyKeyboardMarkup:
             ...


Test patch:
diff --git a/tests/test_utils/test_keyboard.py b/tests/test_utils/test_keyboard.py
index 5beb6d58..27aeb1ac 100644
--- a/tests/test_utils/test_keyboard.py
+++ b/tests/test_utils/test_keyboard.py
@@ -61,36 +61,38 @@ class TestKeyboardBuilder:
 
         with pytest.raises(ValueError):
             assert builder._validate_row(
-                row=(KeyboardButton(text=f"test {index}") for index in range(10))
+                row=(
+                    KeyboardButton(text=f"test {index}") for index in range(builder.max_width + 5)
+                )
             )
 
         with pytest.raises(ValueError):
             assert builder._validate_row(
-                row=[KeyboardButton(text=f"test {index}") for index in range(10)]
+                row=[
+                    KeyboardButton(text=f"test {index}") for index in range(builder.max_width + 5)
+                ]
             )
 
-        for count in range(9):
+        for count in range(11):
             assert builder._validate_row(
                 row=[KeyboardButton(text=f"test {index}") for index in range(count)]
             )
 
-    def test_validate_markup(self):
+    def test_validate_markup_invalid_type(self):
         builder = ReplyKeyboardBuilder()
-
         with pytest.raises(ValueError):
             builder._validate_markup(markup=())
 
+    def test_validate_markup_too_many_buttons(self):
+        builder = ReplyKeyboardBuilder()
         with pytest.raises(ValueError):
             builder._validate_markup(
                 markup=[
-                    [KeyboardButton(text=f"{row}.{col}") for col in range(8)] for row in range(15)
+                    [KeyboardButton(text=f"{row}.{col}") for col in range(builder.max_width)]
+                    for row in range(builder.max_buttons)
                 ]
             )
 
-        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):
@@ -102,7 +104,7 @@ class TestKeyboardBuilder:
             builder._validate_size(0)
 
         with pytest.raises(ValueError):
-            builder._validate_size(10)
+            builder._validate_size(builder.max_width + 5)
         for size in range(1, 9):
             builder._validate_size(size)
 
@@ -126,12 +128,6 @@ class TestKeyboardBuilder:
                 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):
@@ -153,7 +149,14 @@ class TestKeyboardBuilder:
 
     @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]],
+        [
+            [0, 0, 0],
+            [3, 1, 3],
+            [8, 1, 8],
+            [12, 2, 2],
+            [16, 2, 6],
+            [22, 3, 2],
+        ],
     )
     def test_add(self, count: int, rows: int, last_columns: int):
         builder = ReplyKeyboardBuilder()
@@ -182,8 +185,8 @@ class TestKeyboardBuilder:
             [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, False, [], [10, 2]],
             [12, True, [3, 2, 1], [3, 2, 1, 3, 2, 1]],
         ],
     )