From 5b20f8165494fb201a29a75db8309f40d30abd3a Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 10 Jun 2023 20:47:45 +0300 Subject: [PATCH] Formatting tools (#1172) * Added base implementation of formatting utility * Refactored and added docs * Added changelog * Coverage --- CHANGES/1172.feature.rst | 2 + aiogram/exceptions.py | 6 +- aiogram/utils/formatting.py | 577 +++++++++++++++++++++ docs/api/methods/set_sticker_set_thumb.rst | 44 -- docs/utils/formatting.rst | 199 +++++++ docs/utils/index.rst | 1 + tests/test_utils/test_formatting.py | 319 ++++++++++++ tests/test_utils/test_link.py | 2 +- 8 files changed, 1103 insertions(+), 47 deletions(-) create mode 100644 CHANGES/1172.feature.rst create mode 100644 aiogram/utils/formatting.py delete mode 100644 docs/api/methods/set_sticker_set_thumb.rst create mode 100644 docs/utils/formatting.rst create mode 100644 tests/test_utils/test_formatting.py diff --git a/CHANGES/1172.feature.rst b/CHANGES/1172.feature.rst new file mode 100644 index 00000000..5a1005da --- /dev/null +++ b/CHANGES/1172.feature.rst @@ -0,0 +1,2 @@ +Added a tool to make text formatting flexible and easy. +More details on the :ref:`corresponding documentation page ` diff --git a/aiogram/exceptions.py b/aiogram/exceptions.py index 1c1e59fb..7ca7dcdd 100644 --- a/aiogram/exceptions.py +++ b/aiogram/exceptions.py @@ -34,6 +34,8 @@ class UnsupportedKeywordArgument(DetailedAiogramError): class TelegramAPIError(DetailedAiogramError): + label: str = "Telegram server says" + def __init__( self, method: TelegramMethod[TelegramType], @@ -44,11 +46,11 @@ class TelegramAPIError(DetailedAiogramError): def __str__(self) -> str: original_message = super().__str__() - return f"Telegram server says {original_message}" + return f"{self.label} - {original_message}" class TelegramNetworkError(TelegramAPIError): - pass + label = "HTTP Client says" class TelegramRetryAfter(TelegramAPIError): diff --git a/aiogram/utils/formatting.py b/aiogram/utils/formatting.py new file mode 100644 index 00000000..513d27fd --- /dev/null +++ b/aiogram/utils/formatting.py @@ -0,0 +1,577 @@ +import textwrap +from typing import ( + Any, + ClassVar, + Dict, + Generator, + Iterable, + Iterator, + List, + Optional, + Tuple, + Type, +) + +from typing_extensions import Self + +from aiogram.enums import MessageEntityType +from aiogram.types import MessageEntity, User +from aiogram.utils.text_decorations import ( + add_surrogates, + html_decoration, + markdown_decoration, + remove_surrogates, +) + +NodeType = Any + + +def sizeof(value: str) -> int: + return len(value.encode("utf-16-le")) // 2 + + +class Text(Iterable[NodeType]): + """ + Simple text element + """ + + type: ClassVar[Optional[str]] = None + + __slots__ = ("_body", "_params") + + def __init__( + self, + *body: NodeType, + **params: Any, + ) -> None: + self._body: Tuple[NodeType, ...] = body + self._params: Dict[str, Any] = params + + @classmethod + def from_entities(cls, text: str, entities: List[MessageEntity]) -> "Text": + return cls( + *_unparse_entities( + text=add_surrogates(text), + entities=sorted(entities, key=lambda item: item.offset) if entities else [], + ) + ) + + def render( + self, + *, + _offset: int = 0, + _sort: bool = True, + _collect_entities: bool = True, + ) -> Tuple[str, List[MessageEntity]]: + """ + Render elements tree as text with entities list + + :return: + """ + + text = "" + entities = [] + offset = _offset + + for node in self._body: + if not isinstance(node, Text): + node = str(node) + text += node + offset += sizeof(node) + else: + node_text, node_entities = node.render( + _offset=offset, + _sort=False, + _collect_entities=_collect_entities, + ) + text += node_text + offset += sizeof(node_text) + if _collect_entities: + entities.extend(node_entities) + + if _collect_entities and self.type: + entities.append(self._render_entity(offset=_offset, length=offset - _offset)) + + if _collect_entities and _sort: + entities.sort(key=lambda entity: entity.offset) + + return text, entities + + def _render_entity(self, *, offset: int, length: int) -> MessageEntity: + return MessageEntity(type=self.type, offset=offset, length=length, **self._params) + + def as_kwargs( + self, + *, + text_key: str = "text", + entities_key: str = "entities", + replace_parse_mode: bool = True, + parse_mode_key: str = "parse_mode", + ) -> Dict[str, Any]: + """ + Render elements tree as keyword arguments for usage in the API call, for example: + + .. code-block:: python + + entities = Text(...) + await message.answer(**entities.as_kwargs()) + + :param text_key: + :param entities_key: + :param replace_parse_mode: + :param parse_mode_key: + :return: + """ + text_value, entities_value = self.render() + result: Dict[str, Any] = { + text_key: text_value, + entities_key: entities_value, + } + if replace_parse_mode: + result[parse_mode_key] = None + return result + + def as_html(self) -> str: + """ + Render elements tree as HTML markup + """ + text, entities = self.render() + return html_decoration.unparse(text, entities) + + def as_markdown(self) -> str: + """ + Render elements tree as MarkdownV2 markup + """ + text, entities = self.render() + return markdown_decoration.unparse(text, entities) + + def replace(self: Self, *args: Any, **kwargs: Any) -> Self: + return type(self)(*args, **{**self._params, **kwargs}) + + def as_pretty_string(self, indent: bool = False) -> str: + sep = ",\n" if indent else ", " + body = sep.join( + item.as_pretty_string(indent=indent) if isinstance(item, Text) else repr(item) + for item in self._body + ) + params = sep.join(f"{k}={v!r}" for k, v in self._params.items() if v is not None) + + args = [] + if body: + args.append(body) + if params: + args.append(params) + + args_str = sep.join(args) + if indent: + args_str = textwrap.indent("\n" + args_str + "\n", " ") + return f"{type(self).__name__}({args_str})" + + def __add__(self, other: NodeType) -> "Text": + if isinstance(other, Text) and other.type == self.type and self._params == other._params: + return type(self)(*self, *other, **self._params) + if type(self) == Text and isinstance(other, str): + return type(self)(*self, other, **self._params) + return Text(self, other) + + def __iter__(self) -> Iterator[NodeType]: + yield from self._body + + def __len__(self) -> int: + text, _ = self.render(_collect_entities=False) + return sizeof(text) + + def __getitem__(self, item: slice) -> "Text": + if not isinstance(item, slice): + raise TypeError("Can only be sliced") + if (item.start is None or item.start == 0) and item.stop is None: + return self.replace(*self._body) + start = 0 if item.start is None else item.start + stop = len(self) if item.stop is None else item.stop + if start == stop: + return self.replace() + + nodes = [] + position = 0 + + for node in self._body: + node_size = len(node) + current_position = position + position += node_size + if position < start: + continue + if current_position > stop: + break + a = max((0, start - current_position)) + b = min((node_size, stop - current_position)) + new_node = node[a:b] + if not new_node: + continue + nodes.append(new_node) + + return self.replace(*nodes) + + +class HashTag(Text): + """ + Hashtag element. + + .. warning:: + + The value should always start with '#' symbol + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.HASHTAG` + """ + + type = MessageEntityType.HASHTAG + + +class CashTag(Text): + """ + Cashtag element. + + .. warning:: + + The value should always start with '$' symbol + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.CASHTAG` + """ + + type = MessageEntityType.CASHTAG + + +class BotCommand(Text): + """ + Bot command element. + + .. warning:: + + The value should always start with '/' symbol + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.BOT_COMMAND` + """ + + type = MessageEntityType.BOT_COMMAND + + +class Url(Text): + """ + Url element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.URL` + """ + + type = MessageEntityType.URL + + +class Email(Text): + """ + Email element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.EMAIL` + """ + + type = MessageEntityType.EMAIL + + +class PhoneNumber(Text): + """ + Phone number element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.PHONE_NUMBER` + """ + + type = MessageEntityType.PHONE_NUMBER + + +class Bold(Text): + """ + Bold element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.BOLD` + """ + + type = MessageEntityType.BOLD + + +class Italic(Text): + """ + Italic element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.ITALIC` + """ + + type = MessageEntityType.ITALIC + + +class Underline(Text): + """ + Underline element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.UNDERLINE` + """ + + type = MessageEntityType.UNDERLINE + + +class Strikethrough(Text): + """ + Strikethrough element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.STRIKETHROUGH` + """ + + type = MessageEntityType.STRIKETHROUGH + + +class Spoiler(Text): + """ + Spoiler element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.SPOILER` + """ + + type = MessageEntityType.SPOILER + + +class Code(Text): + """ + Code element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.CODE` + """ + + type = MessageEntityType.CODE + + +class Pre(Text): + """ + Pre element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.PRE` + """ + + type = MessageEntityType.PRE + + def __init__(self, *body: NodeType, language: Optional[str] = None, **params: Any) -> None: + super().__init__(*body, language=language, **params) + + +class TextLink(Text): + """ + Text link element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.TEXT_LINK` + """ + + type = MessageEntityType.TEXT_LINK + + def __init__(self, *body: NodeType, url: str, **params: Any) -> None: + super().__init__(*body, url=url, **params) + + +class TextMention(Text): + """ + Text mention element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.TEXT_MENTION` + """ + + type = MessageEntityType.TEXT_MENTION + + def __init__(self, *body: NodeType, user: User, **params: Any) -> None: + super().__init__(*body, user=user, **params) + + +class CustomEmoji(Text): + """ + Custom emoji element. + + Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity` + with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.CUSTOM_EMOJI` + """ + + type = MessageEntityType.CUSTOM_EMOJI + + def __init__(self, *body: NodeType, custom_emoji_id: str, **params: Any) -> None: + super().__init__(*body, custom_emoji_id=custom_emoji_id, **params) + + +NODE_TYPES: Dict[Optional[str], Type[Text]] = { + Text.type: Text, + HashTag.type: HashTag, + CashTag.type: CashTag, + BotCommand.type: BotCommand, + Url.type: Url, + Email.type: Email, + PhoneNumber.type: PhoneNumber, + Bold.type: Bold, + Italic.type: Italic, + Underline.type: Underline, + Strikethrough.type: Strikethrough, + Spoiler.type: Spoiler, + Code.type: Code, + Pre.type: Pre, + TextLink.type: TextLink, + TextMention.type: TextMention, +} + + +def _apply_entity(entity: MessageEntity, *nodes: NodeType) -> NodeType: + """ + Apply single entity to text + + :param entity: + :param text: + :return: + """ + node_type = NODE_TYPES.get(entity.type, Text) + return node_type(*nodes, **entity.dict(exclude={"type", "offset", "length"})) + + +def _unparse_entities( + text: bytes, + entities: List[MessageEntity], + offset: Optional[int] = None, + length: Optional[int] = None, +) -> Generator[NodeType, None, None]: + if offset is None: + offset = 0 + length = length or len(text) + + for index, entity in enumerate(entities): + if entity.offset * 2 < offset: + continue + if entity.offset * 2 > offset: + yield remove_surrogates(text[offset : entity.offset * 2]) + start = entity.offset * 2 + offset = entity.offset * 2 + entity.length * 2 + + sub_entities = list(filter(lambda e: e.offset * 2 < (offset or 0), entities[index + 1 :])) + yield _apply_entity( + entity, + *_unparse_entities(text, sub_entities, offset=start, length=offset), + ) + + if offset < length: + yield remove_surrogates(text[offset:length]) + + +def as_line(*items: NodeType, end: str = "\n") -> Text: + """ + Wrap multiple nodes into line with :code:`\\\\n` at the end of line. + + :param items: Text or Any + :param end: ending of the line, by default is :code:`\\\\n` + :return: Text + """ + return Text(*items, end) + + +def as_list(*items: NodeType, sep: str = "\n") -> Text: + """ + Wrap each element to separated lines + + :param items: + :param sep: + :return: + """ + nodes = [] + for item in items[:-1]: + nodes.extend([item, sep]) + nodes.append(items[-1]) + return Text(*nodes) + + +def as_marked_list(*items: NodeType, marker: str = "- ") -> Text: + """ + Wrap elements as marked list + + :param items: + :param marker: line marker, by default is :code:`- ` + :return: Text + """ + return as_list(*(Text(marker, item) for item in items)) + + +def as_numbered_list(*items: NodeType, start: int = 1, fmt: str = "{}. ") -> Text: + """ + Wrap elements as numbered list + + :param items: + :param start: initial number, by default 1 + :param fmt: number format, by default :code:`{}. ` + :return: Text + """ + return as_list(*(Text(fmt.format(index), item) for index, item in enumerate(items, start))) + + +def as_section(title: NodeType, *body: NodeType) -> Text: + """ + Wrap elements as simple section, section has title and body + + :param title: + :param body: + :return: Text + """ + return Text(title, "\n", *body) + + +def as_marked_section( + title: NodeType, + *body: NodeType, + marker: str = "- ", +) -> Text: + """ + Wrap elements as section with marked list + + :param title: + :param body: + :param marker: + :return: + """ + return as_section(title, as_marked_list(*body, marker=marker)) + + +def as_numbered_section( + title: NodeType, + *body: NodeType, + start: int = 1, + fmt: str = "{}. ", +) -> Text: + """ + Wrap elements as section with numbered list + + :param title: + :param body: + :param start: + :param fmt: + :return: + """ + return as_section(title, as_numbered_list(*body, start=start, fmt=fmt)) + + +def as_key_value(key: NodeType, value: NodeType) -> Text: + """ + Wrap elements pair as key-value line. (:code:`{key}: {value}`) + + :param key: + :param value: + :return: Text + """ + return Text(Bold(key, ":"), " ", value) diff --git a/docs/api/methods/set_sticker_set_thumb.rst b/docs/api/methods/set_sticker_set_thumb.rst deleted file mode 100644 index 133fad94..00000000 --- a/docs/api/methods/set_sticker_set_thumb.rst +++ /dev/null @@ -1,44 +0,0 @@ -################## -setStickerSetThumb -################## - -Returns: :obj:`bool` - -.. automodule:: aiogram.methods.set_sticker_set_thumb - :members: - :member-order: bysource - :undoc-members: True - - -Usage -===== - -As bot method -------------- - -.. code-block:: - - result: bool = await bot.set_sticker_set_thumb(...) - - -Method as object ----------------- - -Imports: - -- :code:`from aiogram.methods.set_sticker_set_thumb import SetStickerSetThumb` -- alias: :code:`from aiogram.methods import SetStickerSetThumb` - -With specific bot -~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - result: bool = await bot(SetStickerSetThumb(...)) - -As reply into Webhook in handler -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - return SetStickerSetThumb(...) diff --git a/docs/utils/formatting.rst b/docs/utils/formatting.rst new file mode 100644 index 00000000..96d727f3 --- /dev/null +++ b/docs/utils/formatting.rst @@ -0,0 +1,199 @@ +.. _formatting-tool + +========== +Formatting +========== + +Make your message formatting flexible and simple + +This instrument works on top of Message entities instead of using HTML or Markdown markups, +you can easily construct your message and sent it to the Telegram without the need to +remember tag parity (opening and closing) or escaping user input. + +Usage +===== + +Basic scenario +-------------- + +Construct your message and send it to the Telegram. + +.. code-block:: python + + content = Text("Hello, ", Bold(message.from_user.full_name), "!") + await message.answer(**content.as_kwargs()) + +Is the same as the next example, but without usage markup + +.. code-block:: python + + await message.answer( + text=f"Hello, {html.quote(message.from_user.full_name)}!", + parse_mode=ParseMode.HTML + ) + +Literally when you execute :code:`as_kwargs` method the Text object is converted +into text :code:`Hello, Alex!` with entities list :code:`[MessageEntity(type='bold', offset=7, length=4)]` +and passed into dict which can be used as :code:`**kwargs` in API call. + +The complete list of elements is listed `on this page below <#available-elements>`_. + +Advanced scenario +----------------- + +On top of base elements can be implemented content rendering structures, +so, out of the box aiogram has a few already implemented functions that helps you to format +your messages: + +.. autofunction:: aiogram.utils.formatting.as_line + +.. autofunction:: aiogram.utils.formatting.as_list + +.. autofunction:: aiogram.utils.formatting.as_marked_list + +.. autofunction:: aiogram.utils.formatting.as_numbered_list + +.. autofunction:: aiogram.utils.formatting.as_section + +.. autofunction:: aiogram.utils.formatting.as_marked_section + +.. autofunction:: aiogram.utils.formatting.as_numbered_section + +.. autofunction:: aiogram.utils.formatting.as_key_value + +and lets complete them all: + +.. code-block:: python + + content = as_list( + as_marked_section( + Bold("Success:"), + "Test 1", + "Test 3", + "Test 4", + marker="✅ ", + ), + as_marked_section( + Bold("Failed:"), + "Test 2", + marker="❌ ", + ), + as_marked_section( + Bold("Summary:"), + as_key_value("Total", 4), + as_key_value("Success", 3), + as_key_value("Failed", 1), + marker=" ", + ), + HashTag("#test"), + sep="\n\n", + ) + +Will be rendered into: + + **Success:** + + ✅ Test 1 + + ✅ Test 3 + + ✅ Test 4 + + **Failed:** + + ❌ Test 2 + + **Summary:** + + **Total**: 4 + + **Success**: 3 + + **Failed**: 1 + + #test + + +Or as HTML: + +.. code-block:: html + + Success: + ✅ Test 1 + ✅ Test 3 + ✅ Test 4 + + Failed: + ❌ Test 2 + + Summary: + Total: 4 + Success: 3 + Failed: 1 + + #test + +Available methods +================= + +.. autoclass:: aiogram.utils.formatting.Text + :members: + :show-inheritance: + :member-order: bysource + :special-members: __init__ + + +Available elements +================== + +.. autoclass:: aiogram.utils.formatting.Text + :show-inheritance: + :noindex: + +.. autoclass:: aiogram.utils.formatting.HashTag + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.CashTag + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.BotCommand + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Url + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Email + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.PhoneNumber + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Bold + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Italic + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Underline + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Strikethrough + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Spoiler + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Code + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.Pre + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.TextLink + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.TextMention + :show-inheritance: + +.. autoclass:: aiogram.utils.formatting.CustomEmoji + :show-inheritance: diff --git a/docs/utils/index.rst b/docs/utils/index.rst index cfe5a543..fbab2e4a 100644 --- a/docs/utils/index.rst +++ b/docs/utils/index.rst @@ -9,3 +9,4 @@ Utils chat_action web_app callback_answer + formatting diff --git a/tests/test_utils/test_formatting.py b/tests/test_utils/test_formatting.py new file mode 100644 index 00000000..5e14c4dc --- /dev/null +++ b/tests/test_utils/test_formatting.py @@ -0,0 +1,319 @@ +import pytest + +from aiogram.enums import MessageEntityType +from aiogram.types import MessageEntity, User +from aiogram.utils.formatting import ( + Bold, + BotCommand, + CashTag, + Code, + CustomEmoji, + Email, + HashTag, + Italic, + PhoneNumber, + Pre, + Spoiler, + Strikethrough, + Text, + TextLink, + TextMention, + Underline, + Url, + _apply_entity, + as_key_value, + as_line, + as_list, + as_marked_list, + as_marked_section, + as_numbered_list, + as_numbered_section, + as_section, +) +from aiogram.utils.text_decorations import html_decoration + + +class TestNode: + @pytest.mark.parametrize( + "node,result", + [ + [ + Text("test"), + "test", + ], + [ + HashTag("#test"), + "#test", + ], + [ + CashTag("$TEST"), + "$TEST", + ], + [ + BotCommand("/test"), + "/test", + ], + [ + Url("https://example.com"), + "https://example.com", + ], + [ + Email("test@example.com"), + "test@example.com", + ], + [ + PhoneNumber("test"), + "test", + ], + [ + Bold("test"), + "test", + ], + [ + Italic("test"), + "test", + ], + [ + Underline("test"), + "test", + ], + [ + Strikethrough("test"), + "test", + ], + [ + Spoiler("test"), + "test", + ], + [ + Code("test"), + "test", + ], + [ + Pre("test", language="python"), + '
test
', + ], + [ + TextLink("test", url="https://example.com"), + 'test', + ], + [ + TextMention("test", user=User(id=42, is_bot=False, first_name="Test")), + 'test', + ], + [ + CustomEmoji("test", custom_emoji_id="42"), + 'test', + ], + ], + ) + def test_render_plain_only(self, node: Text, result: str): + text, entities = node.render() + if node.type: + assert len(entities) == 1 + entity = entities[0] + assert entity.type == node.type + + content = html_decoration.unparse(text, entities) + assert content == result + + def test_render_text(self): + node = Text("Hello, ", "World", "!") + text, entities = node.render() + assert text == "Hello, World!" + assert not entities + + def test_render_nested(self): + node = Text( + Text("Hello, ", Bold("World"), "!"), + "\n", + Text(Bold("This ", Underline("is"), " test", Italic("!"))), + "\n", + HashTag("#test"), + ) + text, entities = node.render() + assert text == "Hello, World!\nThis is test!\n#test" + assert entities == [ + MessageEntity(type="bold", offset=7, length=5), + MessageEntity(type="bold", offset=14, length=13), + MessageEntity(type="underline", offset=19, length=2), + MessageEntity(type="italic", offset=26, length=1), + MessageEntity(type="hashtag", offset=28, length=5), + ] + + def test_as_kwargs_default(self): + node = Text("Hello, ", Bold("World"), "!") + result = node.as_kwargs() + assert "text" in result + assert "entities" in result + assert "parse_mode" in result + + def test_as_kwargs_custom(self): + node = Text("Hello, ", Bold("World"), "!") + result = node.as_kwargs( + text_key="caption", + entities_key="custom_entities", + parse_mode_key="custom_parse_mode", + ) + assert "text" not in result + assert "caption" in result + assert "entities" not in result + assert "custom_entities" in result + assert "parse_mode" not in result + assert "custom_parse_mode" in result + + def test_as_html(self): + node = Text("Hello, ", Bold("World"), "!") + assert node.as_html() == "Hello, World!" + + def test_as_markdown(self): + node = Text("Hello, ", Bold("World"), "!") + assert node.as_markdown() == r"Hello, *World*\!" + + def test_replace(self): + node0 = Text("test0", param0="test1") + node1 = node0.replace("test1", "test2", param1="test1") + assert node0._body != node1._body + assert node0._params != node1._params + assert "param1" not in node0._params + assert "param1" in node1._params + + def test_add(self): + node0 = Text("Hello") + node1 = Bold("World") + + node2 = node0 + Text(", ") + node1 + "!" + assert node0 != node2 + assert node1 != node2 + assert len(node0._body) == 1 + assert len(node1._body) == 1 + assert len(node2._body) == 3 + + text, entities = node2.render() + assert text == "Hello, World!" + + def test_getitem_position(self): + node = Text("Hello, ", Bold("World"), "!") + with pytest.raises(TypeError): + node[2] + + def test_getitem_empty_slice(self): + node = Text("Hello, ", Bold("World"), "!") + new_node = node[:] + assert new_node is not node + assert isinstance(new_node, Text) + assert new_node._body == node._body + + def test_getitem_slice_zero(self): + node = Text("Hello, ", Bold("World"), "!") + new_node = node[2:2] + assert node is not new_node + assert isinstance(new_node, Text) + assert not new_node._body + + def test_getitem_slice_simple(self): + node = Text("Hello, ", Bold("World"), "!") + new_node = node[2:10] + assert isinstance(new_node, Text) + text, entities = new_node.render() + assert text == "llo, Wor" + assert len(entities) == 1 + assert entities[0].type == MessageEntityType.BOLD + + def test_getitem_slice_inside_child(self): + node = Text("Hello, ", Bold("World"), "!") + new_node = node[8:10] + assert isinstance(new_node, Text) + text, entities = new_node.render() + assert text == "or" + assert len(entities) == 1 + assert entities[0].type == MessageEntityType.BOLD + + def test_getitem_slice_tail(self): + node = Text("Hello, ", Bold("World"), "!") + new_node = node[12:13] + assert isinstance(new_node, Text) + text, entities = new_node.render() + assert text == "!" + assert not entities + + def test_from_entities(self): + # Most of the cases covered by text_decorations module + + node = Strikethrough.from_entities( + text="test1 test2 test3 test4 test5 test6 test7", + entities=[ + MessageEntity(type="bold", offset=6, length=29), + MessageEntity(type="underline", offset=12, length=5), + MessageEntity(type="italic", offset=24, length=5), + ], + ) + assert len(node._body) == 3 + assert isinstance(node, Strikethrough) + rendered = node.as_html() + assert rendered == "test1 test2 test3 test4 test5 test6 test7" + + def test_pretty_string(self): + node = Strikethrough.from_entities( + text="X", + entities=[ + MessageEntity( + type=MessageEntityType.CUSTOM_EMOJI, + offset=0, + length=1, + custom_emoji_id="42", + ), + ], + ) + assert ( + node.as_pretty_string(indent=True) + == """Strikethrough( + Text( + 'X', + custom_emoji_id='42' + ) +)""" + ) + + +class TestUtils: + def test_apply_entity(self): + node = _apply_entity( + MessageEntity(type=MessageEntityType.BOLD, offset=0, length=4), "test" + ) + assert isinstance(node, Bold) + assert node._body == ("test",) + + def test_as_line(self): + node = as_line("test", "test", "test") + assert isinstance(node, Text) + assert len(node._body) == 4 # 3 + '\n' + + def test_as_list(self): + node = as_list("test", "test", "test") + assert isinstance(node, Text) + assert len(node._body) == 5 # 3 + 2 * '\n' between lines + + def test_as_marked_list(self): + node = as_marked_list("test 1", "test 2", "test 3") + assert node.as_html() == "- test 1\n- test 2\n- test 3" + + def test_as_numbered_list(self): + node = as_numbered_list("test 1", "test 2", "test 3", start=5) + assert node.as_html() == "5. test 1\n6. test 2\n7. test 3" + + def test_as_section(self): + node = as_section("title", "test 1", "test 2", "test 3") + assert node.as_html() == "title\ntest 1test 2test 3" + + def test_as_marked_section(self): + node = as_marked_section("Section", "test 1", "test 2", "test 3") + assert node.as_html() == "Section\n- test 1\n- test 2\n- test 3" + + def test_as_numbered_section(self): + node = as_numbered_section("Section", "test 1", "test 2", "test 3", start=5) + assert node.as_html() == "Section\n5. test 1\n6. test 2\n7. test 3" + + def test_as_key_value(self): + node = as_key_value("key", "test 1") + assert node.as_html() == "key: test 1" diff --git a/tests/test_utils/test_link.py b/tests/test_utils/test_link.py index 77419441..f0276703 100644 --- a/tests/test_utils/test_link.py +++ b/tests/test_utils/test_link.py @@ -6,10 +6,10 @@ import pytest from aiogram.utils.link import ( BRANCH, + create_channel_bot_link, create_telegram_link, create_tg_link, docs_url, - create_channel_bot_link, )