Refactored and added docs

This commit is contained in:
Alex Root Junior 2023-04-30 21:37:27 +03:00
parent 28e4c15d75
commit bfa69f6deb
No known key found for this signature in database
GPG key ID: 074C1D455EBEA4AC
5 changed files with 527 additions and 126 deletions

View file

@ -1,28 +1,19 @@
""" import textwrap
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())
"""
from typing import ( from typing import (
Any, Any,
ClassVar, ClassVar,
Dict, Dict,
Generator, Generator,
Iterable,
Iterator, Iterator,
List, List,
Optional, Optional,
Tuple, Tuple,
TypeVar, Type,
Union,
) )
from typing_extensions import Self
from aiogram.enums import MessageEntityType from aiogram.enums import MessageEntityType
from aiogram.types import MessageEntity, User from aiogram.types import MessageEntity, User
from aiogram.utils.text_decorations import ( from aiogram.utils.text_decorations import (
@ -32,16 +23,18 @@ from aiogram.utils.text_decorations import (
remove_surrogates, remove_surrogates,
) )
NodeType = Union[str, "Node"] NodeType = Any
NodeT = TypeVar("NodeT", bound=NodeType)
def sizeof(value: str) -> int: def sizeof(value: str) -> int:
return len(value.encode("utf-16-le")) // 2 return len(value.encode("utf-16-le")) // 2
class Node: class Text(Iterable[NodeType]):
"""
Simple text element
"""
type: ClassVar[Optional[str]] = None type: ClassVar[Optional[str]] = None
__slots__ = ("_body", "_params") __slots__ = ("_body", "_params")
@ -51,12 +44,12 @@ class Node:
*body: NodeType, *body: NodeType,
**params: Any, **params: Any,
) -> None: ) -> None:
self._body = body self._body: Tuple[NodeType, ...] = body
self._params = params self._params: Dict[str, Any] = params
@classmethod @classmethod
def from_entities(cls, text: str, entities: List[MessageEntity]) -> "Node": def from_entities(cls, text: str, entities: List[MessageEntity]) -> "Text":
return Node( return Text(
*_unparse_entities( *_unparse_entities(
text=add_surrogates(text), text=add_surrogates(text),
entities=sorted(entities, key=lambda item: item.offset) if entities else [], entities=sorted(entities, key=lambda item: item.offset) if entities else [],
@ -70,17 +63,26 @@ class Node:
_sort: bool = True, _sort: bool = True,
_collect_entities: bool = True, _collect_entities: bool = True,
) -> Tuple[str, List[MessageEntity]]: ) -> Tuple[str, List[MessageEntity]]:
"""
Render elements tree as text with entities list
:return:
"""
text = "" text = ""
entities = [] entities = []
offset = _offset offset = _offset
for node in self._body: for node in self._body:
if isinstance(node, str): if not isinstance(node, Text):
node = str(node)
text += node text += node
offset += sizeof(node) offset += sizeof(node)
else: else:
node_text, node_entities = node.render( 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 text += node_text
offset += sizeof(node_text) offset += sizeof(node_text)
@ -98,16 +100,30 @@ class Node:
def _render_entity(self, *, offset: int, length: int) -> MessageEntity: def _render_entity(self, *, offset: int, length: int) -> MessageEntity:
return MessageEntity(type=self.type, offset=offset, length=length, **self._params) return MessageEntity(type=self.type, offset=offset, length=length, **self._params)
def to_kwargs( def as_kwargs(
self, self,
*, *,
text_key: str = "text", text_key: str = "text",
entities_key: str = "entities", entities_key: str = "entities",
replace_parse_mode: bool = False, replace_parse_mode: bool = True,
parse_mode_key: str = "parse_mode", 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() text_value, entities_value = self.render()
result = { result: Dict[str, Any] = {
text_key: text_value, text_key: text_value,
entities_key: entities_value, entities_key: entities_value,
} }
@ -115,17 +131,27 @@ class Node:
result[parse_mode_key] = None result[parse_mode_key] = None
return result return result
def to_html(self) -> str: def as_html(self) -> str:
"""
Render elements tree as HTML markup
"""
text, entities = self.render() text, entities = self.render()
return html_decoration.unparse(text, entities) 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() text, entities = self.render()
return markdown_decoration.unparse(text, entities) return markdown_decoration.unparse(text, entities)
def __repr__(self) -> str: def as_pretty_string(self, indent: bool = False) -> str:
body = ", ".join(repr(item) for item in self._body) sep = ",\n" if indent else ", "
params = ", ".join(f"{k}={v!r}" for k, v in self._params.items()) 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 = [] args = []
if body: if body:
@ -133,38 +159,40 @@ class Node:
if params: if params:
args.append(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": def replace(self: Self, *args: Any, **kwargs: Any) -> Self:
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:
return type(self)(*args, **{**self._params, **kwargs}) return type(self)(*args, **{**self._params, **kwargs})
def __iter__(self) -> Iterator[NodeT]: def __repr__(self) -> str:
return iter(self._body) 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: def __len__(self) -> int:
text, _ = self.render(_collect_entities=False) text, _ = self.render(_collect_entities=False)
return sizeof(text) return sizeof(text)
def __getitem__(self, item): def __getitem__(self, item: slice) -> "Text":
# FIXME: currently is not always separate text in correct place
if not isinstance(item, slice): if not isinstance(item, slice):
raise TypeError("Can only be sliced") raise TypeError("Can only be sliced")
if (item.start is None or item.start == 0) and item.stop is None: if (item.start is None or item.start == 0) and item.stop is None:
return self return self
start = 0 if item.start is None else item.start
start = item.start or 0 stop = len(self) if item.stop is None else item.stop
stop = item.stop or len(self) if start == stop:
return self.replace()
nodes = [] nodes = []
position = 0 position = 0
@ -177,7 +205,9 @@ class Node:
continue continue
if current_position > stop: if current_position > stop:
break 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: if not new_node:
continue continue
nodes.append(new_node) nodes.append(new_node)
@ -185,79 +215,208 @@ class Node:
return self.replace(*nodes) 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 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 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 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 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 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 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 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 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 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 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 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 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 type = MessageEntityType.PRE
def __init__(self, *body: NodeType, language: str, **params: Any) -> None: def __init__(self, *body: NodeType, language: str, **params: Any) -> None:
super().__init__(*body, language=language, **params) 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 type = MessageEntityType.TEXT_LINK
def __init__(self, *body: NodeType, url: str, **params: Any) -> None: def __init__(self, *body: NodeType, url: str, **params: Any) -> None:
super().__init__(*body, url=url, **params) 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 type = MessageEntityType.TEXT_MENTION
def __init__(self, *body: NodeType, user: User, **params: Any) -> None: def __init__(self, *body: NodeType, user: User, **params: Any) -> None:
super().__init__(*body, user=user, **params) super().__init__(*body, user=user, **params)
Text = Node class CustomEmoji(Text):
Strong = Bold """
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, HashTag.type: HashTag,
CashTag.type: CashTag, CashTag.type: CashTag,
BotCommand.type: BotCommand, BotCommand.type: BotCommand,
@ -273,7 +432,6 @@ NODE_TYPES = {
Pre.type: Pre, Pre.type: Pre,
TextLink.type: TextLink, TextLink.type: TextLink,
TextMention.type: TextMention, TextMention.type: TextMention,
Text.type: Text,
} }
@ -285,7 +443,7 @@ def _apply_entity(entity: MessageEntity, *nodes: NodeType) -> NodeType:
:param text: :param text:
:return: :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"})) return node_type(*nodes, **entity.dict(exclude={"type", "offset", "length"}))
@ -317,17 +475,106 @@ def _unparse_entities(
yield remove_surrogates(text[offset:length]) 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 = [] nodes = []
for item in items[:-1]: for item in items[:-1]:
nodes.extend([item, "\n"]) nodes.extend([item, sep])
nodes.append(items[-1]) nodes.append(items[-1])
return Node(*nodes) return Text(*nodes)
def as_marked_list(*items: NodeType, marker: str = "- ") -> Node: def as_marked_list(*items: NodeType, marker: str = "- ") -> Text:
return as_list(*(Node(marker, item) for item in items)) """
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: def as_numbered_list(*items: NodeType, start: int = 1, fmt: str = "{}. ") -> Text:
return as_list(*(Node(fmt.format(index), item) for index, item in enumerate(items, start))) """
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(...)

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

@ -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, <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 chat_action
web_app web_app
callback_answer callback_answer
formatting

View file

@ -6,10 +6,10 @@ import pytest
from aiogram.utils.link import ( from aiogram.utils.link import (
BRANCH, BRANCH,
create_channel_bot_link,
create_telegram_link, create_telegram_link,
create_tg_link, create_tg_link,
docs_url, docs_url,
create_channel_bot_link,
) )