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