mirror of
https://github.com/aiogram/aiogram.git
synced 2026-04-08 16:37:47 +00:00
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:
parent
5bd1162f57
commit
e4046095d7
223 changed files with 1909 additions and 1121 deletions
|
|
@ -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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from aiogram.utils.exceptions.base import TelegramAPIError
|
||||
|
||||
|
||||
class BadRequest(TelegramAPIError):
|
||||
pass
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from aiogram.utils.exceptions.base import TelegramAPIError
|
||||
|
||||
|
||||
class ConflictError(TelegramAPIError):
|
||||
pass
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from aiogram.utils.exceptions.base import TelegramAPIError
|
||||
|
||||
|
||||
class BadRequest(TelegramAPIError):
|
||||
pass
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from aiogram.utils.exceptions.base import TelegramAPIError
|
||||
|
||||
|
||||
class NotFound(TelegramAPIError):
|
||||
pass
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
from aiogram.utils.exceptions.base import TelegramAPIError
|
||||
|
||||
|
||||
class ServerError(TelegramAPIError):
|
||||
pass
|
||||
|
||||
|
||||
class RestartingTelegram(ServerError):
|
||||
pass
|
||||
|
|
@ -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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from aiogram.utils.exceptions.base import TelegramAPIError
|
||||
|
||||
|
||||
class UnauthorizedError(TelegramAPIError):
|
||||
pass
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
]
|
||||
)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
21
aiogram/utils/i18n/__init__.py
Normal file
21
aiogram/utils/i18n/__init__.py
Normal 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",
|
||||
)
|
||||
26
aiogram/utils/i18n/context.py
Normal file
26
aiogram/utils/i18n/context.py
Normal 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
|
||||
97
aiogram/utils/i18n/core.py
Normal file
97
aiogram/utils/i18n/core.py
Normal 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)
|
||||
13
aiogram/utils/i18n/lazy_proxy.py
Normal file
13
aiogram/utils/i18n/lazy_proxy.py
Normal 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]`)"
|
||||
)
|
||||
182
aiogram/utils/i18n/middleware.py
Normal file
182
aiogram/utils/i18n/middleware.py
Normal 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
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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__ = (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue