Dev 3.x i18n & improvements (#696)

* Added base code and make code improvements
* Auto-exclude coverage for `if TYPE_CHECKING:`
* Fixed current coverage
* Cover I18n module
* Update pipeline
* Fixed annotations
* Added docs
* Move exceptions
* Added tests for KeyboardBuilder and initial docs
* Remove help generator (removed from sources tree, requires rewrite)
* Added patch-notes #698, #699, #700, #701, #702, #703
This commit is contained in:
Alex Root Junior 2021-09-22 00:52:38 +03:00 committed by GitHub
parent 5bd1162f57
commit e4046095d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
223 changed files with 1909 additions and 1121 deletions

View file

@ -1,140 +0,0 @@
import asyncio
import functools
import inspect
import warnings
from typing import Any, Callable, Type
def deprecated(reason: str, stacklevel: int = 2) -> Callable[..., Any]:
"""
This is a decorator which can be used to mark functions
as deprecated. It will result in a warning being emitted
when the function is used.
Source: https://stackoverflow.com/questions/2536307/decorators-in-the-python-standard-lib-deprecated-specifically
"""
if isinstance(reason, str):
# The @deprecated is used with a 'reason'.
#
# .. code-block:: python
#
# @deprecated("please, use another function")
# def old_function(x, y):
# pass
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
if inspect.isclass(func):
msg = "Call to deprecated class {name} ({reason})."
else:
msg = "Call to deprecated function {name} ({reason})."
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
warn_deprecated(
msg.format(name=func.__name__, reason=reason), stacklevel=stacklevel
)
warnings.simplefilter("default", DeprecationWarning)
return func(*args, **kwargs)
return wrapper
return decorator
if inspect.isclass(reason) or inspect.isfunction(reason):
# The @deprecated is used without any 'reason'.
#
# .. code-block:: python
#
# @deprecated
# def old_function(x, y):
# pass
func1 = reason
if inspect.isclass(func1):
msg1 = "Call to deprecated class {name}."
else:
msg1 = "Call to deprecated function {name}."
@functools.wraps(func1)
def wrapper1(*args, **kwargs):
warn_deprecated(msg1.format(name=func1.__name__), stacklevel=stacklevel)
return func1(*args, **kwargs)
return wrapper1
raise TypeError(repr(type(reason)))
def warn_deprecated(
message: str, warning: Type[Warning] = DeprecationWarning, stacklevel: int = 2
) -> None:
warnings.simplefilter("always", warning)
warnings.warn(message, category=warning, stacklevel=stacklevel)
warnings.simplefilter("default", warning)
def renamed_argument(
old_name: str, new_name: str, until_version: str, stacklevel: int = 3
) -> Callable[..., Any]:
"""
A meta-decorator to mark an argument as deprecated.
.. code-block:: python3
@renamed_argument("chat", "chat_id", "3.0") # stacklevel=3 by default
@renamed_argument("user", "user_id", "3.0", stacklevel=4)
def some_function(user_id, chat_id=None):
print(f"user_id={user_id}, chat_id={chat_id}")
some_function(user=123) # prints 'user_id=123, chat_id=None' with warning
some_function(123) # prints 'user_id=123, chat_id=None' without warning
some_function(user_id=123) # prints 'user_id=123, chat_id=None' without warning
:param old_name:
:param new_name:
:param until_version: the version in which the argument is scheduled to be removed
:param stacklevel: leave it to default if it's the first decorator used.
Increment with any new decorator used.
:return: decorator
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def wrapped(*args: Any, **kwargs: Any) -> Any:
if old_name in kwargs:
warn_deprecated(
f"In coroutine '{func.__name__}' argument '{old_name}' "
f"is renamed to '{new_name}' "
f"and will be removed in aiogram {until_version}",
stacklevel=stacklevel,
)
kwargs.update({new_name: kwargs[old_name]})
kwargs.pop(old_name)
return await func(*args, **kwargs)
else:
@functools.wraps(func)
def wrapped(*args: Any, **kwargs: Any) -> Any:
if old_name in kwargs:
warn_deprecated(
f"In function `{func.__name__}` argument `{old_name}` "
f"is renamed to `{new_name}` "
f"and will be removed in aiogram {until_version}",
stacklevel=stacklevel,
)
kwargs.update({new_name: kwargs[old_name]})
kwargs.pop(old_name)
return func(*args, **kwargs)
return wrapped
return decorator

View file

@ -1,5 +0,0 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class BadRequest(TelegramAPIError):
pass

View file

@ -1,27 +0,0 @@
from typing import Optional, TypeVar
from aiogram.methods import TelegramMethod
from aiogram.methods.base import TelegramType
ErrorType = TypeVar("ErrorType")
class TelegramAPIError(Exception):
url: Optional[str] = None
def __init__(
self,
method: TelegramMethod[TelegramType],
message: str,
) -> None:
self.method = method
self.message = message
def render_description(self) -> str:
return self.message
def __str__(self) -> str:
message = [self.render_description()]
if self.url:
message.append(f"(background on this error at: {self.url})")
return "\n".join(message)

View file

@ -1,5 +0,0 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class ConflictError(TelegramAPIError):
pass

View file

@ -1,5 +0,0 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class BadRequest(TelegramAPIError):
pass

View file

@ -1,9 +0,0 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class NetworkError(TelegramAPIError):
pass
class EntityTooLarge(NetworkError):
url = "https://core.telegram.org/bots/api#sending-files"

View file

@ -1,5 +0,0 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class NotFound(TelegramAPIError):
pass

View file

@ -1,9 +0,0 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class ServerError(TelegramAPIError):
pass
class RestartingTelegram(ServerError):
pass

View file

@ -1,44 +0,0 @@
from aiogram.methods import TelegramMethod
from aiogram.methods.base import TelegramType
from aiogram.utils.exceptions.base import TelegramAPIError
class RetryAfter(TelegramAPIError):
url = "https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this"
def __init__(
self,
method: TelegramMethod[TelegramType],
message: str,
retry_after: int,
) -> None:
super().__init__(method=method, message=message)
self.retry_after = retry_after
def render_description(self) -> str:
description = f"Flood control exceeded on method {type(self.method).__name__!r}"
if chat_id := getattr(self.method, "chat_id", None):
description += f" in chat {chat_id}"
description += f". Retry in {self.retry_after} seconds."
return description
class MigrateToChat(TelegramAPIError):
url = "https://core.telegram.org/bots/api#responseparameters"
def __init__(
self,
method: TelegramMethod[TelegramType],
message: str,
migrate_to_chat_id: int,
) -> None:
super().__init__(method=method, message=message)
self.migrate_to_chat_id = migrate_to_chat_id
def render_description(self) -> str:
description = (
f"The group has been migrated to a supergroup with id {self.migrate_to_chat_id}"
)
if chat_id := getattr(self.method, "chat_id", None):
description += f" from {chat_id}"
return description

View file

@ -1,5 +0,0 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class UnauthorizedError(TelegramAPIError):
pass

View file

@ -1,48 +0,0 @@
from abc import ABC, abstractmethod
from collections import Generator
from typing import Dict, List
from aiogram.utils.help.record import CommandRecord
class BaseHelpBackend(ABC):
@abstractmethod
def add(self, record: CommandRecord) -> None:
pass
@abstractmethod
def search(self, value: str) -> CommandRecord:
pass
@abstractmethod
def all(self) -> Generator[CommandRecord, None, None]:
pass
def __getitem__(self, item: str) -> CommandRecord:
return self.search(item)
def __iter__(self) -> Generator[CommandRecord, None, None]:
return self.all()
class MappingBackend(BaseHelpBackend):
def __init__(self, search_empty_prefix: bool = True) -> None:
self._records: List[CommandRecord] = []
self._mapping: Dict[str, CommandRecord] = {}
self.search_empty_prefix = search_empty_prefix
def search(self, value: str) -> CommandRecord:
return self._mapping[value]
def add(self, record: CommandRecord) -> None:
new_records = {}
for key in record.as_keys(with_empty_prefix=self.search_empty_prefix):
if key in self._mapping:
raise ValueError(f"Key '{key}' is already indexed")
new_records[key] = record
self._mapping.update(new_records)
self._records.append(record)
self._records.sort(key=lambda rec: (rec.priority, rec.commands[0]))
def all(self) -> Generator[CommandRecord, None, None]:
yield from self._records

View file

@ -1,113 +0,0 @@
from typing import Any, Optional, Tuple
from aiogram import Bot, Router
from aiogram.dispatcher.filters import Command, CommandObject
from aiogram.types import BotCommand, Message
from aiogram.utils.help.engine import BaseHelpBackend, MappingBackend
from aiogram.utils.help.record import DEFAULT_PREFIXES, CommandRecord
from aiogram.utils.help.render import BaseHelpRenderer, SimpleRenderer
class HelpManager:
def __init__(
self,
backend: Optional[BaseHelpBackend] = None,
renderer: Optional[BaseHelpRenderer] = None,
) -> None:
if backend is None:
backend = MappingBackend()
if renderer is None:
renderer = SimpleRenderer()
self._backend = backend
self._renderer = renderer
def add(
self,
*commands: str,
help: str,
description: Optional[str] = None,
prefix: str = DEFAULT_PREFIXES,
ignore_case: bool = False,
ignore_mention: bool = False,
priority: int = 0,
) -> CommandRecord:
record = CommandRecord(
commands=commands,
help=help,
description=description,
prefix=prefix,
ignore_case=ignore_case,
ignore_mention=ignore_mention,
priority=priority,
)
self._backend.add(record)
return record
def command(
self,
*commands: str,
help: str,
description: Optional[str] = None,
prefix: str = DEFAULT_PREFIXES,
ignore_case: bool = False,
ignore_mention: bool = False,
priority: int = 0,
) -> Command:
record = self.add(
*commands,
help=help,
description=description,
prefix=prefix,
ignore_case=ignore_case,
ignore_mention=ignore_mention,
priority=priority,
)
return record.as_filter()
def mount_help(
self,
router: Router,
*commands: str,
prefix: str = "/",
help: str = "Help",
description: str = "Show help for the commands\n"
"Also you can use '/help command' for get help for specific command",
as_reply: bool = True,
filters: Tuple[Any, ...] = (),
**kw_filters: Any,
) -> Any:
if not commands:
commands = ("help",)
help_filter = self.command(*commands, prefix=prefix, help=help, description=description)
async def handle(message: Message, command: CommandObject, **kwargs: Any) -> Any:
return await self._handle_help(
message=message, command=command, as_reply=as_reply, **kwargs
)
return router.message.register(handle, help_filter, *filters, **kw_filters)
async def _handle_help(
self,
message: Message,
bot: Bot,
command: CommandObject,
as_reply: bool = True,
**kwargs: Any,
) -> Any:
lines = self._renderer.render(backend=self._backend, command=command, **kwargs)
text = "\n".join(line or "" for line in lines)
return await bot.send_message(
chat_id=message.chat.id,
text=text,
reply_to_message_id=message.message_id if as_reply else None,
)
async def set_bot_commands(self, bot: Bot) -> bool:
return await bot.set_my_commands(
commands=[
BotCommand(command=record.commands[0], description=record.help)
for record in self._backend
if "/" in record.prefix
]
)

View file

@ -1,33 +0,0 @@
from dataclasses import dataclass
from itertools import product
from typing import Generator, Optional, Sequence
from aiogram.dispatcher.filters import Command
DEFAULT_PREFIXES = "/"
@dataclass
class CommandRecord:
commands: Sequence[str]
help: str
description: Optional[str] = None
prefix: str = DEFAULT_PREFIXES
ignore_case: bool = False
ignore_mention: bool = False
priority: int = 0
def as_filter(self) -> Command:
return Command(commands=self.commands, commands_prefix=self.prefix)
def as_keys(self, with_empty_prefix: bool = False) -> Generator[str, None, None]:
for command in self.commands:
yield command
for prefix in self.prefix:
yield f"{prefix}{command}"
def as_command(self) -> str:
return f"{self.prefix[0]}{self.commands[0]}"
def as_aliases(self) -> str:
return ", ".join(f"{p}{c}" for c, p in product(self.commands, self.prefix))

View file

@ -1,64 +0,0 @@
from abc import ABC, abstractmethod
from typing import Any, Generator, Optional
from aiogram.dispatcher.filters import CommandObject
from aiogram.utils.help.engine import BaseHelpBackend
class BaseHelpRenderer(ABC):
@abstractmethod
def render(
self, backend: BaseHelpBackend, command: CommandObject, **kwargs: Any
) -> Generator[Optional[str], None, None]:
pass
class SimpleRenderer(BaseHelpRenderer):
def __init__(
self,
help_title: str = "Commands list:",
help_footer: str = "",
aliases_line: str = "Aliases",
command_title: str = "Help for command:",
unknown_command: str = "Command not found",
):
self.help_title = help_title
self.help_footer = help_footer
self.aliases_line = aliases_line
self.command_title = command_title
self.unknown_command = unknown_command
def render_help(self, backend: BaseHelpBackend) -> Generator[Optional[str], None, None]:
yield self.help_title
for command in backend:
yield f"{command.prefix[0]}{command.commands[0]} - {command.help}"
if self.help_footer:
yield None
yield self.help_footer
def render_command_help(
self, backend: BaseHelpBackend, target: str
) -> Generator[Optional[str], None, None]:
try:
record = backend[target]
except KeyError:
yield f"{self.command_title} {target}"
yield self.unknown_command
return
yield f"{self.command_title} {record.as_command()}"
if len(record.commands) > 1 or len(record.prefix) > 1:
yield f"{self.aliases_line}: {record.as_aliases()}"
yield record.help
yield None
yield record.description
def render(
self, backend: BaseHelpBackend, command: CommandObject, **kwargs: Any
) -> Generator[Optional[str], None, None]:
if command.args:
yield from self.render_command_help(backend=backend, target=command.args)
else:
yield from self.render_help(backend=backend)

View file

@ -216,8 +216,7 @@ class OrderedHelperMeta(type):
setattr(cls, PROPS_KEYS_ATTR_NAME, props_keys)
# ref: https://gitter.im/python/typing?at=5da98cc5fa637359fc9cbfe1
return cast(OrderedHelperMeta, cls)
return cls
class OrderedHelper(Helper, metaclass=OrderedHelperMeta):

View file

@ -0,0 +1,21 @@
from .context import get_i18n, gettext, lazy_gettext, lazy_ngettext, ngettext
from .core import I18n
from .middleware import (
ConstI18nMiddleware,
FSMI18nMiddleware,
I18nMiddleware,
SimpleI18nMiddleware,
)
__all__ = (
"I18n",
"I18nMiddleware",
"SimpleI18nMiddleware",
"ConstI18nMiddleware",
"FSMI18nMiddleware",
"gettext",
"lazy_gettext",
"ngettext",
"lazy_ngettext",
"get_i18n",
)

View file

@ -0,0 +1,26 @@
from contextvars import ContextVar
from typing import Any, Optional
from aiogram.utils.i18n.core import I18n
from aiogram.utils.i18n.lazy_proxy import LazyProxy
ctx_i18n: ContextVar[Optional[I18n]] = ContextVar("aiogram_ctx_i18n", default=None)
def get_i18n() -> I18n:
i18n = ctx_i18n.get()
if i18n is None:
raise LookupError("I18n context is not set")
return i18n
def gettext(*args: Any, **kwargs: Any) -> str:
return get_i18n().gettext(*args, **kwargs)
def lazy_gettext(*args: Any, **kwargs: Any) -> LazyProxy:
return LazyProxy(gettext, *args, **kwargs)
ngettext = gettext
lazy_ngettext = lazy_gettext

View file

@ -0,0 +1,97 @@
import gettext
import os
from contextvars import ContextVar
from pathlib import Path
from typing import Dict, Optional, Tuple, Union
from aiogram.utils.i18n.lazy_proxy import LazyProxy
class I18n:
def __init__(
self,
*,
path: Union[str, Path],
locale: str = "en",
domain: str = "messages",
) -> None:
self.path = path
self.locale = locale
self.domain = domain
self.ctx_locale = ContextVar("aiogram_ctx_locale", default=locale)
self.locales = self.find_locales()
@property
def current_locale(self) -> str:
return self.ctx_locale.get()
@current_locale.setter
def current_locale(self, value: str) -> None:
self.ctx_locale.set(value)
def find_locales(self) -> Dict[str, gettext.GNUTranslations]:
"""
Load all compiled locales from path
:return: dict with locales
"""
translations: Dict[str, gettext.GNUTranslations] = {}
for name in os.listdir(self.path):
if not os.path.isdir(os.path.join(self.path, name)):
continue
mo_path = os.path.join(self.path, name, "LC_MESSAGES", self.domain + ".mo")
if os.path.exists(mo_path):
with open(mo_path, "rb") as fp:
translations[name] = gettext.GNUTranslations(fp) # type: ignore
elif os.path.exists(mo_path[:-2] + "po"): # pragma: no cover
raise RuntimeError(f"Found locale '{name}' but this language is not compiled!")
return translations
def reload(self) -> None:
"""
Hot reload locales
"""
self.locales = self.find_locales()
@property
def available_locales(self) -> Tuple[str, ...]:
"""
list of loaded locales
:return:
"""
return tuple(self.locales.keys())
def gettext(
self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None
) -> str:
"""
Get text
:param singular:
:param plural:
:param n:
:param locale:
:return:
"""
if locale is None:
locale = self.current_locale
if locale not in self.locales:
if n == 1:
return singular
return plural if plural else singular
translator = self.locales[locale]
if plural is None:
return translator.gettext(singular)
return translator.ngettext(singular, plural, n)
def lazy_gettext(
self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None
) -> LazyProxy:
return LazyProxy(self.gettext, singular=singular, plural=plural, n=n, locale=locale)

View file

@ -0,0 +1,13 @@
from typing import Any
try:
from babel.support import LazyProxy
except ImportError: # pragma: no cover
class LazyProxy: # type: ignore
def __init__(self, func: Any, *args: Any, **kwargs: Any) -> None:
raise RuntimeError(
"LazyProxy can be used only when Babel installed\n"
"Just install Babel (`pip install Babel`) "
"or aiogram with i18n support (`pip install aiogram[i18n]`)"
)

View file

@ -0,0 +1,182 @@
from abc import ABC, abstractmethod
from typing import Any, Awaitable, Callable, Dict, Optional, Set, cast
try:
from babel import Locale
except ImportError: # pragma: no cover
Locale = None
from aiogram import BaseMiddleware, Router
from aiogram.dispatcher.fsm.context import FSMContext
from aiogram.types import TelegramObject, User
from aiogram.utils.i18n.context import ctx_i18n
from aiogram.utils.i18n.core import I18n
class I18nMiddleware(BaseMiddleware, ABC):
"""
Abstract I18n middleware.
"""
def __init__(
self,
i18n: I18n,
i18n_key: Optional[str] = "i18n",
middleware_key: str = "i18n_middleware",
) -> None:
"""
Create an instance of middleware
:param i18n: instance of I18n
:param i18n_key: context key for I18n instance
:param middleware_key: context key for this middleware
"""
self.i18n = i18n
self.i18n_key = i18n_key
self.middleware_key = middleware_key
def setup(
self: BaseMiddleware, router: Router, exclude: Optional[Set[str]] = None
) -> BaseMiddleware:
"""
Register middleware for all events in the Router
:param router:
:param exclude:
:return:
"""
if exclude is None:
exclude = set()
exclude_events = {"update", "error", *exclude}
for event_name, observer in router.observers.items():
if event_name in exclude_events:
continue
observer.outer_middleware(self)
return self
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any],
) -> Any:
self.i18n.current_locale = await self.get_locale(event=event, data=data)
if self.i18n_key:
data[self.i18n_key] = self.i18n
if self.middleware_key:
data[self.middleware_key] = self
token = ctx_i18n.set(self.i18n)
try:
return await handler(event, data)
finally:
ctx_i18n.reset(token)
@abstractmethod
async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str:
"""
Detect current user locale based on event and context.
**This method must be defined in child classes**
:param event:
:param data:
:return:
"""
pass
class SimpleI18nMiddleware(I18nMiddleware):
"""
Simple I18n middleware.
Chooses language code from the User object received in event
"""
def __init__(
self,
i18n: I18n,
i18n_key: Optional[str] = "i18n",
middleware_key: str = "i18n_middleware",
) -> None:
super().__init__(i18n=i18n, i18n_key=i18n_key, middleware_key=middleware_key)
if Locale is None: # pragma: no cover
raise RuntimeError(
f"{type(self).__name__} can be used only when Babel installed\n"
"Just install Babel (`pip install Babel`) "
"or aiogram with i18n support (`pip install aiogram[i18n]`)"
)
async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str:
if Locale is None: # pragma: no cover
raise RuntimeError(
f"{type(self).__name__} can be used only when Babel installed\n"
"Just install Babel (`pip install Babel`) "
"or aiogram with i18n support (`pip install aiogram[i18n]`)"
)
event_from_user: Optional[User] = data.get("event_from_user", None)
if event_from_user is None:
return self.i18n.locale
locale = Locale.parse(event_from_user.language_code, sep="-")
if locale.language not in self.i18n.available_locales:
return self.i18n.locale
return cast(str, locale.language)
class ConstI18nMiddleware(I18nMiddleware):
"""
Const middleware chooses statically defined locale
"""
def __init__(
self,
locale: str,
i18n: I18n,
i18n_key: Optional[str] = "i18n",
middleware_key: str = "i18n_middleware",
) -> None:
super().__init__(i18n=i18n, i18n_key=i18n_key, middleware_key=middleware_key)
self.locale = locale
async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str:
return self.locale
class FSMI18nMiddleware(SimpleI18nMiddleware):
"""
This middleware stores locale in the FSM storage
"""
def __init__(
self,
i18n: I18n,
key: str = "locale",
i18n_key: Optional[str] = "i18n",
middleware_key: str = "i18n_middleware",
) -> None:
super().__init__(i18n=i18n, i18n_key=i18n_key, middleware_key=middleware_key)
self.key = key
async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str:
fsm_context: Optional[FSMContext] = data.get("state")
locale = None
if fsm_context:
fsm_data = await fsm_context.get_data()
locale = fsm_data.get(self.key, None)
if not locale:
locale = await super().get_locale(event=event, data=data)
if fsm_context:
await fsm_context.update_data(data={self.key: locale})
return locale
async def set_locale(self, state: FSMContext, locale: str) -> None:
"""
Write new locale to the storage
:param state: instance of FSMContext
:param locale: new locale
"""
await state.update_data(data={self.key: locale})
self.i18n.current_locale = locale

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from copy import deepcopy
from itertools import chain
from itertools import cycle as repeat_all
from typing import (
@ -149,7 +150,7 @@ class KeyboardBuilder(Generic[ButtonType]):
:return:
"""
return self._markup.copy()
return deepcopy(self._markup)
def add(self, *buttons: ButtonType) -> "KeyboardBuilder[ButtonType]":
"""
@ -241,7 +242,8 @@ def repeat_last(items: Iterable[T]) -> Generator[T, None, None]:
items_iter = iter(items)
try:
value = next(items_iter)
except StopIteration:
except StopIteration: # pragma: no cover
# Possible case but not in place where this function is used
return
yield value
finished = False
@ -255,7 +257,7 @@ def repeat_last(items: Iterable[T]) -> Generator[T, None, None]:
class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]):
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
@no_type_check
def button(
@ -275,12 +277,20 @@ class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]):
def as_markup(self, **kwargs: Any) -> InlineKeyboardMarkup:
...
def __init__(self) -> None:
super().__init__(InlineKeyboardButton)
def __init__(self, markup: Optional[List[List[InlineKeyboardButton]]] = None) -> None:
super().__init__(button_type=InlineKeyboardButton, markup=markup)
def copy(self: "InlineKeyboardBuilder") -> "InlineKeyboardBuilder":
"""
Make full copy of current builder with markup
:return:
"""
return InlineKeyboardBuilder(markup=self.export())
class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]):
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
@no_type_check
def button(
@ -296,5 +306,13 @@ class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]):
def as_markup(self, **kwargs: Any) -> ReplyKeyboardMarkup:
...
def __init__(self) -> None:
super().__init__(KeyboardButton)
def __init__(self, markup: Optional[List[List[KeyboardButton]]] = None) -> None:
super().__init__(button_type=KeyboardButton, markup=markup)
def copy(self: "ReplyKeyboardBuilder") -> "ReplyKeyboardBuilder":
"""
Make full copy of current builder with markup
:return:
"""
return ReplyKeyboardBuilder(markup=self.export())

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import contextvars
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Generic, Optional, TypeVar, cast, overload
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from typing_extensions import Literal
__all__ = ("ContextInstanceMixin", "DataMixin")

View file

@ -5,7 +5,7 @@ import re
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Generator, List, Optional, Pattern, cast
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from aiogram.types import MessageEntity
__all__ = (