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 @@
"""
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:`<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
web_app
callback_answer
formatting

View file

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