From a7e44eeb8e654d1e260d37a93d825d0cf76f8993 Mon Sep 17 00:00:00 2001 From: JRoot Junior Date: Mon, 17 Jun 2024 03:35:32 +0300 Subject: [PATCH] Add serialization utilities and update documentation Introduced utilities to deserialize Telegram objects to JSON-compliant Python objects and vice versa. These utilities manage both cases with and without files. The documentation has been updated to reflect these changes, including updates in migration recommendations and tutorials. A new unit test is added to verify the new functionality. --- CHANGES/1450.feature.rst | 2 + aiogram/types/custom.py | 2 +- aiogram/utils/serialization.py | 89 ++++++++++++++++++++++++++ docs/migration_2_to_3.rst | 14 ++-- docs/utils/index.rst | 1 + docs/utils/serialization.rst | 42 ++++++++++++ tests/test_utils/test_serialization.py | 54 ++++++++++++++++ 7 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 CHANGES/1450.feature.rst create mode 100644 aiogram/utils/serialization.py create mode 100644 docs/utils/serialization.rst create mode 100644 tests/test_utils/test_serialization.py diff --git a/CHANGES/1450.feature.rst b/CHANGES/1450.feature.rst new file mode 100644 index 00000000..e071f8f4 --- /dev/null +++ b/CHANGES/1450.feature.rst @@ -0,0 +1,2 @@ +Added utility to safely deserialize any Telegram object or method to a JSON-compatible object (dict). +(:ref:`>> Read more `) diff --git a/aiogram/types/custom.py b/aiogram/types/custom.py index 5098caa6..70a62ded 100644 --- a/aiogram/types/custom.py +++ b/aiogram/types/custom.py @@ -9,6 +9,6 @@ DateTime = Annotated[ PlainSerializer( func=lambda dt: int(dt.timestamp()), return_type=int, - when_used="json-unless-none", + when_used="unless-none", ), ] diff --git a/aiogram/utils/serialization.py b/aiogram/utils/serialization.py new file mode 100644 index 00000000..cc6ef8aa --- /dev/null +++ b/aiogram/utils/serialization.py @@ -0,0 +1,89 @@ +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from pydantic import BaseModel + +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.methods import TelegramMethod +from aiogram.types import InputFile + + +def _get_fake_bot(default: Optional[DefaultBotProperties] = None) -> Bot: + if default is None: + default = DefaultBotProperties() + return Bot(token="42:Fake", default=default) + + +@dataclass +class DeserializedTelegramObject: + """ + Represents a dumped Telegram object. + + :param data: The dumped data of the Telegram object. + :type data: Any + :param files: The dictionary containing the file names as keys + and the corresponding `InputFile` objects as values. + :type files: Dict[str, InputFile] + """ + + data: Any + files: Dict[str, InputFile] + + +def deserialize_telegram_object( + obj: Any, + default: Optional[DefaultBotProperties] = None, + include_api_method_name: bool = True, +) -> DeserializedTelegramObject: + """ + Deserialize Telegram Object to JSON compatible Python object. + + :param obj: The object to be deserialized. + :param default: Default bot properties + should be passed only if you want to use custom defaults. + :param include_api_method_name: Whether to include the API method name in the result. + :return: The deserialized Telegram object. + """ + extends = {} + if include_api_method_name and isinstance(obj, TelegramMethod): + extends["method"] = obj.__api_method__ + + if isinstance(obj, BaseModel): + obj = obj.model_dump(mode="python", warnings=False) + + # Fake bot is needed to exclude global defaults from the object. + fake_bot = _get_fake_bot(default=default) + + files: Dict[str, InputFile] = {} + prepared = fake_bot.session.prepare_value( + obj, + bot=fake_bot, + files=files, + _dumps_json=False, + ) + + if isinstance(prepared, dict): + prepared.update(extends) + return DeserializedTelegramObject(data=prepared, files=files) + + +def deserialize_telegram_object_to_python( + obj: Any, + default: Optional[DefaultBotProperties] = None, + include_api_method_name: bool = True, +) -> Any: + """ + Deserialize telegram object to JSON compatible Python object excluding files. + + :param obj: The telegram object to be deserialized. + :param default: Default bot properties + should be passed only if you want to use custom defaults. + :param include_api_method_name: Whether to include the API method name in the result. + :return: The deserialized telegram object. + """ + return deserialize_telegram_object( + obj, + default=default, + include_api_method_name=include_api_method_name, + ).data diff --git a/docs/migration_2_to_3.rst b/docs/migration_2_to_3.rst index 1ce8bd21..2650cc38 100644 --- a/docs/migration_2_to_3.rst +++ b/docs/migration_2_to_3.rst @@ -162,14 +162,12 @@ Telegram API Server Telegram objects transformation (to dict, to json, from json) ============================================================= -- Methods :class:`TelegramObject.to_object()`, :class:`TelegramObject.to_json()` and :class:`TelegramObject.to_python()` +- Methods :code:`TelegramObject.to_object()`, :code:`TelegramObject.to_json()` and :code:`TelegramObject.to_python()` have been removed due to the use of `pydantic `_ models. -- :class:`TelegramObject.to_object()` should be replaced by :class:`TelegramObject.model_validate()` +- :code:`TelegramObject.to_object()` should be replaced by :code:`TelegramObject.model_validate()` (`Read more `_) -- :class:`TelegramObject.as_json()` should be replaced by :class:`TelegramObject.model_dump_json()` - (`Read more `_) -- :class:`TelegramObject.to_python()` should be replaced by :class:`TelegramObject.model_dump()` - (`Read more `_) +- :code:`TelegramObject.as_json()` should be replaced by :func:`aiogram.utils.serialization.deserialize_telegram_object_to_python` +- :code:`.to_python()` should be replaced by :code:`json.dumps(deserialize_telegram_object_to_python())` Here are some usage examples: @@ -206,7 +204,7 @@ Here are some usage examples: # # Version 3.x - message_json = message.model_dump_json() + message_json = json.dumps(deserialize_telegram_object_to_python(message)) print(message_json) # {"id": 42, ...} print(type(message_json)) @@ -225,7 +223,7 @@ Here are some usage examples: # # Version 3.x - message_dict = message.model_dump() + message_dict = deserialize_telegram_object_to_python(message) print(message_dict) # {"id": 42, ...} print(type(message_dict)) diff --git a/docs/utils/index.rst b/docs/utils/index.rst index 10df8c84..732c37e7 100644 --- a/docs/utils/index.rst +++ b/docs/utils/index.rst @@ -12,3 +12,4 @@ Utils formatting media_group deep_linking + serialization diff --git a/docs/utils/serialization.rst b/docs/utils/serialization.rst new file mode 100644 index 00000000..01b77f4a --- /dev/null +++ b/docs/utils/serialization.rst @@ -0,0 +1,42 @@ +.. _serialization-tool: + +============================= +Telegram object serialization +============================= + +Serialization +============= + +To serialize Python object to Telegram object you can use pydantic serialization methods, for example: + +.. code-block:: python + + message_data = { ... } # Some message data as dict + message = Message.model_validate(message_data) + +If you want to bind serialized object to the Bot instance, you can use context: + +.. code-block:: python + + message_data = { ... } # Some message data as dict + message = Message.model_validate(message_data, context={"bot": bot}) + + +Deserialization +=============== + +In cases when you need to deserialize Telegram object to Python object, you can use this module. + +To convert Telegram object to Python object excluding files you can use +:func:`aiogram.utils.serialization.deserialize_telegram_object_to_python` function. + +.. autofunction:: aiogram.utils.serialization.deserialize_telegram_object_to_python + +To convert Telegram object to Python object including files you can use +:func:`aiogram.utils.serialization.deserialize_telegram_object` function, +which returns :class:`aiogram.utils.serialization.DeserializedTelegramObject` object. + +.. autofunction:: aiogram.utils.serialization.deserialize_telegram_object + +.. autoclass:: aiogram.utils.serialization.DeserializedTelegramObject + :members: diff --git a/tests/test_utils/test_serialization.py b/tests/test_utils/test_serialization.py new file mode 100644 index 00000000..f4343188 --- /dev/null +++ b/tests/test_utils/test_serialization.py @@ -0,0 +1,54 @@ +from datetime import datetime + +import pytest +from pydantic_core import PydanticSerializationError + +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ChatType, MessageEntityType, ParseMode +from aiogram.methods import SendMessage +from aiogram.types import Chat, LinkPreviewOptions, Message, MessageEntity, User +from aiogram.utils.serialization import ( + DeserializedTelegramObject, + deserialize_telegram_object, + deserialize_telegram_object_to_python, +) + + +class TestSerialize: + def test_deserialize(self): + method = SendMessage(chat_id=42, text="test", parse_mode="HTML") + deserialized = deserialize_telegram_object(method) + assert isinstance(deserialized, DeserializedTelegramObject) + assert isinstance(deserialized.data, dict) + assert deserialized.data["chat_id"] == 42 + + def test_deserialize_default(self): + message = Message( + message_id=42, + date=datetime.now(), + chat=Chat(id=42, type=ChatType.PRIVATE, first_name="Test"), + from_user=User(id=42, first_name="Test", is_bot=False), + text="https://example.com", + link_preview_options=LinkPreviewOptions(is_disabled=True), + entities=[MessageEntity(type=MessageEntityType.URL, length=19, offset=0)], + ) + with pytest.raises(PydanticSerializationError): + # https://github.com/aiogram/aiogram/issues/1450 + message.model_dump_json(exclude_none=True) + + deserialized = deserialize_telegram_object(message) + assert deserialized.data["link_preview_options"] == {"is_disabled": True} + assert isinstance(deserialized.data["date"], int) + + def test_deserialize_with_custom_default(self): + default = DefaultBotProperties(parse_mode="HTML") + method = SendMessage(chat_id=42, text="test") + + deserialized = deserialize_telegram_object(method, default=default) + assert deserialized.data["parse_mode"] == ParseMode.HTML + assert deserialized.data["parse_mode"] != method.parse_mode + + def test_deserialize_telegram_object_to_python(self): + method = SendMessage(chat_id=42, text="test", parse_mode="HTML") + deserialized = deserialize_telegram_object_to_python(method) + assert isinstance(deserialized, dict)