mirror of
https://github.com/aiogram/aiogram.git
synced 2025-12-06 07:50:32 +00:00
Formatting tools (#1172)
* Added base implementation of formatting utility * Refactored and added docs * Added changelog * Coverage
This commit is contained in:
parent
c418689dc1
commit
5b20f81654
8 changed files with 1103 additions and 47 deletions
2
CHANGES/1172.feature.rst
Normal file
2
CHANGES/1172.feature.rst
Normal 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>`
|
||||
|
|
@ -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
577
aiogram/utils/formatting.py
Normal 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)
|
||||
|
|
@ -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
199
docs/utils/formatting.rst
Normal 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:
|
||||
|
|
@ -9,3 +9,4 @@ Utils
|
|||
chat_action
|
||||
web_app
|
||||
callback_answer
|
||||
formatting
|
||||
|
|
|
|||
319
tests/test_utils/test_formatting.py
Normal file
319
tests/test_utils/test_formatting.py
Normal 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"
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue