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

Binary file not shown.

View file

@ -0,0 +1,2 @@
msgid "test"
msgstr ""

View file

@ -0,0 +1,2 @@
msgid "test"
msgstr ""

Binary file not shown.

View file

@ -0,0 +1,2 @@
msgid "test"
msgstr "тест"

View file

@ -1,4 +1,6 @@
import io
import os
from tempfile import mkstemp
import aiofiles
import pytest
@ -6,6 +8,7 @@ from aresponses import ResponsesMockServer
from aiogram import Bot
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.methods import GetFile, GetMe
from aiogram.types import File, PhotoSize
from tests.mocked_bot import MockedBot
@ -128,3 +131,15 @@ class TestBot:
await bot.download(
[PhotoSize(file_id="file id", file_unique_id="file id", width=123, height=123)]
)
async def test_download_local_file(self, bot: MockedBot):
bot.session.api = TelegramAPIServer.from_base("http://localhost:8081", is_local=True)
fd, tmp = mkstemp(prefix="test-", suffix=".txt")
value = b"KABOOM"
try:
with open(fd, "wb") as f:
f.write(value)
content = await bot.download_file(tmp)
assert content.getvalue() == value
finally:
os.unlink(tmp)

View file

@ -9,9 +9,9 @@ from aresponses import ResponsesMockServer
from aiogram import Bot
from aiogram.client.session import aiohttp
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.exceptions import TelegramNetworkError
from aiogram.methods import Request, TelegramMethod
from aiogram.types import UNSET, InputFile
from aiogram.utils.exceptions.network import NetworkError
from tests.mocked_bot import MockedBot
try:
@ -187,7 +187,7 @@ class TestAiohttpSession:
new_callable=CoroutineMock,
side_effect=side_effect,
):
with pytest.raises(NetworkError):
with pytest.raises(TelegramNetworkError):
await bot.get_me()
async def test_stream_content(self, aresponses: ResponsesMockServer):

View file

@ -7,16 +7,21 @@ import pytest
from aiogram import Bot
from aiogram.client.session.base import BaseSession, TelegramType
from aiogram.client.telegram import PRODUCTION, TelegramAPIServer
from aiogram.exceptions import (
RestartingTelegram,
TelegramAPIError,
TelegramBadRequest,
TelegramConflictError,
TelegramEntityTooLarge,
TelegramForbiddenError,
TelegramMigrateToChat,
TelegramNotFound,
TelegramRetryAfter,
TelegramServerError,
TelegramUnauthorizedError,
)
from aiogram.methods import DeleteMessage, GetMe, TelegramMethod
from aiogram.types import UNSET, User
from aiogram.utils.exceptions.bad_request import BadRequest
from aiogram.utils.exceptions.base import TelegramAPIError
from aiogram.utils.exceptions.conflict import ConflictError
from aiogram.utils.exceptions.network import EntityTooLarge
from aiogram.utils.exceptions.not_found import NotFound
from aiogram.utils.exceptions.server import RestartingTelegram, ServerError
from aiogram.utils.exceptions.special import MigrateToChat, RetryAfter
from aiogram.utils.exceptions.unauthorized import UnauthorizedError
from tests.mocked_bot import MockedBot
try:
@ -153,25 +158,25 @@ class TestBaseSession:
"status_code,content,error",
[
[200, '{"ok":true,"result":true}', None],
[400, '{"ok":false,"description":"test"}', BadRequest],
[400, '{"ok":false,"description":"test"}', TelegramBadRequest],
[
400,
'{"ok":false,"description":"test", "parameters": {"retry_after": 1}}',
RetryAfter,
TelegramRetryAfter,
],
[
400,
'{"ok":false,"description":"test", "parameters": {"migrate_to_chat_id": -42}}',
MigrateToChat,
TelegramMigrateToChat,
],
[404, '{"ok":false,"description":"test"}', NotFound],
[401, '{"ok":false,"description":"test"}', UnauthorizedError],
[403, '{"ok":false,"description":"test"}', UnauthorizedError],
[409, '{"ok":false,"description":"test"}', ConflictError],
[413, '{"ok":false,"description":"test"}', EntityTooLarge],
[404, '{"ok":false,"description":"test"}', TelegramNotFound],
[401, '{"ok":false,"description":"test"}', TelegramUnauthorizedError],
[403, '{"ok":false,"description":"test"}', TelegramForbiddenError],
[409, '{"ok":false,"description":"test"}', TelegramConflictError],
[413, '{"ok":false,"description":"test"}', TelegramEntityTooLarge],
[500, '{"ok":false,"description":"restarting"}', RestartingTelegram],
[500, '{"ok":false,"description":"test"}', ServerError],
[502, '{"ok":false,"description":"test"}', ServerError],
[500, '{"ok":false,"description":"test"}', TelegramServerError],
[502, '{"ok":false,"description":"test"}', TelegramServerError],
[499, '{"ok":false,"description":"test"}', TelegramAPIError],
[499, '{"ok":false,"description":"test"}', TelegramAPIError],
],

View file

@ -0,0 +1,49 @@
from inspect import isclass
import pytest
from aiogram.dispatcher.filters import StateFilter
from aiogram.dispatcher.fsm.state import State, StatesGroup
from aiogram.types import Update
pytestmark = pytest.mark.asyncio
class MyGroup(StatesGroup):
state = State()
class TestStateFilter:
@pytest.mark.parametrize(
"state", [None, State("test"), MyGroup, MyGroup(), "state", ["state"]]
)
def test_validator(self, state):
f = StateFilter(state=state)
assert isinstance(f.state, list)
value = f.state[0]
assert (
isinstance(value, (State, str, MyGroup))
or (isclass(value) and issubclass(value, StatesGroup))
or value is None
)
@pytest.mark.parametrize(
"state,current_state,result",
[
[State("state"), "@:state", True],
[[State("state")], "@:state", True],
[MyGroup, "MyGroup:state", True],
[[MyGroup], "MyGroup:state", True],
[MyGroup(), "MyGroup:state", True],
[[MyGroup()], "MyGroup:state", True],
["*", "state", True],
[None, None, True],
[[None], None, True],
[None, "state", False],
[[], "state", False],
],
)
@pytestmark
async def test_filter(self, state, current_state, result):
f = StateFilter(state=state)
assert bool(await f(obj=Update(update_id=42), raw_state=current_state)) is result

View file

@ -0,0 +1,14 @@
import pytest
from aiogram.dispatcher.middlewares.user_context import UserContextMiddleware
async def next_handler(*args, **kwargs):
pass
class TestUserContextMiddleware:
@pytest.mark.asyncio
async def test_unexpected_event_type(self):
with pytest.raises(RuntimeError):
await UserContextMiddleware()(next_handler, object(), {})

View file

@ -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 == "тест"

View file

@ -0,0 +1,226 @@
import pytest
from aiogram.dispatcher.filters.callback_data import CallbackData
from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder, KeyboardBuilder, ReplyKeyboardBuilder
class MyCallback(CallbackData, prefix="test"):
value: str
class TestKeyboardBuilder:
def test_init(self):
with pytest.raises(ValueError):
KeyboardBuilder(button_type=object)
def test_init_success(self):
builder = KeyboardBuilder(button_type=KeyboardButton)
assert builder._button_type is KeyboardButton
builder = InlineKeyboardBuilder()
assert builder._button_type is InlineKeyboardButton
builder = ReplyKeyboardBuilder()
assert builder._button_type is KeyboardButton
def test_validate_button(self):
builder = InlineKeyboardBuilder()
with pytest.raises(ValueError):
builder._validate_button(button=object())
with pytest.raises(ValueError):
builder._validate_button(button=KeyboardButton(text="test"))
assert builder._validate_button(
button=InlineKeyboardButton(text="test", callback_data="callback")
)
def test_validate_buttons(self):
builder = InlineKeyboardBuilder()
with pytest.raises(ValueError):
builder._validate_buttons(object(), object())
with pytest.raises(ValueError):
builder._validate_buttons(KeyboardButton(text="test"))
with pytest.raises(ValueError):
builder._validate_buttons(
InlineKeyboardButton(text="test", callback_data="callback"),
KeyboardButton(text="test"),
)
assert builder._validate_button(
InlineKeyboardButton(text="test", callback_data="callback")
)
def test_validate_row(self):
builder = ReplyKeyboardBuilder()
with pytest.raises(ValueError):
assert builder._validate_row(
row=(KeyboardButton(text=f"test {index}") for index in range(10))
)
with pytest.raises(ValueError):
assert builder._validate_row(
row=[KeyboardButton(text=f"test {index}") for index in range(10)]
)
for count in range(9):
assert builder._validate_row(
row=[KeyboardButton(text=f"test {index}") for index in range(count)]
)
def test_validate_markup(self):
builder = ReplyKeyboardBuilder()
with pytest.raises(ValueError):
builder._validate_markup(markup=())
with pytest.raises(ValueError):
builder._validate_markup(
markup=[
[KeyboardButton(text=f"{row}.{col}") for col in range(8)] for row in range(15)
]
)
assert builder._validate_markup(
markup=[[KeyboardButton(text=f"{row}.{col}") for col in range(8)] for row in range(8)]
)
def test_validate_size(self):
builder = ReplyKeyboardBuilder()
with pytest.raises(ValueError):
builder._validate_size(None)
with pytest.raises(ValueError):
builder._validate_size(2.0)
with pytest.raises(ValueError):
builder._validate_size(0)
with pytest.raises(ValueError):
builder._validate_size(10)
for size in range(1, 9):
builder._validate_size(size)
def test_export(self):
builder = ReplyKeyboardBuilder(markup=[[KeyboardButton(text="test")]])
markup = builder.export()
assert id(builder._markup) != id(markup)
markup.clear()
assert len(builder._markup) == 1
assert len(markup) == 0
@pytest.mark.parametrize(
"builder,button",
[
[
ReplyKeyboardBuilder(markup=[[KeyboardButton(text="test")]]),
KeyboardButton(text="test2"),
],
[
InlineKeyboardBuilder(markup=[[InlineKeyboardButton(text="test")]]),
InlineKeyboardButton(text="test2"),
],
[
KeyboardBuilder(
button_type=InlineKeyboardButton, markup=[[InlineKeyboardButton(text="test")]]
),
InlineKeyboardButton(text="test2"),
],
],
)
def test_copy(self, builder, button):
builder1 = builder
builder2 = builder1.copy()
assert builder1 != builder2
builder1.add(button)
builder2.row(button)
markup1 = builder1.export()
markup2 = builder2.export()
assert markup1 != markup2
assert len(markup1) == 1
assert len(markup2) == 2
assert len(markup1[0]) == 2
assert len(markup2[0]) == 1
@pytest.mark.parametrize(
"count,rows,last_columns",
[[0, 0, 0], [3, 1, 3], [8, 1, 8], [9, 2, 1], [16, 2, 8], [19, 3, 3]],
)
def test_add(self, count: int, rows: int, last_columns: int):
builder = ReplyKeyboardBuilder()
for index in range(count):
builder.add(KeyboardButton(text=f"btn-{index}"))
markup = builder.export()
assert len(list(builder.buttons)) == count
assert len(markup) == rows
if last_columns:
assert len(markup[-1]) == last_columns
def test_row(
self,
):
builder = ReplyKeyboardBuilder(markup=[[KeyboardButton(text="test")]])
builder.row(*(KeyboardButton(text=f"test-{index}") for index in range(10)), width=3)
markup = builder.export()
assert len(markup) == 5
@pytest.mark.parametrize(
"count,repeat,sizes,shape",
[
[0, False, [], []],
[0, False, [2], []],
[1, False, [2], [1]],
[3, False, [2], [2, 1]],
[10, False, [], [8, 2]],
[10, False, [3, 2, 1], [3, 2, 1, 1, 1, 1, 1]],
[12, True, [3, 2, 1], [3, 2, 1, 3, 2, 1]],
],
)
def test_adjust(self, count, repeat, sizes, shape):
builder = ReplyKeyboardBuilder()
builder.row(*(KeyboardButton(text=f"test-{index}") for index in range(count)))
builder.adjust(*sizes, repeat=repeat)
markup = builder.export()
assert len(markup) == len(shape)
for row, expected_size in zip(markup, shape):
assert len(row) == expected_size
@pytest.mark.parametrize(
"builder_type,kwargs,expected",
[
[ReplyKeyboardBuilder, dict(text="test"), KeyboardButton(text="test")],
[
InlineKeyboardBuilder,
dict(text="test", callback_data="callback"),
InlineKeyboardButton(text="test", callback_data="callback"),
],
[
InlineKeyboardBuilder,
dict(text="test", callback_data=MyCallback(value="test")),
InlineKeyboardButton(text="test", callback_data="test:test"),
],
],
)
def test_button(self, builder_type, kwargs, expected):
builder = builder_type()
builder.button(**kwargs)
markup = builder.export()
assert markup[0][0] == expected
@pytest.mark.parametrize(
"builder,expected",
[
[ReplyKeyboardBuilder(), ReplyKeyboardMarkup],
[InlineKeyboardBuilder(), InlineKeyboardMarkup],
],
)
def test_as_markup(self, builder, expected):
assert isinstance(builder.as_markup(), expected)