From bfa69f6debd045b730d2050b50e645ff34c6f349 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 30 Apr 2023 21:37:27 +0300 Subject: [PATCH] Refactored and added docs --- aiogram/utils/formatting.py | 409 +++++++++++++++++---- docs/api/methods/set_sticker_set_thumb.rst | 44 --- docs/utils/formatting.rst | 197 ++++++++++ docs/utils/index.rst | 1 + tests/test_utils/test_link.py | 2 +- 5 files changed, 527 insertions(+), 126 deletions(-) delete mode 100644 docs/api/methods/set_sticker_set_thumb.rst create mode 100644 docs/utils/formatting.rst diff --git a/aiogram/utils/formatting.py b/aiogram/utils/formatting.py index d9a8ea16..45a19c9e 100644 --- a/aiogram/utils/formatting.py +++ b/aiogram/utils/formatting.py @@ -1,28 +1,19 @@ -""" -Proof of Concept text decoration utility for aiogram 3.0 - -This part of the code is licensed under MIT as the same as aiogarm - -Soon it will be moved into main package - -Usage: - ->>> formatting = Text("Hello, ", Bold("World"), "!") ->>> await bot.send_message(chat_id=..., **formatting.to_kwargs()) -""" +import textwrap from typing import ( Any, ClassVar, Dict, Generator, + Iterable, Iterator, List, Optional, Tuple, - TypeVar, - Union, + Type, ) +from typing_extensions import Self + from aiogram.enums import MessageEntityType from aiogram.types import MessageEntity, User from aiogram.utils.text_decorations import ( @@ -32,16 +23,18 @@ from aiogram.utils.text_decorations import ( remove_surrogates, ) -NodeType = Union[str, "Node"] - -NodeT = TypeVar("NodeT", bound=NodeType) +NodeType = Any def sizeof(value: str) -> int: return len(value.encode("utf-16-le")) // 2 -class Node: +class Text(Iterable[NodeType]): + """ + Simple text element + """ + type: ClassVar[Optional[str]] = None __slots__ = ("_body", "_params") @@ -51,12 +44,12 @@ class Node: *body: NodeType, **params: Any, ) -> None: - self._body = body - self._params = params + self._body: Tuple[NodeType, ...] = body + self._params: Dict[str, Any] = params @classmethod - def from_entities(cls, text: str, entities: List[MessageEntity]) -> "Node": - return Node( + def from_entities(cls, text: str, entities: List[MessageEntity]) -> "Text": + return Text( *_unparse_entities( text=add_surrogates(text), entities=sorted(entities, key=lambda item: item.offset) if entities else [], @@ -70,17 +63,26 @@ class Node: _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 isinstance(node, str): + 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 + _offset=offset, + _sort=False, + _collect_entities=_collect_entities, ) text += node_text offset += sizeof(node_text) @@ -98,16 +100,30 @@ class Node: def _render_entity(self, *, offset: int, length: int) -> MessageEntity: return MessageEntity(type=self.type, offset=offset, length=length, **self._params) - def to_kwargs( + def as_kwargs( self, *, text_key: str = "text", entities_key: str = "entities", - replace_parse_mode: bool = False, + replace_parse_mode: bool = True, parse_mode_key: str = "parse_mode", - ) -> Dict[str, Union[str, List[MessageEntity]]]: + ) -> 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 = { + result: Dict[str, Any] = { text_key: text_value, entities_key: entities_value, } @@ -115,17 +131,27 @@ class Node: result[parse_mode_key] = None return result - def to_html(self) -> str: + def as_html(self) -> str: + """ + Render elements tree as HTML markup + """ text, entities = self.render() return html_decoration.unparse(text, entities) - def to_markdown(self) -> str: + def as_markdown(self) -> str: + """ + Render elements tree as MarkdownV2 markup + """ text, entities = self.render() return markdown_decoration.unparse(text, entities) - def __repr__(self) -> str: - body = ", ".join(repr(item) for item in self._body) - params = ", ".join(f"{k}={v!r}" for k, v in self._params.items()) + 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()) args = [] if body: @@ -133,38 +159,40 @@ class Node: if params: args.append(params) - return f"{type(self).__name__}({', '.join(args)})" + 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) -> "Node": - if type(self) == type(other) and self._params == other._params: - return type(self)(*self._body, *other._body, **self._params) - if type(self) == Node and isinstance(other, str): - return type(self)(*self._body, other, **self._params) - return Node(self, other) - - def line(self: NodeT, *nodes: NodeType) -> NodeT: - first_node = Text(self) if isinstance(self, str) else self - return first_node + Text(*nodes, "\n") - - def replace(self: NodeT, *args: Any, **kwargs: Any) -> NodeT: + def replace(self: Self, *args: Any, **kwargs: Any) -> Self: return type(self)(*args, **{**self._params, **kwargs}) - def __iter__(self) -> Iterator[NodeT]: - return iter(self._body) + 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) + 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): - # FIXME: currently is not always separate text in correct place + 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 - - start = item.start or 0 - stop = item.stop or len(self) + 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 @@ -177,7 +205,9 @@ class Node: continue if current_position > stop: break - new_node = node[start - current_position : stop - current_position] + 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) @@ -185,79 +215,208 @@ class Node: return self.replace(*nodes) -class HashTag(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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(Node): +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: str, **params: Any) -> None: super().__init__(*body, language=language, **params) -class TextLink(Node): +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(Node): +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) -Text = Node -Strong = Bold +class CustomEmoji(Text): + """ + Custom emoji element. -NODE_TYPES = { + 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, emoji_id: str, **params: Any) -> None: + super().__init__(*body, emoji_id=emoji_id, **params) + + +NODE_TYPES: Dict[Optional[str], Type[Text]] = { + Text.type: Text, HashTag.type: HashTag, CashTag.type: CashTag, BotCommand.type: BotCommand, @@ -273,7 +432,6 @@ NODE_TYPES = { Pre.type: Pre, TextLink.type: TextLink, TextMention.type: TextMention, - Text.type: Text, } @@ -285,7 +443,7 @@ def _apply_entity(entity: MessageEntity, *nodes: NodeType) -> NodeType: :param text: :return: """ - node_type = NODE_TYPES.get(entity.type, Node) + node_type = NODE_TYPES.get(entity.type, Text) return node_type(*nodes, **entity.dict(exclude={"type", "offset", "length"})) @@ -317,17 +475,106 @@ def _unparse_entities( yield remove_surrogates(text[offset:length]) -def as_list(*items: NodeType) -> Node: +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, "\n"]) + nodes.extend([item, sep]) nodes.append(items[-1]) - return Node(*nodes) + return Text(*nodes) -def as_marked_list(*items: NodeType, marker: str = "- ") -> Node: - return as_list(*(Node(marker, item) for item in items)) +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 = "{}. ") -> Node: - return as_list(*(Node(fmt.format(index), item) for index, item in enumerate(items, start))) +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..ee6a1674 --- /dev/null +++ b/docs/utils/formatting.rst @@ -0,0 +1,197 @@ +========== +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_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, )