Repository: aiogram/aiogram
Pull request #1163 (2023-04-12 20:59:29)

Improve callback data serialization
# Description

# Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes


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

Patch:
diff --git a/CHANGES/1160.bugfix b/CHANGES/1160.bugfix
new file mode 100644
index 00000000..68e82835
--- /dev/null
+++ b/CHANGES/1160.bugfix
@@ -0,0 +1,1 @@
+Added missing FORUM_TOPIC_EDITED value to content_type property
diff --git a/CHANGES/1162.bugfix.rst b/CHANGES/1162.bugfix.rst
new file mode 100644
index 00000000..16d5be16
--- /dev/null
+++ b/CHANGES/1162.bugfix.rst
@@ -0,0 +1,1 @@
+Fixed compatibility with Python 3.8-3.9
diff --git a/CHANGES/1163.feature.rst b/CHANGES/1163.feature.rst
new file mode 100644
index 00000000..06e97c3d
--- /dev/null
+++ b/CHANGES/1163.feature.rst
@@ -0,0 +1,4 @@
+Improved CallbackData serialization.
+
+- Minimized UUID (hex without dashes)
+- Replaced bool values with int (true=1, false=0)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ada02324..93f96099 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,1 +1,1 @@
-Please read the [Contributing](https://docs.aiogram.dev/en/dev-3.x/contributing/) guidelines in the documentation site.
+Please read the [Contributing](https://docs.aiogram.dev/en/dev-3.x/contributing.html) guidelines in the documentation site.
diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py
index 8485d83c..9c61a447 100644
--- a/aiogram/dispatcher/dispatcher.py
+++ b/aiogram/dispatcher/dispatcher.py
@@ -92,8 +92,8 @@ class Dispatcher(Router):
 
         self.workflow_data: Dict[str, Any] = kwargs
         self._running_lock = Lock()
-        self._stop_signal = Event()
-        self._stopped_signal = Event()
+        self._stop_signal: Optional[Event] = None
+        self._stopped_signal: Optional[Event] = None
 
     def __getitem__(self, item: str) -> Any:
         return self.workflow_data[item]
@@ -430,6 +430,8 @@ class Dispatcher(Router):
         """
         if not self._running_lock.locked():
             raise RuntimeError("Polling is not started")
+        if not self._stop_signal or not self._stopped_signal:
+            return
         self._stop_signal.set()
         await self._stopped_signal.wait()
 
@@ -438,6 +440,8 @@ class Dispatcher(Router):
             return
 
         loggers.dispatcher.warning("Received %s signal", sig.name)
+        if not self._stop_signal:
+            return
         self._stop_signal.set()
 
     async def start_polling(
@@ -473,6 +477,11 @@ class Dispatcher(Router):
             )
 
         async with self._running_lock:  # Prevent to run this method twice at a once
+            if self._stop_signal is None:
+                self._stop_signal = Event()
+            if self._stopped_signal is None:
+                self._stopped_signal = Event()
+
             self._stop_signal.clear()
             self._stopped_signal.clear()
 
diff --git a/aiogram/filters/callback_data.py b/aiogram/filters/callback_data.py
index 84ec1e88..05d7783d 100644
--- a/aiogram/filters/callback_data.py
+++ b/aiogram/filters/callback_data.py
@@ -67,7 +67,11 @@ class CallbackData(BaseModel):
             return ""
         if isinstance(value, Enum):
             return str(value.value)
-        if isinstance(value, (int, str, float, Decimal, Fraction, UUID)):
+        if isinstance(value, UUID):
+            return value.hex
+        if isinstance(value, bool):
+            return str(int(value))
+        if isinstance(value, (int, str, float, Decimal, Fraction)):
             return str(value)
         raise ValueError(
             f"Attribute {key}={value!r} of type {type(value).__name__!r}"
diff --git a/aiogram/types/message.py b/aiogram/types/message.py
index c74ccb97..a057ef97 100644
--- a/aiogram/types/message.py
+++ b/aiogram/types/message.py
@@ -317,6 +317,8 @@ class Message(TelegramObject):
             return ContentType.MESSAGE_AUTO_DELETE_TIMER_CHANGED
         if self.forum_topic_created:
             return ContentType.FORUM_TOPIC_CREATED
+        if self.forum_topic_edited:
+            return ContentType.FORUM_TOPIC_EDITED
         if self.forum_topic_closed:
             return ContentType.FORUM_TOPIC_CLOSED
         if self.forum_topic_reopened:
diff --git a/examples/echo_bot.py b/examples/echo_bot.py
index d51a0eeb..e18201af 100644
--- a/examples/echo_bot.py
+++ b/examples/echo_bot.py
@@ -5,7 +5,7 @@ from aiogram import Bot, Dispatcher, Router, types
 from aiogram.filters import Command
 from aiogram.types import Message
 
-# Bot token can be obtained via https://t.me/BotFahter
+# Bot token can be obtained via https://t.me/BotFather
 TOKEN = "42:TOKEN"
 
 # All handlers should be attached to the Router (or Dispatcher)
diff --git a/pyproject.toml b/pyproject.toml
index 5a203c92..cf0f81ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -148,7 +148,14 @@ features = [
     "test",
 ]
 extra-dependencies = [
-    "butcher @ git+https://github.com/aiogram/butcher.git@v0.1.13"
+    "butcher @ git+https://github.com/aiogram/butcher.git@v0.1.14"
+]
+
+[tool.hatch.envs.dev.scripts]
+update = [
+    "butcher parse",
+    "butcher refresh",
+    "butcher apply all",
 ]
 
 [tool.hatch.envs.test]


Test patch:
diff --git a/tests/test_api/test_types/test_message.py b/tests/test_api/test_types/test_message.py
index 7eec99c7..563141f1 100644
--- a/tests/test_api/test_types/test_message.py
+++ b/tests/test_api/test_types/test_message.py
@@ -43,6 +43,7 @@ from aiogram.types import (
     Document,
     EncryptedCredentials,
     ForumTopicClosed,
+    ForumTopicEdited,
     ForumTopicCreated,
     ForumTopicReopened,
     Game,
@@ -414,6 +415,16 @@ TEST_FORUM_TOPIC_CREATED = Message(
         icon_color=0xFFD67E,
     ),
 )
+TEST_FORUM_TOPIC_EDITED = Message(
+    message_id=42,
+    date=datetime.datetime.now(),
+    chat=Chat(id=42, type="private"),
+    from_user=User(id=42, is_bot=False, first_name="Test"),
+    forum_topic_edited=ForumTopicEdited(
+        name="test_edited",
+        icon_color=0xFFD67E,
+    ),
+)
 TEST_FORUM_TOPIC_CLOSED = Message(
     message_id=42,
     date=datetime.datetime.now(),
@@ -484,6 +495,7 @@ class TestMessage:
             [TEST_MESSAGE_DICE, ContentType.DICE],
             [TEST_MESSAGE_WEB_APP_DATA, ContentType.WEB_APP_DATA],
             [TEST_FORUM_TOPIC_CREATED, ContentType.FORUM_TOPIC_CREATED],
+            [TEST_FORUM_TOPIC_EDITED, ContentType.FORUM_TOPIC_EDITED],
             [TEST_FORUM_TOPIC_CLOSED, ContentType.FORUM_TOPIC_CLOSED],
             [TEST_FORUM_TOPIC_REOPENED, ContentType.FORUM_TOPIC_REOPENED],
             [TEST_MESSAGE_UNKNOWN, ContentType.UNKNOWN],
diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py
index 4fb2b113..bcebfaa2 100644
--- a/tests/test_dispatcher/test_dispatcher.py
+++ b/tests/test_dispatcher/test_dispatcher.py
@@ -708,10 +708,15 @@ class TestDispatcher:
         with pytest.raises(RuntimeError):
             await dispatcher.stop_polling()
 
-        assert not dispatcher._stop_signal.is_set()
-        assert not dispatcher._stopped_signal.is_set()
+        assert not dispatcher._stop_signal
+        assert not dispatcher._stopped_signal
         with patch("asyncio.locks.Event.wait", new_callable=AsyncMock) as mocked_wait:
             async with dispatcher._running_lock:
+                await dispatcher.stop_polling()
+                assert not dispatcher._stop_signal
+
+                dispatcher._stop_signal = Event()
+                dispatcher._stopped_signal = Event()
                 await dispatcher.stop_polling()
                 assert dispatcher._stop_signal.is_set()
                 mocked_wait.assert_awaited()
@@ -723,6 +728,11 @@ class TestDispatcher:
             mocked_set.assert_not_called()
 
             async with dispatcher._running_lock:
+                dispatcher._signal_stop_polling(signal.SIGINT)
+                mocked_set.assert_not_called()
+
+                dispatcher._stop_signal = Event()
+                dispatcher._stopped_signal = Event()
                 dispatcher._signal_stop_polling(signal.SIGINT)
                 mocked_set.assert_called()
 
@@ -764,12 +774,29 @@ class TestDispatcher:
 
     def test_run_polling(self, bot: MockedBot):
         dispatcher = Dispatcher()
+
+        async def stop():
+            await asyncio.sleep(0.5)
+            await dispatcher.stop_polling()
+
+        start_called = False
+
+        @dispatcher.startup()
+        async def startup():
+            nonlocal start_called
+            start_called = True
+            asyncio.create_task(stop())
+
+        original_start_polling = dispatcher.start_polling
         with patch(
-            "aiogram.dispatcher.dispatcher.Dispatcher.start_polling"
+            "aiogram.dispatcher.dispatcher.Dispatcher.start_polling",
+            side_effect=original_start_polling,
         ) as patched_start_polling:
             dispatcher.run_polling(bot)
             patched_start_polling.assert_awaited_once()
 
+        assert start_called
+
     async def test_feed_webhook_update_fast_process(self, bot: MockedBot):
         dispatcher = Dispatcher()
         dispatcher.message.register(simple_message_handler)
diff --git a/tests/test_filters/test_callback_data.py b/tests/test_filters/test_callback_data.py
index 70388689..e8721a41 100644
--- a/tests/test_filters/test_callback_data.py
+++ b/tests/test_filters/test_callback_data.py
@@ -49,34 +49,38 @@ class TestCallbackData:
                 pass
 
     @pytest.mark.parametrize(
-        "value,success,expected",
+        "value,expected",
         [
-            [None, True, ""],
-            [42, True, "42"],
-            ["test", True, "test"],
-            [9.99, True, "9.99"],
-            [Decimal("9.99"), True, "9.99"],
-            [Fraction("3/2"), True, "3/2"],
-            [
-                UUID("123e4567-e89b-12d3-a456-426655440000"),
-                True,
-                "123e4567-e89b-12d3-a456-426655440000",
-            ],
-            [MyIntEnum.FOO, True, "1"],
-            [MyStringEnum.FOO, True, "FOO"],
-            [..., False, "..."],
-            [object, False, "..."],
-            [object(), False, "..."],
-            [User(id=42, is_bot=False, first_name="test"), False, "..."],
+            [None, ""],
+            [True, "1"],
+            [False, "0"],
+            [42, "42"],
+            ["test", "test"],
+            [9.99, "9.99"],
+            [Decimal("9.99"), "9.99"],
+            [Fraction("3/2"), "3/2"],
+            [UUID("123e4567-e89b-12d3-a456-426655440000"), "123e4567e89b12d3a456426655440000"],
+            [MyIntEnum.FOO, "1"],
+            [MyStringEnum.FOO, "FOO"],
         ],
     )
-    def test_encode_value(self, value, success, expected):
+    def test_encode_value_positive(self, value, expected):
         callback = MyCallback(foo="test", bar=42)
-        if success:
-            assert callback._encode_value("test", value) == expected
-        else:
-            with pytest.raises(ValueError):
-                assert callback._encode_value("test", value) == expected
+        assert callback._encode_value("test", value) == expected
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            ...,
+            object,
+            object(),
+            User(id=42, is_bot=False, first_name="test"),
+        ],
+    )
+    def test_encode_value_negative(self, value):
+        callback = MyCallback(foo="test", bar=42)
+        with pytest.raises(ValueError):
+            callback._encode_value("test", value)
 
     def test_pack(self):
         with pytest.raises(ValueError, match="Separator symbol .+"):