Formatting tools (#1172)

* Added base implementation of formatting utility

* Refactored and added docs

* Added changelog

* Coverage
This commit is contained in:
Alex Root Junior 2023-06-10 20:47:45 +03:00 committed by GitHub
parent c418689dc1
commit 5b20f81654
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 1103 additions and 47 deletions

2
CHANGES/1172.feature.rst Normal file
View file

@ -0,0 +1,2 @@
Added a tool to make text formatting flexible and easy.
More details on the :ref:`corresponding documentation page <formatting-tool>`

View file

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

577
aiogram/utils/formatting.py Normal file
View file

@ -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:`<b>{key}:</b> {value}`)
:param key:
:param value:
:return: Text
"""
return Text(Bold(key, ":"), " ", value)

View file

@ -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(...)

199
docs/utils/formatting.rst Normal file
View file

@ -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, <b>{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
<b>Success:</b>
✅ Test 1
✅ Test 3
✅ Test 4
<b>Failed:</b>
❌ Test 2
<b>Summary:</b>
<b>Total:</b> 4
<b>Success:</b> 3
<b>Failed:</b> 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:

View file

@ -9,3 +9,4 @@ Utils
chat_action
web_app
callback_answer
formatting

View file

@ -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"),
"<b>test</b>",
],
[
Italic("test"),
"<i>test</i>",
],
[
Underline("test"),
"<u>test</u>",
],
[
Strikethrough("test"),
"<s>test</s>",
],
[
Spoiler("test"),
"<tg-spoiler>test</tg-spoiler>",
],
[
Code("test"),
"<code>test</code>",
],
[
Pre("test", language="python"),
'<pre><code class="language-python">test</code></pre>',
],
[
TextLink("test", url="https://example.com"),
'<a href="https://example.com">test</a>',
],
[
TextMention("test", user=User(id=42, is_bot=False, first_name="Test")),
'<a href="tg://user?id=42">test</a>',
],
[
CustomEmoji("test", custom_emoji_id="42"),
'<tg-emoji emoji-id="42">test</tg-emoji>',
],
],
)
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, <b>World</b>!"
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 == "<s>test1 <b>test2 <u>test3</u> test4 <i>test5</i> test6</b> test7</s>"
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() == "<b>key:</b> test 1"

View file

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