Skip to content

Commit

Permalink
Add serialization utilities and update documentation (#1515)
Browse files Browse the repository at this point in the history
* 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.

* Fixed Must-die implementation of the datetime serialization

* Fixed `TypeError: can't subtract offset-naive and offset-aware datetimes`
  • Loading branch information
JrooTJunior committed Jun 18, 2024
1 parent 1f7bbeb commit 1888039
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 10 deletions.
2 changes: 2 additions & 0 deletions CHANGES/1450.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added utility to safely deserialize any Telegram object or method to a JSON-compatible object (dict).
(:ref:`>> Read more <serialization-tool>`)
21 changes: 19 additions & 2 deletions aiogram/types/custom.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import sys
from datetime import datetime

from datetime import timezone
from pydantic import PlainSerializer
from typing_extensions import Annotated

if sys.platform == "win32": # pragma: no cover

def _datetime_serializer(value: datetime) -> int:
tz = timezone.utc if value.tzinfo else None

# https://github.com/aiogram/aiogram/issues/349
# https://github.com/aiogram/aiogram/pull/880
return round((value - datetime(1970, 1, 1, tzinfo=tz)).total_seconds())

else: # pragma: no cover

def _datetime_serializer(value: datetime) -> int:
return round(value.timestamp())


# Make datetime compatible with Telegram Bot API (unixtime)
DateTime = Annotated[
datetime,
PlainSerializer(
func=lambda dt: int(dt.timestamp()),
func=_datetime_serializer,
return_type=int,
when_used="json-unless-none",
when_used="unless-none",
),
]
89 changes: 89 additions & 0 deletions aiogram/utils/serialization.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 6 additions & 8 deletions docs/migration_2_to_3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://docs.pydantic.dev/>`_ 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 <https://docs.pydantic.dev/2.7/api/base_model/#pydantic.BaseModel.model_validate>`_)
- :class:`TelegramObject.as_json()` should be replaced by :class:`TelegramObject.model_dump_json()`
(`Read more <https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump_json>`_)
- :class:`TelegramObject.to_python()` should be replaced by :class:`TelegramObject.model_dump()`
(`Read more <https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump>`_)
- :code:`TelegramObject.as_json()` should be replaced by :func:`aiogram.utils.serialization.deserialize_telegram_object_to_python`
- :code:`<TelegramObject>.to_python()` should be replaced by :code:`json.dumps(deserialize_telegram_object_to_python(<TelegramObject>))`

Here are some usage examples:

Expand Down Expand Up @@ -206,7 +204,7 @@ Here are some usage examples:
# <class 'str'>
# 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))
Expand All @@ -225,7 +223,7 @@ Here are some usage examples:
# <class 'dict'>
# 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))
Expand Down
1 change: 1 addition & 0 deletions docs/utils/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ Utils
formatting
media_group
deep_linking
serialization
42 changes: 42 additions & 0 deletions docs/utils/serialization.rst
Original file line number Diff line number Diff line change
@@ -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:
54 changes: 54 additions & 0 deletions tests/test_utils/test_serialization.py
Original file line number Diff line number Diff line change
@@ -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="<b>test</b>", 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="<b>test</b>")

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="<b>test</b>", parse_mode="HTML")
deserialized = deserialize_telegram_object_to_python(method)
assert isinstance(deserialized, dict)

0 comments on commit 1888039

Please sign in to comment.