diff --git a/CHANGES/708.misc b/CHANGES/708.misc new file mode 100644 index 00000000..ba18a645 --- /dev/null +++ b/CHANGES/708.misc @@ -0,0 +1 @@ +Added :code:`html_text` and :code:`md_text` to Message object diff --git a/CHANGES/709.misc b/CHANGES/709.misc new file mode 100644 index 00000000..2d974b1e --- /dev/null +++ b/CHANGES/709.misc @@ -0,0 +1 @@ +Refactored I18n, added context managers for I18n engine and current locale diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 51c84ac8..eeea0b41 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -37,5 +37,5 @@ __all__ = ( "md", ) -__version__ = "3.0.0a16" +__version__ = "3.0.0a17" __api_version__ = "5.3" diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 7f05941f..c33e9991 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, List, Optional, Union from pydantic import Field from aiogram.utils import helper +from aiogram.utils.text_decorations import TextDecoration, html_decoration, markdown_decoration from .base import UNSET, TelegramObject @@ -259,6 +260,22 @@ class Message(TelegramObject): return ContentType.UNKNOWN + def _unparse_entities(self, text_decoration: TextDecoration) -> str: + text = self.text or self.caption + if text is None: + raise TypeError("This message doesn't have any text.") + + entities = self.entities or self.caption_entities + return text_decoration.unparse(text=text, entities=entities) + + @property + def html_text(self) -> str: + return self._unparse_entities(html_decoration) + + @property + def md_text(self) -> str: + return self._unparse_entities(markdown_decoration) + def reply_animation( self, animation: Union[InputFile, str], diff --git a/aiogram/utils/i18n/context.py b/aiogram/utils/i18n/context.py index 7060a7a9..13de4d9c 100644 --- a/aiogram/utils/i18n/context.py +++ b/aiogram/utils/i18n/context.py @@ -1,14 +1,11 @@ -from contextvars import ContextVar -from typing import Any, Optional +from typing import Any 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() + i18n = I18n.get_current(no_error=True) if i18n is None: raise LookupError("I18n context is not set") return i18n diff --git a/aiogram/utils/i18n/core.py b/aiogram/utils/i18n/core.py index d234ffea..d564fdb3 100644 --- a/aiogram/utils/i18n/core.py +++ b/aiogram/utils/i18n/core.py @@ -1,24 +1,26 @@ import gettext import os +from contextlib import contextmanager from contextvars import ContextVar from pathlib import Path -from typing import Dict, Optional, Tuple, Union +from typing import Dict, Generator, Optional, Tuple, Union from aiogram.utils.i18n.lazy_proxy import LazyProxy +from aiogram.utils.mixins import ContextInstanceMixin -class I18n: +class I18n(ContextInstanceMixin["I18n"]): def __init__( self, *, path: Union[str, Path], - locale: str = "en", + default_locale: str = "en", domain: str = "messages", ) -> None: self.path = path - self.locale = locale + self.default_locale = default_locale self.domain = domain - self.ctx_locale = ContextVar("aiogram_ctx_locale", default=locale) + self.ctx_locale = ContextVar("aiogram_ctx_locale", default=default_locale) self.locales = self.find_locales() @property @@ -29,6 +31,28 @@ class I18n: def current_locale(self, value: str) -> None: self.ctx_locale.set(value) + @contextmanager + def use_locale(self, locale: str) -> Generator[None, None, None]: + """ + Create context with specified locale + """ + ctx_token = self.ctx_locale.set(locale) + try: + yield + finally: + self.ctx_locale.reset(ctx_token) + + @contextmanager + def context(self) -> Generator["I18n", None, None]: + """ + Use I18n context + """ + token = self.set_current(self) + try: + yield self + finally: + self.reset_current(token) + def find_locales(self) -> Dict[str, gettext.GNUTranslations]: """ Load all compiled locales from path diff --git a/aiogram/utils/i18n/middleware.py b/aiogram/utils/i18n/middleware.py index 3c810256..91250fde 100644 --- a/aiogram/utils/i18n/middleware.py +++ b/aiogram/utils/i18n/middleware.py @@ -1,3 +1,4 @@ +import logging from abc import ABC, abstractmethod from typing import Any, Awaitable, Callable, Dict, Optional, Set, cast @@ -9,9 +10,10 @@ except ImportError: # pragma: no cover 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 +logger = logging.getLogger(__name__) + class I18nMiddleware(BaseMiddleware, ABC): """ @@ -60,17 +62,16 @@ class I18nMiddleware(BaseMiddleware, ABC): event: TelegramObject, data: Dict[str, Any], ) -> Any: - self.i18n.current_locale = await self.get_locale(event=event, data=data) + current_locale = await self.get_locale(event=event, data=data) or self.i18n.default_locale + logger.debug("Detected locale %r", current_locale) 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: + + with self.i18n.context(), self.i18n.use_locale(current_locale): return await handler(event, data) - finally: - ctx_i18n.reset(token) @abstractmethod async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str: @@ -118,10 +119,10 @@ class SimpleI18nMiddleware(I18nMiddleware): event_from_user: Optional[User] = data.get("event_from_user", None) if event_from_user is None: - return self.i18n.locale + return self.i18n.default_locale locale = Locale.parse(event_from_user.language_code, sep="-") if locale.language not in self.i18n.available_locales: - return self.i18n.locale + return self.i18n.default_locale return cast(str, locale.language) diff --git a/pyproject.toml b/pyproject.toml index 5e132bfe..53a3aa34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.16" +version = "3.0.0-alpha.17" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT" diff --git a/tests/test_api/test_types/test_message.py b/tests/test_api/test_types/test_message.py index cb450731..93341eb2 100644 --- a/tests/test_api/test_types/test_message.py +++ b/tests/test_api/test_types/test_message.py @@ -42,6 +42,7 @@ from aiogram.types import ( Invoice, Location, MessageAutoDeleteTimerChanged, + MessageEntity, PassportData, PhotoSize, Poll, @@ -638,3 +639,27 @@ class TestMessage: assert isinstance(method, DeleteMessage) assert method.chat_id == message.chat.id assert method.message_id == message.message_id + + @pytest.mark.parametrize( + "text,entities,correct", + [ + ["test", [MessageEntity(type="bold", offset=0, length=4)], True], + ["", [], False], + ], + ) + def test_html_text(self, text, entities, correct): + message = Message( + message_id=42, + chat=Chat(id=42, type="private"), + date=datetime.datetime.now(), + text=text, + entities=entities, + ) + if correct: + assert message.html_text + assert message.md_text + else: + with pytest.raises(TypeError): + assert message.html_text + with pytest.raises(TypeError): + assert message.md_text diff --git a/tests/test_utils/test_i18n.py b/tests/test_utils/test_i18n.py index 72da0cbc..8491c8d8 100644 --- a/tests/test_utils/test_i18n.py +++ b/tests/test_utils/test_i18n.py @@ -7,7 +7,7 @@ from aiogram.dispatcher.fsm.context import FSMContext from aiogram.dispatcher.fsm.storage.memory import MemoryStorage from aiogram.types import Update, User from aiogram.utils.i18n import ConstI18nMiddleware, FSMI18nMiddleware, I18n, SimpleI18nMiddleware -from aiogram.utils.i18n.context import ctx_i18n, get_i18n, gettext, lazy_gettext +from aiogram.utils.i18n.context import get_i18n, gettext, lazy_gettext from tests.conftest import DATA_DIR from tests.mocked_bot import MockedBot @@ -31,13 +31,21 @@ class TestI18nCore: assert i18n.current_locale == "uk" assert i18n.ctx_locale.get() == "uk" + def test_use_locale(self, i18n: I18n): + assert i18n.current_locale == "en" + with i18n.use_locale("uk"): + assert i18n.current_locale == "uk" + with i18n.use_locale("it"): + assert i18n.current_locale == "it" + assert i18n.current_locale == "uk" + assert i18n.current_locale == "en" + def test_get_i18n(self, i18n: I18n): with pytest.raises(LookupError): get_i18n() - token = ctx_i18n.set(i18n) - assert get_i18n() == i18n - ctx_i18n.reset(token) + with i18n.context(): + assert get_i18n() == i18n @pytest.mark.parametrize( "locale,case,result", @@ -65,14 +73,11 @@ class TestI18nCore: def test_gettext(self, i18n: I18n, locale: str, case: Dict[str, Any], result: str): if locale is not None: i18n.current_locale = locale - token = ctx_i18n.set(i18n) - try: + with i18n.context(): assert i18n.gettext(**case) == result assert str(i18n.lazy_gettext(**case)) == result assert gettext(**case) == result assert str(lazy_gettext(**case)) == result - finally: - ctx_i18n.reset(token) async def next_call(event, data):