Repository: aiogram/aiogram
Pull request #1468 (2024-04-22 20:38:07)

Add context manager support for bot client
The bot client now supports the context manager protocol, providing automatic resource management. This enhancement helps to automatically close the session when leaving the context, which cleans up resources better. The documentation and tests have been updated accordingly to illustrate this new feature. Moreover, an example of usage without a dispatcher has been provided to clarify its use in simple cases.


From now you can use:

```python
async with Bot(...) as bot:
    ...
```
instead of
```python
async with Bot(...).context():
    ...
```

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/1468?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`9756dac`)](https://app.codecov.io/gh/aiogram/aiogram/commit/9756dac877b69a0e217da2f7bf920394f27549c1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) to head [(`b2b2fcf`)](https://app.codecov.io/gh/aiogram/aiogram/pull/1468?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram).

> :exclamation: Current head b2b2fcf differs from pull request most recent head 5684bc3. Consider uploading reports for the commit 5684bc3 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/1468/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/1468?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     #1468   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          422       422           
  Lines        10932     10937    +5     
=========================================
+ Hits         10932     10937    +5     
```

| [Flag](https://app.codecov.io/gh/aiogram/aiogram/pull/1468/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/1468/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | `100.00% <100.00%> (ø)` | |

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/1468?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [aiogram/client/bot.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1468?src=pr&el=tree&filepath=aiogram%2Fclient%2Fbot.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS9jbGllbnQvYm90LnB5) | `100.00% <100.00%> (ø)` | |

</details>

Patch:
diff --git a/CHANGES/1468.feature.rst b/CHANGES/1468.feature.rst
new file mode 100644
index 00000000..de78716c
--- /dev/null
+++ b/CHANGES/1468.feature.rst
@@ -0,0 +1,13 @@
+Added context manager interface to Bot instance, from now you can use:
+
+.. code-block:: python
+
+    async with Bot(...) as bot:
+        ...
+
+instead of
+
+.. code-block:: python
+
+    async with Bot(...).context():
+        ...
diff --git a/aiogram/client/bot.py b/aiogram/client/bot.py
index bf556d1d..52ba326a 100644
--- a/aiogram/client/bot.py
+++ b/aiogram/client/bot.py
@@ -5,6 +5,7 @@ import io
 import pathlib
 import warnings
 from contextlib import asynccontextmanager
+from types import TracebackType
 from typing import (
     Any,
     AsyncGenerator,
@@ -12,6 +13,7 @@ from typing import (
     BinaryIO,
     List,
     Optional,
+    Type,
     TypeVar,
     Union,
     cast,
@@ -300,6 +302,17 @@ class Bot:
         self.__token = token
         self._me: Optional[User] = None
 
+    async def __aenter__(self) -> "Bot":
+        return self
+
+    async def __aexit__(
+        self,
+        exc_type: Optional[Type[BaseException]],
+        exc_value: Optional[BaseException],
+        traceback: Optional[TracebackType],
+    ) -> None:
+        await self.session.close()
+
     @property
     def parse_mode(self) -> Optional[str]:
         warnings.warn(
diff --git a/docs/index.rst b/docs/index.rst
index 6be454e7..a923cab1 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -5,6 +5,15 @@ Simple usage
 
 .. literalinclude:: ../examples/echo_bot.py
 
+
+Usage without dispatcher
+------------------------
+
+Just only interact with Bot API, without handling events
+
+.. literalinclude:: ../examples/without_dispatcher.py
+
+
 Contents
 ========
 
diff --git a/examples/without_dispatcher.py b/examples/without_dispatcher.py
new file mode 100644
index 00000000..87e1d8e6
--- /dev/null
+++ b/examples/without_dispatcher.py
@@ -0,0 +1,36 @@
+import asyncio
+from argparse import ArgumentParser
+
+from aiogram import Bot
+from aiogram.client.default import DefaultBotProperties
+from aiogram.enums import ParseMode
+
+
+def create_parser() -> ArgumentParser:
+    parser = ArgumentParser()
+    parser.add_argument("--token", help="Telegram Bot API Token")
+    parser.add_argument("--chat-id", type=int, help="Target chat id")
+    parser.add_argument("--message", "-m", help="Message text to sent", default="Hello, World!")
+
+    return parser
+
+
+async def main():
+    parser = create_parser()
+    ns = parser.parse_args()
+
+    token = ns.token
+    chat_id = ns.chat_id
+    message = ns.message
+
+    async with Bot(
+        token=token,
+        default=DefaultBotProperties(
+            parse_mode=ParseMode.HTML,
+        ),
+    ) as bot:
+        await bot.send_message(chat_id=chat_id, text=message)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())


Test patch:
diff --git a/tests/test_api/test_client/test_bot.py b/tests/test_api/test_client/test_bot.py
index 7a836b04..5db1845a 100644
--- a/tests/test_api/test_client/test_bot.py
+++ b/tests/test_api/test_client/test_bot.py
@@ -14,6 +14,7 @@ from aiogram.methods import GetFile, GetMe
 from aiogram.types import File, PhotoSize
 from tests.deprecated import check_deprecated
 from tests.mocked_bot import MockedBot
+from tests.test_api.test_client.test_session.test_base_session import CustomSession
 
 
 @pytest.fixture()
@@ -42,6 +43,18 @@ class TestBot:
         assert isinstance(bot.session, AiohttpSession)
         assert bot.id == 42
 
+    async def test_bot_context_manager_over_session(self):
+        session = CustomSession()
+        with patch(
+            "tests.test_api.test_client.test_session.test_base_session.CustomSession.close",
+            new_callable=AsyncMock,
+        ) as mocked_close:
+            async with Bot(token="42:TEST", session=session) as bot:
+                assert bot.id == 42
+                assert bot.session is session
+
+            mocked_close.assert_awaited_once()
+
     def test_init_default(self):
         with check_deprecated(
             max_version="3.7.0",