Repository: aiogram/aiogram
Pull request #1236 (2023-07-30 22:20:53)

Enhance keyboard utility, improved documentation page.
Updated keyboard builders with new methods for integrating buttons and keyboard creation more seamlessly. Added functionality to create buttons from existing markup and attach another builder. This improvement aims to make the keyboard building process more user-friendly and flexible.

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

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

For example, you can run `towncrier create <issue>.<type>` to create a file in the change directory and then write a description on that file.

Read more at [Towncrier docs](https://towncrier.readthedocs.io/en/latest/quickstart.html#creating-news-fragments)
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aiogram/aiogram/pull/1236?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
> Merging [#1236](https://app.codecov.io/gh/aiogram/aiogram/pull/1236?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (bcda7be) into [dev-3.x](https://app.codecov.io/gh/aiogram/aiogram/commit/faadc8046032d64350c03eebe6f88a36cc4a2d23?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (faadc80) will **decrease** coverage by `0.09%`.
> Report is 3 commits behind head on dev-3.x.
> The diff coverage is `38.46%`.

[![Impacted file tree graph](https://app.codecov.io/gh/aiogram/aiogram/pull/1236/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/1236?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    #1236      +/-   ##
===========================================
- Coverage   100.00%   99.91%   -0.09%     
===========================================
  Files          363      363              
  Lines         9127     9140      +13     
===========================================
+ Hits          9127     9132       +5     
- Misses           0        8       +8     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `99.91% <38.46%> (-0.09%)` | :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 Changed](https://app.codecov.io/gh/aiogram/aiogram/pull/1236?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/1236?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS91dGlscy9rZXlib2FyZC5weQ==) | `94.24% <38.46%> (-5.76%)` | :arrow_down: |


Patch:
diff --git a/CHANGES/1236.feature.rst b/CHANGES/1236.feature.rst
new file mode 100644
index 00000000..3e651c17
--- /dev/null
+++ b/CHANGES/1236.feature.rst
@@ -0,0 +1,3 @@
+Updated keyboard builders with new methods for integrating buttons and keyboard creation more seamlessly.
+Added functionality to create buttons from existing markup and attach another builder.
+This improvement aims to make the keyboard building process more user-friendly and flexible.
diff --git a/aiogram/utils/keyboard.py b/aiogram/utils/keyboard.py
index d276bcbe..429cbe46 100644
--- a/aiogram/utils/keyboard.py
+++ b/aiogram/utils/keyboard.py
@@ -238,6 +238,12 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
         return self
 
     def button(self, **kwargs: Any) -> "KeyboardBuilder[ButtonType]":
+        """
+        Add button to markup
+
+        :param kwargs:
+        :return:
+        """
         if isinstance(callback_data := kwargs.get("callback_data", None), CallbackData):
             kwargs["callback_data"] = callback_data.pack()
         button = self._button_type(**kwargs)
@@ -250,6 +256,17 @@ class KeyboardBuilder(Generic[ButtonType], ABC):
         inline_keyboard = cast(List[List[InlineKeyboardButton]], self.export())  # type: ignore
         return InlineKeyboardMarkup(inline_keyboard=inline_keyboard)
 
+    def attach(self, builder: "KeyboardBuilder[ButtonType]") -> "KeyboardBuilder[ButtonType]":
+        if not isinstance(builder, KeyboardBuilder):
+            raise ValueError(f"Only KeyboardBuilder can be attached, not {type(builder).__name__}")
+        if builder._button_type is not self._button_type:
+            raise ValueError(
+                f"Only builders with same button type can be attached, "
+                f"not {self._button_type.__name__} and {builder._button_type.__name__}"
+            )
+        self._markup.extend(builder.export())
+        return self
+
 
 def repeat_last(items: Iterable[T]) -> Generator[T, None, None]:
     items_iter = iter(items)
@@ -307,6 +324,18 @@ class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]):
         """
         return InlineKeyboardBuilder(markup=self.export())
 
+    @classmethod
+    def from_markup(
+        cls: Type["InlineKeyboardBuilder"], markup: InlineKeyboardMarkup
+    ) -> "InlineKeyboardBuilder":
+        """
+        Create builder from existing markup
+
+        :param markup:
+        :return:
+        """
+        return cls(markup=markup.inline_keyboard)
+
 
 class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]):
     """
@@ -340,3 +369,13 @@ class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]):
         :return:
         """
         return ReplyKeyboardBuilder(markup=self.export())
+
+    @classmethod
+    def from_markup(cls, markup: ReplyKeyboardMarkup) -> "ReplyKeyboardBuilder":
+        """
+        Create builder from existing markup
+
+        :param markup:
+        :return:
+        """
+        return cls(markup=markup.keyboard)
diff --git a/docs/utils/keyboard.rst b/docs/utils/keyboard.rst
index 8988244e..35559fc0 100644
--- a/docs/utils/keyboard.rst
+++ b/docs/utils/keyboard.rst
@@ -14,6 +14,8 @@ Keyboard builder helps to dynamically generate markup.
 Usage example
 =============
 
+For example you want to generate inline keyboard with 10 buttons
+
 .. code-block:: python
 
     builder = InlineKeyboardBuilder()
@@ -21,22 +23,45 @@ Usage example
     for index in range(1, 11):
         builder.button(text=f"Set {index}", callback_data=f"set:{index}")
 
+
+then adjust this buttons to some grid, for example first line will have 3 buttons, the next lines will have 2 buttons
+
+.. code-block::
+
     builder.adjust(3, 2)
 
+also you can attach another builder to this one
+
+.. code-block:: python
+
+    another_builder = InlineKeyboardBuilder(...)...  # Another builder with some buttons
+    builder.attach(another_builder)
+
+or you can attach some already generated markup
+
+.. code-block:: python
+
+    markup = InlineKeyboardMarkup(inline_keyboard=[...])  # Some markup
+    builder.attach(InlineKeyboardBuilder.from_markup(markup))
+
+and finally you can export this markup to use it in your message
+
+.. code-block:: python
+
     await message.answer("Some text here", reply_markup=builder.as_markup())
 
+Reply keyboard builder has the same interface
+
+.. warning::
+
+    Note that you can't attach reply keyboard builder to inline keyboard builder and vice versa
 
-Base builder
-============
-.. autoclass:: aiogram.utils.keyboard.ReplyKeyboardBuilder
-    :members: __init__, buttons, copy, export, add, row, adjust, button, as_markup
-    :undoc-members: True
 
 Inline Keyboard
 ===============
 
 .. autoclass:: aiogram.utils.keyboard.InlineKeyboardBuilder
-    :noindex:
+    :members: __init__, buttons, copy, export, add, row, adjust, from_markup, attach
 
     .. method:: button(text: str, url: Optional[str] = None, login_url: Optional[LoginUrl] = None, callback_data: Optional[Union[str, CallbackData]] = None, switch_inline_query: Optional[str] = None, switch_inline_query_current_chat: Optional[str] = None, callback_game: Optional[CallbackGame] = None, pay: Optional[bool] = None, **kwargs: Any) -> aiogram.utils.keyboard.InlineKeyboardBuilder
         :noindex:
@@ -52,7 +77,7 @@ Reply Keyboard
 ==============
 
 .. autoclass:: aiogram.utils.keyboard.ReplyKeyboardBuilder
-    :noindex:
+    :members: __init__, buttons, copy, export, add, row, adjust, from_markup, attach
 
     .. method:: button(text: str, request_contact: Optional[bool] = None, request_location: Optional[bool] = None, request_poll: Optional[KeyboardButtonPollType] = None, **kwargs: Any) -> aiogram.utils.keyboard.ReplyKeyboardBuilder
         :noindex:


Test patch:
diff --git a/tests/test_utils/test_keyboard.py b/tests/test_utils/test_keyboard.py
index 65bb97e4..5beb6d58 100644
--- a/tests/test_utils/test_keyboard.py
+++ b/tests/test_utils/test_keyboard.py
@@ -228,3 +228,75 @@ class TestKeyboardBuilder:
     )
     def test_as_markup(self, builder, expected):
         assert isinstance(builder.as_markup(), expected)
+
+    @pytest.mark.parametrize(
+        "markup,builder_type",
+        [
+            [
+                ReplyKeyboardMarkup(
+                    keyboard=[
+                        [KeyboardButton(text="test-1"), KeyboardButton(text="test-2")],
+                        [KeyboardButton(text="test-3")],
+                    ]
+                ),
+                ReplyKeyboardBuilder,
+            ],
+            [
+                InlineKeyboardMarkup(
+                    inline_keyboard=[
+                        [InlineKeyboardButton(text="test-1"), InlineKeyboardButton(text="test-2")],
+                        [InlineKeyboardButton(text="test-3")],
+                    ]
+                ),
+                InlineKeyboardBuilder,
+            ],
+        ],
+    )
+    def test_from_markup(self, markup, builder_type):
+        builder = builder_type.from_markup(markup)
+        assert len(builder.export()) == 2
+        assert len(list(builder.buttons)) == 3
+
+    def test_attach(self):
+        builder = ReplyKeyboardBuilder(
+            markup=[
+                [
+                    KeyboardButton(text="test1"),
+                    KeyboardButton(text="test2"),
+                    KeyboardButton(text="test3"),
+                ]
+            ]
+        )
+        builder.adjust(2)
+        builder.attach(ReplyKeyboardBuilder(markup=[[KeyboardButton(text="test2")]]))
+        markup = builder.export()
+        assert len(markup) == 3
+        assert len(markup[0]) == 2
+        assert len(markup[1]) == 1
+        assert len(markup[2]) == 1
+
+    def test_attach_invalid_button_type(self):
+        builder = ReplyKeyboardBuilder(
+            markup=[
+                [
+                    KeyboardButton(text="test1"),
+                    KeyboardButton(text="test2"),
+                    KeyboardButton(text="test3"),
+                ]
+            ]
+        )
+        with pytest.raises(ValueError):
+            builder.attach(InlineKeyboardBuilder(markup=[[InlineKeyboardButton(text="test2")]]))
+
+    def test_attach_not_builder(self):
+        builder = ReplyKeyboardBuilder(
+            markup=[
+                [
+                    KeyboardButton(text="test1"),
+                    KeyboardButton(text="test2"),
+                    KeyboardButton(text="test3"),
+                ]
+            ]
+        )
+        with pytest.raises(ValueError):
+            builder.attach(KeyboardButton(text="test2"))