From 10c6cc517d0d53e4c7fb73b1e7bde16fdcea5860 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 10 Jun 2023 20:39:03 +0300 Subject: [PATCH] Coverage --- aiogram/exceptions.py | 6 +- aiogram/utils/formatting.py | 21 +- tests/test_utils/test_formatting.py | 319 ++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 14 deletions(-) create mode 100644 tests/test_utils/test_formatting.py 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 index 45a19c9e..513d27fd 100644 --- a/aiogram/utils/formatting.py +++ b/aiogram/utils/formatting.py @@ -49,7 +49,7 @@ class Text(Iterable[NodeType]): @classmethod def from_entities(cls, text: str, entities: List[MessageEntity]) -> "Text": - return Text( + return cls( *_unparse_entities( text=add_surrogates(text), entities=sorted(entities, key=lambda item: item.offset) if entities else [], @@ -145,13 +145,16 @@ class Text(Iterable[NodeType]): 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()) + params = sep.join(f"{k}={v!r}" for k, v in self._params.items() if v is not None) args = [] if body: @@ -164,12 +167,6 @@ class Text(Iterable[NodeType]): args_str = textwrap.indent("\n" + args_str + "\n", " ") return f"{type(self).__name__}({args_str})" - def replace(self: Self, *args: Any, **kwargs: Any) -> Self: - return type(self)(*args, **{**self._params, **kwargs}) - - def __repr__(self) -> str: - return self.as_pretty_string() - 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) @@ -188,7 +185,7 @@ class Text(Iterable[NodeType]): 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 + 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: @@ -369,7 +366,7 @@ class Pre(Text): type = MessageEntityType.PRE - def __init__(self, *body: NodeType, language: str, **params: Any) -> None: + def __init__(self, *body: NodeType, language: Optional[str] = None, **params: Any) -> None: super().__init__(*body, language=language, **params) @@ -411,8 +408,8 @@ class CustomEmoji(Text): type = MessageEntityType.CUSTOM_EMOJI - def __init__(self, *body: NodeType, emoji_id: str, **params: Any) -> None: - super().__init__(*body, emoji_id=emoji_id, **params) + 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]] = { 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"