From 40dfbf804cad7221c50218b351e1ce3b730207f3 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 21 Sep 2021 01:41:31 +0300 Subject: [PATCH] Cover I18n module --- .coveragerc | 1 + aiogram/dispatcher/filters/state.py | 3 +- aiogram/utils/i18n/__init__.py | 2 +- aiogram/utils/i18n/context.py | 8 +- aiogram/utils/i18n/{babel.py => core.py} | 6 +- aiogram/utils/i18n/middleware.py | 23 ++- tests/conftest.py | 4 + tests/data/locales/en/LC_MESSAGES/messages.mo | Bin 0 -> 443 bytes tests/data/locales/en/LC_MESSAGES/messages.po | 2 + tests/data/locales/messages.pot | 2 + tests/data/locales/uk/LC_MESSAGES/messages.mo | Bin 0 -> 547 bytes tests/data/locales/uk/LC_MESSAGES/messages.po | 2 + tests/test_utils/test_i18n.py | 139 ++++++++++++++++++ 13 files changed, 169 insertions(+), 23 deletions(-) rename aiogram/utils/i18n/{babel.py => core.py} (94%) create mode 100644 tests/data/locales/en/LC_MESSAGES/messages.mo create mode 100644 tests/data/locales/en/LC_MESSAGES/messages.po create mode 100644 tests/data/locales/messages.pot create mode 100644 tests/data/locales/uk/LC_MESSAGES/messages.mo create mode 100644 tests/data/locales/uk/LC_MESSAGES/messages.po create mode 100644 tests/test_utils/test_i18n.py diff --git a/.coveragerc b/.coveragerc index 06e4886d..9feee202 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,3 +2,4 @@ exclude_lines = pragma: no cover if TYPE_CHECKING: + @abstractmethod diff --git a/aiogram/dispatcher/filters/state.py b/aiogram/dispatcher/filters/state.py index e0a0386f..294c1ada 100644 --- a/aiogram/dispatcher/filters/state.py +++ b/aiogram/dispatcher/filters/state.py @@ -1,5 +1,5 @@ from inspect import isclass -from typing import Any, Dict, Optional, Sequence, Type, Union, cast +from typing import Any, Dict, Optional, Sequence, Type, Union, cast, no_type_check from pydantic import validator @@ -21,6 +21,7 @@ class StateFilter(BaseFilter): arbitrary_types_allowed = True @validator("state", always=True) + @no_type_check # issubclass breaks things def _validate_state(cls, v: Union[StateType, Sequence[StateType]]) -> Sequence[StateType]: if ( isinstance(v, (str, State, StatesGroup)) diff --git a/aiogram/utils/i18n/__init__.py b/aiogram/utils/i18n/__init__.py index 9cb3a353..e48a4c7f 100644 --- a/aiogram/utils/i18n/__init__.py +++ b/aiogram/utils/i18n/__init__.py @@ -1,5 +1,5 @@ -from .babel import I18n from .context import get_i18n, gettext, lazy_gettext, lazy_ngettext, ngettext +from .core import I18n from .middleware import ( ConstI18nMiddleware, FSMI18nMiddleware, diff --git a/aiogram/utils/i18n/context.py b/aiogram/utils/i18n/context.py index 1c72c555..7060a7a9 100644 --- a/aiogram/utils/i18n/context.py +++ b/aiogram/utils/i18n/context.py @@ -1,7 +1,7 @@ from contextvars import ContextVar from typing import Any, Optional -from aiogram.utils.i18n.babel import I18n +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) @@ -18,12 +18,8 @@ def gettext(*args: Any, **kwargs: Any) -> str: return get_i18n().gettext(*args, **kwargs) -def _lazy_lazy_gettext(*args: Any, **kwargs: Any) -> str: - return str(get_i18n().lazy_gettext(*args, **kwargs)) - - def lazy_gettext(*args: Any, **kwargs: Any) -> LazyProxy: - return LazyProxy(_lazy_lazy_gettext, *args, **kwargs) + return LazyProxy(gettext, *args, **kwargs) ngettext = gettext diff --git a/aiogram/utils/i18n/babel.py b/aiogram/utils/i18n/core.py similarity index 94% rename from aiogram/utils/i18n/babel.py rename to aiogram/utils/i18n/core.py index a5c3d34e..e7af5b60 100644 --- a/aiogram/utils/i18n/babel.py +++ b/aiogram/utils/i18n/core.py @@ -44,9 +44,9 @@ class I18n: mo_path = os.path.join(self.path, name, "LC_MESSAGES", self.domain + ".mo") if os.path.exists(mo_path): - with open(mo_path, "r") as fp: - translations[name] = gettext.GNUTranslations(fp) - elif os.path.exists(mo_path[:-2] + "po"): + 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 diff --git a/aiogram/utils/i18n/middleware.py b/aiogram/utils/i18n/middleware.py index 27fac10b..bcf11a50 100644 --- a/aiogram/utils/i18n/middleware.py +++ b/aiogram/utils/i18n/middleware.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, Awaitable, Callable, Dict, Optional, Set, TypeVar, cast +from typing import Any, Awaitable, Callable, Dict, Optional, Set, cast try: from babel import Locale @@ -9,30 +9,29 @@ 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.babel import I18n from aiogram.utils.i18n.context import ctx_i18n - -T = TypeVar("T") +from aiogram.utils.i18n.core import I18n class I18nMiddleware(BaseMiddleware, ABC): def __init__( self, i18n: I18n, - gettext_key: Optional[str] = "gettext", + i18n_key: Optional[str] = "i18n", middleware_key: str = "i18n_middleware", ) -> None: self.i18n = i18n - self.gettext_key = gettext_key + self.i18n_key = i18n_key self.middleware_key = middleware_key def setup( self: BaseMiddleware, router: Router, exclude: Optional[Set[str]] = None ) -> BaseMiddleware: if exclude is None: - exclude = {"update"} + exclude = set() + exclude_events = {"update", "error", *exclude} for event_name, observer in router.observers.items(): - if event_name in exclude: + if event_name in exclude_events: continue observer.outer_middleware(self) return self @@ -45,8 +44,8 @@ class I18nMiddleware(BaseMiddleware, ABC): ) -> Any: self.i18n.current_locale = await self.get_locale(event=event, data=data) - if self.gettext_key: - data[self.gettext_key] = self.i18n + 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) @@ -64,7 +63,7 @@ class SimpleI18nMiddleware(I18nMiddleware): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - if Locale is None: + 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`) " @@ -72,7 +71,7 @@ class SimpleI18nMiddleware(I18nMiddleware): ) async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str: - if Locale is None: + 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`) " diff --git a/tests/conftest.py b/tests/conftest.py index f2d0cebc..51621a43 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from _pytest.config import UsageError from aioredis.connection import parse_url as parse_redis_url @@ -7,6 +9,8 @@ from aiogram.dispatcher.fsm.storage.memory import MemoryStorage from aiogram.dispatcher.fsm.storage.redis import RedisStorage from tests.mocked_bot import MockedBot +DATA_DIR = Path(__file__).parent / "data" + def pytest_addoption(parser): parser.addoption("--redis", default=None, help="run tests which require redis connection") diff --git a/tests/data/locales/en/LC_MESSAGES/messages.mo b/tests/data/locales/en/LC_MESSAGES/messages.mo new file mode 100644 index 0000000000000000000000000000000000000000..02bb93bf317cc90ffc97f2f9e44305bbda4eee6f GIT binary patch literal 443 zcmY*VO;5r=5XHn(kDfh@i3bf1wlyZOe1t+5t!XJKl|*j~EVZWWlI>#TM|kw_`CHto z5uM~E^JZS=P2R`R;d{;0fx)rCX+{3jh3VhS606xn$!EUM7%XwdRhdgcV3NdlbeO`7 zCet7uTM6Gtr7aAguQS?&l7*qj&OHd+nGs(k_^?|(!9 literal 0 HcmV?d00001 diff --git a/tests/data/locales/en/LC_MESSAGES/messages.po b/tests/data/locales/en/LC_MESSAGES/messages.po new file mode 100644 index 00000000..b2a8e35f --- /dev/null +++ b/tests/data/locales/en/LC_MESSAGES/messages.po @@ -0,0 +1,2 @@ +msgid "test" +msgstr "" diff --git a/tests/data/locales/messages.pot b/tests/data/locales/messages.pot new file mode 100644 index 00000000..b2a8e35f --- /dev/null +++ b/tests/data/locales/messages.pot @@ -0,0 +1,2 @@ +msgid "test" +msgstr "" diff --git a/tests/data/locales/uk/LC_MESSAGES/messages.mo b/tests/data/locales/uk/LC_MESSAGES/messages.mo new file mode 100644 index 0000000000000000000000000000000000000000..d745c75cfbbf0976f1c6668715d1269bf91315ee GIT binary patch literal 547 zcmY*W!EV$r6bz^blq1KA!{b0jRlL|KNUa+$&F;Dolx$YnPzi4CuA2r@XXQ8*m3pE7 zz^CvZ?1>xS!b=MjER8&4&-mHTKOb&<^>ExrJVHF{#XloNzJU;kANM?Og!2Q$W*>06 zb>7T2AN9hKcu9`6ZL7v`nC019IWFK>=JPn4`ng^;){(R=t0ftAWlM^NgG{41IgBQg zT+ZkIEGx*^YUS_*GEq)*2x!0v-6sJ9O8HB+Lw6}fBgyqe)mQrMdoQD$q#2n+@FwGF zyz?QONG7rr@w*}TAlYR5GKiCT@4yr z4*gl(Syhvx#-6tv%<4wAV)p=Usd#FhFe(Ittu5#&eJmJ*S3m*nsD~o>vlWULJ-tcL zkWsk0x{aWRZexaFCfN4VIF)zJWdrJu8{@RW=UuMw(oTPN&sVjo%mFOUlx?*WojWCa zcfP(2r`i%}7R|CUC5OF}%K5Lg(H47+zjDc8q)v1V!C-&Dd~bdI{oDG>`r7*iMz)r9 literal 0 HcmV?d00001 diff --git a/tests/data/locales/uk/LC_MESSAGES/messages.po b/tests/data/locales/uk/LC_MESSAGES/messages.po new file mode 100644 index 00000000..6097258d --- /dev/null +++ b/tests/data/locales/uk/LC_MESSAGES/messages.po @@ -0,0 +1,2 @@ +msgid "test" +msgstr "тест" diff --git a/tests/test_utils/test_i18n.py b/tests/test_utils/test_i18n.py new file mode 100644 index 00000000..72da0cbc --- /dev/null +++ b/tests/test_utils/test_i18n.py @@ -0,0 +1,139 @@ +from typing import Any, Dict + +import pytest + +from aiogram import Dispatcher +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 tests.conftest import DATA_DIR +from tests.mocked_bot import MockedBot + + +@pytest.fixture(name="i18n") +def i18n_fixture() -> I18n: + return I18n(path=DATA_DIR / "locales") + + +class TestI18nCore: + def test_init(self, i18n: I18n): + assert set(i18n.available_locales) == {"en", "uk"} + + def test_reload(self, i18n: I18n): + i18n.reload() + assert set(i18n.available_locales) == {"en", "uk"} + + def test_current_locale(self, i18n: I18n): + assert i18n.current_locale == "en" + i18n.current_locale = "uk" + assert i18n.current_locale == "uk" + assert i18n.ctx_locale.get() == "uk" + + 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) + + @pytest.mark.parametrize( + "locale,case,result", + [ + [None, dict(singular="test"), "test"], + [None, dict(singular="test", locale="uk"), "тест"], + ["en", dict(singular="test", locale="uk"), "тест"], + ["uk", dict(singular="test", locale="uk"), "тест"], + ["uk", dict(singular="test"), "тест"], + ["it", dict(singular="test"), "test"], + [None, dict(singular="test", n=2), "test"], + [None, dict(singular="test", n=2, locale="uk"), "тест"], + ["en", dict(singular="test", n=2, locale="uk"), "тест"], + ["uk", dict(singular="test", n=2, locale="uk"), "тест"], + ["uk", dict(singular="test", n=2), "тест"], + ["it", dict(singular="test", n=2), "test"], + [None, dict(singular="test", plural="test2", n=2), "test2"], + [None, dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], + ["en", dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], + ["uk", dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], + ["uk", dict(singular="test", plural="test2", n=2), "test2"], + ["it", dict(singular="test", plural="test2", n=2), "test2"], + ], + ) + 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: + 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): + assert "i18n" in data + assert "i18n_middleware" in data + return gettext("test") + + +@pytest.mark.asyncio +class TestSimpleI18nMiddleware: + @pytest.mark.parametrize( + "event_from_user,result", + [ + [None, "test"], + [User(id=42, is_bot=False, language_code="uk", first_name="Test"), "тест"], + [User(id=42, is_bot=False, language_code="it", first_name="Test"), "test"], + ], + ) + async def test_middleware(self, i18n: I18n, event_from_user, result): + middleware = SimpleI18nMiddleware(i18n=i18n) + result = await middleware( + next_call, + Update(update_id=42), + {"event_from_user": event_from_user}, + ) + assert result == result + + async def test_setup(self, i18n: I18n): + dp = Dispatcher() + middleware = SimpleI18nMiddleware(i18n=i18n) + middleware.setup(router=dp) + + assert middleware not in dp.update.outer_middlewares + assert middleware in dp.message.outer_middlewares + + +@pytest.mark.asyncio +class TestConstI18nMiddleware: + async def test_middleware(self, i18n: I18n): + middleware = ConstI18nMiddleware(i18n=i18n, locale="uk") + result = await middleware( + next_call, + Update(update_id=42), + {"event_from_user": User(id=42, is_bot=False, language_code="it", first_name="Test")}, + ) + assert result == "тест" + + +@pytest.mark.asyncio +class TestFSMI18nMiddleware: + async def test_middleware(self, i18n: I18n, bot: MockedBot): + middleware = FSMI18nMiddleware(i18n=i18n) + storage = MemoryStorage() + state = FSMContext(bot=bot, storage=storage, user_id=42, chat_id=42) + data = { + "event_from_user": User(id=42, is_bot=False, language_code="it", first_name="Test"), + "state": state, + } + result = await middleware(next_call, Update(update_id=42), data) + assert result == "test" + await middleware.set_locale(state, "uk") + assert i18n.current_locale == "uk" + result = await middleware(next_call, Update(update_id=42), data) + assert result == "тест"